凌晨三点钟,我被一个电话从床上拽了起来:线上数据库的连接数冲到了上限,一堆服务在疯狂重试,用户端App的请求大面积超时。查了一圈,Redis里的缓存数据还在,命中率却掉到了谷底,数据库的读写量在全网流量的带动下直线飙升。这个场景,凡是做过后端的人应该都不陌生——有人把它叫缓存雪崩,有人说是缓存穿透,还有人一口咬定是缓存击穿。说实话,这三个词在技术社区里被当成"缓存三大难题"讲了很多年,但真正能把它们分清楚、分场景给出对应解法的人,并没有想象中那么多。
这篇内容就围绕Redis缓存穿透、缓存雪崩、缓存击穿三个问题展开,把每个问题的触发链路、根因、防御手段和落地参数都讲透,再补上我实际排障过程中的教训和选择思路。标题虽然是"全解析",但我不打算写成教科书式的概念罗列,而是站在一个被线上事故反复教育过的人的角度,讲清楚"为什么会出现"和"到底怎么防",顺带给出可以直接拿去用的配置和代码思路,适合正在用Redis做缓存、经历过或担心经历这三类事故的开发者参考。
1. 三个"缓存杀手":穿透、雪崩、击穿的本质区别
在实际工作里,我发现很多人对这三个概念的理解是模糊的,遇到故障时全靠"猜"——数据库被打挂了,先说是穿透,再看一眼缓存发现数据还在,又改口说雪崩。这不是技术能力的问题,而是这三个问题在表象上确实很像:都是"缓存没拦住请求,压力落到了数据库"。但它们的内核完全不同,对应的解法、监控指标、防御体系也完全不同。分不清这三个概念,等于拿着同一把钥匙去开三把不同的锁。
1.1 先给三个问题各画一张解剖图
缓存穿透,说的是查询一个在缓存和数据库中都不存在的key。正常情况下,请求先查Redis,查不到再去查数据库,数据库有数据则写回缓存。但如果你查的是一个不存在的ID——比如用户恶意遍历id从1到1000000,而实际用户只有1000个——那么每次查询都会直接从缓存miss到数据库。缓存在这里完全失效,因为根本没有值可以被缓存。这类流量一旦被恶意利用,数据库会在短时间内被打穿,这就是"穿透"这个词的由来。
缓存雪崩,说的是大量key在同一段时间内集中失效,或者整个Redis节点宕机,导致原本应该被缓存挡住的请求,瞬间全部落到数据库上。它和穿透最大的不同是:缓存里有数据,只是同时不可用了。最常见的诱因是同一批数据用了相同的过期时间,比如凌晨定时任务批量写入,TTL都设成3600秒,那么一小时后这批key集体到期,流量就像雪崩一样涌向数据库。
缓存击穿,说的是一个热点key在缓存过期的瞬间,恰好被大量并发请求访问。这个key平时非常热,比如秒杀商品的库存、明星的微博详情页,所有请求都从Redis直接返回。但Redis的key过期机制是惰性的——某个key到了过期时间,并不会马上物理删除,而是等下一次访问时才会判断是否过期。如果热点key过期后,第一个请求触发回源,在它把数据库的数据写回缓存之前,后面成千上万个请求同时到达,全部都穿透到了数据库。它不是穿透那种"根本不存在"的查询,而是"存在但刚好过期"的一瞬间被打穿。
1.2 用生活化的类比理解三者的不同
我习惯用一个"图书馆"的类比来给团队新人讲这三个概念:
- 穿透,就像一个访客来图书馆查一本根本不存在的书。管理员每次都要去仓库翻一遍,翻完发现没有,回去告诉他。如果他反复来问一百本不存在的书,管理员就被折腾得没法干正事了。缓存中间层在这个场景里形同虚设。
- 雪崩,就像图书馆的所有索引卡片在同一天全部作废,管理员必须亲自去仓库一本一本找书。不管是谁来借什么书,都绕不开他的体力劳动,整个服务体系的压力陡增。
- 击穿,就像一本热门书的索引卡片刚刚被人抽走,还没来得及换新的,此刻恰好有一百个人同时来借这本书。平时大家通过索引卡就能秒查,但这一瞬间所有人都涌向仓库管理员。
这个类比能直观说明一个关键点:穿透的问题是"数据不存在",雪崩的问题是"缓存整体失效",击穿的问题是"单点热key窗口期失效"。根因不同,解决方案自然完全不同。
1.3 一张表快速对照三个问题
| 维度 | 缓存穿透 | 缓存雪崩 | 缓存击穿 |
|---|---|---|---|
| 本质 | 查询了根本不存在的key | 大量key同时失效或缓存层宕机 | 单个热点key过期瞬间被高并发访问 |
| 缓存中有无数据 | 无 | 有,但集体失效 | 有,但刚好过期 |
| 触发规模 | 通常由恶意请求或异常调用触发 | 系统级,影响面巨大 | 单key,但并发量极大 |
| 数据库压力特征 | 持续低水平5倍以上的无效查询 | 瞬时爆发,QPS陡增 | 单个key瞬间流量集中 |
| 核心防御思路 | 拦截不存在的数据请求 | 错开失效时间 + 高可用 + 兜底 | 保证热点key过期时只有一个线程回源 |
在继续往下看每种问题的详细方案之前,我建议你先记住这个判断逻辑:看缓存里有没有数据,看失效的key数量,看是不是同一个key。这三个问题,用这三个维度就能快速区分开。
2. 缓存穿透:不存在的key如何绕过防线直击数据库
缓存穿透的核心特征,是"缓存永远无法命中"。无论你查多少次,Redis里都没有结果,所以每一次请求都会穿过缓存层,直接打到数据库。这也意味着如果穿透流量持续存在,数据库承受的不是一个瞬时峰值,而是一波绵绵不绝的无效压力。比起雪崩和击穿那种爆发型故障,穿透更像是一种慢性的、持续性的消耗战,但它的破坏力一点都不低——原本100 QPS的数据库,因为几个脚本批量刷不存在的id,很快就能被打到 5000 QPS,而业务上根本不会产生任何有效数据。
2.1 穿透发生的链路还原
我用一个典型的商品详情接口来说明这个过程。请求进来后,业务代码先查Redis,key是product:detail:123456。这个key在Redis里不存在,于是回源查MySQL,MySQL里也没有这条记录。代码把空结果写回缓存了吗?大多数朴素实现的答案是没有——因为许多开发者认为缓存空值没意义,还平白占用内存。于是下一次同样的请求进来,Redis依旧miss,MySQL依旧要再查一次。
如果是正常业务,偶尔几次这样的查询问题不大,但问题往往出在两个地方:
- 恶意攻击者批量构造不存在的ID,比如从1000000往下递减,你的服务端如果没做基础校验,这些请求会全部穿透到DB;
- 大量并发导致缓存失效后同时回源,但数据库里确实没有数据,缓存自然也不会重建,穿透局面会一直持续到攻击停止。
我在协助某团队排查一个真实案例时,当时他们的商品库只有几千条记录,但监控显示数据库的Select语句峰值到了每秒上万次,而且持续了快两个小时。追溯请求日志后发现,是有人在批量爬取商品ID,而且爬到了很多已下架、被删掉的商品编号——这些编号在缓存和DB里都不存在。这种场景,光靠"提升数据库性能"完全是徒劳,必须在缓存之前把不存在的key挡掉。
2.2 方案一:缓存空值,把"没有"也缓存起来
最简单的办法,也是性价比最高的办法:当数据库查询结果为空时,仍然把这个key写入Redis,但value设置成一个特殊的空标记,并设置一个很短的过期时间。比如:
# 伪代码,示意核心逻辑 def get_product(product_id): cache_key = f"product:detail:{product_id}" cache_value = redis.get(cache_key) if cache_value is not None: # 缓存里存的可能是空标记 if cache_value == "EMPTY": return None return cache_value # 缓存未命中,查询DB product = db.query(product_id) if product is None: # 空值也缓存,TTL设置短一些,比如60秒 redis.setex(cache_key, 60, "EMPTY") return None redis.setex(cache_key, 3600, product) return product这段代码的关键有两个:一是空值必须设置过期时间,否则恶意请求用无限多个不同的不存在的ID,会把Redis内存撑爆;二是空值标记要和真实数据区分开,不能用一个空字符串混过去,否则业务层分不清是"没数据"还是"缓存出错了"。我一般在实践中会把空值TTL设置在30秒到60秒之间,既能挡住短时间内的穿透洪峰,又不至于让空值占据内存太久。
这个方案的优点是非常直观、改造成本低,几乎所有用缓存的地方都能套用。缺点也很明显:如果攻击者每次都用不同的不存在的ID,比如把ID上加上时间戳随机化,那么空值缓存会不断创建新key,内存消耗依然可观,需要在写入侧做频率限制或监控。
2.3 方案二:布隆过滤器,把不存在的key挡在门外
如果空值缓存已经扛不住"大量不同的不存在key"这种攻击,就需要上布隆过滤器(Bloom Filter)了。它的原理,简单说就是:用一个很长的bit数组 + 多个不同的哈希函数,只需要几十KB到几MB的内存,就能判断一个元素"绝对不存在"或者"可能存在"。
布隆过滤器的工作流程是这样的:系统启动时,把全量有效的ID(比如所有在售商品的ID)写入布隆过滤器。每个ID经过k个哈希函数,映射到bit数组的k个位置,置为1。查询时,对目标ID做同样的k次哈希,如果发现其中有任何一位是0,就说明这个ID肯定不在集合里,直接返回"不存在",连Redis都不用查。如果所有位都是1,说明这个ID"可能在"集合里,此时才允许请求进入Redis和数据库。
我在实际项目里通常用这个思路来组织逻辑:
def get_product_guarded(product_id): # 第一步:布隆过滤器判断 if not bloom_filter.contains(f"product:{product_id}"): return None # 一定不存在,直接拦截 # 第二步:查缓存 cache_value = redis.get(f"product:detail:{product_id}") if cache_value is not None: return cache_value # 第三步:查DB,并回写缓存 product = db.query(product_id) if product is not None: redis.setex(f"product:detail:{product_id}", 3600, product) return product注意,布隆过滤器的"可能存在"是一个fpp(误判率)问题,位数组长度、哈希函数数量、元素数量三者共同决定误判率。如果位数组太短、元素太多,误判率会飙升,大量不存在的key也会被放行,防御效果大打折扣。经验值是:预期元素数量为n时,位数组长度建议设置为10 * n左右,哈希函数数量通常取7个,这样可以控制误判率在1%上下。元素数量一定要预判业务峰值,因为布隆过滤器不能删除元素,后期扩容也很麻烦,需要在设计初期留足余量。
这个方案的代价是引入了一个额外的组件(Redis自带bit操作,可以自己实现;也可以引入现成的库),以及写库后需要同步更新过滤器。它的收益是能彻底挡住"大量不同不存在ID"的穿透流量,适合ID空间大、恶意访问多的业务,比如有公开API的接口或爬虫对抗场景。
2.4 方案三:入口参数校验,最便宜的防线
很多穿透问题,其实是入口校验缺失导致的。比如前面提到的场景:商品ID是纯数字自增,如果业务明确规定ID只能在某个范围内,那么请求进来后第一件事就是参数校验——格式不对、范围不对、位数不对,直接拒绝,根本不给后续组件添麻烦。
这条防线听起来简单,却是很多人最容易忽略的。我见过不少团队花了大量精力配置布隆过滤器、空值缓存,结果发现攻击流量里有一大半连基本格式都不满足,比如把一个字符串类型的ID传到数字接口里,服务端直愣愣地拿这个畸形参数去查库。如果先在入口层做一道校验,能把穿透流量削减掉七成。
2.5 我的选型建议与踩坑记录
就三种方案而言,我个人的实践顺序是:参数校验 > 空值缓存 > 布隆过滤器。前两个方案几乎零成本,适合绝大多数业务;布隆过滤器适合那些"不想让不存在的ID进入任何业务链路"的场景。要注意的是,空值缓存方案里,如果业务里有"数据恢复"的逻辑——比如商品被删除了,但后来又被恢复上架——那么空值标记可能还在缓存里,导致恢复后的商品短期内无法被查询到。这是我在一次真实故障中踩过的坑:运营在后台恢复了几个商品ID,但用户端整整一分钟内看不到这些商品,排查后才发现是空值缓存还没过期。
解决方式有两个:一是在数据恢复时主动删除对应的缓存空值key;二是把空值TTL控制在更短的范围,比如30秒。两个方案可以同时用,数据操作链路里主动清key是标准写法,短TTL是兜底手段。
3. 缓存雪崩:大面积key同时失效引发的连锁毁灭
如果说穿透是"慢性病",那雪崩就是"急性心肌梗死"。它发病急、破坏面广、处置难度高,而且往往不是单一原因导致的,修复起来需要多层级协作。我先把它拆成两个截然不同的子场景,因为这两个子场景的防御手段几乎不重叠。
3.1 雪崩的两类触发场景:过期风暴与缓存层宕机
第一类是"过期风暴":大量key在同一时间点到期。经典案例是定时任务批量预热数据。比如凌晨2点,一个定时任务把当天所有热卖商品的信息写入缓存,统一设TTL为7200秒,到了凌晨4点,这批key同时失效。此时恰好有早高峰流量,用户请求全部miss到数据库。这属于"缓存层本身没问题,但缓存集体失效"的场景。
第二类是"缓存层宕机":Redis进程崩溃、节点不可用、网络分区,导致缓存全部不可访问。此时不管key有没有过期,请求都拿不到数据,只能全部回源到DB。这比过期风暴更致命,因为DB瞬间承受的是全站流量,而非某个业务分片的流量。
这两类场景必须分开防御。只解决了"过期随机化",应对不了宕机;只做了高可用,也防不住同一批key集体过期。
3.2 防御一:过期时间加随机抖动,打散到期高峰
针对过期风暴,最基础的手段是在设置TTL时加入随机数。假设业务期望缓存存活时间在30分钟左右,不要在代码里写死1800秒,而应该写成:
import random base_ttl = 1800 random_ttl = base_ttl + random.randint(0, 300) # 30分钟到35分钟之间随机 redis.setex(cache_key, random_ttl, value)这个"TTL随机抖动"的思路,目的是让同一批次写入的key不要在同一点集体到期。要注意的是,随机范围不能太小,比如3600到3605秒,几乎没有意义;也不要太大,否则业务上"缓存有效期"变得不可控。我个人习惯是抖动幅度控制在基准TTL的 10%~15%。
但随机TTL不能解决所有场景。如果业务里的数据是按某个固定时间窗口失效的,比如"每日0点更新排行榜",那么无论TTL怎么随机,到了0点之后依然会有大量key因为业务逻辑被删除或重写。这种场景,需要配合"异步刷新"来处理,也就是在服务启动或定时任务里,提前把即将失效的数据重新加载一遍,而不是等key真正过期了再由请求去触发回源。
3.3 防御二:本地缓存兜底的多级架构
只靠Redis一层缓存,在宕机场景下完全没有退路。所以成熟的系统会在应用进程内再加一层本地缓存——可以是简单的HashMap,也可以是Caffeine这类高性能本地缓存。请求链路变成:本地缓存 -> Redis -> 数据库。Redis挂了,本地缓存还能挡住相当一部分热门数据的查询;Redis没挂但大量key过期时,本地缓存也相当于一道缓冲垫。
我见到过的多级缓存实践,通常把本地缓存TTL设置得很短,比如30秒到60秒。它的职责不是"长效缓存",而是"扛住瞬时穿透"。即便Redis完全宕机,数据库会在前60秒内收到大量请求,但只要本地缓存没有全部失效,数据库压力就会小很多。等运维把Redis拉起来,本地缓存再逐步释放。
这里有一个容易被忽略的问题:本地缓存是每个应用节点独立的,所以它的命中率受限于节点数和流量分布。如果应用有20个节点,某个热门key每秒被访问1000次,分摊到每个节点就是50次,本地缓存的命中率会高一些;但如果流量都集中在一个节点上,那这个节点的本地缓存压力会非常大,其他节点闲着。因此多级缓存方案要和负载分担策略一起设计。
3.4 防御三:高可用部署与快速恢复
Redis本身的高可用是雪崩防御的底线。最少要保证Redis不在单点上挂掉。
| 部署方式 | 能力 | 适用规模 |
|---|---|---|
| 主从 + Sentinel | 故障自动切换,主节点挂了从节点顶上 | 中小规模,绝大多数业务 |
| Redis Cluster | 数据分片 + 高可用,单节点故障不影响整体 | 大规模,数据量超过单机内存 |
| 多机房 / 多集群 | 机房故障时仍有可用副本 | 核心链路,必须有跨机房容灾 |
高可用部署解决的只是"Redis不可用"的问题,但切换过程中的几秒到十几秒内,应用依然会面临缓存miss。所以高可用不是全部,还需要配合快速恢复机制。我建议给Redis开启AOF追加日志和RDB快照的混合持久化,这样在节点重启后,能比较快地恢复内存数据,而不是冷启动一个空Redis。这个细节在切换场景里特别关键——如果Redis重启后内存里什么都没有,那么即使服务恢复,数据库也会迎来一波巨大的回源流量,这就是二次雪崩。
3.5 防御四:熔断、降级与限流
雪崩最危险的地方在于"连锁反应":数据库被打垮后,依赖数据库的服务也出问题,服务之间互相等待线程资源,最终拖垮整个应用集群。所以,当缓存大规模失效、数据库即将承受不住时,必须主动做"外科手术"——牺牲部分功能,保住核心链路。
我在实践中遵循的思路如下:
- 给回源操作加并发限制。同一时间段内只允许有限数量的线程访问数据库,比如数据库连接池上限是50,超过这个数量的请求快速失败,返回兜底数据,而不是排队等连接;
- 接口层面做流量准入。对热点接口配置单独的限流规则,超过阈值的请求直接返回"繁忙",而不是穿透到数据库。
- 服务降级。非核心功能(如推荐列表、评论计数)直接关闭,将系统资源集中到核心功能(如订单创建、支付接口)上。
这里我想特别强调一点:很多团队在雪崩时舍不得丢弃请求,总觉得返回错误会被用户投诉。但实际上,当数据库已经濒临崩溃时,"让一部分请求快速失败"比"让所有请求无限期等待"要健康得多。跟用户明确提示"稍后再试",比整个App转圈卡死要好太多。
3.6 雪崩预案的落地清单
基于我参与处理过的几次雪崩,我整理了一个检查清单,团队照这个清单准备,至少能覆盖90%的雪崩场景:
- 所有缓存key的TTL设置是否加了随机抖动?同一批写入的数据到期时间是否分散?
- 是否有本地缓存兜底?本地缓存TTL和失效策略是否明确?
- Redis是否为主从/Cluster高可用部署?切换后能否快速恢复数据?
- 回源数据库有没有并发限制和快速失败机制?
- 核心接口有没有独立的限流阈值?
- 有没有监控告警来感知"缓存命中率骤降"和"DB连接数飙升"?
- 应急预案里,谁来负责重启Redis、谁来负责降级开关、谁来负责通知业务方?
这个清单听起来很基本,但我发现许多项目在平时状态下,六七条里能落实一半就已经很不错了。说实话,雪崩防御最大的敌人不是技术复杂度,而是"觉得不会发生"的侥幸心理。
4. 缓存击穿:单个热点key过期瞬间的并发风暴
击穿是三个问题里最"拧巴"的一个。它只涉及一个key,但因为这个key太热,瞬间并发太高,造成的后果却可能比雪崩还严重。我自己经历过一次:某个抽奖活动的奖品库存key,在过期瞬间正好有一波集中点击,数据库在0.2秒内被打了近2万次查询,直接触发了主库的CPU告警。
4.1 击穿的现场还原:为什么单key却能打垮数据库
先还原一下击穿的完整时间线:
- T时刻,热点key(比如
stock:lottery:1001)在Redis中的值已过期,但因为Redis惰性删除,这个key还"躺"在内存里,只是已标记过期; - T+1ms,第一个请求来查这个key,发现已过期,开始回源数据库;
- T+2ms到T+500ms,这个key在数据库被查询、结果被写回缓存的过程中,其他成千上万个请求也来查这个key——它们同样发现key已过期,于是全部回源数据库;
- 数据库在极短时间里被同一份业务数据打爆,而Redis自始至终没有发挥作用。
为什么说是"同一个key"导致的灾难?因为热点key的访问集中度极高,100个请求打向同一个key,等于100个请求同时发起一次完全相同的数据库查询。这在正常情况下看是"浪费",但在数据库负载已经较高时,这100次冗余查询就是压垮骆驼的最后一根稻草。
4.2 方案一:互斥锁回源,只让一个线程去查库
击穿最直接的解法,就是"同一时间只允许一个请求去查数据库"。这个思路的实现方式是互斥锁:在回源之前,先尝试获取一把针对当前key的锁。拿到锁的线程才允许查询数据库并回写缓存;其他线程拿不到锁,就等待一小段时间后重试,或者快速返回一个旧值/空值。
下面这个Java风格的代码,是我在实际项目里常用的简化版逻辑:
public String getData(String key) { // 1. 先查缓存 String value = redis.get(key); if (value != null) { return value; } // 2. 尝试获取分布式锁 String lockKey = "lock:" + key; boolean locked = redis.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (!locked) { // 3. 没拿到锁,说明有人正在回源,短暂等待后重试 Thread.sleep(50); return redis.get(key); // 此时可能已经有值了 } // 4. 拿到锁,查询数据库并回写 try { value = db.query(key); if (value != null) { redis.setex(key, 1800, value); } return value; } finally { // 5. 释放锁 redis.del(lockKey); } }这个方案的几个关键参数和注意点:
- 锁的超时时间一定要大于"数据库查询 + 回写缓存"的最坏耗时。比如查询耗时最差200ms,锁超时设置5秒已经足够,设置太短的话,锁提前释放了,但回源还没结束,其他线程会再次抢锁,仍然打穿数据库;
- 锁粒度是"key级别的细粒度锁",也就是锁key跟缓存key一一对应,千万不能用一把全局锁去保护所有缓存key,否则所有缓存miss的请求都会排队,性能直接崩掉;
- 拿到锁后的双重检查。因为其他线程可能在你等待期间已经把缓存写好了,所以拿到锁后应该再查一次缓存,如果已经有了就直接返回,不必重复查库。这个优化很多人会漏掉,白白浪费一次数据库查询。
互斥锁方案的缺点是:缓存miss期间,所有请求都会处于等待重试的状态,接口响应时间会变长。如果热点key每秒的访问量是上万,miss瞬间就会有大量线程卡在sleep + retry里,线程池很可能被打满。所以需要配合"快速失败"策略:等待重试次数上限为3次,超过就直接返回降级结果,不要无限等下去。
4.3 方案二:逻辑过期,用旧数据顶住新流量
互斥锁解决的问题是"别让所有人同时去查库",但它并没有避免缓存miss。逻辑过期方案走的是另一条路——缓存中的数据永远不物理过期,业务层自己判断数据是否过期。
具体做法是:value里除了业务数据,再加一个过期时间戳字段。读取缓存时,业务代码判断时间戳是否超过当前时间:
- 未超过,正常返回数据;
- 已超过,先把旧数据返回给当前请求(至少不是miss),同时后台异步发起一个更新任务,把数据库的最新数据重新加载到缓存里,并刷新时间戳。
public String getData(String key) { CacheValue cache = redis.get(key); if (cache == null) { // 完全没有缓存,说明key本身不存在或从未加载,按穿透处理 return loadFromDb(key); } long now = System.currentTimeMillis(); if (cache.expireTime > now) { // 未过期,直接返回 return cache.data; } // 已过期:先返回旧数据,再异步刷新 asyncRefresh(key); return cache.data; }这个方案的好处特别明显:在过期瞬间,请求不会被阻塞,也不会穿透到数据库,因为它拿到的是"旧数据"。对于那些允许短暂数据不一致的业务(比如商品详情、推荐列表),逻辑过期几乎是击穿场景的最优解。
但也有需要注意的坑:异步刷新任务如果并发量大,同一个key会被多个线程同时触发刷新。因此依然需要"只有一个线程负责刷新",可以借助分布式锁或本地锁来控制。另外,如果旧数据本身已经严重过期(比如数据库中该记录已删除),继续返回旧数据可能带来业务风险。这种场景要加入"最大容忍过期时长"的判断,超过容忍时间就强制回源。
4.4 方案三:热点key永不过期 + 异步定时更新
逻辑过期的变体,是"热点key永不过期 + 定时任务主动更新"。
它的做法:针对已知的热点key(可以通过流量分析提前识别),在Redis中设置永不过期的key,然后由后台定时任务周期性把数据库的最新数据刷入缓存。比如每5分钟全量刷新一次热卖商品列表。
这个方案执行起来非常直观,而且线上没有缓存的过期窗口期,请求永远不会因为过期而穿透到数据库。但它有一个明显的前提条件:热点key要能被提前识别出来。像秒杀活动、预约抢购这些业务,热点key是可以提前知晓的;但日常的某些内容型产品,热点往往具有突发性,很难提前预知。所以这个方案更适合作为"已知热点"的加固手段,不适合作为应对突发热点的通用方案。
4.5 三种方案对比与选型建议
| 方案 | 是否阻塞请求 | 数据一致性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 互斥锁回源 | 是,短暂阻塞等待 | 高 | 低 | 数据一致性要求高的场景,如库存、订单 |
| 逻辑过期 | 否,直接返回旧值 | 低,短暂不一致 | 中 | 可接受短暂不一致的内容型业务 |
| 永不过期+定时刷新 | 否,永远有值 | 取决于刷新频率 | 低 | 能够提前识别热点key的场景 |
从我的经验来说,一个系统里往往同时存在这三类key的混合场景。我会给一致性要求高的接口(比如库存查询)配互斥锁,给内容型接口配逻辑过期,给已知秒杀场景配永不过期+定时刷新。把方案当作工具,把每个key当作场景,逐一分配合适的防御策略,效果远好于统一套用一种方案。
5. 组合防御与监控:把三类问题装进一套架构
前面分别讲了三个问题各自的解法,但现实中我们不会只遇到其中一种。一个高并发系统,往往是穿透、雪崩、击穿同时存在,或者交替出现。所以真正的工程能力,在于把三类防御整合进一条请求链路里,让每一层各司其职。
5.1 从请求到数据库的完整防护链路设计
前端请求进入后,我倾向于按下面的顺序逐层拦截,我把它称为"四道防线":
- 第一道防线:入口参数校验 + 布隆过滤器。最基础的格式、范围校验在这一层完成;对ID类参数再用布隆过滤器判断是否"合法存在"。这一层挡掉的是穿透流量;
- 第二道防线:Redis缓存读取。所有合法请求到这里,Redis命中则直接返回,未命中进入下一层。缓存key的TTL在设计时已经做了随机化处理,尽力避免大面积同时失效;
- 第三道防线:本地缓存 + 互斥锁。Redis未命中后,先看本地缓存有没有(这是应对Redis宕机/大面积失效的最后缓冲),本地也没有时,再用互斥锁控制回源并发;
- 第四道防线:数据库回源 + 空值缓存。真正到达数据库的请求已经是"层层筛选"过后的少数,查询完毕后把结果写入Redis,空结果写入短TTL空值缓存,为后续请求建立防线。
这套链路写下来只有四层,但每一层都要配合具体的参数和策略。我以一次完整请求为例说明:用户请求查询一个商品详情,参数校验通过,布隆过滤器返回"可能存在",进入Redis,不幸的是这个key刚刚过期,于是查询本地缓存——本地缓存里还有一份60秒前的旧数据,接口直接返回了旧数据,同时后台异步触发回源更新Redis。你看,这一整套过程中,数据库一次都没有被打到。
5.2 分层设计的取舍原则
多级防护确实能最大程度降低三高并发对数据库的压力,但它不是免费的:每一层都会带来额外的开发成本和维护复杂度。我在设计时坚持三个原则:
- 能挡在入口的,绝不放进链路深处。参数校验、布隆过滤器这类"零成本防御"尽量前置;
- 能异步处理的,绝不让请求线程同步等待。回源刷新、空值缓存构建这些操作,尽量由后台任务完成,请求线程只负责读取结果;
- 不追求每个环节都做到100%精确。布隆过滤器有误判率、本地缓存有短暂陈旧,这些都是为了更高可用性付出的合理代价。
5.3 监控指标与告警阈值
任何时候都要记住,这三类问题往往是"始于监控、止于监控"。没有监控,事故发生的前几分钟内你无法感知,等人工发现时数据库通常已经瘫痪。我建议至少监控以下指标:
| 指标 | 获取方式 | 告警触发条件 |
|---|---|---|
| Redis命中率 | 从Redis的INFO命令或监控平台直接读取 | 命中率从正常值(>95%)短时间内下跌超过20个百分点,且持续5分钟以上 |
| 数据库QPS / 连接数 | 数据库监控平台 | 数据库QPS超过正常基线的3倍,或连接数接近连接池上限 |
| Redis慢查询 | Redis慢查询日志 | 出现大量超过100ms的GET/SET操作 |
| 单key访问分布 | 中间件埋点 / 日志统计 | 某个key的访问量占总体访问量的比例异常升高,超过总流量的30% |
我在实际排障中最依赖的是"命中率曲线 + 数据库QPS曲线"这两条线。它们同时异常,就基本锁定了缓存层出现问题。至于具体是哪一类问题,再看"失效key的数量"和"是不是同一个key",很快就能定位。
6. 从故障现场倒推问题类型:一份排查SOP
前面讲的都是"防患于未然",但线上总是会有意外。真到故障发生时,能多快定位问题类型,决定了处置效率。我给自己总结了一套快速排查SOP,按现象倒推问题,基本能在几分钟内锁定方向。
6.1 现象一:DB读写量暴增,但Redis命中率极低
如果数据库收到大量"查无记录"的请求,Redis的命中率持续处于低位,且业务日志显示查询的key五花八门、没有明显集中度,那么大概率是缓存穿透。
处理步骤:
- 先看请求的key分布,确认是否存在恶意遍历或不存在的ID;
- 立即在入口层启用参数校验和布隆过滤器(如果之前没配的话),临时可以先做"短时间缓存空值"的处理;
- 黑名单/限流恶意来源IP或Token;
- 排查业务代码是否存在"先查缓存、直接查库、不回写空值"的漏洞。
6.2 现象二:Redis命中率断崖式下跌 + 大量key同时过期
如果DB的QPS在某个节点突然飙升,同时Redis的expired_keys指标在那一瞬间跳增,而且失效的是一大批不同的key,那么是缓存雪崩。
处理步骤:
- 检查是否同一批数据设置了相同的TTL,确认是否存在到期时间重叠;
- 如果Redis节点有宕机记录,先恢复Redis可用性,并检查持久化配置是否合理;
- 开启/加强本地缓存,让请求在Redis恢复前有一个缓冲;
- 临时给进入数据库的回源操作加并发限制,防止DB被打垮。
6.3 现象三:单个key请求积压、DB瞬时冲高
如果失效的key高度集中——比如某个秒杀商品、某个热点新闻——且出现"请求积压"和"线程池占用率升高",那么是缓存击穿。
处理步骤:
- 立刻给该key加上互斥锁,让回源并发降为1;
- 如果业务允许,直接把该key的TTL改成永不过期,后台立即触发一次数据刷新;
- 检查该key的过期时间设置是否合理,如果热点key的TTL本来就设置得过短(比如几分钟),应该在生产环境用动态参数把热点key的TTL拉长;
- 观察DB恢复情况,确认锁释放后没有产生新的穿透。
6.4 我的紧急处置SOP总结
我梳理了多次线上问题处置,总结出下面这份一个比较通用的"紧急处置SOP",可以在故障发生时按顺序执行:
- 先降级,再排查。第一时间开启核心接口的限流/降级开关,保证系统不崩,然后再定位问题;
- 动态调整缓存策略。如果判断是穿透,临时打开"空值缓存"开关,不需要改代码,在配置中心操作即可;
- 拉长热点key的TTL。如果判断是击穿,把热点key的TTL直接从配置中心调大,或者直接设置为永不过期;
- 给回源操作加熔断。用一个简单的并发信号量控制数据库回源并发数,超出即快速失败;
- 事后复盘。故障恢复后,检查监控曲线,确认问题类型,补上对应的防御机制,更新告警阈值。
这套SOP里最重要的经验是:故障处置的第一目标永远是"止血",而不是"优雅"。宁可临时关闭一些非核心功能、牺牲一部分用户请求,也要保证数据库和应用集群不被打垮。只要系统还活着,数据还没有被破坏,后面就还有修复的余地。
最后一个个人体会送给大家:Redis缓存三兄弟(穿透、雪崩、击穿)在教科书里是三个独立章节,但线上问题从来不会按章节出牌。真正成熟的团队,靠的不是记住某一个知识点的答案,而是建立一套"分层防御 + 快速感知 + 果断止血"的机制。把上面的方案落地到自己的代码和配置里,再配合一份可靠的监控告警,你至少能在下一次缓存故障来临时,从容地把问题控制在最小范围内。