下载工作台
企业级架构运维

跟练二:缓存击穿故障复盘

试读上半部分 · 解锁后可读全文

第 17 章 · 跟练二:缓存击穿故障复盘

类型:故障复盘工作坊(约 2.5~3 小时)

场景:贤紫社区电商 爆款商品缓存击穿 导致 order-api 雪崩 —— 基于真实生产事故模式的教学案例。

前置:ch03 缓存、ch07 可观测性、ch13 错误预算、ch16 大促预热(本事故暴露 ch16 Runbook 缺口)。

产出物(内嵌本章):

  1. 完整 Postmortem 报告(§17.6 模板 + §17.7 示范)
  2. ADR-017 热点库存策略(§17.8)
  3. staging 复现实验记录(§17.5)
  4. Grafana 告警规则(§17.9)
  5. 修复前后压测对比(§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:03stock:HOT-999 Redis key 批量写入,TTL=60sRedis MONITOR 抽样
20:05同一秒数千 key 同时过期Redis expired_keys 尖刺
20:06500+ QPS 回源 MySQL inventoryMySQL slow log
20:08order-api 连接池耗尽,5xx 升至 18%Prometheus
20:12P1 告警:下单成功率 <90%Alertmanager
20:15War room 建立,怀疑 DB会议记录
20:20人工降级:关闭下单ADR 临时开关
20:28DBA 发现非慢 SQL 而是 连接打满SHOW PROCESSLIST
20:35手工 SET stock:HOT-999 + APISIX 限流 50 QPS操作日志
20:425xx <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 内重复读直播秒杀
预热脚本上架前写入 Redisch16 Runbook
网关限流保护 DB 最后防线APISIX

17.5 跟练实验:staging 复现击穿(分步)

环境

Namespacestaging-app
SKUHOT-999
Redisprod-middleware 或 staging 实例
工具redis-cliabk6

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/cP995xx%MySQL 连接%备注
1反例100TTL=5s 集体过期
2修复 A100互斥+抖动
3修复 B+C100逻辑过期+本地

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。