代金券系统把整条支付链路拖垮的那个凌晨,我才真正明白一件事——Redis 的“温柔”从来不是它能扛住多少流量,而是它替你挡住了多少次本该落在数据库头上的毁灭性打击。那天之前,我以为缓存就是“查一下、存一下”那么简单;那天之后,我重新理解了缓存穿透、击穿、雪崩这几个词背后沉甸甸的分量。
这篇复盘不是教科书式的科普,是我把真实故障现场、排查链路、修复方案和事后对 Redis 各种机制的理解全部倒出来。适合正在用 Redis 做缓存、遇到过线上诡异超时、或者准备系统学习缓存治理的同学。如果你还没踩过雪崩的坑,那更值得提前看完——有些教训,真的不需要亲身体验一次。
1. 那场凌晨的雪崩:从“一切正常”到“全线飘红”的半小时
1.1 故障现象:谁也想不到是缓存先扛不住了
那天凌晨两点,平台在做一次大促预热,流量比平时高出六七倍。我盯着监控大屏,Redis 的 QPS 稳定在 8 万左右,数据库连接池水位 60%,一切都在安全区间内。可就在某一个整点时刻,系统像是被什么东西掐住了喉咙。
先是网关层开始飘出零星超时,紧接着业务日志里出现了大量Read timed out,然后是数据库连接池被打满的告警,最后连消息队列的消费 lag 都在肉眼可见地飙升。整个链路像多米诺骨牌一样,从缓存层开始一块一块倒下去。
当时我的第一反应是数据库出问题了。毕竟 Redis 的命中率之前一直稳定在 98% 以上,谁会想到缓存本身会变成灾难的源头?可等我连上 Redis 一查,冷汗瞬间就下来了——命中率已经掉到了 31%,大量 key 在同一时间窗口内集体消失。
1.2 临时止血:重启不是万能,限流才是
故障发生后的前十五分钟,团队是有点慌的。有人提议重启 Redis,有人提议直接扩容数据库,还有人想关掉一部分非核心接口。这些动作不能说完全错,但都解决不了根本问题。
重启 Redis 只会让事情更糟——缓存一旦清空,所有请求都会直接穿透到数据库,本身就是雪上加霜。扩容数据库也要时间,远水解不了近渴。最后我们做对了三件事:
- 在网关层对非核心接口启动限流,把流量控制在数据库能承受的范围之内;
- 对核心的热点 key 手动写入一份过期时间错开的缓存副本,快速恢复命中率;
- 把部分读多写少的接口临时切换为本地缓存(Caffeine),先保住主链路。
这套组合拳打下去,大概二十分钟左右,系统才逐渐恢复平静。但所有人都清楚,这只是把雪崩按住了,真正的病根还没挖出来。那天晚上我没回家,直接在工位上开始逐条翻 Redis 的日志和 key 的过期配置。
1.3 复盘第一刀:Redis 命中率从 98% 掉到 31%
故障稳定后,我拉取了故障前后一个小时的监控数据做对比。结果很扎心:Redis 里有一批业务的 key 设置了完全相同的过期时间,精确到秒。这些 key 大多是活动页的商品详情缓存,由一份配置统一管理,TTL 全部设成了 1800 秒。
由于大促预热期间所有请求几乎是同时打进来的,这些 key 的写入时间点非常集中。半小时后,它们也集中在同一秒过期。那一瞬间,所有过期 key 对应的请求全部绕过 Redis,直接涌向数据库。数据库的连接池和线程池瞬间被打满,新请求排队等待,等待又导致超时,超时又触发重试,重试又带来更多请求——一个教科书级的缓存雪崩场景,就这么在凌晨真实上演了。
这只是导火索。顺着这条线我继续往下挖,发现缓存穿透和缓存击穿的问题也早就埋在了代码里,只是平时流量低,没被引爆而已。那一刻我意识到,Redis 的“温柔”是有前提的——它的过期策略、淘汰机制、内存管理,每一样设计都在替你想办法保护下游,但你得真正读懂它,才能用好这份温柔。
2. Redis 的“温柔”本质:过期策略里藏着的保护机制
2.1 三种“破防”场景:穿透、击穿、雪崩的区别
很多人把缓存穿透、击穿、雪崩混为一谈,面试的时候能背概念,线上出了问题却分不清是哪一种。我用自己的话重新理一遍,你感受一下区别。
缓存穿透是请求了一个缓存和数据库里都不存在的数据。比如查一个不存在的商品 ID,缓存查不到,数据库也查不到,那这个请求就每次都打到数据库。如果有人在恶意刷这种不存在的 ID,数据库就变成了裸奔状态。
缓存击穿是某个热点 key 在过期的瞬间,大量并发请求同时涌进来。这些请求发现缓存没命中,于是一起去查数据库。相当于一个 key 的失效,引发了一波集中流量打向数据库。
缓存雪崩是一大批 key 在同一时间过期,或者 Redis 节点直接宕机。这时候整个缓存层短时间失灵,所有流量无差别地打到数据库,数据库基本扛不住。我们那次事故,就属于典型的雪崩。
三者的核心区别在于:穿透是“数据不存在”导致的问题,击穿是“单个热点 key”失效的问题,雪崩是“大面积 key 同时失效”或“Redis 不可用”的问题。对应的解法也完全不同,后面我会逐个展开。
2.2 惰性删除与定期删除:Redis 为什么“不急着”清垃圾
先说一个很多初学者没搞明白的点:Redis 的过期 key 到底是什么时候被删除的?不是 key 一到时间就立刻从内存里消失,也不是由一个后台线程不停扫描所有 key。
Redis 用了两套策略配合。一套叫惰性删除——每次访问 key 的时候,先检查它有没有过期,过期就删除并返回空。另一套叫定期删除——Redis 每隔一段时间(默认 100ms)随机抽取一部分设置了过期时间的 key,检查并清理其中的过期 key。
为什么这么设计?因为如果用一个精确的定时器去检查所有 key,当 key 数量达到百万级甚至千万级时,光扫描这些 key 就能把 CPU 吃光。惰性删除保证读取时“绝不返回过期数据”,定期删除则是兜底,避免那些一直不访问的过期 key 永远占用内存。
这个机制本身很聪明,但也埋了一个认知陷阱:一批 key 到了过期时间,并不会被“同时清掉”,而是分散在几轮定期删除里处理。看起来这对雪崩有缓解作用,实际上流量根本不会等它慢慢清——在 key 刚过期的那个时间点,请求就已经打过来了,删除是异步的,穿透是实时的。
2.3 内存淘汰策略:Redis 在最坏情况下的“底线思维”
如果 Redis 内存满了,怎么办?这就要说到maxmemory-policy这个配置项。很多人在本地开发时根本不设置,或者直接选noeviction,线上出了问题才意识到这个参数有多重要。
Redis 提供了好几种淘汰策略,常用的有:
| 策略 | 含义 | 适用场景 |
|---|---|---|
noeviction | 内存满了直接返回错误,不淘汰任何 key | 不能丢任何数据的场景(但缓存一般不用) |
allkeys-lru | 从所有 key 中按 LRU 淘汰最久没用的 | 通用缓存场景最推荐 |
volatile-lru | 只从设置了过期时间的 key 中按 LRU 淘汰 | 混合了持久 key 和缓存 key 的场景 |
allkeys-random | 从所有 key 中随机淘汰 | 访问模式非常均匀时 |
volatile-ttl | 淘汰剩余存活时间最短的 key | 想优先清理即将过期的 key |
我们现在的核心缓存服务用的是allkeys-lru。为什么不用volatile-lru?因为如果某些 key 因为代码 bug 没设过期时间,那它们就永远不会被淘汰,内存被这些“打不死的常驻 key”占满的那天,整个缓存服务就废了。allkeys-lru是底线思维——哪怕有 key 没设过期时间,内存吃紧时也会被淘汰,保证服务不会因为内存问题直接挂掉。
理解这个之后你就会发现,Redis 的种种设计其实都是“牺牲一部分确定性,换取整体服务不崩溃”。这份温柔,是工程上的温柔,不是感情上的温柔。
3. 给 Redis 穿上防弹衣:四套缓存加固方案的取舍
3.1 互斥锁与分布式锁:setnx 的正确用法
对付缓存击穿,最经典的做法就是互斥锁。思路是这样的:当缓存 miss 的时候,不是让所有请求都去查数据库,而是先尝试获取一把分布式锁,只有拿到锁的请求才去查数据库并回填缓存,其他请求短暂等待后重新读缓存。
Redis 里常用的命令是SET key value NX PX 10000,也就是传说中的SETNX。在 Spring Boot 项目里,用 Redisson 封装的话就更简单,直接加RLock就行。但这里有几个坑,我踩过,得说清楚:
- 锁一定要设置过期时间,否则持有锁的线程挂了,锁永远不会释放,后面所有请求都会卡死。
- 锁的粒度要尽量小,按业务 key 去加锁,而不是用一个全局锁把所有缓存请求都串行化。全局锁等于把系统打回单线程,性能不可接受。
- 等待锁的时间要设置超时,不能无限等。一旦超过阈值,要降级返回默认数据或直接报错,不能让请求无限堆积。
互斥锁的优点是实现简单,能精确挡住并发冲击;缺点也很明显,如果热点 key 的请求量极大,锁的争抢本身就会消耗不少性能,而且在极端情况下,大量线程阻塞在锁上,也可能把线程池打满。
3.2 逻辑过期:热点 key 的“续命”方案
互斥锁能挡住击穿,但每个请求都得经历“缓存 miss → 获取锁 → 查库 → 回填”这个流程,对热点 key 来说仍然不够优雅。有没有一种办法,让缓存里的热点 key 永远不消失?
有,就是逻辑过期。具体做法是:给缓存 value 额外包装一个过期时间字段,比如塞进一个RedisData对象里,存的是业务数据和逻辑过期时间戳。读取的时候,如果发现逻辑时间还没到,直接返回数据;如果逻辑时间到了,异步开一个线程去更新缓存,当前请求仍然返回旧数据。
对,你没看错,返回的是过期数据,但业务上可以接受。商品标题、价格、库存这类数据,延迟几秒甚至几十秒更新,在大多数场景下用户根本感知不到。这个方法几乎完美地避开了缓存击穿,因为缓存永远不会被物理删除,也就不会出现“一瞬间所有请求一起 miss”的情况。
用逻辑过期也要注意几点:
- 异步更新缓存时要做互斥,防止多个线程同时去查库回填。可以配合一个分布式锁,抢到锁的线程负责更新,没抢到的直接返回旧值。
- 逻辑过期时间要根据业务容忍度设置。对一致性要求高的数据,比如支付状态,这个方案就不太适合。
我们后来把商品详情缓存改成了逻辑过期,效果立竿见影,故障后到现在再没出现过热点 key 击穿的问题。
3.3 布隆过滤器与空值缓存:穿透问题的两手准备
缓存穿透的本质是“查了一个不存在的东西”。解决方案也无非两条路:让请求根本到不了数据库,或者让“查不到”的结果也能被缓存。
先说缓存空值。最简单,key查缓存没命中,查数据库也没有,那就往缓存里写一个空值或标记值,TTL 设短一点,比如 60 秒。这样同一个不存在的 key 在短时间内的重复请求就都能命中缓存了,数据库被穿透的压力就大幅下降。
缺点很直接——如果恶意请求构造了大量不同的不存在的 key,空值缓存会占用大量内存。这时候就要配合布隆过滤器了。
布隆过滤器是一个很巧妙的概率型数据结构,它可以极省空间地告诉你“一个元素肯定不存在”或者“可能存在”。缓存的流程变成:请求先经过布隆过滤器,如果过滤器判断 key 不存在,直接返回空,根本不会去查缓存和数据库;如果过滤器判断可能存在,才走正常缓存流程。
很多人布隆过滤器用错,就是没用对哈希函数的数量和数据总量。初始化时要根据预期的数据量和误判率,计算位数组大小和哈希函数个数,不能随手写一个。误判率设置得越低,位数组就越大,内存占用越高。一般业务场景误判率设 1% 就够用了,对应的内存开销比空值缓存小得多。
3.4 过期时间加随机值:最简单却最容易被忽视的雪崩解药
回到我们那场事故的根因——大量 key 在同一秒过期。最直接的修复办法,不是换数据结构,也不是加锁,而是给 TTL 加一个随机扰动。
比如原来是TTL = 1800秒,改成TTL = 1800 + random(0, 300)秒。这么一来,每个 key 的过期时间都错开了,不再集中在同一秒钟集体失效,数据库收到的穿透流量就被摊平了。
这个方案简单到让人觉得不靠谱,但它真的是抵御缓存雪崩成本最低、收益最明显的一招。我在很多项目里都刻意强调过:所有批量写入缓存的 key,TTL 一律加上随机值,默认随机区间是 0 到 TTL 的十分之一。
当然,随机 TTL 解决的是“过期步调一致”的问题。如果 Redis 节点直接宕机导致的大面积缓存不可用,那就不是 TTL 能解决的了,需要靠下一节说的高可用架构。
除了随机 TTL,缓存预热也是常规操作。大促或活动开始前,把热点数据提前写入缓存,并错开过期时间。不要让第一批请求在活动开始的瞬间去扛“冷缓存”的穿透流量。我们后来写了一个定时预热任务,活动前半小时会把热点商品数据提前加载到 Redis,效果非常明显。
4. 从缓存到高可用:哨兵、集群与持久化的保命设计
4.1 主从与哨兵:Redis 单点不是“温柔”,是“脆弱”
如果说 TTL 随机化解决了“雪崩”的一种触发方式,那 Redis 节点宕机就是雪崩的另一种触发方式,而且破坏力更大。一个缓存服务如果只有一个节点,硬件故障、内存耗尽、网络分区,任何一个问题都可能导致缓存整体不可用,所有流量瞬间打向数据库。
所以生产环境的 Redis 至少得做主从加哨兵。主节点负责写,从节点负责读,哨兵负责监控主节点的健康状态,主节点挂了就自动把从节点提升为新的主节点。整个过程对应用层基本是透明的。
这里要特别说一个容易踩的坑:主从复制的延迟。主节点写入一个 key,从节点还没同步到,这时候读请求打到从节点上就会 miss。如果这个 key 是刚写入的热点数据,miss 之后请求穿透到数据库,高并发下照样可能压垮下游。
解决办法有几个方向:对实时性要求极高的数据,读请求强制走主节点;或者开启wait命令等待同步完成;也可以在业务层做短暂重试。具体怎么选,得结合业务场景。但从架构角度,你一定要意识到主从复制不是强一致的,它是异步复制,存在丢数据和延迟的风险。
4.2 集群分片:数据再多也不能让一个节点扛
如果业务量再往上走,单节点的内存和 CPU 就成了瓶颈,这时候就得考虑 Redis Cluster 集群模式了。Cluster 把数据按照CRC16(key) % 16384的算法分散到 16384 个哈希槽里,每个节点负责一部分哈希槽,读写请求会经过客户端路由到正确的节点上。
集群的好处显而易见:数据分散、压力分散、节点冗余。但代价是运维复杂度显著上升,而且多 key 操作受限——跨节点的MGET、事务、Lua 脚本执行都会变得很麻烦,除非你用 hash tag 把相关的 key 强制分配到同一个哈希槽里。
如果你是在 Docker 环境里搭建主从或集群,千万别忘了端口映射和数据卷挂载。Redis 的 Docker 镜像本身不包含配置文件,需要自己挂载redis.conf。热词里提到“docker 安装 redis 主从”和“redis docker compose 生产环境部署”,我就多说一句:生产级的 docker compose 一定要设置command: redis-server /usr/local/etc/redis/redis.conf,并且关闭protected-mode的错误做法不要学,安全配置不能省。
4.3 持久化与安全基线:别让 Redis 裸奔在公网
最后说两个经常被忽略,但出事就很惨的地方:持久化和安全。
持久化方面,Redis 提供 RDB 快照和 AOF 日志两种方式。RDB 是定时把内存数据全量写到磁盘,恢复快但可能丢最后一次快照之后的数据;AOF 是追加写命令日志,丢数据少但文件大、恢复慢。生产环境一般两个都开,或者至少开 AOF。缓存场景可能觉得“数据丢了重新查库就行”,但持久的缓存数据丢失后,瞬间穿透带来的压力同样不小。
安全方面,最典型的就是热词里的“Redis 未授权漏洞”。很多人在服务器上装好 Redis,protected-mode no一关就完事了,Redis 默认端口 6379 就裸奔在公网上,谁都能连上来执行命令。这个问题早年引发过大量 Redis 被写定时任务挖矿的案例。
我的底线是三条:绑内网 IP、开密码认证、禁止公网直接暴露 6379 端口。如果必须在云上公网访问,至少要通过安全组白名单控制来源 IP,而不是全网放开。安全基线比任何花哨的功能都重要,因为 Redis 一旦被入侵,丢的可不只是缓存数据,还可能变成攻击者控制你服务器的跳板。
结尾:写在恢复平静之后
那次事故给我的最大教训不是某个命令用错了,而是对缓存机制的理解不够立体。Redis 的每一次过期删除、每一种内存淘汰、每一份持久化策略,背后都是“避免你被流量击穿”的工程智慧。你读懂了它,它就是最温柔的保护者;你轻慢了它,它就会用最残酷的方式让你长记性。
最后分享一个我后来一直保留的小习惯:在压测环境里模拟缓存层全部失效的场景,看看数据库到底能扛住多少并发。这个数字如果低于你现在线上的峰值流量,请立刻回去检查 TTL 分布和降级方案。等技术债还完、架构加固完毕、监控告警全覆盖之后,你再看 Redis,会觉得它真的很温柔——温柔到愿意把所有压力都扛在自己身上,就为了给数据库留一条活路。