|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
一、具体的问题
某天上午商品详情页接口 P99 从 80ms 涨到 2.3s,MySQL CPU 打到 95%,慢查询日志里全是同一条 SQL:SELECT * FROM item WHERE item_id = ?。诡异的是这条查询走主键,单次执行不到 1ms。真相是三类缓存失效问题叠加在一起:爬虫在批量枚举不存在的 item_id(缓存穿透);秒杀热点 key 集中在整点过期,过期瞬间上千并发同时打到 DB(缓存击穿);前一天晚上批量导入任务给十几万个 key 设了完全相同的 TTL 86400 秒,次日凌晨集中失效(缓存雪崩)。三种失效混在一起,缓存形同虚设,DB 直接被打穿。这篇文章记录当时的完整排查与治理过程。
二、核心原理
先把三个概念掰清楚,它们经常被混为一谈,但根因和药方完全不同:
- 穿透:查询的数据在缓存和 DB 里都不存在。每次请求都绕过缓存直达 DB。根因是"缓存不会缓存'不存在'这个事实"。
- 击穿:某个热点 key 过期的一瞬间,成百上千个并发同时未命中,一起涌向 DB。根因是过期瞬间的并发没有互斥。
- 雪崩:大量 key 在同一时间集中过期,或者 Redis 实例本身挂掉。根因是过期时间或可用性没有做隔离。
三个容易误判的点:
- 布隆过滤器判"不存在"是一定准的,判"存在"却可能是误判——所以它只能拦截穿透流量的大头,误判漏网的部分还得靠空值缓存兜底。另外标准布隆过滤器不支持删除,硬删会把别的 key 也误判成不存在,要么用计数布隆,要么定期重建。
- 击穿的"互斥锁"不是加了分布式锁就完事——拿到锁的线程负责回填缓存,其余线程应该短暂等待后重读缓存拿到数据,而不是直接抛异常把错误放大给用户。
- 雪崩最常见的根因其实不是 TTL 撞车,而是 Redis 单实例没有高可用。TTL 随机抖动只解决"集中过期",解决不了实例挂掉;应用层限流降级才是最后一道防线。
三、实例参考(动手步骤)
步骤 0:确认现状基线
- redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses'
- # 命中率 = hits / (hits + misses)
复制代码- SHOW GLOBAL STATUS LIKE 'Threads_running';
复制代码
当时命中率只有 71%(健康值应大于 95%),misses 的突增曲线与 MySQL CPU 尖峰完全同步,先确认"DB 被 miss 流量打穿"这个大方向。
步骤 1:定位 miss 流量来源
在应用侧临时加日志抽样记录未命中的 key,再回 DB 验证:
- SELECT COUNT(*) FROM item WHERE item_id IN (/* 抽样的 100 个未命中 key */);
复制代码
结果 40% 的 miss 来自根本不存在的 item_id(爬虫枚举 id 空间),这就是穿透源头。
步骤 2:布隆过滤器前置拦截
安装 RedisBloom 模块后:
- BF.RESERVE item_bf 0.001 150000000
复制代码
误判率 0.1%,1.5 亿容量约占 180MB。上线前先用离线任务把全量 item_id 灌进去(BF.ADD item_bf <id> 循环或 pipeline 批量)。读路径改造为:先 BF.EXISTS item_bf <id>,返回 0 直接返回"商品不存在",不再碰缓存和 DB。
注意:标准布隆过滤器不支持删除,商品下架不删过滤器元素,漏网的由下一步兜底。
步骤 3:空值缓存兜底
- val = r.get(key)
- if val is None:
- data = db.query(item_id)
- if data is None:
- r.set(key, "", ex=60) # 空值,短 TTL
- return None
- r.set(key, json.dumps(data), ex=base_ttl)
复制代码
布隆误判的漏网流量、过滤器上线前的存量穿透都由它兜住。TTL 60 秒足够:太长会导致商品上架后一段时间内"查无此货"。
步骤 4:热点 key 互斥回填(防击穿)
- def get_item(key, item_id, retry=3):
- val = r.get(key)
- if val is not None:
- return json.loads(val)
- if retry <= 0:
- return db.query(item_id) # 兜底直查,宁可压 DB 也不能死循环
- if r.set("lock:" + key, 1, nx=True, ex=5):
- data = db.query(item_id)
- r.set(key, json.dumps(data), ex=86400)
- r.delete("lock:" + key)
- return data
- time.sleep(0.05)
- return get_item(key, item_id, retry - 1)
复制代码
另一种做法是逻辑过期:value 里带 expire_at 字段,发现逻辑过期后不删 key,由异步线程刷新,读请求永远拿得到旧数据,彻底消除"过期空窗"。
步骤 5:TTL 随机抖动(防集中过期)
- ttl = 86400 + random.randint(0, 3600)
复制代码
批量导入任务里原本统一的 EXPIRE key 86400 全部改成上述写法,把过期时间摊开到一个小时的区间里,削平失效尖峰。
步骤 6:限流与降级最后防线
- 应用层对详情接口加单机限流(信号量 500 QPS),超限直接返回上一次快照的静态兜底页。
- Redis 整体不可用时,读请求降级到本地二级缓存(Guava Cache,容量 10 万条、TTL 5 分钟)——数据可能旧五分钟,但比把 DB 打穿强得多。
步骤 7:验证
- 用 wrk 回放 10 万个不存在 id 的穿透流量,确认 DB QPS 增量接近 0。
- 观察命中率回升到 97% 以上、Threads_running 回落到个位数,才算治理闭环。
四、实操检查清单
- 缓存命中率监控:hit ratio < 95% 告警,突降必须当天定位。
- 布隆过滤器:容量按预估 key 数 1.5 倍设置,误判率 ≤ 0.1%;每月核对 BF.INFO 的 numItems 与业务表行数偏差。
- 空值缓存 TTL ≤ 5 分钟;写路径创建数据后必须删除对应空值 key。
- 所有写缓存路径的 TTL 必须加随机抖动(基础值 ±10% 以上)。
- 热点 key 用 redis-cli --hotkeys 每日巡检;秒杀类 key 提前预热并采用逻辑过期。
- 互斥锁必须带 ex 超时,重试必须有次数上限和 DB 直查兜底。
- 应用层限流阈值必须压测演练过,兜底页不能是"纸面方案"。
- Redis 本身要哨兵或集群高可用,每月做一次故障切换演练。
几个容易踩的坑
- 布隆过滤器没配空值缓存:误判的流量照样打到 DB,过滤器等于白装——两者必须成对上线。
- 空值缓存 TTL 设几小时:商品上架后长时间"查无此货",客诉比慢查询更难解释。
- 互斥锁不设过期时间:持锁线程一旦崩溃,这个 key 永远没人能回填,等于给缓存埋了颗雷。
- 把布隆的"可能存在"当"一定存在":它只能用于读路径拦截,写路径用它判断会漏数据。
- 限流阈值拍脑袋:没压测过的降级链路,真触发时兜底页自己先 5xx,比不降级还难看。
治理前后对比
| 指标 | 治理前 | 治理后 | | 缓存命中率 | 71% | 98.6% | | MySQL CPU 峰值 | 95% | 12% | | 详情接口 P99 | 2.3s | 82ms | | 穿透流量到 DB | 约 4200 QPS | < 5 QPS | | 热点过期瞬间 DB 尖峰 | 3800 QPS | 单次回填(互斥锁) | | Redis 故障演练 | 无 | 每月 1 次,降级链路验证可用 |
一句话总结:穿透靠"前置拦截 + 空值兜底",击穿靠"互斥回填 + 逻辑过期",雪崩靠"TTL 抖动 + 限流降级"——三道防线各管各的,缺一道,DB 就替缓存还债。 |
|