缓存击穿是缓存系统中最棘手的场景之一。一个被高频访问的热点Key在缓存中过期消失,此时恰好有大量并发请求同时查询这个Key,由于缓存未命中,所有请求都会直接打到数据库上。数据库的承受能力有限,瞬时压力过大就会导致连接耗尽、响应变慢,甚至引发整个服务雪崩。解决这个问题的关键思路就是让重建缓存的动作只由一个请求完成,其他请求要么等待结果,要么快速返回旧值或默认值。

实现这个目标最常用的手段就是分布式锁。在分布式环境中,单机锁无法协调多个服务实例,而Redis天然支持原子操作,非常适合用来构建分布式锁。通过在重建缓存前获取一把针对该Key的锁,可以确保同一时刻只有一个线程去查询数据库并回写缓存,其他线程则进入等待或重试流程。这样数据库的负载就从并发峰值降到了单次查询,大大提升了系统的稳定性。
一、缓存击穿的本质:为什么热点过期会压垮数据库
缓存击穿和缓存穿透、缓存雪崩经常被混淆。穿透指的是查询一个根本不存在的Key,导致每次请求都打到数据库;雪崩指的是大量Key在同一个时间窗口内集中过期,造成数据库压力陡增。而击穿聚焦在单个热点Key上,这个Key本身是存在的,只是刚好在某个时刻过期了。由于热点Key的访问频率极高,可能每秒有数千甚至上万次请求,一旦缓存失效,这些请求会像洪水一样涌向数据库。
举一个典型的例子:某个爆款商品的详情页信息被缓存在Redis中,过期时间设置为1小时。在过期前,所有请求都能通过缓存快速返回。当过期时间到达的那一刻,几十个用户同时刷新页面,此时缓存中已经没有数据,每个请求都会执行数据库查询。如果数据库每秒只能承受几百次查询,却被瞬间灌入几千次请求,连接池很快会被占满,后续请求只能排队等待,最终导致整个商品服务不可用。
缓存击穿的危害并不仅仅是数据库压力增大。当数据库响应变慢后,请求线程会长时间占用Web服务器的连接资源,连接数耗尽又会导致其他接口也无法正常响应,形成连锁故障。因此,在缓存设计阶段就必须考虑热点Key过期时的并发控制策略。分布式锁重建缓存正是解决这一问题的经典方案之一。
二、分布式锁重建缓存的核心流程与代码实现
使用Redis实现分布式锁来重建缓存的基本流程很清晰:请求先查缓存,如果缓存命中直接返回;如果未命中,则尝试获取一个针对该Key的分布式锁。获取锁成功的请求负责查询数据库、写入缓存并释放锁;获取锁失败的请求可以选择短暂等待后重新查缓存,或者直接返回一个降级结果。整个流程的核心是保证只有一个请求能进入数据库查询阶段。
下面用Java代码展示基于Jedis的简单实现。使用Redis的set命令配合NX和PX参数可以原子性地完成锁的获取和过期时间设置,避免先set再expire带来的原子性问题。
public String getData(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
try {
// 尝试获取分布式锁,NX表示不存在才设置,PX表示设置毫秒级过期时间
boolean locked = redis.set(lockKey, requestId, "NX", "PX", 10000);
if (locked) {
// 双重检查:获取锁后再次查询缓存,防止其他线程已经完成重建
value = redis.get(key);
if (value != null) {
return value;
}
// 查询数据库
value = db.query(key);
// 写入缓存,设置过期时间
redis.setex(key, 3600, value);
return value;
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(50);
return getData(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
// 释放锁时校验requestId,防止误删其他线程的锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
}
}
这段代码有几个关键细节需要注意。首先,锁的value必须是一个唯一标识,比如UUID,这样在释放锁时可以校验这把锁是否还属于当前线程。如果不做校验,可能出现A线程持有锁超时被自动释放,B线程获取到锁后,A线程执行完数据库查询再去释放锁,结果把B线程的锁误删了。使用Lua脚本可以保证读取value和删除key这两个操作在Redis中原子执行。
其次,锁的过期时间设置需要谨慎。如果设置得太短,数据库查询还没完成锁就过期了,其他请求会重新获取锁并再次查询数据库,导致并发重建;如果设置得太长,持有锁的线程宕机后其他请求需要等待很久才能获取锁。通常可以设置一个相对合理的初始超时时间,并配合锁续期机制来动态延长。
三、双重检查与锁续期:避免锁失效导致并发重建
双重检查是分布式锁重建缓存中非常重要的一步。在上面的代码中,获取锁成功后并没有直接查询数据库,而是再次查询了一次缓存。这是因为在等待获取锁的过程中,前一个持有锁的线程可能已经完成了数据库查询并写入了缓存。如果跳过这次检查,第二个获取到锁的线程就会重复查询数据库,失去分布式锁保护的意义。双重检查能有效降低数据库的无效压力。
锁续期是另一个需要重点关注的环节。如果数据库查询耗时超过锁的过期时间,锁会自动释放,其他请求就有机会进入临界区。为了解决这个问题,可以使用Redisson这类客户端提供的看门狗机制,它会定期检查锁是否仍然被当前线程持有,并自动延长过期时间。下面展示使用Redisson的写法,代码更加简洁且具备锁续期能力。
public String getDataWithRedisson(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
RLock lock = redisson.getLock("lock:" + key);
lock.lock(10, TimeUnit.SECONDS);
try {
// 双重检查
value = redis.get(key);
if (value != null) {
return value;
}
value = db.query(key);
redis.setex(key, 3600, value);
return value;
} finally {
lock.unlock();
}
}
Redisson的lock方法默认启动看门狗,每隔一段时间检查锁是否仍然持有,如果持有则自动续期到指定时间,从而避免业务执行时间超过锁过期时间的问题。不过需要注意的是,如果业务逻辑中包含了非常耗时的外部调用,建议评估锁的持有时间是否合理,或者考虑使用逻辑过期方案来降低锁等待时间。
锁的粒度也很关键。分布式锁应该针对具体的缓存Key来设置,而不是使用一把全局锁。如果所有缓存重建都争抢同一把锁,那么不同业务之间会互相阻塞,锁竞争会非常激烈。针对每个Key生成独立的lockKey,可以实现细粒度的并发控制,只有访问同一个热点Key的请求才会争抢同一把锁。
四、缓存重建的替代方案与选型建议
分布式锁重建缓存并不是解决缓存击穿的唯一手段。逻辑过期方案也是一种常见思路:缓存数据本身不设置过期时间,而是额外保存一个逻辑过期时间字段。请求发现数据逻辑过期后,先返回旧数据,同时异步启动一个线程去更新缓存。这种方式不会阻塞用户请求,但需要维护额外的过期字段和异步更新任务,实现复杂度更高。
本地互斥锁适用于单实例服务内的缓存击穿场景。由于不需要跨进程协调,性能比分布式锁更好,实现也简单。但在微服务架构下,多个实例之间无法共享本地锁,仍然可能出现多个实例同时重建缓存的情况。因此,如果服务部署了多个副本,就必须使用分布式锁或者引入集中式的协调机制。
还有一种方案是对热点Key做永不过期处理,配合后台定时刷新逻辑。例如在活动开始前提前预热缓存,并在活动期间通过定时任务每隔几分钟更新一次缓存,避免Key自然过期。这种方案对业务侵入较小,适合可预测的热点数据。对于突发性的热点Key,可以结合限流组件在缓存未命中时限制进入数据库的请求数量。
综合来看,分布式锁重建缓存方案在实现难度、通用性和实时性之间取得了较好的平衡,适合大多数业务场景。如果请求量特别大且允许短暂的数据不一致,逻辑过期方案能提供更好的响应性能;如果服务是单实例且热点不多,本地锁可能更简单高效。
五、总结
缓存击穿是热点数据过期时最容易引发故障的问题之一。通过Redis分布式锁控制缓存重建的并发度,可以有效保护数据库免受瞬时高并发的冲击。实现时需要重点关注锁的唯一标识、原子释放、锁超时与续期、双重检查以及锁粒度等细节,任何一个环节处理不当都可能导致锁失效或并发重建。
实际生产环境中,建议优先使用成熟的分布式锁实现如Redisson,避免自己封装Redis命令时遗漏边界情况。同时,分布式锁只解决了重建过程的互斥问题,还需要结合合理的缓存过期时间、热点数据预热、限流降级等策略,构建完整的缓存稳定性体系。只有将多种手段配合使用,才能在高并发场景下保障系统的平稳运行。