一次设备映射缓存设计:用多索引 Map 把高频查询从遍历变成直接命中

一次设备映射缓存设计:用多索引 Map 把高频查询从遍历变成直接命中

很多时候,我们在业务里并不是没有用到算法,而是已经在用了,只是平时不把它叫做算法。

比如缓存、索引、预计算,这些在项目中非常常见的设计,本质上其实都和算法思维有关。尤其是在高频查询场景下,真正决定性能的,往往不是你有没有缓存,而是你有没有把数据按照访问方式提前组织好。

我自己在实际工作里,用得最多的一类"算法思想",其实就是四个字:

空间换时间。

这篇文章就结合一个很真实的业务场景,聊聊我是怎么通过本地缓存 + 多索引 Map / Set 的方式,把高频查询从"反复遍历"优化成"直接命中"的。

业务背景:很多地方都要查同一批设备映射

在我们的系统里,有一类数据属于非常典型的基础映射数据,比如设备映射关系。

这些数据本身不复杂,但会被很多业务模块反复使用。不同的地方,会根据不同的字段去查询同一批设备数据,比如:

  • 根据 cameraSn 查询设备映射信息
  • 根据 pilotSn 查询设备映射信息
  • 判断某个 dockSn 是否存在
  • 根据 cameraSn 获取文件存储路径
  • 获取当前在线设备对应的厂家类型集合

单看某一个查询,好像都不复杂,甚至可以说很普通。

但问题在于,这类查询不是偶尔发生一次,而是会在系统里被很多地方高频调用。只要业务一多、调用一多,这种"基础查询"就会慢慢变成性能瓶颈。

这个时候,问题的本质就不再是"查一条设备信息",而是:

同一批设备映射数据,会被系统按多种维度、高频率地重复查询。

一旦意识到这一点,你就会发现,这已经不是一个简单的业务代码问题了,而是一个很典型的数据组织问题。

最直观的做法:实时查远程服务,或者本地遍历列表

面对这种需求,最直接的思路通常有两种。

第一种,是每次需要的时候都去调用远程映射服务,查最新的数据。

这种方式的优点是简单,数据也比较新。但缺点也很明显:每次查询都依赖远程接口,调用一多,性能压力和稳定性压力都会上来。

第二种,是把远程数据拉到本地缓存起来,避免每次都调接口。

乍一看,这好像已经是一个不错的优化了。但如果本地只是简单缓存一份 List<DeviceMappingVo>,然后每次查询时再去遍历,其实问题并没有真正解决,只是从"远程查找慢"变成了"本地遍历慢"。

例如下面这种写法,就很常见:

java 复制代码
List<DeviceMappingVo> deviceList = remoteService.selectAll();

// 按 cameraSn 查
for (DeviceMappingVo device : deviceList) {
    if (cameraSn.equals(device.getCameraSn())) {
        return device;
    }
}

// 按 pilotSn 查
for (DeviceMappingVo device : deviceList) {
    if (pilotSn.equals(device.getPilotSn())) {
        return device;
    }
}

// 判断 dockSn 是否存在
for (DeviceMappingVo device : deviceList) {
    if (dockSn.equals(device.getDockSn())) {
        return true;
    }
}

// ...

这段代码在数据量小、调用频率低的时候没有问题,甚至还挺直观。

但如果系统里很多地方都在反复做这样的查找,那问题就来了:

  • 每次都要遍历同一批数据
  • 查询维度一多,遍历逻辑会越来越多
  • 调用频率越高,本地查找成本越明显
  • 同一批数据被重复扫描很多次

也就是说,哪怕你已经做了"缓存",如果缓存里的数据结构不匹配实际访问方式,性能提升也是有限的。

问题本质:不是有没有缓存,而是缓存怎么组织

后来我慢慢意识到,这类问题真正要优化的,不是"要不要缓存",而是:

缓存里的数据,到底应该怎么组织,才能匹配后续的查询路径?

因为系统里并不是只存在一种查询方式。

有的地方按 cameraSn 查。

有的地方按 pilotSn 查。

有的地方只关心某个 dockSn 是否存在。

有的地方还要根据设备信息提前算出对应的文件路径。

如果只是把远程返回的原始列表原封不动地放进内存,那么每一次查询,本质上仍然是在做线性扫描。

这种缓存,只解决了"远程调用"的问题,却没有解决"本地查找"的问题。

真正更合理的思路应该是:

同一批数据拉回来之后,不只是存起来,而是按照不同的访问方式,提前组织成不同的索引结构。

这时候,缓存才真正开始发挥价值。

优化思路:同一批数据,按不同查询路径提前建索引

远程设备映射服务

selectAll()
原始设备数据

List<DeviceMappingVo>
构建多索引缓存
cameraSn -> DeviceMappingVo
pilotSn -> DeviceMappingVo
cameraSn -> path
dockSn Set
pilotSn Set
按 cameraSn 查询

直接命中
按 pilotSn 查询

直接命中
按 cameraSn 取路径

直接命中
判断 dockSn 是否存在

直接命中
判断 pilotSn 是否存在

直接命中
优化前:多次遍历 List,查询慢
优化后:空间换时间,查询直接命中

既然后续会按多种字段去查同一批设备映射数据,那最自然的做法就是:

  • cameraSn 建一张索引表
  • pilotSn 再建一张索引表
  • 需要做存在性判断的数据,直接放进 Set
  • 需要反复计算的衍生结果,提前算好放进缓存

这样一来,后续不同业务的查询,就不需要再反复遍历原始列表了,而是可以直接走对应的索引结构。

例如可以提前构建这些缓存:

  • cameraSn -> DeviceMappingVo
  • pilotSn -> DeviceMappingVo
  • cameraSn -> path
  • pilotSn Set
  • dockSn Set

对应的简化代码大概是这样:

java 复制代码
Map<String, DeviceMappingVo> deviceMap = new HashMap<>();
Map<String, DeviceMappingVo> pilotMap = new HashMap<>();
Map<String, String> pathByCameraSnMap = new HashMap<>();
Set<String> pilotSnSet = new HashSet<>();
Set<String> dockSnSet = new HashSet<>();

for (DeviceMappingVo device : deviceList) {
    // 按 cameraSn 建索引
    deviceMap.put(device.getCameraSn(), device);

    // 按 pilotSn 建索引
    pilotMap.put(device.getPilotSn(), device);

    // 提前构建查询时要用到的路径
    pathByCameraSnMap.put(device.getCameraSn(), buildPath(device));

    // 只做存在性判断的数据,放到 Set
    pilotSnSet.add(device.getPilotSn());
    dockSnSet.add(device.getDockSn());

    // ...
}

这一步做完之后,后续查询方式就完全变了。

以前是"拿到一批数据,然后一遍遍遍历去找"。

现在是"同一批数据提前建好多张索引表,查询时直接命中"。

这两种写法看起来只是代码形式不一样,但背后的思路差别其实很大。

前者是把查找成本分散到每一次调用里。

后者是把这些成本前移,在缓存刷新时一次性处理掉。

这就是很典型的:

用更多空间,换更少时间。

查询方式变了,后续代码也会简单很多

当缓存里的数据已经按不同查询路径提前组织好之后,后续业务代码其实会变得非常直接。

比如:

java 复制代码
public DeviceMappingVo getByCameraSn(String cameraSn) {
    return deviceMap.get(cameraSn);
}

public DeviceMappingVo getByPilotSn(String pilotSn) {
    return pilotMap.get(pilotSn);
}

public boolean existsDockSn(String dockSn) {
    return dockSnSet.contains(dockSn);
}

public String getPathByCameraSn(String cameraSn) {
    return pathByCameraSnMap.getOrDefault(cameraSn, "");
}

这类代码的好处,不只是"写起来更短"这么简单。

更重要的是,它把原来分散在各个业务里的遍历逻辑,统一收敛成了固定的查询入口。后面谁要用这批设备映射数据,不需要再关心底层怎么查,只需要调用对应的方法即可。

这样做至少有几个明显好处:

  • 查询性能更稳定,不再随着列表大小线性增长
  • 业务代码更简洁,不用到处重复写遍历判断
  • 不同查询方式各自有明确入口,代码可维护性更好
  • 后续如果缓存结构要调整,影响范围也更可控

很多时候,性能优化和代码可维护性并不是冲突的。

如果数据结构设计得合适,往往两边都会一起受益。

这件事的本质,其实就是空间换时间

回头看这个方案,你会发现它做的事情其实并不复杂:

  • 原始数据还是那一批
  • 没有引入什么特别高深的算法
  • 只是把这批数据按照不同用途,多存了几份索引结构

但恰恰就是这个动作,带来了很大的变化。

原来每次查询,都要重新在列表里找一遍。

现在是提前把查找路径准备好,真正查询时直接命中。

这背后的思想,就是非常典型的 空间换时间

为了让后续查询更快,我愿意付出这些额外成本:

  • 多维护几张 Map
  • 多维护几个 Set
  • 多占用一些内存
  • 在缓存刷新时多做一次预处理

换来的收益是:

  • 后续查询不再反复遍历
  • 高并发下查询压力更小
  • 远程服务调用次数明显减少
  • 系统整体响应会更稳定

很多人一说到算法,容易想到二分、动态规划、最短路径这些东西。

但在真实业务开发里,更常见的情况是:

你并没有手写复杂算法,但你已经在用数据结构和复杂度思维解决问题了。

像这种"提前建索引""预先组织数据""让查询直接命中"的设计,本质上也是算法思维的一部分。

只缓存一份 List,和建立多索引缓存,差别到底在哪

我觉得这一点特别值得单独拎出来讲,因为很多时候我们以为自己已经做了缓存,实际上只是做了"远程结果暂存"。

比如只缓存一份 List<DeviceMappingVo>,它解决的是这个问题:

  • 不用每次都调用远程服务了

但它没有解决这个问题:

  • 本地如何高效查找

也就是说,这种缓存更多只是"减少网络开销",并没有真正优化"查询路径"。

而多索引缓存解决的是另外一个层面的问题:

  • 按什么字段查,就提前建什么索引
  • 谁需要存在性判断,就提前放进 Set
  • 谁需要衍生结果,就提前算好

所以两者最大的差别,不是"有没有缓存",而是:

你缓存的是原始数据,还是已经为查询准备好的数据结构。

这也是我后来越来越强烈的一个感受:

真正拉开查询性能差距的,往往不是有没有缓存,而是你有没有按访问方式把数据提前组织好。

工程上还要补的一步:缓存不仅要快,还要稳

当然,真实项目里只把 MapSet 建出来还不够,还要考虑缓存怎么更新。

因为设备映射关系不是完全不变的,随着设备上下线、替换、绑定关系调整,缓存里的数据也要定期刷新。

这一块在工程实现上,通常会这样做:

  • 定时从远程服务重新拉取一份最新数据
  • 先基于新数据构建新的 MapSet
  • 全部构建完成后,再整体替换旧引用

简化理解大概就是这样:

java 复制代码
public void refreshCache() {
    List<DeviceMappingVo> deviceList = remoteService.selectAll();

    Map<String, DeviceMappingVo> newDeviceMap = new HashMap<>();
    Map<String, DeviceMappingVo> newPilotMap = new HashMap<>();
    Set<String> newDockSnSet = new HashSet<>();

    for (DeviceMappingVo device : deviceList) {
        // 构建新缓存
        newDeviceMap.put(device.getCameraSn(), device);
        newPilotMap.put(device.getPilotSn(), device);
        newDockSnSet.add(device.getDockSn());

        // ...
    }

    // 一次性替换
    this.deviceMap = newDeviceMap;
    this.pilotMap = newPilotMap;
    this.dockSnSet = newDockSnSet;
}

这里我不展开讲并发细节,但至少有一个思路是明确的:

不要一边清空旧缓存一边往里塞新数据,而是先把新缓存完整准备好,再整体替换。

这样查询线程要么看到旧数据,要么看到新数据,不容易读到一个"更新到一半"的状态。

所以你会发现,一个看起来只是"加缓存"的事,真正落到工程里,其实同时包含了几个层面的思考:

  • 用什么结构存
  • 怎么让查找更快
  • 怎么让缓存刷新更稳
  • 怎么让业务使用更统一

总结

这件事给我最大的一个感受是:

很多时候,我们在业务开发里并不是没有用到算法,而是已经在用了,只是名字不叫算法。

我们把它叫成了:

  • 缓存
  • 索引
  • 预计算
  • 映射表

但如果往底层看,它们背后其实都是同一种思路:

根据数据的访问方式,提前组织结构,用更多空间换更少时间。

在这个设备映射场景里,真正有价值的优化,不只是把远程数据拉到本地,而是把同一批数据提前组织成多张 MapSet,让后续不同维度的查询都能直接命中。

所以如果以后再遇到类似问题,我觉得很值得先问自己一句:

我现在优化的,到底只是"少调一次接口",还是已经真正把查询路径优化掉了?

很多性能差距,答案就藏在这一步里。

相关推荐
好家伙VCC2 小时前
# React发散创新:从状态管理到自定义Hook的极致实践与性能优化在现代前端开发
java·javascript·python·react.js·性能优化
Irissgwe2 小时前
redis之持久化
数据库·redis·缓存
eLIN TECE2 小时前
Redis重大版本整理(Redis2.6-Redis7.0)
java·数据库·redis
apollowing2 小时前
启发式算法WebApp实验室:从搜索策略到群体智能的能力进阶(三十)
算法·启发式算法·web app
花千树-0102 小时前
两行注解把企业 RPC 接口变成 AI 工具
java·rpc·langchain·react·function call·ai agent·mcp
田野追逐星光2 小时前
C++继承 -- 讲解超详细(上)
c++·算法
迷藏4942 小时前
**绿色AI:用Python构建节能型机器学习模型的实践与优化策略**在人工智能飞速发展的今天,模型训练和
java·人工智能·python·机器学习
juniperhan2 小时前
Flink 系列第13篇:Flink 生产环境中的并行度与资源配置
java·大数据·数据仓库·分布式·flink
StackNoOverflow2 小时前
SpringCloud 声明式服务调用 —— Feign 全面解析(入门 + 原理 + 优化)
后端·spring·spring cloud