发生了什么
某 B 端商城的商品列表,普通用户数据量小、加载正常;但有一类大客户需要展示覆盖全国各地的全量价格数据,量非常大。系统查这批价格时,缓存里把整块数据存成了一个超大 Key,每次读都要把一整坨数据搬出来,很慢。同时代码是按地区一个个串行去查后端、挨个等结果,前端侧接口又配了很短的超时时间。慢缓存加串行多次调用加短超时叠在一起,大客户一打开商城,接口就超时、逐级传导到前端网关,页面直接加载失败。短期靠调长超时止血,长期靠改并行查询加拆分大 Key 治理。
本质
被破坏的约束是缓存和查询要能扛住最坏情况的数据量,而不是只按普通量设计。把海量数据堆进单个大 Key,读一次就得整块加载,数据一大耗时线性上涨;再叠加能并行却写成串行的多次远程调用,耗时进一步累加;而超时阈值是按普通用户的平均耗时拍的,遇到大数据量必然击穿。根子是设计时没有考虑数据规模的长尾,用平均场景的方案去接了极端场景。
下次遇到这些场景要警惕
凡是缓存里存的是随业务规模增长的集合(全量列表、全国价格、某大客户的全部记录),就要警惕大 Key:按维度拆分成多个小 Key、或分页分片存储,避免单次整块加载。多段互不依赖的查询优先并行发起而不是串行等待。超时阈值要按最坏数据量的用户来设,并在压测里专门造最大规模数据的用例,断言 P99 耗时和缓存读取时间在阈值内。上线前对头部大客户这类长尾场景单独评估数据量级,别只用普通账号验收。
下面用代码把三个点讲清楚:串行改并行、大 Key 拆分。
问题版本:逐个城市串行查,每次还读同一个巨大的缓存 Key。
List<Price> queryAllCityPrices(List<String> cityCodes) {
List<Price> result = new ArrayList<>();
for (String city : cityCodes) {
// 串行:一个城市查完才查下一个,N 个城市耗时累加
result.addAll(commodityRpc.queryPrice(city));
}
return result;
}
// 缓存侧:整个全国价格塞进一个大 Key
// key = commodity:allCityPrice value 是几 MB 的大对象,读一次搬一坨
String bigJson = redis.get("commodity:allCityPrice"); // 大 Key,读取慢
修复版本一:互不依赖的多次查询改并行。
List<Price> queryAllCityPrices(List<String> cityCodes) {
List<CompletableFuture<List<Price>>> futures = cityCodes.stream()
.map(city -> CompletableFuture.supplyAsync(
() -> commodityRpc.queryPrice(city), rpcExecutor)) // 并行发起
.collect(Collectors.toList());
return futures.stream()
.flatMap(f -> f.join().stream())
.collect(Collectors.toList());
}
修复版本二:大 Key 拆成按维度的小 Key,只读需要的那部分。
// 旧:一个大 Key 存全国
// redis.set("commodity:allCityPrice", allCitiesBigJson);
// 新:按城市拆成小 Key,用哪个城市读哪个,避免整块加载
String price = redis.get("commodity:price:" + cityCode);
// 需要多个城市时用 mget 一次批量取,仍是小 value
List<String> prices = redis.mget(
cityCodes.stream().map(c -> "commodity:price:" + c).toList());
前后端边界:这条主要是后端性能问题,后端的缓存结构(大 Key)和查询方式(串行)导致耗时过长。前端只是把后端超时如实反映成加载失败。前端能做的配合是给列表页做分页或分批加载、加加载态和失败重试,别一次性同步等全量返回;但根因治理在后端:拆大 Key、改并行、合理设超时。