
代金券系统把整条支付链路拖垮的那个凌晨我才真正明白一件事——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 的过期时间都错开了不再集中在同一秒钟集体失效数据库收到的穿透流量就被摊平了。这个方案简单到让人觉得不靠谱但它真的是抵御缓存雪崩成本最低、收益最明显的一招。我在很多项目里都刻意强调过所有批量写入缓存的 keyTTL 一律加上随机值默认随机区间是 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 未授权漏洞”。很多人在服务器上装好 Redisprotected-mode no一关就完事了Redis 默认端口 6379 就裸奔在公网上谁都能连上来执行命令。这个问题早年引发过大量 Redis 被写定时任务挖矿的案例。我的底线是三条绑内网 IP、开密码认证、禁止公网直接暴露 6379 端口。如果必须在云上公网访问至少要通过安全组白名单控制来源 IP而不是全网放开。安全基线比任何花哨的功能都重要因为 Redis 一旦被入侵丢的可不只是缓存数据还可能变成攻击者控制你服务器的跳板。结尾写在恢复平静之后那次事故给我的最大教训不是某个命令用错了而是对缓存机制的理解不够立体。Redis 的每一次过期删除、每一种内存淘汰、每一份持久化策略背后都是“避免你被流量击穿”的工程智慧。你读懂了它它就是最温柔的保护者你轻慢了它它就会用最残酷的方式让你长记性。最后分享一个我后来一直保留的小习惯在压测环境里模拟缓存层全部失效的场景看看数据库到底能扛住多少并发。这个数字如果低于你现在线上的峰值流量请立刻回去检查 TTL 分布和降级方案。等技术债还完、架构加固完毕、监控告警全覆盖之后你再看 Redis会觉得它真的很温柔——温柔到愿意把所有压力都扛在自己身上就为了给数据库留一条活路。