折腾侠
技术教程

缓存服务突然被打挂?排查穿透、击穿、雪崩的完整实战指南

折腾侠
2026/04/27 发布
0约 16 分钟3485 字 / 1467 词00

缓存服务突然被打挂?排查穿透、击穿、雪崩的完整实战指南

问题现象:线上服务雪崩式宕机,日志里找不到具体原因

你一定见过这样的场景:

  • 某个平时运行良好的后端服务,在某个时间点突然 CPU 飙高,接口响应从 10ms 变成 5 秒以上;
  • 紧接着数据库连接池被打满,INLINE_CODE_0 错误开始大量涌现;
  • 负载均衡器检测到后端不健康,自动摘除节点,剩下节点承受更大压力,加速崩溃;
  • 你打开日志,发现全是 INLINE_CODE_1INLINE_CODE_2INLINE_CODE_3,但根本找不到「第一条」异常发生在哪里。

最让人崩溃的是:这个问题只在特定时间出现(比如促销活动、热点事件后),事后重启服务就好了,但没人知道为什么会发生。 这就是典型的缓存失效引发的连锁崩溃。

下面我把这个排查过程拆解为 5 个步骤,每一步都附带可以直接复制运行的代码和命令。


一、为什么缓存失效会引发服务雪崩?

很多人以为缓存挂了只是变慢一点,但实际上一旦缓存层失效,请求会全部打到数据库上。数据库连接数是有限的(通常 100~500),当并发请求数远超这个阈值时,数据库就会拒绝新连接,整个服务链崩溃。

缓存失效不是单一问题,而是三类问题的统称,它们的触发条件和解决方案各不相同:

问题类型触发条件典型场景
缓存穿透查询不存在的数据,缓存无法命中恶意请求不存在的用户 ID、商品 ID
缓存击穿热点 key 在过期瞬间被大量并发请求打到数据库秒杀商品、热搜词条在过期瞬间
缓存雪崩大量 key 同时过期,或缓存节点宕机批量设置缓存时统一 TTL、Redis 宕机

核心区别:穿透是「查不到」,击穿是「key 刚好过期」,雪崩是「大量 key 同时过期或缓存不可用」。分清这三者,才能对症下药。


二、缓存穿透:恶意请求把数据库拖垮的元凶

2.1 复现步骤(本地验证)

INLINE_CODE_4 模拟攻击场景:

Bash
# 连接本地 Redis(默认端口 6379)
redis-cli

# 故意查询一个不存在的 key,确认返回 null
GET user:999999
# (nil)

# 现在想象有 10000 个并发请求查询同一个不存在的 ID
# 每个请求都会穿透缓存,直接打到 MySQL

如果你用 JMeter 或 wrk 压测:

Bash
# 100 并发,发送 10000 次请求,查询不存在的用户
wrk -t10 -c100 -d10s http://localhost:8080/api/user/999999

你会发现数据库的 QPS 突然飙升,而那些 INLINE_CODE_5 的记录在数据库里根本不存在——这就是穿透。

2.2 根本原因分析

穿透的本质是:缓存对不存在的 key 不做记录。每次查询都走到数据库,数据库也查不到,但不会缓存这个「查不到」的结果。下一次请求又来一遍,无限循环。

2.3 解决方案:布隆过滤器 + 空值缓存(双重防线)

防线一:布隆过滤器(前置拦截)

布隆过滤器能在 O(1) 时间内判断一个 key「一定不存在」或「可能存在」,在请求到达数据库前就拦截掉。

Python 实现(使用 INLINE_CODE_6):

Python
from pybloom_live import ScalableBloomFilter
import redis

# 初始化布隆过滤器,误判率 0.1%
bloom = ScalableBloomFilter(initial_capacity=1000000, error_rate=0.001)

# 预热:将所有已存在的用户 ID 加入布隆过滤器
def warmup_bloom():
    # 从数据库加载所有有效用户 ID(实际应分页加载避免 OOM)
    valid_ids = get_all_valid_user_ids_from_db()
    for uid in valid_ids:
        bloom.add(uid)
    print(f"布隆过滤器预热完成,已加载 {len(valid_ids)} 个 ID")

# 查询接口的前置检查
def get_user(user_id):
    # 第一步:布隆过滤器检查(一定不存在 → 直接返回 404)
    if user_id not in bloom:
        print(f"[拦截] 用户 {user_id} 一定不存在,直接返回 404")
        return None

    # 第二步:查缓存(可能存在 → 继续查)
    cache_key = f"user:{user_id}"
    cached = redis_client.get(cache_key)
    if cached:
        return cached

    # 第三步:查数据库,结果写回缓存(含空值)
    user = db_query_user(user_id)
    if user:
        redis_client.setex(cache_key, 3600, user)
    else:
        # 空值缓存,短 TTL 防止误判过期后数据才入库的情况
        redis_client.setex(cache_key, 60, "NULL")
        print(f"[空值缓存] user:{user_id} 不存在,缓存 60 秒")
    return user

防线二:空值缓存(兜底策略)

即使没有布隆过滤器,也要缓存「查不到」的结果:

Python
# 在 DAO 层统一处理:查到空结果也缓存
def query_with_cache(key, query_fn, ttl=3600, null_ttl=60):
    val = redis_client.get(key)
    if val is not None:
        return None if val == "NULL" else val
    
    result = query_fn()
    if result is None:
        redis_client.setex(key, null_ttl, "NULL")
    else:
        redis_client.setex(key, ttl, result)
    return result

空值缓存的 TTL 要短(通常 30~60 秒),因为如果数据是后来才插入的,短的 TTL 能保证数据写入后很快被正确查询到。

三、缓存击穿:热点 key 过期瞬间的并发灾难

3.1 复现步骤(本地验证)

Bash
# 1. 设置一个热点 key,TTL 为 5 秒
redis-cli SET hot_article:1 "热门文章内容" EX 5

# 2. 等待 5 秒让 key 过期(用 watch 观察)
watch -n 0.1 'redis-cli GET hot_article:1'

# 3. 在 key 过期的瞬间,用压测工具发起高并发请求
date && wrk -t20 -c500 -d2s http://localhost:8080/api/article/1

如果应用层的缓存读取逻辑是这样的:

Python
# ❌ 有击穿风险的写法(最常见错误)
def get_article(article_id):
    cache_key = f"article:{article_id}"
    cached = redis_client.get(cache_key)
    if cached:
        return cached
    # 这里没有并发控制!100 个请求同时发现缓存为空,
    # 会同时执行下面的数据库查询,产生 100 倍数据库压力!
    article = db_query_article(article_id)
    redis_client.setex(cache_key, 300, article)
    return article

在 key 过期的瞬间,所有并发请求都会走到 INLINE_CODE_7,数据库压力骤增。

3.2 根本原因分析击穿的本质是:缓存过期的时间窗口内,没有并发控制。所有请求同时发现缓存为空,同时查询数据库。

3.3 解决方案:互斥锁(SETNX)+ 逻辑过期(双策略)

策略一:分布式锁(SETNX)

最经典的方案,保证只有一个请求去查数据库:

Python
import time
import redis

def get_article_with_lock(article_id, timeout=5):
    cache_key = f"article:{article_id}"
    lock_key = f"lock:article:{article_id}"
    
    # 先查缓存(快速路径)
    cached = redis_client.get(cache_key)
    if cached:
        return cached
    
    # 尝试获取分布式锁(SET NX EX,原子操作)
    acquired = redis_client.set(lock_key, "1", nx=True, ex=timeout)
    if acquired:
        try:
            # 获取到锁的线程:查数据库 → 写缓存
            article = db_query_article(article_id)
            redis_client.setex(cache_key, 300, article)
            return article
        finally:
            redis_client.delete(lock_key)
    else:
        # 没获取到锁的线程:等待后重试或返回旧值(如果有)
        time.sleep(0.05)  # 短暂休眠避免忙等(可优化为信号量)
        # 重试读缓存(此时持有锁的线程应该已经写入了)
        return redis_client.get(cache_key) or db_query_article(article_id)

关键细节

  • INLINE_CODE_8 必须是原子操作,不能用 INLINE_CODE_9 + INLINE_CODE_10 分开写;
  • INLINE_CODE_11 中一定要释放锁,否则异常会导致锁泄漏;
  • 等待重试策略可以优化为信号量或条件变量,避免 INLINE_CODE_12

策略二:逻辑过期(永不过期 + 异步刷新)

适合对实时性要求不高的热点数据:

Python
import threading
import json

def get_article_logical_expiry(article_id):
    cache_key = f"article:{article_id}"
    cached = redis_client.get(cache_key)
    
    if not cached:
        # 缓存完全不存在时,同步加载(只会在首次触发)
        article = db_query_article(article_id)
        # 存储格式:{"data": ..., "expire_at": <unix_timestamp>}
        payload = {"data": article, "expire_at": time.time() + 300}
        redis_client.set(cache_key, json.dumps(payload))
        return article
    
    payload = json.loads(cached)
    
    if time.time() < payload["expire_at"]:
        # 逻辑未过期,直接返回数据(注意:即使物理 key 永不过期也要判断逻辑时间)
        return payload["data"]
    else:
        # 逻辑已过期,但先返回旧值,异步刷新缓存(用户体验优先)
        threading.Thread(target=async_refresh_article, args=(article_id, cache_key), daemon=True).start()
        return payload["data"]

def async_refresh_article(article_id, cache_key):
    article = db_query_article(article_id)
    payload = {"data": article, "expire_at": time.time() + 300}
    redis_client.set(cache_key, json.dumps(payload))
    print(f"[异步刷新] article:{article_id} 缓存已更新")

逻辑过期的取舍

  • ✅ 用户体验好:始终返回数据(即使是旧的),零等待;
  • ⚠️ 数据不一致:在异步刷新完成前,用户看到的是旧数据;
  • ✅ 适合场景:热门文章、商品详情等允许短暂不一致的数据。

四、缓存雪崩:大量 key 同时过期导致数据库被「淹没」

4.1 复现步骤(本地验证)

Bash
# 模拟批量设置缓存(注意:所有 key 的 TTL 都是 300 秒)
redis-cli MSET item:1 "data1" item:2 "data2" item:3 "data3" ... item:10000 "data10000"

# 批量设置过期时间(问题所在:所有 key 在同一时间过期)
for i in $(seq 1 10000); do
    redis-cli EXPIRE item:$i 300
done

# 300 秒后,所有 key 同时过期
# 此时如果有 10000 QPS 的请求到来,全部穿透缓存打到数据库
date && wrk -t50 -c2000 -d5s http://localhost:8080/api/item/{1..10000}

4.2 根本原因分析雪崩有两个常见触发原因:

  1. 批量设置相同 TTL:定时任务或初始化脚本中,对大量 key 设置了相同的过期时间,导致它们在同一时刻集体过期;
  2. 缓存节点宕机:Redis 实例宕机或网络分区,整个缓存层不可用,所有请求直接打到数据库。

第二种更危险,但本文重点讲第一种(因为第二种本质上变成了「100% 穿透」,解决方案是缓存高可用架构)。

4.3 解决方案:随机 TTL + 缓存预热 + 多级缓存(三层防护)

防护一:给 TTL 加随机抖动(最简单有效)

Python
import random

def set_cache_with_jitter(key, value, base_ttl=300):
    """
    在基础 TTL 上增加 ±20% 的随机抖动
    例:base_ttl=300 → 实际 TTL 在 240~360 秒之间随机分布
    这样即使 10000 个 key 同时设置,也会在 2 分钟内分批过期,而非同时过期。
    """
    jitter = int(base_ttl * 0.2 * (2 * random.random() - 1))  # ±20%
    actual_ttl = base_ttl + jitter
    redis_client.setex(key, actual_ttl, value)
    # 调试日志:print(f"设置 {key}, TTL={actual_ttl}s (base={base_ttl}s, jitter={jitter})")

为什么 ±20%? 如果设置 10000 个 key,base_ttl=300s,抖动后过期时间分布在 240s~360s 之间(即 2 分钟窗口内分批过期)。即使每分钟 5000 QPS 的请求均匀到来,每批过期只会影响一小部分请求,不会瞬间打满数据库。

防护二:缓存预热(启动时加载热点数据)

Python
def warmup_hot_keys():
    """
    服务启动时预热热点 key,避免冷启动时缓存空窗期。
    同时给预热 key 设置随机 TTL,防止后续集体过期。
    """
    hot_keys = get_hot_keys_from_config()  # 从配置或数据库中读取需要预热的 key 列表
    
    for key_template in hot_keys:
        # 查询数据(带重试机制)
        data = db_query_with_retry(key_template, max_retries=3)
        if data:
            base_ttl = get_ttl_for_key_type(key_template)
            set_cache_with_jitter(key_template, data, base_ttl)
            print(f"[预热] {key_template} 已缓存")
        else:
            print(f"[预热跳过] {key_template} 数据不存在")
    
    print(f"[预热完成] 共加载 {len(hot_keys)} 个热点 key")

def db_query_with_retry(key, max_retries=3):
    """带重试的数据库查询,防止预热阶段数据库瞬时压力过大"""
    for attempt in range(max_retries):
        try:
            return db_query(key)
        except Exception as e:
            if attempt == max_retries - 1:
                print(f"[预热失败] {key} 查询失败: {e}")
                return None
            time.sleep(0.1 * (2 ** attempt))  # 指数退避:0.1s, 0.2s, 0.4s

预热时机:服务启动后、流量接入前执行。可以用 KubernetesINLINE_CODE_13 配合预热脚本来确保预热完成后再接收流量。

防护三:多级缓存架构(终极防线)

当 Redis 宕机时,本地缓存可以作为最后防线:

Python
from functools import lru_cache
import threading

# 本地缓存(进程内,容量有限但速度极快)
@lru_cache(maxsize=10000)
def local_cache_get(key):
    return None  # lru_cache 本身不提供 TTL,需配合其他方案(如 cachetools)

def get_data_multi_level(key, db_query_fn, redis_ttl=300, local_ttl=60):
    """
    三级缓存策略:本地缓存 → Redis → 数据库
    - L1: 进程内 LRU 缓存,极快但容量有限,适合极热点数据(单机 10000 条)
    - L2: Redis 分布式缓存,容量大,适合大部分热数据(集群 1000 万条)
    - L3: 数据库,持久化数据源,容量最大但最慢(亿级数据)
    
    降级逻辑:
    - Redis 宕机 → 降级为 L1 + L3(本地缓存兜底)
    - 本地缓存命中 → 不经过 Redis 和 DB(最快路径)
    """
    # L1: 本地缓存检查(进程内,微秒级)
    local_val = local_cache_get(key)
    if local_val:
        return local_val
    
    # L2: Redis 缓存检查(网络请求,毫秒级)
    try:
        redis_val = redis_client.get(key)
        if redis_val:
            # 写入本地缓存(双写一致性:L2 命中时也更新 L1)
            local_cache_get.cache_clear()  # 实际应用中应使用更精细的更新策略(如 cachetools)
            return redis_val
    except redis.ConnectionError as e:
        print(f"[降级] Redis 不可用,跳过 L2 缓存: {e}")
        # Redis 宕机时的降级逻辑:直接查数据库,同时缓存到本地(跳过 L2)
        data = db_query_fn()
        if data:
            # 本地缓存兜底,但 TTL 更短(防止本地缓存过期导致后续大量请求打到 DB)
            _set_local_with_ttl(key, data, local_ttl)  # 需要自定义实现
        return data
    
    # L3: 数据库查询(磁盘 I/O,最慢)
    data = db_query_fn()
    if data:
        redis_client.setex(key, redis_ttl, data)
        _set_local_with_ttl(key, data, local_ttl)
    return data

多级缓存的注意事项

  • 本地缓存容量要限制(INLINE_CODE_14),避免 OOM;
  • 多级缓存间的一致性难以保证,建议在业务上接受「最终一致」;
  • 本地缓存适合「读多写少」的数据,不适合频繁更新的数据。

五、验证方法:怎么确认问题已解决?

5.1 验证缓存穿透防护生效```bash

查询一个不存在的 ID,应该被布隆过滤器或空值缓存拦截,不会打到数据库

监控 MySQL 慢查询日志(观察是否有对应 ID 的查询)

tail -f /var/log/mysql/slow-query.log | grep "999999"

如果没有输出 → 说明拦截成功,数据库没收到查询请求!


### 5.2 验证缓存击穿防护生效(并发压测)
```bash
# 1. 清空缓存中某个热点 key(模拟过期)
redis-cli DEL hot_article:1

# 2. 用 wrk 发起高并发请求(500 并发,2 秒持续)
wrk -t20 -c500 -d2s http://localhost:8080/api/article/1

# 3. 同时监控 MySQL 连接数和慢查询
echo "--- MySQL 当前连接数 ---"
mysql -u root -e "SHOW STATUS LIKE 'Threads_connected';"

# 预期结果:
# - 加了互斥锁:Threads_connected 基本不变(只有 1~2 个查库线程)
# - 没加锁:Threads_connected 飙升到几百,出现 Too many connections 错误

5.3 验证 TTL 抖动效果(防止雪崩)

Python
import redis
import time

# 创建 100 个 key,使用抖动后的 TTL
def verify_ttl_jitter():
    r = redis.Redis()
    base_ttl = 300
    for i in range(100):
        r.setex(f"test_key:{i}", 300, f"value_{i}")
    
    # 检查每个 key 的实际剩余 TTL(应该分布在 240~360 秒之间)
    ttls = []
    for i in range(100):
        ttl = r.ttl(f"test_key:{i}")
        ttls.append(ttl)
    
    print(f"TTL 分布统计:")
    print(f"  最小值: {min(ttls)}s")
    print(f"  最大值: {max(ttls)}s")
    print(f"  平均值: {sum(ttls)/len(ttls):.1f}s")
    print(f"  范围: {max(ttls) - min(ttls)}s")
    print(f"  预期范围: {int(base_ttl * 0.8)}s ~ {int(base_ttl * 1.2)}s")
    assert min(ttls) >= int(base_ttl * 0.8), "TTL 抖动范围不符合预期!"
    assert max(ttls) <= int(base_ttl * 1.2), "TTL 抖动范围不符合预期!"
    print("✅ TTL 抖动验证通过!")

verify_ttl_jitter()

六、预防复发:如何避免再次踩坑?

6.1 监控告警配置(必须项)

YAML
# Prometheus + Alertmanager 规则示例(prometheus-rules.yml)
groups:
  - name: cache_alerts
    rules:
      # 规则 1:缓存命中率低于 70% 持续 5 分钟 → 告警(说明缓存命中率骤降,可能有大面积失效)
      - alert: CacheHitRateLow
        expr: redis_cache_hit_rate < 0.70
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "缓存命中率低于 70%,可能存在缓存失效问题"
          description: "当前命中率: {{ $value }}%,请立即检查缓存 key 的过期设置"
      
      # 规则 2:Redis 实例不可用 → 紧急告警(说明整个缓存层挂了)
      - alert: RedisDown
        expr: redis_up == 0
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "Redis 实例 {{ $labels.instance }} 不可用!"
          description: "Redis 已宕机 {{ $value }} 秒,服务已降级到数据库查询,请立即处理!"
      
      # 规则 3:数据库连接数超过 80% → 预警(可能是缓存穿透导致的连锁反应)
      - alert: DbConnectionHigh
        expr: mysql_threads_connected / mysql_max_connections > 0.80
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "数据库连接数超过 80%"
          description: "当前连接: {{ $value }}%,可能是缓存失效导致请求全部打到数据库"

6.2 开发规范(团队约束)

  1. 禁止裸查数据库:所有数据库查询必须先经过缓存层(用中间件或 AOP 强制拦截);
  2. TTL 必须加随机抖动:禁止在代码中硬编码固定 TTL,统一使用 INLINE_CODE_15 封装;
  3. 热点 key 必须配置逻辑过期或互斥锁:代码审查时检查热点数据的缓存策略,拒绝裸查裸写;
  4. 压测中必须包含缓存失效场景:CI/CD 流程中加入缓存失效压测,确保新功能不会引入新的击穿风险;
  5. 缓存预热脚本纳入部署流程:服务上线前自动执行预热,避免冷启动空窗期。

6.3 推荐的监控工具栈```markdown

监控维度 | 推荐工具 | 关键指标------------------|------------------------|---------------------缓存命中率 | Prometheus + Grafana | redis_cache_hit_rate, redis_keyspace_hits/misses缓存实例状态 | Redis Sentinel + 哨兵 | redis_up, redis_connected_clients数据库连接池 | HikariCP / Druid | activeConnections, waitingThreads服务响应时间 | APM(SkyWalking/Jaeger) | p99 latency, error_rate


---

## 七、总结:一套完整的缓存防护架构长什么样?

把上面的方案组合起来,一个生产级的缓存查询流程应该是这样的:

请求进来 → 布隆过滤器(拦截不存在的 key) → 本地缓存 L1(进程内,最快路径) → Redis 缓存 L2(分布式,毫秒级) → 命中 → 返回数据(同时更新 L1) → 未命中 → 互斥锁保护(防止击穿) → 查数据库 → 写回缓存(TTL 加随机抖动) → 返回数据``` 这套组合拳可以防御穿透(布隆过滤器 + 空值缓存)、击穿(互斥锁 / 逻辑过期)、雪崩(随机 TTL + 预热 + 多级缓存)三类问题,同时通过监控告警在问题扩大前及时介入。 最后提醒:缓存不是银弹。在缓存之上还有限流、降级、熔断等更上层的保护机制(如 Hystrix、Sentinel)。如果你的缓存问题只是更大系统架构问题的一部分,那可能还需要从限流和降级策略入手。

分享到:

如果这篇文章对你有帮助,欢迎请作者喝杯咖啡 ☕

加载评论中...