在高并发系统中,缓存是保护数据库的第一道防线,但缓存失效的瞬间,成百上千个请求可能同时穿透缓存直接打到数据库,这就是典型的缓存击穿问题。请求合并Batch是一种被广泛验证的解决方案:将同一时刻针对同一key的多个请求合并为一次真实的回源查询,其余请求等待并共享这次查询的结果。Redis凭借其单线程原子操作和丰富的命令支持,是实现请求合并的理想组件。本文将从问题成因、实现方案、代码实战和进阶优化几个维度,完整讲解如何用Redis做缓存请求合并。

一、为什么需要请求合并:缓存击穿的成因分析
缓存击穿通常发生在热点数据过期的时刻。假设某个商品详情缓存的过期时间到了,恰好此时有1000个请求同时访问该商品。由于缓存中已经没有数据,这1000个请求都会执行“查缓存未命中、回源数据库、写入缓存”这套流程,数据库瞬间承受1000倍于平时的查询压力。
有人会想到加互斥锁,只允许一个线程去查库,其他线程等待。这个思路在单体应用中确实有效,可以用JVM内的锁直接实现。但一旦系统部署了多个实例,每个实例各自持有本地锁,同一个key仍然会被每个实例各查一次库,合并效果大打折扣。这就需要一个跨实例的、全局可见的互斥机制,而Redis正好扮演这个角色。
请求合并的核心目标可以归纳为三点:第一,同一时刻对同一key只发起一次回源查询;第二,其他请求能快速拿到这次查询的结果而不是各自轮询数据库;第三,查询失败时要有兜底手段,避免所有等待方一起失败。理解了这三点,再看下面的实现方案就会清晰很多。
二、基于Redis分布式锁的单飞方案
最经典的实现是SingleFlight模式结合Redis分布式锁。流程是:请求先查Redis缓存,未命中后尝试用SET key value NX EX seconds命令抢占锁;抢到锁的线程负责回源数据库并写回缓存,随后释放锁;没抢到锁的线程则短暂休眠后重试读取缓存。
public String queryWithMerge(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试抢占回源锁,锁的值为唯一标识,用于安全释放
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
if (locked) {
try {
// 双重检查:可能别的线程刚好已经写回缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 只有一个线程真正回源数据库
value = queryFromDatabase(key);
redis.set(key, value, "EX", 300);
return value;
} finally {
releaseLock(lockKey, requestId);
}
} else {
// 未抢到锁,短暂等待后重试读缓存
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return queryWithMerge(key);
}
}释放锁必须用Lua脚本保证“判断持有者”和“删除锁”的原子性,否则可能出现A线程的锁过期后被B线程持有,A再去删除就会误删B的锁。
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end这种方案的优点是实现简单、语义清晰,几乎所有Redis客户端都支持。缺点是等待方采用自旋重试,会带来一定的延迟抖动;如果回源查询耗时较长,重试次数增多还会增加Redis的读压力。因此在高并发极端场景下,通常需要配合下面的PubSub广播方案一起使用。
三、基于PubSub广播的等待唤醒方案
自旋重试的问题在于等待方不知道结果什么时候就绪。Redis的发布订阅机制可以解决这个问题:抢到锁的线程在查询完成后,通过PUBLISH命令向特定频道发布结果就绪的消息,所有等待中的线程订阅该频道,收到消息后立刻去读缓存,一次唤醒所有等待方。
具体做法是每个key对应一个频道,例如channel:ready:user:1001。未抢到锁的线程执行SUBSCRIBE订阅该频道并阻塞等待,持有锁的线程写完缓存后执行一次PUBLISH。这样等待方从轮询变成了事件驱动,延迟更低,Redis的读压力也大幅下降。
// 等待方:订阅就绪频道并阻塞等待
JedisPubSub listener = new JedisPubSub() {
@Override
public void onMessage(String channel, String message) {
latch.countDown(); // 收到就绪消息,唤醒等待线程
}
};
CountDownLatch latch = new CountDownLatch(1);
new Thread(() -> jedis.subscribe(listener, "channel:ready:" + key)).start();
boolean ok = latch.await(3, TimeUnit.SECONDS); // 设置超时兜底
if (ok) {
return redis.get(key); // 结果已写入,直接读取
}
// 超时则降级为直接回源,避免无限等待需要注意的是,PubSub消息不具备持久化能力,如果等待方订阅晚于发布,消息就丢失了。因此必须设置等待超时,超时后降级为直接回源或返回兜底数据。对于可靠性要求更高的场景,可以考虑用Redis Stream替代PubSub,Stream支持消费组和消息回溯,不会丢消息,代价是实现复杂度略高。
四、进阶策略:合并窗口与批量回源
前面讨论的都是“同一key的请求合并”,另一类场景是“不同key的批量合并”。比如商品列表页需要查询500个商品的信息,逐个查询会产生500次Redis往返。利用Redis的管道或者MGET命令,可以把500次网络往返压缩为1次,这是Batch在缓存层面最直接的价值。
List<String> keys = skuIds.stream()
.map(id -> "sku:" + id)
.collect(Collectors.toList());
// MGET一次性批量获取,1次网络往返代替N次
List<String> cached = redis.mget(keys.toArray(new String[0]));
// 找出未命中的key,合并成一次数据库 IN 查询
List<String> missedIds = new ArrayList<>();
for (int i = 0; i < cached.size(); i++) {
if (cached.get(i) == null) {
missedIds.add(skuIds.get(i));
}
}
if (!missedIds.isEmpty()) {
List<Sku> fromDb = skuMapper.selectBatchIds(missedIds);
// 批量写回缓存
}合并窗口是另一种进阶手段:把短时间内的请求先积攒到一个队列中,比如50毫秒,然后统一处理。这种方式能让批量大小更可控,数据库侧可以用一条IN语句一次查回。窗口的时长需要权衡:窗口越长合并率越高,但请求的额外延迟也越大,一般经验值在20毫秒到100毫秒之间,具体应根据业务的P99延迟要求来调整。
最后还要考虑缓存写入的原子性问题。批量写回时如果使用循环SETEX,中途失败会导致部分数据缺失。可以借助PIPELINE把多个写命令打包发送,减少网络开销的同时保证命令按序执行;如果需要严格的事务性,则可以使用MULTI和EXEC,但要清楚Redis事务在执行中途出错时不会回滚已执行的命令,这与关系型数据库的事务语义不同。
五、方案对比与选型建议
单飞加锁方案适合回源耗时较短、并发量中等的场景,实现成本最低;PubSub广播方案适合回源耗时较长、等待方众多的热点key,能显著降低重试风暴;MGET批量合并适合读多写少的列表类查询;合并窗口则适合对延迟不太敏感但对吞吐量要求极高的后台批量任务。
实践中这几个方案往往组合使用:入口层用MGET合并不同key的读请求,未命中的key走单飞加锁回源,热点key再加PubSub唤醒。无论采用哪种组合,都要记得三个兜底措施:锁一定要设置过期时间防止死锁,等待一定要设置超时防止无限阻塞,缓存写不进去时要有降级逻辑防止雪崩。把这些细节处理好,Redis请求合并才能真正成为高并发系统的稳定基石。