请求合并的核心逻辑是让同一时刻回源查同一个Key的请求排队等待,把多次重复的数据库查询压成一次,穿透量自然就降下来,缓存命中率也随之回升。这套技术不改变缓存淘汰策略,也不依赖预热脚本,纯粹从请求入口做收敛,适用于读多写少、热点集中的业务场景。
热点Key请求合并方案:缓存穿透解决的核心逻辑
缓存体系里有一个不成文的共识:命中率是缓存的命根子,但真正拖垮命中率的不是普通发散流量,而是热点Key的集中穿透比如秒杀商品详情、微博热搜榜、爆款文章阅读数,当几十万个请求同时打到一个还没缓存的Key上,每一个请求都会穿透到后端数据库,内存缓存层形同虚设。
请求合并的思路很直接:在缓存层和数据库之间加一道调度逻辑,第一个请求发现Key不存在,立即去数据库加载数据;后续同时到达的相同Key请求不再各自回源,而是阻塞等待第一个请求的结果返回。第一个请求是探路者,其余请求是跟随者,数据库的压力从乘以N降到了加一。
什么样的场景真正需要请求合并
不是所有缓存穿透都能靠合并解决,需要先看穿透的性质:
- 冷启动穿透:系统重启或缓存Key批量过期,大量请求打向数据库,请求合并可以显著缓解,因为缓存Miss是暂时性的,合并窗口内数据就能加载完毕。
- 恶意攻击穿透:攻击者故意构造不存在的Key,例如查询不存在的用户ID,这类请求合并效果有限每条请求的Key都不同,合并无从谈起,需要配合布隆过滤器或参数校验。
- 高并发热点穿透:单一或少数几个Key被高频访问,缓存一旦过期重建成本极高,这是请求合并的最佳适用场景。
判断标准就一条:Miss的Key是否高度集中,如果Miss请求均匀分布在大量不同Key上,合并等于白做。
合并窗口是怎么控制等待时长的
请求合并的等待时间不能无限长,否则下游数据库慢查询会拖垮整个调用链路,实际落地时,常用的窗口策略是:
- 时间窗口合并:按毫秒级时间片(比如5ms、10ms)切割请求流,窗口内的相同Key请求共享一次回源,窗口大小直接决定数据库压力与请求延迟的平衡。
- 信号量限制:对每个Key的并发回源数做限制,Signal时容量设为1,其余请求阻塞或快速失败返回兜底值。
- 超时熔断:等待时间超过阈值(如200ms)直接抛出降级响应,避免线程池耗尽,业界主流实践是配合
Future.get(timeout)控制阻塞上限。
关键细节在于:合并逻辑本身不能成为新的瓶颈,一般用ConcurrentHashMap存储Key对应的Future或CompletableFuture,请求到达时先检查Map中是否已有进行中的加载任务,有则等待该任务,没有则创建新任务并放入Map,任务完成后从Map中移除,保证下一个周期的请求能触发新的回源。

缓存击穿解决方案对比:三种主流技术的取舍
请求合并不是唯一的防击穿手段,它和加锁、互斥、逻辑过期经常放在一起比较,这块在缓存架构设计中一直有争论,实际选型要看团队对一致性和延迟的容忍度。
| 方案 | 数据库压力 | 响应延迟 | 实现难度 | 一致性风险 |
|---|---|---|---|---|
| 请求合并 | 极低(N次变1次) | 首个请求稍高 | 中 | 无 |
| 分布式锁(Redis SETNX) | 较低(N次变1次) | 等待锁的请求偏高 | 低 | 锁过期导致重复回源 |
| 逻辑过期(不物理删除) | 低(回源异步) | 基本无感 | 中高 | 短暂返回旧数据 |
请求合并和分布式锁的区别在哪儿
分布式锁的关键在于“互斥”,请求合并的关键在于“共享”,加锁方案下,抢到锁的线程回源数据库,抢不到的线程自旋等待或直接返回,线程资源被白白占用,请求合并则是让所有相同Key的请求复用同一个异步任务,等待是基于回调而不是轮询,线程会阻塞但不会做CPU空转。
以Redis热点Key为例,本地请求合并配合进程内锁(如Java的synchronized或ReentrantLock)效果更佳,进程内合并属于JVM级别的收敛,请求到达本机时已按Key聚合,对同机多线程场景特别有效,如果业务是集群部署,单机合并只能挡住本机的穿透量,跨节点的重复回源还需要靠分布式锁或者建议直接上Redis本身的HotKey探测和本地缓存。
为什么请求合并不适用于写操作
请求合并的前提是“读多写少”,而且要求数据在短时间内不会频繁变化,如果核心数据写入频率较高,合并反而会放大一致性问题多个请求等待一个加载结果,期间任何写操作都可能让返回的旧缓存值成为脏数据。
行业共识认为:缓存写操作应该直接穿透,读操作才做合并,如果必须在写多的场景用请求合并,要额外加版本号或更新时间戳校验,在返回前比较数据新旧,这个复杂度一般不建议引入。
请求合并如何避免缓存雪崩:从单Key到多Key的防护
缓存雪崩往往同时涉及多个Key的批量过期,这时候单Key合并不够用,需要考虑的是如何在“同一时刻”保护多个Key的回源。
批量过期场景下的合并策略
当大量Key在同一秒过期时,请求合并仍然有效,但需要把合并维度从“单Key”提升到“分片”或“业务前缀”,具体操作建议:
- 按业务前缀分组:例如
user:{id}的所有Key共享一个回源协调器 - 过期时间加随机偏差:语义上不算合并,但能有效降低并发Miss的峰值
- 双层缓存架构:L1用本地Caffeine(存活几十秒),L2用Redis,请求合并放在Redis层,Caffeine命中时根本不会走到合并逻辑,本地层的过滤效果更明显

防击穿的兜底策略
请求合并解决的是“查询不存在缓存”的问题,但数据库一旦挂掉,合并逻辑会让所有请求都卡在同一个等待任务上,所以兜底策略必须配套:
- 空值缓存:合并加载的结果如果是空数据,也要写入缓存(短TTL),防止同一Key反复触发合并任务形成击穿。
- 限流降级:当数据库响应时间超过预设阈值,合并窗口直接缩短,同时返回预置的降级JSON或默认值,避免线程阻塞池化。
- 本地熔断标记:如果某个Key连续回源失败(例如数据库连接异常),可以标记该Key为不可用,短时间内不再触发回源,同步返回快速失败。
一个实际的前置条件是:合并要生效,缓存Key的设计必须极其稳定,如果Key生成逻辑里带时间戳或用户ID的随机参数,请求无法聚合,合并就形同虚设,建议在中间件或网关层解析并规范化Key前缀,让同义请求指向同一Key。
Java与Redis环境中请求合并的实操步骤
假设业务使用Spring Boot框架,Redis缓存访问使用StringRedisTemplate,需要处理一个商品详情的热点Key查询,以下操作可直接复用。
单机版本地请求合并实现
private ConcurrentHashMap<String, CompletableFuture<Object>> queryMap = new ConcurrentHashMap<>();
public Object getProductDetail(String productId) {
String cacheKey = "product:detail:" + productId;
Object value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return value;
}
// 检查是否已有合并任务
CompletableFuture<Object> existingTask = queryMap.get(cacheKey);
if (existingTask != null) {
return existingTask.join(); // 等待已有任务的结果
}
// 创建新的回源任务
CompletableFuture<Object> newTask = CompletableFuture.supplyAsync(() -> {
Object dbValue = queryFromDatabase(productId);
if (dbValue != null) {
redisTemplate.opsForValue().set(cacheKey, dbValue, 30, TimeUnit.SECONDS);
}
return dbValue;
});
CompletableFuture<Object> race = queryMap.putIfAbsent(cacheKey, newTask);
if (race == null) {
return newTask.join();
} else {
return race.join();
}
}
private Object queryFromDatabase(String productId) {
// 数据库查询逻辑
return null;
}
需要注意的关键点是queryMap的清理时机。合并任务执行后必须从Map中移除Key,否则下一个缓存过期周期会直接复用已过期任务的引用,常见做法是在newTask的whenComplete回调中移除。
集群环境下的合并扩展方式
单机合并无法避免跨节点的重复回源,解决思路有两种:
- Redis分布式锁+回调:多个节点同时Miss时,竞争一把锁,抢到锁的节点回源,其余节点等待锁释放后重新读缓存,这里的等待不是自旋,而是轮询Redis缓存是否被写入,轮询间隔建议控制在20-50ms。
- 请求染色与代理层合并:在微服务网关层按
Cache-Key做哈希分组,将同一Key的请求路由到同一后端节点,变相利用节点内的合并能力,这种方式实现成本高,但能彻底消除跨节点重复回源。

落地层面的经验是:约80%的场景用单机合并+短TTL缓存足够,只有当单个Key的并发QPS超过单机承载能力时才需要引入分布式锁,如果QPS已经高到打爆单机,优先考虑本地缓存分层而不是复杂化合并逻辑。
请求合并与缓存预热怎么配合使用效果更好
预热能减少缓存Miss总量,但无法应对瞬时高峰,请求合并不能完全替代预热,两者搭配使用能覆盖绝大多数穿透场景。
预热的常规做法:启动时从数据库加载热点Key列表,提前写入缓存,但预热数据如果和实际业务脱节,会白白占用内存,更合理的思路是根据请求合并组件的命中率统计动态调整预热列表哪些Key在合并窗口中反复出现,说明存在竞争,把它们写入预热队列,下一次重启后提前加载。
统计方式:在合并逻辑中每次回源时计数,按分钟滑动窗口统计Top N Key,同步到管理后台,超过内存阈值时自动淘汰不常用的Key,这套机制和热点探测没有本质冲突,热点的识别天然就是通过Miss请求的频率来判定的。
整合后的建议架构
- 底层用Caffeine做本地缓存(设置极短过期,比如5秒)
- 中间用Redis做分布式缓存(TTL 30-60秒)
- 回源层使用请求合并组件负责并发收敛
- 后台定时任务扫Redis的Miss日志,动态更新本地的热点Key预热列表
这套架构下命中率提升主要来自两层:本地缓存分担了相当一部分热点流量,加上请求合并减少了回源请求数量,最终的效果是数据库只承担极小比例的真正冷查请求。
热点Key请求合并方案常见问题解答
请求合并会造成缓存和数据库的不一致吗?
如果业务要求强一致(如订单状态、余额变更),请求合并不适合做主查询路径,合并过程中有一个短暂的时间窗,旧数据可能被返回,适合合并的场景都是弱一致业务,如用户主页、商品列表、资讯详情,若必须做严格一致,建议走Cache Aside模式(先更新数据库,再删除缓存),并缩短缓存的TTL。
请求合并能解决所有缓存击穿问题吗?
不能,请求合并是针对“集中回源”的治理,对随机Key攻击基本无效,对写热点也无效,它和布隆过滤器、空值缓存、分布式锁是互补关系,不是替代关系,多数架构师会把请求合并作为热点Key降级的第一道防线,再配合限流和熔断,形成完整的缓存穿透防护体系。
请求合并的落地难度不大,但从数据看,真正把它用好的团队并不多,大部分实现忽略了合并窗口的清理和超时控制,核心原则只有一条:合并的是并发请求,保护的是后端资源,牺牲的是一点点响应延迟,换来的却是稳定性和命中率的双重收益。