第 17 章 · 跟练二:缓存击穿故障复盘
类型:故障复盘工作坊(约 2.5~3 小时)
场景:贤紫社区电商 爆款商品缓存击穿 导致 order-api 雪崩 —— 基于真实生产事故模式的教学案例。
前置:ch03 缓存、ch07 可观测性、ch13 错误预算、ch16 大促预热(本事故暴露 ch16 Runbook 缺口)。
产出物(内嵌本章):
- 完整 Postmortem 报告(§17.6 模板 + §17.7 示范)
- ADR-017 热点库存策略(§17.8)
- staging 复现实验记录(§17.5)
- Grafana 告警规则(§17.9)
- 修复前后压测对比(§17.4)
17.0 工作坊目标
| 能力 | 验收标准 |
|---|
| 事故定性 | 区分击穿 / 雪崩 / 穿透 |
| 根因分析 | 完成 5 Whys + 时间线 |
| 代码修复 | singleflight + TTL 抖动 + 逻辑过期 |
| 可观测 | 补命中率、DB 连接率告警 |
| 治理 | ADR 归档 + 错误预算复盘(ch13) |
17.1 事故概要
| 项 | 内容 |
|---|
| 事故编号 | INC-2026-0618-001 |
| 等级 | P1(核心交易不可用) |
| 时长 | 47 分钟(20:00~20:47) |
| 影响用户 | 约 12 万 无法完成下单 |
| GMV 估损 | 85 万(教学假设,按客单价与转化估算) |
| 数据丢失 | 无 |
| SLO 影响 | 99.95% 月预算 21.6min(ch13),本次单事故已耗尽并超标 |
17.2 事故时间线(必填进 Postmortem)
| 时间 (UTC+8) | 事件 | 证据来源 |
|---|
| 19:55 | 运营预告直播间爆款 HOT-999 | 工单 #MKT-8812 |
| 20:00 | 上架瞬间 QPS 升至 280(网关) | APISIX metrics |
| 20:03 | stock:HOT-999 Redis key 批量写入,TTL=60s | Redis MONITOR 抽样 |
| 20:05 | 同一秒数千 key 同时过期 | Redis expired_keys 尖刺 |
| 20:06 | 500+ QPS 回源 MySQL inventory | MySQL slow log |
| 20:08 | order-api 连接池耗尽,5xx 升至 18% | Prometheus |
| 20:12 | P1 告警:下单成功率 <90% | Alertmanager |
| 20:15 | War room 建立,怀疑 DB | 会议记录 |
| 20:20 | 人工降级:关闭下单 | ADR 临时开关 |
| 20:28 | DBA 发现非慢 SQL 而是 连接打满 | SHOW PROCESSLIST |
| 20:35 | 手工 SET stock:HOT-999 + APISIX 限流 50 QPS | 操作日志 |
| 20:42 | 5xx <1%,尝试灰度开放下单 | Grafana |
| 20:47 | 全量恢复,错误率 <0.1% | SLO 面板 |
时间线绘制要求:提交物用表格或 Mermaid timeline 均可,须标注 检测延迟(20:00 出事 → 20:12 告警 = 12 分钟)。
17.3 根因分析(5 Whys)
Why 1: 为什么用户看到大量 500?
→ order-api 超时,连接池与线程池饱和
Why 2: 为什么 order-api 饱和?
→ 每次下单都回源查库存,RT 从 5ms 升至 800ms+
Why 3: 为什么集体回源?
→ Redis key `stock:HOT-999` 同时失效,缓存 miss 风暴
Why 4: 为什么同时失效?
→ 上架脚本批量 SETEX,固定 TTL=60s,无随机抖动
Why 5: 为什么 miss 风暴打穿 DB?
→ 无互斥重建(singleflight)、无限流、无本地缓存
| 根因分类 | 具体项 | 责任域 |
|---|
| 架构缺陷 | 热点 key 无击穿保护 | 架构/开发 |
| 变更缺陷 | 上架脚本未走预热规范 | 运营/开发 |
| 可观测缺口 | 无缓存命中率、DB 连接率大盘 | SRE |
| 流程缺口 | ch16 大促 Runbook 未含「TTL 抖动」检查 | 运维 |
非根因(排除项)
| 怀疑 | 排除理由 |
|---|
| MySQL 硬件故障 | CPU 30%,磁盘正常 |
| Redis 集群宕机 | Redis 可用,QPS 正常 |
| DDoS | 流量为正常直播引流 |
| 发布变更 | 20:00 前 6 小时无 prod 部署 |
17.4 技术复盘:反例与修复
17.4.1 反例代码(catalog-api / order-api 共用库存读)
# ❌ 反例:apps/inventory/cache.py(教学简化)
import redis
from django.db import connection
redis_client = redis.Redis.from_url(settings.REDIS_URL)
def get_stock(sku_id: str) -> int:
key = f"stock:{sku_id}"
raw = redis_client.get(key)
if raw is not None:
return int(raw)
# 击穿点:数百并发同时进入
with connection.cursor() as cur:
cur.execute("SELECT qty FROM inventory WHERE sku=%s", [sku_id])
row = cur.fetchone()
qty = row[0] if row else 0
redis_client.setex(key, 60, qty) # 固定 TTL,批量写入时同时过期
return qty
17.4.2 修复方案 A:互斥锁 + TTL 抖动
# ✅ 修复:互斥重建 + 抖动 TTL
import random
import time
import redis
from django.db import connection
redis_client = redis.Redis.from_url(settings.REDIS_URL)
LOCK_TTL_SEC = 5
BASE_TTL_SEC = 300
def get_stock(sku_id: str) -> int:
key = f"stock:{sku_id}"
raw = redis_client.get(key)
if raw is not None:
return int(raw)
lock_key = f"lock:stock:{sku_id}"
acquired = redis_client.set(lock_key, "1", nx=True, ex=LOCK_TTL_SEC)
if acquired:
try:
with connection.cursor() as cur:
cur.execute("SELECT qty FROM inventory WHERE sku=%s", [sku_id])
row = cur.fetchone()
qty = row[0] if row else 0
ttl = BASE_TTL_SEC + random.randint(0, 30)
redis_client.setex(key, ttl, qty)
return qty
finally:
redis_client.delete(lock_key)
else:
time.sleep(0.05)
return get_stock(sku_id) # 生产可改有限重试 + 降级文案
17.4.3 修复方案 B:逻辑过期(高并发推荐)
# ✅ 逻辑过期:物理 key 仍在,逻辑时间戳判断
import json, time, random
def get_stock_logical(sku_id: str) -> int:
key = f"stock:v2:{sku_id}"
raw = redis_client.get(key)
if raw:
obj = json.loads(raw)
if obj["expire_at"] > time.time():
return obj["qty"]
# 逻辑过期:先返回旧值,异步刷新
if redis_client.set(f"refresh:{sku_id}", "1", nx=True, ex=10):
enqueue_refresh_stock(sku_id) # Celery / 线程池
return obj["qty"]
return _rebuild_stock(sku_id) # 冷启动走互斥
17.4.4 修复方案 C:进程内本地缓存(极热 key)
# Caffeine 等价:cachetools TTLCache,1 秒本地缓存
from cachetools import TTLCache
_local = TTLCache(maxsize=1000, ttl=1)
def get_stock_layered(sku_id: str) -> int:
if sku_id in _local:
return _local[sku_id]
qty = get_stock_logical(sku_id)
_local[sku_id] = qty
return qty
| 措施 | 作用 | 适用 |
|---|
| 互斥锁 / SETNX | 单飞回源 | 通用 |
| TTL 抖动 | 打散过期时刻 | 批量预热 |
| 逻辑过期 | 永不「硬 miss」 | 爆款 SKU |
| 本地缓存 1s | 挡 100ms 内重复读 | 直播秒杀 |
| 预热脚本 | 上架前写入 Redis | ch16 Runbook |
| 网关限流 | 保护 DB 最后防线 | APISIX |
17.5 跟练实验:staging 复现击穿(分步)
环境
| 项 | 值 |
|---|
| Namespace | staging-app |
| SKU | HOT-999 |
| Redis | prod-middleware 或 staging 实例 |
| 工具 | redis-cli、ab 或 k6 |
Step 1:写入热点 key(模拟上架)
redis-cli -h redis.staging SETEX stock:HOT-999 5 1000
# TTL 故意设 5 秒,便于快速复现
Step 2:启动流量(另一终端)
# 简易 ab 压测(或复用 ch16 k6 脚本改 skuId)
ab -n 5000 -c 100 -p order.json -T application/json \
http://staging-api/api/v1/orders
order.json 内容:{"userId":1,"items":[{"skuId":"HOT-999","qty":1}]}
Step 3:在 key 即将过期时批量触发
# 监控
watch -n1 'redis-cli TTL stock:HOT-999; curl -s -o /dev/null -w "%{http_code} %{time_total}\n" http://staging-api/api/v1/health"'
Step 4:观察击穿特征
| 指标 | 击穿时 | 修复后 |
|---|
Redis keyspace_misses | 尖刺 | 平缓 |
| MySQL 连接数 | >80% | <40% |
| order-api P99 | >800ms | <400ms |
| 5xx 率 | >5% | <0.1% |
Step 5:部署修复并复测
# 部署含 §17.4.2 的镜像后重复 Step 1~4
# 填写你的对比表截图
实验记录表(提交)
| 轮次 | 版本 | VU/c | P99 | 5xx% | MySQL 连接% | 备注 |
|---|
| 1 | 反例 | 100 | | | | TTL=5s 集体过期 |
| 2 | 修复 A | 100 | | | | 互斥+抖动 |
| 3 | 修复 B+C | 100 | | | | 逻辑过期+本地 |