缓存雪崩一片数据全塌

1.1 场景还原:两种雪崩,两大惨剧

 
═══════════════════════════════════════════════════════════════
 
雪崩类型一:大量缓存同时过期
 
═══════════════════════════════════════════════════════════════
 
  场景:你用定时任务,凌晨 3 点把所有 10 万件商品的缓存刷新了一遍,
 
       全部设了 1 小时过期。
 
  凌晨 4:00:00 整 → 10 万个 key 同时过期!
 
  → 凌晨 4:00:01 → 所有请求都发现缓存没了
 
  → 10 万个请求同时涌向 MySQL
 
  → 数据库:我是谁我在哪为什么这么多请求???
 
  → 数据库 CPU 100% → 执行超时 → 连锁反应 → 整站崩溃
 
═══════════════════════════════════════════════════════════════
 
雪崩类型二:Redis 本身挂了
 
═══════════════════════════════════════════════════════════════
 
  场景:Redis 进程 crash / Redis 服务器宕机 / 网络中断
 
  → 所有请求绕过 Redis 直接打向 MySQL
 
  → 原来 Redis 扛着的 95% 流量全部砸到 MySQL 身上
 
  → MySQL 平时扛 1000 QPS,现在迎面 20000 QPS
 
  → 瞬间被打死 → 整个应用瘫痪
 
类比——和你有关的生活画面:
 
  类型一 = 学校食堂中午 12:00 同时下课,全校 2 万人冲向 3 个窗口
 
  类型二 = 食堂突然停电了,所有人涌向校外唯一的小卖部
 

一句话定义:缓存雪崩 = 大量缓存同一时间段失效,或 Redis 本身不可用,导致海量请求直接打到数据库。

1.2 解法一:随机过期时间——最简单有效的”削峰”

核心思路:同一批缓存的过期时间不要设成一样的。在基础时间上随机加减一个偏移量,让过期时间分散开来。

 
❌ 错误做法——同一批全部设 3600 秒:
 
  SETEX product:1 3600 "..."
 
  SETEX product:2 3600 "..."
 
  SETEX product:3 3600 "..."
 
  ...
 
  SETEX product:10000 3600 "..."
 
  → 3600 秒后同时过期 → 雪崩!
 
✅ 正确做法——每个 key 的过期时间加随机偏移:
 
  SETEX product:1 3650 "..."    # 3600 + random(0, 600)
 
  SETEX product:2 3823 "..."    # 3600 + random(0, 600)
 
  SETEX product:3 3489 "..."    # 3600 + random(0, 600)
 
  ...
 
  SETEX product:10000 4105 "..." # 3600 + random(0, 600)
 
  → 过期时间分散在 3600~4200 秒之间
 
  → 不会同时过期 → 不会同时回源 → 数据库压力被分摊
 
Python 实现:
 
═══════════════════════════════════════════════════════════════
 
  import random
 
  base_ttl = 3600           # 基础 1 小时
 
  random_offset = random.randint(0, 600)  # 随机 0~10 分钟
 
  actual_ttl = base_ttl + random_offset
 
  r.setex(f"product:{product_id}", actual_ttl, json.dumps(product))
 

一句话:过期时间 = 基础时间 + 随机值。关键不是随机算法多高级,是不要所有 key 同一秒过期

1.3 解法二:多级缓存——Redis 挂了还有本地缓存撑一会

核心思路:不要把所有鸡蛋放一个篮子里。在应用服务器本地也存一份热点数据,Redis 挂了至少能撑一阵。


graph TD

    REQ["用户请求"]

    REQ --> B1["① 浏览器缓存<br/>(用户自己的浏览器里)<br/>← 最快,但没有后端控制力"]

    B1 -->|"没命中"| N1["② Nginx 缓存<br/>(反向代理层)<br/>← 极快,适合完全不变的静态数据"]

    N1 -->|"没命中"| L1["③ 本地缓存<br/>(应用服务器内存——Python dict)<br/>← 快,不需要网络,但容量小"]

    L1 -->|"没命中"| R1["④ Redis 缓存<br/>(分布式内存)<br/>← 正常主力,所有服务器共享"]

    R1 -->|"没命中"| M1["⑤ MySQL 数据库(磁盘)<br/>← 兜底,最慢但数据最全"]

    style B1 fill:#e1f5fe

    style N1 fill:#e8f5e9

    style L1 fill:#fff3e0

    style R1 fill:#fce4ec

    style M1 fill:#f3e5f5

 
"""
 
多级缓存演示:本地缓存 + Redis + MySQL
 
"""
 
import json
 
import time
 
import redis
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
# 生产环境可以用 cachetools、functools.lru_cache 等
 
local_cache: dict[str, tuple[dict, float]] = {}  # key → (data, expire_at)
 
def get_product_multi_level(product_id: int) -> dict | None:
 
    """查商品:本地缓存 → Redis → MySQL 三级回源"""
 
    cache_key = f"product:{product_id}"
 
    # ===== 第 1 层:本地缓存(进程内存) =====
 
    if cache_key in local_cache:
 
        data, expire_at = local_cache[cache_key]
 
        if time.time() < expire_at:
 
            return data
 
        # 过期了 → 删掉,继续往下走
 
        del local_cache[cache_key]
 
    # ===== 第 2 层:Redis 缓存 =====
 
    try:
 
        cached = r.get(cache_key)
 
        if cached:
 
            data = json.loads(cached)
 
            # 同步到本地缓存(下次就不用走 Redis 了)
 
            local_cache[cache_key] = (data, time.time() + 60)  # 本地存 60 秒
 
            return data
 
    except redis.ConnectionError:
 
        # 🔴 Redis 挂了!跳过 Redis,直接查本地缓存或数据库
 
        pass
 
    # ===== 第 3 层:MySQL =====
 
    product = db.query("SELECT * FROM products WHERE id = %s", product_id)
 
    if product:
 
        # 写回 Redis(Redis 可能还活着)
 
        try:
 
            r.setex(cache_key, 3600, json.dumps(product))
 
        except redis.ConnectionError:
 
            pass  # Redis 挂了就不写,不能因为写 Redis 失败就报错
 
        # 写本地缓存
 
        local_cache[cache_key] = (product, time.time() + 60)
 
    return product
 
# ===== 定期清理本地缓存(防止内存泄漏) =====
 
def clean_local_cache():
 
    """删掉已过期的本地缓存条目"""
 
    now = time.time()
 
    expired_keys = [
 
        k for k, (_, exp) in local_cache.items() if now >= exp
 
    ]
 
    for k in expired_keys:
 
        del local_cache[k]
 

💡 一句话讲清:“多级缓存是一种纵深防御策略。Nginx 缓存挡掉 70%,本地缓存挡掉 20%,Redis 挡掉 9%,真正打到数据库的只有 1%。任何一层挂了,前面那一层还能撑一会——给运维争取恢复时间。“

1.4 解法三:限流降级 + 熔断——保不住体验就先保命

 
当雪崩已经发生——MySQL 已经被大量请求轰炸时,你需要的不是"优化缓存策略",
 
而是"立刻止血"。
 
═══════════════════════════════════════════════════════════════
 
限流(Rate Limiting)
 
═══════════════════════════════════════════════════════════════
 
  数据库前面加一个限流器——每秒只放 1000 个请求过去。
 
  请求 1~1000  → 放行 → 查 MySQL
 
  请求 1001~10000 → 直接返回"系统繁忙,请稍后再试"(不碰 MySQL!)
 
  牺牲一部分用户体验,保住数据库不被打挂。
 
═══════════════════════════════════════════════════════════════
 
降级(Degradation)
 
═══════════════════════════════════════════════════════════════
 
  MySQL 被打得太惨 → 直接把"商品详情"接口降级。
 
  正常时:返回商品名称、价格、库存、评价、推荐……(十几条 SQL)
 
  降级时:只返回商品名称 + 价格(从 Redis 拿,不查 MySQL)
 
  关掉非核心功能,保住核心链路能跑。
 
═══════════════════════════════════════════════════════════════
 
熔断(Circuit Breaker)
 
═══════════════════════════════════════════════════════════════
 
  发现 MySQL 连续 10 次超时 → "熔断器打开"
 
  → 接下来 30 秒根本不请求 MySQL!
 
  → 所有请求直接返回兜底数据(比如 Redis 里的旧数据 / 默认数据)
 
  → 30 秒后,放一个请求去试探 MySQL 恢复了没
 
    ├── 恢复了 → 熔断器关闭,恢复正常
 
    └── 还是超时 → 继续熔断 30 秒
 
  类比——你家电闸:
 
    电路短路 → 跳闸(熔断)→ 保护电器不被烧
 
    → 过一会推上去试试 → 还短路就再跳
 
三者配合——最完整的防御链条:
 
═══════════════════════════════════════════════════════════════
 
  海量请求 → 限流(卡住大部分)
 
           → 降级(能调的接口简化)
 
           → 熔断(发现 MySQL 不行了就不去了)
 
           → 兜底数据返回(总比白屏报错强)
 

1.5 解法四:Redis 高可用——让 Redis 自己别挂

 
雪崩的根本原因之一是 Redis 挂了。怎么让 Redis 不容易挂?
 
═══════════════════════════════════════════════════════════════
 
方案一:Redis 主从 + 哨兵(Sentinel)
 
═══════════════════════════════════════════════════════════════
 
  一台 Redis 主节点(负责读写)
 
  + 多台 Redis 从节点(只读,复制主节点的数据)
 
  + 哨兵进程(盯着主节点,主挂了自动选一个从节点当新主)
 
  主节点挂了 → 哨兵 10 秒内发现 → 自动把一台从节点升为主节点
 
  → 应用层几乎无感知,Redis 继续工作
 
═══════════════════════════════════════════════════════════════
 
方案二:Redis 集群(Cluster)
 
═══════════════════════════════════════════════════════════════
 
  把数据分片存到多台 Redis 上——每台只存一部分数据。
 
  任一台挂了,只影响那台机器上的数据,其他 5 台继续工作。
 
  但这属于更进阶的话题——能说清"主从+哨兵"已经够了。
 

1.6 雪崩五种防线——从便宜到贵的防御塔

 
  防雪崩五层防线(作为进阶亮点了解即可):
 
  第 1 层:随机过期时间          ← 零成本,一定要做
 
  第 2 层:本地缓存兜底           ← 小成本,存最热的数据
 
  第 3 层:限流降级熔断           ← 中成本,需要框架支持
 
  第 4 层:多级缓存               ← 中成本,架构层面
 
  第 5 层:Redis 高可用集群       ← 高成本,运维层面
 
  前两层你项目里就能做,后三层是公司级架构的事情。
 

速记卡(面试闪卡)

Q1:一句话讲清「缓存雪崩一片数据全塌」到底是什么?

A:缓存雪崩指大量缓存同一时间失效或 Redis 宕机,导致海量请求直接冲垮数据库(Cache Avalanche)。

Q2:第三章:缓存雪崩——不是一条数据,是一片数据全塌了 —— 怎么理解?

A:雪崩分两类——类型一:大量 key 同时过期(如定时任务统一设 1 小时,到点 10 万请求齐刷刷打向 MySQL);类型二:Redis 本身挂了,95% 流量全砸数据库。像全校 2 万人同时下课冲 3 个窗口,或食堂停电全员涌向小卖部(缓存雪崩,Cache Avalanche)。

Q3:1.1 场景还原:两种雪崩,两大惨剧 —— 怎么理解?

A:类型一是”过期时间撞车”——同一批 key 同秒过期,请求并发回源;类型二是”Redis 挂掉”——所有流量绕过缓存直击数据库。两者都让数据库 CPU 100% 连锁崩溃。像两场不同起因的踩踏,根因都是”缓存没兜住”。

Q4:1.2 解法一:随机过期时间——最简单有效的”削峰” —— 怎么理解?

A:核心思路——过期时间 = 基础时间 + 随机偏移,让 key 分散在 3600~4200 秒之间,不同时回源。Python 用 random.randint(0,600) 加偏移。像发电影票错峰入场,别让几万人同一秒进场(削峰,Peak Shaving)。

Q5:1.3 解法二:多级缓存——Redis 挂了还有本地缓存撑一会 —— 怎么理解?

A:本地缓存(应用内存 dict)→ Redis → MySQL 三级回源,任一层命中就返回。Redis 挂了本地还能撑一阵,给运维争取恢复时间。Nginx 挡 70%、本地 20%、Redis 9%,真正打库的只有 1%(多级缓存,Multi-level Cache)。

Q6:核心速记主线有哪些?

  • 雪崩两类:大量 key 同时过期 / Redis 本身宕机

  • 解法一:随机过期时间 = 基础 + 随机偏移,错峰回源

  • 解法二:本地+Redis+MySQL 多级缓存兜底

  • 解法三:限流降级熔断;解法四:Redis 主从+哨兵高可用

口诀

A:缓存雪崩两类型,同过期或 Redis 崩;

随机过期加偏移,错峰回源不作俑;

多级缓存兜底撑,本地 Redis 加 MySQL;

限流熔断降级护,高可用集群不放松。

相关链接