
凌晨2点报警电话把运维小哥从被窝里拽起来某商品详情页接口的RT从平时的30ms飙到了4秒数据库连接池直接打满重启一次好个十分钟然后又被打满。这种症状十有八九不是代码改坏了而是缓存里那个扛了几百万请求的热点key到了过期时间一瞬间所有请求绕过缓存直接砸向了数据库。这就是典型的缓存击穿。这篇文章就把缓存击穿这件事讲透它和缓存穿透、缓存雪崩到底有什么区别防护方案为什么选互斥锁、逻辑过期还是多级缓存以及我压测和线上踩坑后总结出的可落地做法。文章里的代码和参数都是可以直接抄走的适合后端开发、系统架构和运维同学参考。分析过程我会尽量口语化不堆名词但该给的代码和配置不会少。1. 缓存击穿的本质它和穿透、雪崩根本不是一回事1.1 三个容易混淆的“缓存事故”网上聊缓存故障的东西很多但很多文章把击穿、穿透、雪崩混在一起讲看完更糊涂。我这里用一个饭馆例子把它们一次说清。缓存穿透好比一个人进了饭馆不点菜光问“有没有免费的菜”厨师每次都白忙活。对应到系统里就是请求了一个缓存里和数据库里都不存在的数据每次请求都得查一次DB缓存永远帮不上忙。技术上可以用空值缓存、布隆过滤器来挡。缓存雪崩是饭馆一下子停电所有灶台同时熄火。对应到系统里就是大量key在同一时间过期失效或者Redis集群整体宕机所有请求在同一时刻涌向数据库。这种问题的核心在于“量太大”得靠过期时间打散、集群高可用、熔断降级来治。缓存击穿则是饭馆里有一道招牌菜大家都只点这一道偏偏这道菜的食材用完了所有等这道菜的客人同时催单后厨瞬间被挤爆。对应到系统里就是某个热点key在某个瞬间过期失效大量对该key的并发请求在同一时刻直接穿透到数据库。它不是“查不到”而是“查到之前的那一刻大家都没缓存可用”。1.2 为什么热点key过期是事故而不是Bug缓存击穿发生的条件非常苛刻第一这个key必须是热点key也就是同一时刻有成千上万请求访问的key第二这个key刚好在请求峰值期间过期失效。平时我们设置的缓存过期时间大多是30分钟到2小时如果key不是热点根本没几个请求访问过期了也不会有感觉。但热点key不一样比如电商大促的商品详情、热点新闻的置顶内容、直播间的实时榜单单个key的QPS可能就有几千甚至上万。热门key一旦失效相当于在大马路上把红绿灯给关了。分布式的系统里这个key的缓存数据是每个进程各自管理的同一个key在100个应用实例上同时发现缓存过期每个实例都会去查一次数据库。1000个并发请求进来就有1000个请求会打到DB上。如果DB的连接池只有50个连接那剩下的950个请求就只能排队等待时间一长用户侧的表现就是接口超时、页面白屏。更麻烦的是DB一旦被打满所有依赖同一个DB的业务都会受影响哪怕和这个缓存key一点关系都没有的接口也会被拖垮。这种连锁反应往往比击穿本身更致命。1.3 一条完整的事故链复盘我实际经历过一次比较典型的击穿事故可以把链路完整还原给你某个商品详情页的Redis key设置了60分钟过期。平时流量平稳每分钟大概几百个请求DB完全扛得住。但一条短视频突然带火了这个商品流量在10分钟内翻了20倍单个key的QPS到了5000。这个key恰好到了过期时间点缓存从Redis里删除的那一刻5000个请求全部穿过缓存层直接打到了MySQL上。MySQL连接池里的连接数是50但等待的请求数有5000连接池直接被打满后面的请求全部堆积在线程池里。线程池一满Tomcat的accept队列也开始堆积整个应用的RT从30ms涨到3秒以上其他所有接口都变慢最终触发服务熔断商品详情页大面积报错。事后排查发现其实当时DB没有被慢查询拖死就是纯并发太高连接池被占满后所有线程都在排队等数据库连接应用层的线程池被活活饿死。如果提前给热点key加上“互斥”或“永不过期”的防护这场事故完全可以避免。2. 防护方案选型互斥锁、逻辑过期、永不过期到底怎么选2.1 互斥锁方案让99%的请求先等等互斥锁的核心思路很朴素当一个热点key过期后只允许一个请求去数据库回源并重建缓存其他请求先阻塞等待缓存重建完成后再统一从缓存读取。画个简单的流程就是请求A发现缓存没有尝试去拿分布式锁请求B也发现缓存没有尝试拿锁但没抢到于是自旋等待请求A从DB查到了数据写回缓存释放锁请求B醒来后读缓存发现数据已经有了直接返回。这个方案的优点是思路简单数据库压力极小DB的QPS基本可控在个位数。缺点是如果回源时间较长比如查一个复杂SQL要500ms那等待的请求就要在这500ms内阻塞用户的P99延迟看起来会比较高。提示如果发现互斥锁导致大量请求因等待而超时可以先等一下再直接查缓存不要无限等。真正的线上代码里自旋次数超过3次还没有拿到锁我会直接降级为读旧缓存或快速失败。2.2 逻辑过期方案数据可以旧系统不能崩逻辑过期方案的精髓是Redis里的物理key永远不过期但value里存了一个业务过期时间戳。读取的时候先判断这个时间戳是否小于当前时间如果没有过期直接返回如果过期了先返回旧数据同时触发一个异步线程去数据库刷新数据。这个方案的好处显而易见用户几乎感觉不到延迟即使缓存过期了请求也是秒回旧数据系统不会出现任何阻塞。坏处是数据在极端情况下会“不新鲜”如果数据刷新失败用户会一直看到旧数据。实际项目中逻辑过期一般配合消息队列或定时任务来做异步刷新。比如key的过期时间戳到了异步任务发送一个刷新消息由专门的任务消费者去DB拉取最新数据并更新Redis值。Redis里实际存的key本身没有TTL所以永远不会因为物理过期触发穿透。2.3 热点key永不过期主动更新最省心的兜底策略“永不过期”严格来说不是一个独立的技术方案而是一种运维策略识别出热点key后把它的TTL设置成一个非常大的值或者根本不设置过期时间然后由一个后台定时任务定期主动刷新这个key的数据。比如每天凌晨3点全量刷新一次商品价格同时每隔5分钟增量刷新一次库存。只要定时任务是健康的这个key的数据就永远不会过期也就永远不会触发击穿。这个方案有一个地方很关键如果主动更新的定时任务挂了缓存里的数据就会永远停留在旧版本。所以主动更新方案必须搭配健康检查定时任务要监控执行时间刷新失败要报警。2.4 不同业务场景下的组合选择方案延迟影响数据新鲜度实现复杂度适合场景互斥锁回源期间请求阻塞极高低数据更新不频繁但极其重要如订单状态逻辑过期几乎无影响输出短暂旧数据中读多写多且允许短暂不一致如商品列表永不过期主动更新无影响取决于刷新频率中数据源稳定且刷新机制可靠如配置数据多级缓存兜底无影响取决于上层缓存策略较低所有读多写少场景的通用兜底我的习惯是优先上互斥锁因为它的最终一致性最好代码也最好理解。等业务发展到对P99延迟更敏感的时候再升级成“互斥锁 本地缓存”的组合方案。3. 实操Spring Boot Redis 实现一个可上线的防击穿组件3.1 组件设计思路我在项目里实现过一个叫CacheGuard的小组件它的调用方式长这样String data cacheGuard.getWithMutex(product:detail: productId, ProductDetail.class, () - productMapper.selectDetail(productId), Duration.ofMinutes(30));getWithMutex内部做的事情是先查Redis缓存缓存命中直接返回缓存不命中尝试获取分布式锁拿到锁的线程去数据库回源重建缓存后释放锁没拿到锁的线程先短暂等待然后重新查缓存。整体利用Spring Boot的自动配置注入StringRedisTemplate对业务方来说是透明的。这个组件设计时有几个关键决策我比较坚持第一锁的粒度要细锁key必须和缓存key一一对应比如缓存key是product:detail:123锁key就是product:detail:123:lock绝对不能用一把全局锁把整个系统的热点key全部串行化。第二锁必须有过期时间防止持锁线程异常退出导致死锁。但这个时间不能太短不然回源还没完成锁就自动释放了又会有第二波请求触发回源。我一般设置5秒如果实际回源时间可能超过2秒我会在回源前把锁过期时间续到10秒。第三回源前要二次检查缓存因为有可能抢锁之前已经有别的线程把缓存重建好了拿到锁之后直接读缓存返回就行没必要再查一次DB。3.2 核心代码实现与参数说明先看获取缓存和加锁的核心逻辑public T T getWithMutex(String cacheKey, ClassT clazz, SupplierT dataLoader, Duration ttl) throws Exception { String json redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(json)) { return JSON.parseObject(json, clazz); } // 构建分布式锁key带上前缀防止业务冲突 String lockKey cacheKey :lock; String lockValue UUID.randomUUID().toString(); boolean locked Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent( lockKey, lockValue, Duration.ofSeconds(5))); if (locked) { try { // 二次检查避免拿到锁后重复回源 json redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(json)) { return JSON.parseObject(json, clazz); } T data dataLoader.get(); if (data null) { // 防止穿透不存在的数据也缓存一个短时空值 redisTemplate.opsForValue().set( cacheKey, , Duration.ofSeconds(30)); return null; } redisTemplate.opsForValue().set( cacheKey, JSON.toJSONString(data), ttl); return data; } finally { releaseLock(lockKey, lockValue); } } // 没抢到锁自旋等待并重新读取缓存 for (int i 0; i 3; i) { Thread.sleep(50); json redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(json)) { return JSON.parseObject(json, clazz); } } // 重试3次之后缓存仍然没有说明持锁线程回源失败或超时 // 这里降级为直接回源保证可用性 return dataLoader.get(); }释放锁的代码有个细节特别容易踩坑不能用简单的del删锁否则可能把别人刚抢到的锁删掉。必须用Lua脚本先对比值再删除private void releaseLock(String lockKey, String lockValue) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute( new DefaultRedisScriptLong(script, Long.class), Collections.singletonList(lockKey), lockValue); }锁的释放之所以这么麻烦是因为A线程持锁时间太长锁自动过期被B线程抢走了A线程回源完成后如果直接执行del会把B线程刚设置的锁删掉导致C线程又拿到锁去回源。用了Lua脚本保证了原子性只有锁value和自己设置的一样时才删除从而避免误删别人家的锁。3.3 压测结果与调参经验我在测试环境用JMeter模拟了1000并发访问同一个失效key对比加锁前后的表现不加防护时Redis缓存丢失的瞬间1000个请求全部穿透到数据库MySQL连接池瞬间被打满接口的平均响应时间从40ms涨到2.8秒数据库的连接等待线程直接堆到了数百个。加了互斥锁后Redis缓存丢失的瞬间只有1个请求回源数据库其余999个请求在50ms的重试间隙后立刻读到了新缓存接口的P99响应时间稳定在80ms以内数据库的连接数基本没有超过10。有几个调参经验值得分享第一个是重试时间和次数的搭配。我默认用50ms、最多重试3次这个组合在绝大多数场景下是够用的。但如果锁持有时间比较长比如回源SQL要跑300ms那50ms重试3次总共才150ms不到300ms可能重试完了缓存还没建好部分请求会去买便宜保险直接回源击穿防护效果打折扣。这种情况下可以把重试间隔改成300 / 并发数的动态值或者直接改成“无限循环 总超时时间”。第二个是锁过期时间必须根据回源耗时动态调整。我见过有团队把锁过期时间设成固定1秒回源SQL要跑2秒锁在回源完成之前就自动过期了第二波请求又开始抢锁回源等于没防护。正确做法是预估最耗时的回源时间再乘1.5到2倍设置锁过期时间。如果实在不好预估可以在回源过程中对锁进行续期后台起一个定时线程每隔三分之二锁过期时间就续一下但这样代码复杂度就上去了一般用固定5到10秒就够了。4. 全局防护Controller层限流与缓存防击穿的协同配合4.1 热点接口为什么总被爬虫盯上很多缓存击穿事故并不是用户自然流量触发的而是爬虫和恶意程序在推波助澜。一个热点商品详情页一旦价格出现波动或者库存变化就会有大量比价爬虫在秒级频率内轮询同一个接口。爬虫没有浏览器缓存也不会对同一个URL做本地合并它们的请求会原封不动地打到我们的应用层。如果这时候热点key刚好过期爬虫的并发请求叠加正常用户请求数据库面临的压力会成倍放大。这也是为什么有些团队在缓存层做了完整的互斥锁和逻辑过期防护后仍然会在热点活动开始的最初一分钟里看到数据库QPS飙升——因为爬虫流量太猛把连接池和线程池都打满了缓存层根本来不及回源。所以一个完整的缓存击穿防护体系不能只盯着Redis和数据库Controller层需要一把前置的“闸刀”限制单个IP或者单个用户的访问频率把明显异常的流量在进入缓存层之前就拦掉。4.2 自己实现一个轻量限流器很多项目会引入Sentinel或者Hystrix做服务保护但如果你没有这种框架用一个Interceptor加Redis也能实现简单的接口限流核心思路是滑动窗口计数Component public class RateLimitInterceptor implements HandlerInterceptor { Resource private StringRedisTemplate redisTemplate; private static final int MAX_COUNT 50; // 窗口内最大请求数 private static final long WINDOW_SECONDS 1; // 窗口时间 Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri request.getRequestURI(); String clientIp getClientIp(request); String key rate:limit: uri : clientIp; Long count redisTemplate.opsForValue() .increment(key); if (count ! null count 1) { redisTemplate.expire(key, WINDOW_SECONDS, TimeUnit.SECONDS); } if (count ! null count MAX_COUNT) { response.setStatus(429); return false; } return true; } }这个实现里有几个细节值得注意用increment初始化一个key后记得马上设置过期时间因为expire是在下一次请求时才会触发如果某IP只发了1次请求这个key就会一直在Redis里躺着时间久了会积攒大量无意义的key。另外如果有多台应用实例同时跑这个拦截器用Redisincrement计数是原子性的天然支持分布式场景。4.3 限流参数如何设置限流参数设置过紧会把正常用户误杀过松又挡不住爬虫。我一般按照“正常用户行为的峰值频率 × 5到10倍”来设置。比如一个商品详情页正常用户顶多3秒刷新一次单IP的高峰大概是0.3 QPS给10倍余量就是3 QPS。但如果爬虫是用异步协程在刷单IP的QPS能轻松到几十甚至上百。把这个阈值设在5到10 QPS就能精准地挡住两者。接口级的限流和缓存击穿防护结合起来后数据库的压力会小很多。因为缓存失效只是一瞬间的事真正让它变成事故的是“那一瞬间涌入了远超预期的请求量”。Controller层的限流相当于给缓存层加了一道缓冲把短时间的流量尖峰削平让缓存回源操作可以从容完成。5. 常见问题与排查避坑实录问题现象排查方向解决方案锁过期导致回源重复DB的QPS呈脉冲状规律波动查看Redis锁key的TTL与回源SQL耗时比对锁过期时间设置为回源耗时的1.5~2倍或实现自动续期锁误删导致保护失效多个线程同时回源DB连接池打满检查释放锁的脚本是否带value校验改用Lua脚本原子对比后删除并发过高导致自旋等待超时接口大量返回超时但DB压力不大查看持锁线程的回源耗时自旋次数超过3次后直接降级为快速失败或读旧缓存缓存击穿和穿透同时发生缓存不存在的key也造成大量DB查询检查回源为null的数据是否被缓存对null结果缓存短时空值TTL设为30秒到5分钟逻辑过期刷新线程池耗尽Redis缓存正常但数据不刷新查看异步刷新任务队列长度给异步刷新加独立的线程池和监控满时拒绝并降级压测时发现防护后反而更慢加了互斥锁后P99上升回源时间超过500ms时影响明显换用逻辑过期方案或加本地缓存兜底5.1 一个极易被忽略的坑缓存重建期间的读旧值互斥锁方案里锁持有线程回源如果耗时较长没抢到锁的请求只能空转自旋用户体验就是RT变长。有些场景比如商品详情页数据从DB查出来后还需要拼装推荐位、统计销量、格式化描述整个回源过程可能耗时300到800ms这时候单纯用互斥锁就有点吃力了。我的做法是给这类key加上本地缓存做L1Redis做L2。本地缓存采用CaffeinemaximumSize设置为1万条expireAfterWrite设置为5秒。热点key的过期时间戳只放在本地缓存的value里Redis的物理key永不过期数据更新通过MQ推送。这种方案下即使Redis的某个热点key在极端情况下被物理删除了本地缓存还有一份5秒内的副本请求在等待Redis锁的同时能直接从本地返回旧数据用户完全感知不到回源的存在。5.2 线上排查的观测手段排查缓存击穿问题我通常按三步走第一步是看Redis监控指标。如果Redis每秒的get操作数在高并发期间保持平稳但set操作数和miss数突然激增说明有大量请求在重建缓存基本可以确认为击穿。执行redis-cli --latency和redis-cli -c info stats可以快速确认。第二步是看数据库连接池监控。击穿事故发生时数据库连接数会瞬间打满而且连接池里的连接一直在执行同一个SQL查询。如果慢查询日志里某一时间点突然出现大量相同的SELECT语句而且这些SQL对应的数据在缓存里刚过期那就是击穿没跑。第三步是看应用日志中的锁日志。如果我在CacheGuard组件里加了一行“重建缓存开始”和“重建缓存结束”的日志配合访问日志里的耗时统计可以精确到是哪个key触发了击穿回源花了多长时间。等到事故处理完再去Redis里的热点key列表查看哪些key最值得做逻辑过期或者永不过期。5.3 我的兜底习惯在所有缓存重建逻辑里我一定会给数据库回源操作加上一个统一的超时控制。用Java的Future配合线程池或者使用Hystrix/Resilience4j做隔离确保回源调用最多执行800ms超过就快速失败或返回旧缓存不允许一条慢SQL无限期占用数据库连接。另外我会在Redis的key命名里带上业务前缀和版本号比如product:detail:v2:这样万一代码发布后缓存结构变化导致旧数据不可用可以通过前缀直接定位不需要在凌晨的事故现场做数据结构猜测。缓存击穿防护做到位之后我再去看监控面板时特别踏实。热点key在失效的一瞬间数据库最多只会收到1到2个回源请求其他所有请求都在缓存层被接住了。你项目里如果也有那种“平时没人管、一到大促就出事”的热点数据强烈建议先把互斥锁方案加上代码量不大但事故率能肉眼可见地降下来。