周二下午三点零七分,监控告警突然响了。全站平均响应时间从平时的200ms左右,一路飙升到5秒以上。用户反馈页面打不开,客服群里开始炸锅。

这是我经历过的最典型的一次缓存击穿故障,从发现到定位再到修复,前后花了两个多小时。记录一下整个过程,也算给自己提个醒。

故障现象

最先发现异常的是前端同学,说页面加载特别慢。我打开监控面板一看,CPU、内存、带宽都正常,但数据库的连接数已经打满了,慢查询日志刷得飞快。

正常情况下,我们90%的请求都走缓存,数据库QPS大概在200左右。但那天下午,数据库QPS直接冲到了3000+,而且全是同一条查询。

那条查询是根据文章ID查文章详情。按理说这个查询的结果应该在Redis里缓存着,TTL是一小时。为什么会突然全部打到数据库?

排查过程

第一步,先看Redis。Redis本身没问题,内存充足,响应正常。但那个热点文章的缓存key不见了。

查了一下发布记录,发现下午三点整,运营同学修改了那篇文章的标题。我们的逻辑是:文章更新时,删除对应的缓存key,下次访问时重新构建缓存。

问题就出在这里。那篇文章是首页置顶的热门文章,平时每秒有几十次访问。缓存一删,几十并发同时发现缓存不存在,同时去查数据库、同时构建缓存。数据库瞬间被打满,构建缓存的请求也全部超时,然后又有新的请求进来,形成恶性循环。

这就是典型的缓存击穿:一个热点key过期,大量并发同时回源数据库。

紧急止血

定位到问题后,先做紧急处理。第一步,把那篇文章的缓存手动预热上去,数据库压力立刻降下来了。第二步,临时把数据库的最大连接数调大,防止其他查询被拖死。

大概十分钟后,全站响应恢复正常。但这只是止血,根本问题还没解决。

根因分析

事后复盘,为什么会出现这个问题?

第一,缓存删除策略有问题。我们用的是"更新时删缓存",但没有考虑热点key的情况。一个普通文章删了就删了,访问量小,不会有问题。但热门文章删了,瞬间几十上百并发回源,数据库扛不住。

第二,没有互斥锁。缓存不存在时,所有请求都去查数据库,没有任何排队或锁机制。正确的做法应该是:只有一个请求去查数据库构建缓存,其他请求等待或者返回旧值。

第三,没有熔断降级。数据库压力大的时候,没有自动降级机制,所有请求都硬扛,最后全站拖垮。

修复方案

针对这三个问题,我们做了以下改进:

1. 热点key互斥锁

缓存不存在时,先尝试获取一个分布式锁。获取到锁的请求去查数据库构建缓存,其他请求短暂等待后重试。这样保证同一个key只有一个回源请求。

2. 逻辑过期

对于热点数据,不设置物理TTL,而是在value里存一个逻辑过期时间。读取时如果发现逻辑过期了,异步去更新缓存,当前请求还是返回旧值。这样永远不会有大量并发同时回源。

3. 更新时不删缓存,而是更新缓存

文章更新时,直接更新缓存而不是删除缓存。这样缓存永远存在,不会出现空窗期。当然,这需要保证缓存和数据库的一致性,我们用了"先更数据库,再更缓存"的策略,配合短暂的延迟双删。

4. 数据库熔断

加了一个简单的熔断器:当数据库慢查询超过阈值时,自动降级,非核心查询直接返回缓存数据或默认值,保护核心流程。

经验总结

这次故障给我最大的教训是:缓存问题永远要考虑极端情况。平时跑得好好的缓存策略,遇到热点key、遇到并发高峰,可能就出问题了。

另外,监控和告警真的很重要。如果不是监控及时告警,可能要等用户投诉了才发现问题,那时候影响就更大了。

最后,故障复盘不是为了追责,而是为了让系统更健壮。每一次故障,都是一次学习的机会。把问题记录下来,把修复方案落地,下次就不会再踩同样的坑。

毕竟,程序员的成长,就是在一次又一次的故障中踩坑、爬坑、再踩坑、再爬坑。