凌晨3点的告警电话
去年冬天的一个凌晨,我被 PagerDuty 的尖叫声吵醒。迷迷糊糊抓起手机一看——订单服务的 P99 延迟从 80ms 飙到了 2300ms,数据库 CPU 直接打满 100%。
我一边骂骂咧咧穿衣服,一边开电脑登 VPN。看了一圈监控面板,发现问题出在 Redis 上——缓存大面积失效,所有请求直接穿透到 MySQL,数据库当场被打穿。
那天晚上我踩了 三个经典的 Redis 坑:缓存穿透、缓存雪崩、热点 Key。每一个单独拿出来都不算复杂,但三个叠在一起,就是一场完美的风暴。这篇文章就是那次事故的完整复盘——从排查过程到根治方案,都是能直接用的实战代码。
第一坑:缓存穿透——恶意请求打穿了防线
现场还原
登上服务器后,先看了 Redis 的命中率:
$ redis-cli INFO stats | grep keyspace
keyspace_hits:1423781
keyspace_misses:9384721
# 命中率不到 13%……
再查数据库慢查询日志:
SELECT * FROM products WHERE id = -1;
SELECT * FROM products WHERE id = -2;
SELECT * FROM products WHERE id = -99999;
看到这个就明白了——有人在用不存在的 ID 大量请求接口。每个请求先查 Redis(miss),再查 MySQL(也 miss),但 Redis 里没有缓存「不存在」这个结果,每次都会穿透到数据库。
为什么「缓存空值」兜不住
缓存穿透最常见的建议是「缓存空值」。但这里有两个坑:
- 空值缓存撑爆内存:如果攻击者遍历 -1 到 -10000000,Redis 里就得存 1000 万个 null。你的内存是给正常业务用的,不是给垃圾数据用的。
- TTL 窗口漏洞:就算设了 5 分钟过期,攻击者可以每 5 分钟换一批 ID,持续打穿。
根治方案:布隆过滤器
核心思路:在查缓存之前,先用一个概率数据结构判断这个 key 是否可能存在。
下面是 Python 实现(pybloom-live):
from pybloom_live import ScalableBloomFilter
import redis, json
# 初始容量 100 万,误判率 0.1%,支持自动扩容
bloom = ScalableBloomFilter(
initial_capacity=1000000,
error_rate=0.001,
mode=ScalableBloomFilter.SMALL_SET_GROWTH
)
# 启动时从 DB 加载所有有效 ID
def load_bloom_from_db():
valid_ids = db.query("SELECT id FROM products")
for pid in valid_ids:
bloom.add(str(pid))
# 查询入口
def get_product(product_id: str):
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# 第一关:布隆过滤器——说不存在就 100% 不存在
if product_id not in bloom:
return None
# 第二关:Redis
cached = r.get(f"product:{product_id}")
if cached:
return json.loads(cached)
# 第三关:数据库
data = db_get(product_id)
if data:
r.setex(f"product:{product_id}", 3600, json.dumps(data))
return data
关键参数:误判率 0.1%,10 万个 key 只用约 120KB 内存。布隆过滤器 永远不会假阴性——它说不存在,就一定不存在。最坏情况只是多查一次 Redis,不会丢数据。
上线后:命中率从 13% 回到 96%,数据库 CPU 从 100% 降到 35%。
第二坑:缓存雪崩——同一秒过期的定时炸弹
现场还原
穿透问题解决后的第三天,凌晨 4 点,告警又响了。
这次的症状很奇怪——不是持续高负载,而是每隔一小时就有一个 2 分钟的尖峰。查 Redis key 过期时间:
$ redis-cli --scan --pattern "product:*" | while read k; do
echo "$k → TTL: $(redis-cli TTL $k)"
done | head -5
product:1001 → TTL: 12
product:1002 → TTL: 9
product:1003 → TTL: 5
product:1004 → TTL: 7
product:1005 → TTL: 3
# 几十万个 key 的 TTL 全集中在 0-60 秒…
根源:所有缓存用统一的 setex(key, 3600, value) 创建,在业务高峰期同一批写入,TTL 就像同步的定时炸弹,到点一起引爆。
三重保险修复
单纯随机 TTL 不够,上了三件套:
import random, redis, time, threading
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class CacheService:
def __init__(self):
self.local = {} # L0: 本地内存
self.local_ttl = {} # L0 TTL
self._circuit = False # 熔断状态
self._half_open = 0 # 半开时间戳
self._lock = threading.Lock()
def get(self, key: str):
now = time.time()
# L0: 本地缓存(30 秒 TTL)
with self._lock:
if key in self.local and self.local_ttl.get(key, 0) > now:
return self.local[key]
# L1: Redis + 熔断
if not self._circuit or now > self._half_open:
try:
val = r.get(key)
if val:
with self._lock:
self.local[key] = val
self.local_ttl[key] = now + 30
return val
except redis.ConnectionError:
# Redis 挂了 → 开熔断,10 秒后半开试探
self._circuit = True
self._half_open = now + 10
return self.local.get(key)
# L2: 数据库(最后的防线)
data = db_get(key)
if data:
# 🔑 随机 TTL:1800-3600 秒(30-60 分钟)
ttl = random.randint(1800, 3600)
try:
r.setex(key, ttl, data)
except redis.ConnectionError:
pass
with self._lock:
self.local[key] = data
self.local_ttl[key] = now + 30
return data
为什么随机窗口是 30-60 分钟? 经验法则:随机窗口 ≥ 基础 TTL 的 30%-50%。比如基础 TTL 1 小时,随机窗口就用 30 分钟。目的是让 10 万个 key 的过期时间分散到 30 分钟的时间跨度内,不在同一秒集中过期。
上线后那个「每小时一次尖峰」的监控图终于平了。
第三坑:热点 Key——一个爆款商品拖垮了整个集群
现场还原
前两个坑填平之后,系统稳定跑了两个月。然后双十一来了。
运营在首页 banner 推了一个秒杀商品,QPS 从平时的 200 飙到 8000+。问题是:Redis Cluster 里,这个 key 的 hash slot 落在单台节点上,那台节点 CPU 瞬间 100%,连带影响同节点的其他业务 key。
$ redis-cli --cluster info 127.0.0.1:7000
# 节点 3:CPU 100%,QPS 9000
# 节点 1、2:CPU 15%,QPS 400
# 热点 Key 完全集中在一个分片上
集群分片对热点 Key 没用——同一个 key 永远只落在一个 slot 上。
修复:两级缓存 + Key 拆分
对读多写少的热点数据,最有效的方案是在应用进程内做本地缓存:
import threading, time
class HotKeyCache:
"""进程内本地缓存 —— 热点 Key 专用"""
def __init__(self, redis_client):
self.redis = redis_client
self.cache = {}
self.ttl = {}
self.lock = threading.Lock()
def get(self, key: str):
now = time.time()
with self.lock:
if key in self.cache and self.ttl.get(key, 0) > now:
return self.cache[key]
# 本地 miss → 穿透一次到 Redis
val = self.redis.get(key)
if val:
with self.lock:
self.cache[key] = val
self.ttl[key] = now + 3 # 只缓存 3 秒!
return val
def invalidate(self, key: str):
with self.lock:
self.cache.pop(key, None)
self.ttl.pop(key, None)
关键设计:本地 TTL 只有 3 秒。热点数据可能随时变化(库存、价格),3 秒既能拦截 8000 QPS 中的绝大部分(只穿透 1 次到 Redis),数据延迟也完全可接受。实测:8000 QPS → Redis 只收到约 1 QPS。
极端场景(单 key > 10000 QPS),还可以用 Key 拆分——同一数据存多副本到不同 slot:
REPLICAS = 8
def get_hot_product(product_id):
idx = random.randint(0, REPLICAS - 1)
key = f"product:{product_id}:hot:{idx}"
return r.get(key)
8 个副本均匀分布在集群不同节点上,把单点压力分散到整个集群。
监控:没有监控就别用缓存
踩完这三个坑,我在 Prometheus + Grafana 上搭了一套 Redis 监控面板。核心指标就四个:
- 缓存命中率:低于 85% 报警。正常 90-98%。
- Key 过期集中度:每分钟过期 key 数的标准差。突升 = 雪崩风险。
- 热点 Key 检测:
redis-cli --hotkeys或MONITOR采样,单 key QPS > 1000 标记。- 各节点 CPU 偏差:集群节点间 CPU 差异 > 30% 说明有热点。
把面板挂上,配好告警。以后再也不会凌晨 3 点被叫醒了——至少能在面板上提前看到趋势,而不是被 PagerDuty 炸醒。
说实话,这三个问题都不是什么高深技术。但它们的共同点是——你只有被炸过一次,才会真正理解为什么要做这些防护。

FAQ
Q: 布隆过滤器误判了会不会把存在的 key 也拦截了?
不会。布隆过滤器只会「假阳性」——说存在但实际不存在。它永远不会「假阴性」。如果它说不存在,那就是 100% 不存在。最坏情况:多查一次 Redis,不丢数据。误判率 0.1% 意味着 1000 次「不存在」查询里只有 1 次穿透到 Redis,完全可以接受。
Q: 随机 TTL 窗口设多大合适?
窗口 ≥ 基础 TTL 的 30%-50%。比如基础 1 小时,随机窗口就用 30 分钟。核心原则:不要让超过 10% 的 key 在同一分钟内过期。如果你的流量非常集中(如整点推送),就把窗口加宽到 50%。
Q: 本地缓存和 Redis 数据不一致怎么办?
这就是本地 TTL 要设短的原因——3 秒意味着最多 3 秒不一致。对商品详情场景完全可以接受。如果对一致性要求极高(如库存扣减),就不要用本地缓存,走 Redis + Lua 原子操作。另外在数据更新时调用 invalidate() 主动失效。
总结
回顾这次事故,三个问题的本质都一样:你假设流量是均匀的、温和的,但生产环境从来不是。三样东西加起来不到 200 行代码:
- 缓存穿透 → 布隆过滤器挡不存在的 key(成本:几 KB 内存)
- 缓存雪崩 → 随机 TTL + 多级缓存 + 熔断(成本:几十行代码)
- 热点 Key → 本地缓存 + Key 拆分(成本:几十行代码 + 一点内存)
建议今天就把它们加进代码库——别等到被炸了再补。那次事故之后我对自己说:一个凌晨 3 点的 PagerDuty,值一万行防御代码。
相关阅读:
📖 扩展阅读:eBPF + bpftrace 生产环境调试实战 — 不用改代码不用重启,一行命令定位线上问题。