一次设备映射缓存设计:用多索引 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 -> DeviceMappingVopilotSn -> DeviceMappingVocameraSn -> pathpilotSn SetdockSn 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 - 谁需要衍生结果,就提前算好
所以两者最大的差别,不是"有没有缓存",而是:
你缓存的是原始数据,还是已经为查询准备好的数据结构。
这也是我后来越来越强烈的一个感受:
真正拉开查询性能差距的,往往不是有没有缓存,而是你有没有按访问方式把数据提前组织好。
工程上还要补的一步:缓存不仅要快,还要稳
当然,真实项目里只把 Map 和 Set 建出来还不够,还要考虑缓存怎么更新。
因为设备映射关系不是完全不变的,随着设备上下线、替换、绑定关系调整,缓存里的数据也要定期刷新。
这一块在工程实现上,通常会这样做:
- 定时从远程服务重新拉取一份最新数据
- 先基于新数据构建新的
Map和Set - 全部构建完成后,再整体替换旧引用
简化理解大概就是这样:
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;
}
这里我不展开讲并发细节,但至少有一个思路是明确的:
不要一边清空旧缓存一边往里塞新数据,而是先把新缓存完整准备好,再整体替换。
这样查询线程要么看到旧数据,要么看到新数据,不容易读到一个"更新到一半"的状态。
所以你会发现,一个看起来只是"加缓存"的事,真正落到工程里,其实同时包含了几个层面的思考:
- 用什么结构存
- 怎么让查找更快
- 怎么让缓存刷新更稳
- 怎么让业务使用更统一
总结
这件事给我最大的一个感受是:
很多时候,我们在业务开发里并不是没有用到算法,而是已经在用了,只是名字不叫算法。
我们把它叫成了:
- 缓存
- 索引
- 预计算
- 映射表
但如果往底层看,它们背后其实都是同一种思路:
根据数据的访问方式,提前组织结构,用更多空间换更少时间。
在这个设备映射场景里,真正有价值的优化,不只是把远程数据拉到本地,而是把同一批数据提前组织成多张 Map 和 Set,让后续不同维度的查询都能直接命中。
所以如果以后再遇到类似问题,我觉得很值得先问自己一句:
我现在优化的,到底只是"少调一次接口",还是已经真正把查询路径优化掉了?
很多性能差距,答案就藏在这一步里。