Redis缓存击穿原理与解决方案:互斥锁、逻辑过期与高并发实战
Redis 缓存击穿一到高并发场景就容易翻车。我先说个现象某个商品详情页缓存设了一个小时结果刚好在这个小时的临界点上流量高峰来了几万用户同时刷这个页面缓存里的 key 在这一瞬间全部失效数据库直接被压到报警线。技术上这种“热点 key 过期瞬间大量请求同时绕过缓存直连数据库”的异常就叫缓存击穿。这篇文章适合正在做高并发服务、关注 Redis 缓存治理的开发者看也适合准备面试的同学——击穿是面试里和缓存穿透、雪崩并列的“缓存三座大山”之一。下面我结合线上一线踩坑的经验把击穿的原理、并发模型、主流方案和代码细节一次讲透。1. 缓存击穿不是命中率问题而是瞬时并发能力问题1.1 先把三个“兄弟”概念彻底分清楚缓存击穿Cache Breakdown的定义很明确某个热点 key 过期瞬间大量并发请求同时发现缓存里没有数据于是一起打到数据库数据库连接数、CPU、磁盘压力瞬间飙升严重时直接拖垮整个服务。很多人容易把“击穿”和“穿透”“雪崩”混在一起其实它们是三件完全不同的事。我整理了一张对比表建议直接保存概念触发条件数据特征影响范围缓存穿透请求的 key 在缓存和数据库都不存在大量不存在的 key每次请求都打透DB 被无效查询持续消耗缓存击穿热点 key 过期瞬间数据存在但刚好失效只发生在失效窗口流量集中到同一个 key缓存雪崩大量 key 同时过期或 Redis 宕机多个 key 同时失效全局性压力影响范围最大穿透的本质是“数据本来就不存在”所以解决办法是布隆过滤器或空值缓存雪崩的本质是“批量失效”所以解决办法是过期时间加随机值、多级缓存、集群容灾而击穿的场景是“少而热的 key 在错误的时间点失效”解决方案的核心要围绕这个 key 的重建过程做文章。1.2 什么样的 key 最容易被打穿不是所有 key 都值得做击穿防护。从我的实践看一个 key 要成为“高危 key”至少要满足三个条件。第一访问量极大。比如 Redis 单实例 QPS 排名前几的 key日访问量在百万级以上。双十一的商品详情页、微博热搜榜、直播间秒杀库存都是这类典型。第二重建成本高。如果缓存失效后数据库查询要跨多张表、关联商品、库存、促销信息甚至还要调用第三方接口才能组装出完整结果那一次回填就要 50ms 到 200ms。并发一旦高数据库就扛不住。相反如果回填只是简单查一个字段击穿的影响就小得多。第三更新频率低。低频更新的 key 通常会设置较长的过期时间比如一个小时甚至一天。过期时间越长失效瞬间积累的并发流量就越大。高频更新的 key 因为过期时间短天然把并发分散了反而不容易出现击穿。满足这三个条件的场景最典型的就是“秒杀商品详情页”大促时单个商品 ID 的请求能占到全站流量的很大比例详情页缓存 TTL 设 10 分钟恰好在这 10 分钟临界点碰上流量高峰就是教科书式的击穿事故。2. 从线程模型看根源为什么“缓存 miss 同时到达”如此致命2.1 失效窗口里的完整时间线拆解为了真正理解缓存击穿的解法必须把“失效瞬间”发生的事情拆成时间线来看。假设热点 keyproduct:10086原来有缓存T 时刻过期了。此时同时来了 1000 个请求Redis 虽然是单线程模型但它只保证每个命令按顺序执行。问题是这 1000 个请求来自应用层的 1000 个并发线程它们都独立地向 Redis 发起GET product:10086命令结果自然全部返回nil。这个“全部拿到 nil”是关键中的关键。第一个线程发现没有缓存就去数据库查询花了 80ms 拿到数据再执行SET product:10086 ...回填缓存。但在这一瞬间后续到达的几百个线程同样没有缓存它们不会等第一个线程回填完而是各自并发地去数据库执行同样的 SQL。同一行记录同一个热点数据被并发查了几百次。数据库的连接池瞬间打完慢查询飙升然后拖累整个应用。经常有人问“Redis 不是单线程的吗请求不是排队的吗”这就是最大的误解。Redis 确实单线程处理命令但应用层是多线程并发的。多个线程同时向 Redis 发起读取每个线程各自拿到 nil各自去查库。Redis 的串行只保证命令本身不并发执行并不保证“查缓存、查库、回填缓存”这个完整业务流程是原子的。2.2 核心解法思路把并发重建收敛成串行重建击穿的本质就是在失效窗口内多个请求同时执行了高代价的查询。那解决思路就非常明确收敛——让大量并发请求收敛到“只有一个人去查数据库其他人等待结果或者拿旧数据”。这引出了最经典的互斥锁方案缓存 miss 后先尝试获取一把分布式锁。谁拿到锁谁去数据库查询并回填缓存拿不到锁的请求要么阻塞等待锁释放后再读缓存要么直接返回旧数据做降级。这样数据库同一时刻只有一个请求在查询把“N 次并发查询”降为“1 次查询 N 次短暂等待”。这里有个细节必须说清楚锁到底锁的是什么。锁的对象是“对某个 key 的重建动作”不是全局锁也不是所有 key 共用一把锁。用 Redis 的SET key value NX EX seconds实现锁的 key 一般是lock:product:10086这样的独立命名空间。这样不同 key 的回填互不影响只有同一个热 key 的并发请求才会争抢同一把锁。锁粒度越小性能损耗越小粒度越大保护效果越强但并发能力越差。针对击穿场景锁粒度必须精确到业务 key。3. 四种主流方案对比与选型思路3.1 互斥锁方案一致性最硬互斥锁的思路上面已经说了这里重点看实现要点和风险。伪代码逻辑是这样String value redis.get(key); if (value null) { // 尝试获取重建锁设置过期时间防止死锁 if (redis.set(lockKey, token, NX, EX, 5)) { try { value db.query(key); redis.set(key, value, expire); } finally { redis.release(lockKey, token); } } else { // 拿不到锁休眠一段时间后重读缓存 Thread.sleep(50); value redis.get(key); } } return value;优点非常突出实现直观一致性最好数据库同一时刻只会被一个请求打到对数据新鲜度敏感的业务比如库存、价格非常友好。缺点也明显拿不到锁的请求要阻塞等待极端情况下所有请求排队平均延迟会被拉高。而且如果锁没有设置过期时间或者回填逻辑异常导致锁没释放就会造成大面积阻塞。从我的线上实践来看互斥锁适合回填耗时在 100ms 以内的场景。如果回填要 500ms 甚至更长所有请求都在等锁用户体验会很差这时候要考虑逻辑过期或者后台预热方案。另外互斥锁还有个隐含要求:业务方对 Redis 操作要熟练。锁 key 不能和业务缓存 key 混用锁过期时间不能拍脑袋乱设释放锁必须用 Lua 脚本保证原子性。这些细节我在第 4 节的代码里会全部展开。3.2 逻辑过期方案可用性优先逻辑过期的核心思想很巧妙缓存数据本身不设置物理过期时间而是把过期时间放在 value 里。Redis 层面 key 永远不会被自动删除击穿现象在源头上就不存在了。存储结构大概是product:10086 - {\data\: \商品JSON\, \expireTime\: 1700000000}读取逻辑分四步取到缓存判断 value 里的expireTime是否大于当前时间如果没过期直接返回数据如果过期了尝试获取重建锁。拿不到锁就直接返回旧数据牺牲一致性保证可用性拿到锁就去更新缓存和新的逻辑过期时间。这个方案的精华在于过期瞬间并发请求依旧能拿到旧数据不会全部 miss。数据库的压力不再是“并发齐打”而是“更新 key 的只有一个线程”。缺点也很明显——在后台更新完成之前服务返回的是旧数据。对实时性要求不高的场景比如资讯列表、促销位、排行榜完全够用。我在实际项目里用过一个变体把逻辑过期时间设置成真实业务容忍时间的两倍比如业务上允许数据 5 分钟内不刷新那逻辑过期时间就设 10 分钟。这样就算某一次后台更新失败还有额外的时间窗口兜底。后台任务每分钟扫描一次快要过期的 key实现“提前量刷新”发生陈旧读的概率更低。3.3 永不过期 后台预热这个方案的字面意思是缓存 key 不设置EXPIRE由后台定时任务或异步消息负责定期更新。具体做法有两种常见形态。第一种数据库记录有更新时间字段定时任务每分钟扫一遍最近变更的记录把对应缓存 key 逐个刷新。第二种首次访问时写入缓存并设置普通过期时间同时启动一个延时任务在过期时间到达前的三分之一处自动刷新。优点是彻底没有“过期瞬间”对流量冲击免疫代码简单。缺点是必须保证后台任务的及时性和稳定性如果任务挂了缓存数据会永久停更业务直接拿脏数据。所以这个方案适合数据变更频率可预期、对延迟极度敏感的场景。从维护角度看的推荐做法是“物理过期 后台预热”组合核心 key 平时设置 10 到 30 分钟物理过期后台任务在过期前 1 到 5 分钟主动刷新一次保证 key 永远等不到“被动失效”。预热逻辑放在缓存中间件里对业务透明。这个思路既能避免击穿又能在数据源变化时及时更新缓存也是我认为长期治理最稳的方案。3.4 Singleflight 与应用层合并严格说 Singleflight 不是 Redis 方案而是应用层把“同一个 key 的并发查询”合并成一次。Go 生态里有golang.org/x/sync/singleflightJava 里类似的思路是 Guava 的 Striped 锁或者自研的请求合并器。它的逻辑很有意思同一个 key 的第一个请求来了开始执行慢查询后面相同 key 的请求不立刻执行而是等待第一个请求的结果拿到结果后直接复用。本质上和互斥锁是同一个思想只是不依赖 Redis 做分布式协调而是依赖进程内内存做去重。限制也很明显只有在单机进程内或者所有请求都打到同一实例时才有效。多实例部署时不同实例之间无法共享合并状态该打的数据库还是要打。所以大型分布式系统最终通常还得靠 Redis 锁。4. 实战Java Redis 互斥锁与逻辑过期完整实现4.1 开发环境与依赖配置下面我用 Java 8 Spring Boot 2.x Lettuce 做演示Redis 版本要求 5.0 以上因为需要用到SET NX EX原子命令。依赖就引入 Spring Data Redis 足够dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置文件注意 Lettuce 在高并发下的线程模型是共享的容易出现超时建议把超时时间和连接池参数适当放宽spring: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 54.2 互斥锁基础版SET NX EX 双重检查 原子释放直接上代码每一步都加了注释。这段代码是我在线上验证过的版本不是教学 demo。Component public class SeckillDetailService { Autowired private StringRedisTemplate redisTemplate; private static final String LOCK_PREFIX lock:; private static final Long LOCK_EXPIRE_SECONDS 5L; public String getDetail(String productId) { String cacheKey product:detail: productId; String lockKey LOCK_PREFIX cacheKey; // 1. 先读缓存 String cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return cacheValue; } // 2. 缓存 miss尝试获取重建锁 String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 3. 拿到锁后先做双重检查避免等待期间缓存已被其他线程回填 String cacheAfterLock redisTemplate.opsForValue().get(cacheKey); if (cacheAfterLock ! null) { redisTemplate.delete(lockKey); return cacheAfterLock; } try { String dbValue queryFromDb(productId); redisTemplate.opsForValue().set(cacheKey, dbValue, 30, TimeUnit.MINUTES); return dbValue; } finally { releaseLock(lockKey, requestId); } } else { // 4. 拿不到锁休眠 50ms 后重读缓存 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } String valueAfterWait redisTemplate.opsForValue().get(cacheKey); if (valueAfterWait ! null) { return valueAfterWait; } // 极端情况走递归重试但必须有次数上限防止无限循环 return getDetail(productId); } } private void releaseLock(String lockKey, String requestId) { // Lua 脚本保证“判断 删除”原子性防止误删其他线程的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId); } private String queryFromDb(String productId) { // 模拟慢 SQL耗时约 80ms try { Thread.sleep(80); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return {\id\: productId , \name\: \秒杀商品\}; } }这段代码有三个点值得单独拎出来讲。第一setIfAbsent使用的是SET NX EX原子命令锁本身有 5 秒自动过期避免业务异常造成死锁。5 秒不是拍脑袋定的它必须大于“数据库查询 网络开销 应用 GC 停顿”的最大耗时。我见过有人设成 1 秒结果数据库复杂查询跑 1.5 秒锁先过期了另一个请求立刻拿到同一把锁互斥名存实亡。一般建议设成回填耗时的 5 到 10 倍最低不能小于回填耗时。第二拿到锁之后又做了一次get(cacheKey)双重检查。为什么因为这个线程在抢锁过程中可能锁已经被上一个线程释放并成功回填了缓存。如果没有这步检查拿到锁后直接查 DB就白白做了一次无用查询。这个细节很多网上的示例代码都没写但对性能的影响非常明显。第三释放锁不是简单delete而是用 Lua 脚本判断 value 是否等于自己的requestId相等才删除。这是防误删的经典操作假设线程 A 拿到锁因为一次 Full GC 停顿了 6 秒锁已经在第 5 秒自动过期了线程 B 抢到锁开始回填数据A 恢复后如果直接执行delete(lockKey)就会把 B 的锁删掉B 刚释放锁就有人能抢到锁的保护作用直接失效。加上requestId校验只有持有者自己能释放锁这是标准做法。4.3 重试策略与兜底降级别写成死循环上面代码里拿不到锁的请求sleep(50)后重读缓存如果还是没有就递归调用getDetail。这里有个大坑如果数据库真的挂了或者回填异常缓慢所有拿不到锁的请求都会递归重试线程池很快被打满。我线上就出过类似事故上游数据库锁等待严重大量请求囤积在缓存重试逻辑里Tomcat 线程池被拖垮最后整个服务无响应。后来我做了两个强制限制重试次数上限最多尝试 3 次超过后直接走兜底逻辑重试间隔指数退避50ms、100ms、200ms避免所有线程同时醒来再次争抢。改造后的核心片段public String getDetailWithRetry(String productId, int retryCount) { if (retryCount 3) { return fallbackData(productId); } String cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return cacheValue; } String lockKey LOCK_PREFIX cacheKey; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { String dbValue queryFromDb(productId); redisTemplate.opsForValue().set(cacheKey, dbValue, 30, TimeUnit.MINUTES); return dbValue; } finally { releaseLock(lockKey, requestId); } } else { try { Thread.sleep(50L retryCount); // 50, 100, 200 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getDetailWithRetry(productId, retryCount 1); } }兜底函数返回的可以是上次成功的旧快照也可以是根据业务参数拼的默认展示数据。总原则是宁可给用户一个稍旧的数据也不能让数据库被热点流量打挂。大促期间保命比保数据新鲜度重要得多。4.4 逻辑过期方案的 Java 实现对比再贴一个逻辑过期的实现方便你对比两种方案的代码差异。public String getDetailByLogicalExpire(String productId) { String cacheKey product:logical: productId; String lockKey lock:logical: productId; String json redisTemplate.opsForValue().get(cacheKey); if (json null) { // 连缓存都没有说明首次访问走正常查库回填 return loadFromDb(productId, cacheKey); } JSONObject obj JSON.parseObject(json); long expireTime obj.getLong(expireTime); if (expireTime System.currentTimeMillis()) { return obj.getString(data); } // 逻辑已过期尝试获取重建锁 String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 双重检查可能等待期间其他线程已经更新过了 String latestJson redisTemplate.opsForValue().get(cacheKey); if (latestJson ! null JSON.parseObject(latestJson).getLong(expireTime) System.currentTimeMillis()) { return JSON.parseObject(latestJson).getString(data); } String data queryFromDb(productId); JSONObject newObj new JSONObject(); newObj.put(data, data); newObj.put(expireTime, System.currentTimeMillis() 10 * 60 * 1000); redisTemplate.opsForValue().set(cacheKey, newObj.toJSONString()); return data; } finally { releaseLock(lockKey, requestId); } } else { // 拿不到锁直接返回旧数据不阻塞 return obj.getString(data); } }这个实现的关键差异在于拿不到锁的请求不会阻塞而是直接返回过期数据。所以逻辑过期方案的 QPS 表现比互斥锁更好几乎没有排队现象代价是可能短暂读到旧数据。大促期间秒杀详情页用这套价格可能有几十秒的滞后但用户基本察觉不到。我在实际项目里把逻辑过期写在了缓存治理的公共组件中所有读接口默认开启。只有对一致性要求极高的场景比如库存扣减、订单金额关闭逻辑过期切回互斥锁模式。两种模式用配置开关切换很方便。5. 常见问题、排查实录与避坑清单5.1 锁过期时间与锁误删的坑先说锁过期时间。很多人设置锁过期时间时走极端设短了怕业务异常后锁一直占着结果把 TTL 设成 1 秒数据库查询抖动一下就超过 1 秒锁自动释放等于白锁。设长了又怕持锁线程真挂掉其他线程要等很久。正确答案是先评估回填耗时的 P99比如正常 80ms、极端抖动 300ms那 TTL 设 3 到 5 秒是比较稳妥的区间。如果业务回填链路特别长建议配合 Redisson 看门狗这类自动续期机制让长任务能续期而不是干等超时。再说锁误删。很多人释放锁就是一句redisTemplate.delete(lockKey)完全没考虑锁是不是自己持有的。这个问题的本质我在 4.2 小节讲过线程持有锁的时间超过了锁 TTL锁已经自动过期被其他线程抢到原线程恢复后一个 delete 就把别人的锁删了。解决办法就是 Lua 脚本先 get 锁的 value 判断是否等于自己的 token相等才 del。这个脚本一次 RTT 就能完成判断和删除不能拆成两步操作否则中间又会有并发间隙。5.2 等待阻塞与长尾延迟的优化互斥锁方案最大的风险是阻塞等待。如果一个热点 key 回填需要 200ms同时涌进 1000 个请求意味着最后一批请求要等 200ms 甚至更久接口平均延迟会被拉得很高P99 更是惨不忍睹。实践经验有两个优化方向。第一把“等待锁”变成“等待结果”第一个线程把 Future 放入进程内 ConcurrentHashMap其他线程直接future.get(200ms)第一个线程回填完成后所有等待线程立刻拿到结果。这个方案复杂度高适合 P99 敏感的业务普通场景用 sleep 重试已经够用。第二给回填任务加熔断。如果获取锁失败并且重试超过 3 次直接走降级逻辑不再无限循环。降级可以是返回默认数据、旧快照、静态页面也可以是抛异常让前端展示兜底页。总之热点 key 的治理要遵循“快速失败优于长期阻塞”的原则把故障范围控制在单次请求内。5.3 一次线上击穿问题的排查全过程最后分享一个真实事故。当时监控显示一个商品详情接口的数据库慢查询突然飙升但缓存命中率还有 98%看起来一切正常。我一开始怀疑是 SQL 问题翻慢日志发现都是同一个product_id的等值查询执行计划完全没问题纯粹是瞬时并发太大。后来去翻 Redis 监控发现product:detail:{id}的 GET 命令 QPS 在某个时间点有一个明显谷底紧跟着就是数据库 QPS 尖峰。再一看缓存的 TTL正好是 10 分钟而那个时间点恰好是某个大主播开播后的第 10 分钟流量最高峰。破案了就是典型的缓存击穿。当时先做了临时快速恢复手动预置一份新缓存并把 TTL 临时调整到 1 小时数据库压力立刻降下来。然后做了长期治理给核心 key 加了逻辑过期同时建了热点 key 清单和过期前预警任务。这套排查套路现在基本被我固化成标准流程了分享给你先对照数据库慢查询尖峰和 Redis 监控曲线看尖峰是否和某个 key 的过期时间点吻合看 Redis 的 GET 命令返回值如果某个瞬间nil比例剧增说明大量请求打到了回填逻辑看锁争抢日志如果锁抢锁次数突增基本可以确认击穿正在发生临时恢复用“手动设置新缓存 临时调长 TTL”先把 DB 压力降下来长期治理上“逻辑过期 后台预热 过期前预警”把高危 key 全部纳入自动治理范围。我现在还在监控里加了“过期前 5 分钟预警”定时任务扫描所有 TTL 小于 300 秒的热 key提前在流量高峰前刷新缓存。上线后命中率和使用体验都稳定了很多击穿报警基本归零。缓存击穿的技术难度其实不大真正的难点在于“发现得早”。锁、逻辑过期、永不过期这些方案本身都很成熟但对很多团队来说击穿发生一次之后才会意识到热点 key 清单要维护、监控要前置。我建议做缓存治理的第一天就把核心接口的 key 清单拉出来给高危 key 单独建监控别等报警了再被动排查。另外不管选哪种方案都要先想清楚降级逻辑——缓存治理的本质不是提高命中率而是保证数据库永远不被打挂。