凌晨两点,运维群里突然炸锅——数据库连接数飙到2000+,服务响应从50ms跌到15秒。你盯着监控大屏,看着"系统繁忙"的报错像潮水一样涌来,却不知道敌人从哪来。这不是什么神秘攻击,而是Redis缓存的三道裂缝被同时撕开:有人用脚本狂刷不存在的用户ID,爆款商品缓存刚好过期,整库Key又集体失效。三股力量拧成一股绳,把数据库勒到窒息。

今天我们把这三道裂缝摊开来看:穿透、击穿、雪崩。不是背定义,而是看它们怎么联手搞垮一个系统,以及怎么用三行代码守住防线。

打开网易新闻 查看精彩图片

第一道裂缝:穿透——"查无此人"的无限循环

穿透的逻辑很刁钻。攻击者(或Bug)反复查询根本不存在的数据,比如user:999999999。缓存里没有,请求直扑数据库,高频无效查询把DB打成筛子。

原文给了一个触目惊心的场景:「EXISTS user:999999999 返回0,但每秒1万次?DB瞬间变筛子。」

这里有个反直觉的点——缓存对"不存在"是无能为力的。它只能记住"存在过什么",却记不住"什么不存在"。攻击者就是利用这个盲区,用海量不存在的Key把数据库的查询队列塞爆。

防御需要双保险。第一重是存在性前置校验,用布隆过滤器(Bloom Filter)在缓存之前拦一道。布隆过滤器是一种概率型数据结构,能告诉你"这个Key绝对不存在"或"可能存在"。把全量用户ID哈希进去,查询时先过一遍,绝对没有的直接返回None,连Redis都不用碰。

第二重是空值短期缓存。万一布隆过滤器误判(概率极低但存在),或者业务上确实需要查库确认,就把"查无此人"的结果也缓存起来——但只存5分钟。这样同一个无效ID短期内不会反复击穿。

原文的Python示例很清晰:先查布隆过滤器,再查Redis,最后才碰数据库;数据库返回空也写缓存,TTL设300秒。核心就两点:存在性前置校验 + 空值短期缓存,把无效流量挡在数据库门外。

这里有个真实战损案例。原文提到:「我们曾在线上活动遭遇穿透事故:未对注册手机号做空值缓存,黑产脚本遍历user:138****0001~138****9999,DB连接数10秒飙至2000+,服务全面超时。」

黑产脚本的思路很简单:批量遍历手机号段,看哪些是注册过的。如果没做防护,每一次遍历都是一次数据库查询。10秒2000+连接,意味着每秒200次无效查询——对大多数MySQL实例来说,这已经是死刑。

第二道裂缝:击穿——热点Key的"死亡毫秒"

击穿比穿透更隐蔽,因为它发生在"正常业务"里。某个超高频Key(比如首页推荐位hot_item)恰好过期,瞬间大量并发请求穿透缓存,争抢重建,数据库CPU飙升。

原文描述了这个惊险的刹那:「GET hot_item 返回 nil 的刹那,1000个线程同时执行SET——谁先写?谁来查库?没协调就是灾难。」

想象一下:缓存里存着爆款商品的详情,每秒被访问上万次。TTL走到尽头,Redis把Key删掉。第一个请求发现没有,去查数据库;但第二个、第一百个、第一千个请求也在同一毫秒到达,它们都看到nil,都决定去查数据库。数据库瞬间被1000个相同查询压垮,而缓存重建完成后,大部分查询都是浪费的。

这就是击穿的本质:缓存失效的瞬间,并发流量没有"刹车",集体涌向数据库。

解决方案是加锁重建,但锁的设计有很多陷阱。原文给出了一个完整的Python实现,值得逐行拆解:

首先,尝试获取分布式锁,用Redis的SETNX(Set if Not Exists)命令,带5秒自动过期——这是为了防止进程崩溃导致死锁。抢到锁之后,必须做二次检查:因为拿到锁和开始重建之间可能有时间差,别的进程可能已经重建好了。如果缓存已有数据,直接返回,避免重复查询。

重建完成后,设置3600秒的TTL,然后在finally块里释放锁——无论成功与否,锁必须释放,这是铁律。

没抢到锁的进程呢?短暂休眠10毫秒后重试,或者直接返回降级数据(比如旧缓存或静态兜底)。原文强调:「关键点:锁粒度最小化、自动过期、二次检查、异常必释放。」

这里有个常见的反模式:有人用全局锁保护所有缓存重建,结果热点Key没击穿,非热点Key却被串行化拖慢。锁的粒度必须是Key级别的,一个商品的锁不能挡住另一个商品。

第三道裂缝:雪崩——缓存的"集体休眠"

雪崩是击穿的批量版。大量Key设置相同过期时间(比如凌晨2点统一TTL=3600),到期后集中失效,流量洪峰同步涌向数据库。

原文给了一个危险操作:「KEYS item:* 生产禁用!」KEYS命令会遍历全库,在Key多的实例上直接卡死。更安全的做法是SCAN配合TTL采样,渐进式检查。

雪崩的根源是"人为制造的同步"。很多团队为了管理方便,给一批数据设置相同的过期时间,或者批量重启缓存服务后统一刷新。这就像让所有员工同时去食堂吃饭——窗口再多也会挤爆。

根治方案是两招并用。第一招是随机抖动(Jitter):基础TTL上下浮动10%,让过期时间分散开。原文的Python示例里,3600秒的TTL会变成3240~3960秒之间的随机值,Key们不再整齐划一地消失。

第二招是逻辑永不过期:核心数据不设TTL,靠后台任务异步刷新。原文的代码里,set_essential_item只执行SET不执行SETEX,然后每30分钟主动更新一次。这样缓存永远不会"空窗",数据库永远有缓冲层。

口诀记牢:「不设固定过期时间,只设'基础TTL+随机抖动';核心数据宁可冗余更新,也不集体断供。」

这里有个成本权衡。逻辑永不过期意味着缓存和数据库可能短暂不一致(最多30分钟),以及额外的刷新任务开销。但对于首页推荐、用户基础信息这类读多写少、一致性容忍度高的场景,这是值得的。

三道裂缝,一套心法

穿透、击穿、雪崩,表面是三个独立问题,底层是同一道裂缝:缓存与数据源之间的"空窗期"被恶意或意外地放大。

穿透放大的是"查询不存在"的空窗——缓存不会记住不存在的东西,需要外部机制补位。击穿放大的是"重建中"的空窗——一个Key的失效被并发请求乘数放大。雪崩放大的是"批量失效"的空窗——时间上的同步变成流量上的洪峰

守住它们,原文总结了三招:

存在性校验——布隆过滤器+空值缓存,让"不存在"止步于缓存层;

热点保护——原子锁+双重检查,把重建动作串行化;

过期分散——随机TTL+异步刷新,避免缓存集体休眠。

这三招不是互斥的,而是叠加的。一个完善的缓存系统,可能同时用上布隆过滤器拦截穿透、分布式锁保护热点Key、随机抖动防止雪崩,再加上异步任务兜底核心数据。

原文最后抛了一个开放问题:「你在项目中用过哪种防护组合?欢迎在评论区晒出你的redis.conf关键配置或自研工具片段——Dev.to的极客们,正等着抄作业呢!」

监控不是锦上添花,而是故障前的最后一道哨兵。原文的这句提醒,放在任何防御方案之后都不过时。布隆过滤器的误判率、锁的等待超时、缓存命中率的趋势——这些指标应该在Dashboard上实时跳动,而不是等到DB连接数飙到2000才后知后觉。

缓存不是银弹,而是需要精心设计的防护体系。它不会自动解决性能问题,只会把性能问题的爆炸点从数据库转移到缓存层——如果设计不当,爆炸会更剧烈。