缓存服务突然被打挂?排查穿透、击穿、雪崩的完整实战指南
缓存服务突然被打挂?排查穿透、击穿、雪崩的完整实战指南
问题现象:线上服务雪崩式宕机,日志里找不到具体原因
你一定见过这样的场景:
- 某个平时运行良好的后端服务,在某个时间点突然 CPU 飙高,接口响应从 10ms 变成 5 秒以上;
- 紧接着数据库连接池被打满,INLINE_CODE_0 错误开始大量涌现;
- 负载均衡器检测到后端不健康,自动摘除节点,剩下节点承受更大压力,加速崩溃;
- 你打开日志,发现全是 INLINE_CODE_1、INLINE_CODE_2、INLINE_CODE_3,但根本找不到「第一条」异常发生在哪里。
最让人崩溃的是:这个问题只在特定时间出现(比如促销活动、热点事件后),事后重启服务就好了,但没人知道为什么会发生。 这就是典型的缓存失效引发的连锁崩溃。
下面我把这个排查过程拆解为 5 个步骤,每一步都附带可以直接复制运行的代码和命令。
一、为什么缓存失效会引发服务雪崩?
很多人以为缓存挂了只是变慢一点,但实际上一旦缓存层失效,请求会全部打到数据库上。数据库连接数是有限的(通常 100~500),当并发请求数远超这个阈值时,数据库就会拒绝新连接,整个服务链崩溃。
缓存失效不是单一问题,而是三类问题的统称,它们的触发条件和解决方案各不相同:
| 问题类型 | 触发条件 | 典型场景 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据,缓存无法命中 | 恶意请求不存在的用户 ID、商品 ID |
| 缓存击穿 | 热点 key 在过期瞬间被大量并发请求打到数据库 | 秒杀商品、热搜词条在过期瞬间 |
| 缓存雪崩 | 大量 key 同时过期,或缓存节点宕机 | 批量设置缓存时统一 TTL、Redis 宕机 |
核心区别:穿透是「查不到」,击穿是「key 刚好过期」,雪崩是「大量 key 同时过期或缓存不可用」。分清这三者,才能对症下药。
二、缓存穿透:恶意请求把数据库拖垮的元凶
2.1 复现步骤(本地验证)
用 INLINE_CODE_4 模拟攻击场景:
# 连接本地 Redis(默认端口 6379)
redis-cli
# 故意查询一个不存在的 key,确认返回 null
GET user:999999
# (nil)
# 现在想象有 10000 个并发请求查询同一个不存在的 ID
# 每个请求都会穿透缓存,直接打到 MySQL
如果你用 JMeter 或 wrk 压测:
# 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):
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
防线二:空值缓存(兜底策略)
即使没有布隆过滤器,也要缓存「查不到」的结果:
# 在 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 复现步骤(本地验证)
# 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
如果应用层的缓存读取逻辑是这样的:
# ❌ 有击穿风险的写法(最常见错误)
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)
最经典的方案,保证只有一个请求去查数据库:
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。
策略二:逻辑过期(永不过期 + 异步刷新)
适合对实时性要求不高的热点数据:
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 复现步骤(本地验证)
# 模拟批量设置缓存(注意:所有 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 根本原因分析雪崩有两个常见触发原因:
- 批量设置相同 TTL:定时任务或初始化脚本中,对大量 key 设置了相同的过期时间,导致它们在同一时刻集体过期;
- 缓存节点宕机:Redis 实例宕机或网络分区,整个缓存层不可用,所有请求直接打到数据库。
第二种更危险,但本文重点讲第一种(因为第二种本质上变成了「100% 穿透」,解决方案是缓存高可用架构)。
4.3 解决方案:随机 TTL + 缓存预热 + 多级缓存(三层防护)
防护一:给 TTL 加随机抖动(最简单有效)
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 的请求均匀到来,每批过期只会影响一小部分请求,不会瞬间打满数据库。
防护二:缓存预热(启动时加载热点数据)
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
预热时机:服务启动后、流量接入前执行。可以用 Kubernetes 的 INLINE_CODE_13 配合预热脚本来确保预热完成后再接收流量。
防护三:多级缓存架构(终极防线)
当 Redis 宕机时,本地缓存可以作为最后防线:
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 抖动效果(防止雪崩)
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 监控告警配置(必须项)
# 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 开发规范(团队约束)
- 禁止裸查数据库:所有数据库查询必须先经过缓存层(用中间件或 AOP 强制拦截);
- TTL 必须加随机抖动:禁止在代码中硬编码固定 TTL,统一使用 INLINE_CODE_15 封装;
- 热点 key 必须配置逻辑过期或互斥锁:代码审查时检查热点数据的缓存策略,拒绝裸查裸写;
- 压测中必须包含缓存失效场景:CI/CD 流程中加入缓存失效压测,确保新功能不会引入新的击穿风险;
- 缓存预热脚本纳入部署流程:服务上线前自动执行预热,避免冷启动空窗期。
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)。如果你的缓存问题只是更大系统架构问题的一部分,那可能还需要从限流和降级策略入手。