Redis 缓存设计:穿透、击穿与雪崩治理
覆盖 Redis 作为缓存使用时的核心设计决策:缓存一致性策略、三大缓存异常(雪崩/击穿/穿透)的成因与解法、缓存污染的处理,以及 Redis 变慢时的系统排查思路。
目录
| 章节 | 说明 |
|---|---|
| 旁路缓存模式 | Cache-Aside 的标准读写流程 |
| 缓存与数据库一致性 | 先更新 DB 还是先删缓存? |
| 缓存雪崩 | 大量 key 同时失效 |
| 缓存击穿 | 热点 key 失效瞬间 |
| 缓存穿透 | 查询不存在的 key |
| 缓存污染 | 只访问一次的数据占满缓存 |
| Redis 变慢排查 | 8 类阻塞点与排查方法 |
| 大 key 问题 | 识别、拆分、异步删除 |
| 内存碎片 | 成因与在线整理 |
| 缓冲区 | 客户端/复制/AOF 缓冲区的陷阱 |
旁路缓存模式
Cache-Aside(旁路缓存)是最常用的缓存模式,缓存由应用层管理:
读操作:
1. 先读缓存 → 命中则直接返回
2. 未命中 → 读数据库 → 写入缓存 → 返回
写操作:
1. 更新数据库
2. 删除(而非更新)缓存
为什么是"删除缓存"而不是"更新缓存"?
更新缓存存在并发问题:
线程 A:更新 DB(值 10 → 20)
线程 B:更新 DB(值 20 → 30)
线程 B:更新缓存(30)
线程 A:更新缓存(20) ← 缓存最终是 20,但 DB 是 30,不一致
删除缓存更简单、更安全:下次读时重新加载最新值。
缓存与数据库一致性
方案对比
| 方案 | 操作顺序 | 主要问题 |
|---|---|---|
| 先更新缓存,后更新 DB | - | DB 更新失败,缓存有脏数据 |
| 先更新 DB,后更新缓存 | - | 并发写时缓存可能覆盖新值 |
| 先更新 DB,后删缓存(推荐) | DB → DELETE cache | 短暂不一致(极小概率) |
| 先删缓存,后更新 DB | DELETE → DB | 缓存击穿风险,并发读会回填旧值 |
为什么"先更新 DB,后删缓存"是推荐方案
极小概率的不一致场景:
(需要缓存恰好刚好失效 + 读操作在 DB 更新和缓存删除之间完成,概率极低)
1. 缓存刚好失效
2. 线程 A 读 DB(旧值),准备写缓存
3. 线程 B 更新 DB,删缓存
4. 线程 A 将旧值写入缓存 ← 不一致(但持续时间 = 缓存 TTL,可自愈)
兜底方案:给缓存设置合理的 TTL,即使不一致也能自愈。
延迟双删(缓解不一致)
def update(key, new_value):
cache.delete(key) # 1. 先删缓存
db.update(key, new_value) # 2. 更新 DB
time.sleep(0.5) # 3. 等待其他线程的旧读请求完成
cache.delete(key) # 4. 再删一次缓存(兜底)
缓存雪崩
成因
大量缓存 key 同时失效(如批量设置了相同 TTL),导致请求同时打到数据库。
解决方案
# 方案1:过期时间加随机散列(最简单有效)
ttl = base_ttl + random.randint(0, 300) # 在基础 TTL 上加 0-5 分钟随机值
cache.set(key, value, ex=ttl)
# 方案2:服务降级(数据库压力过大时返回默认值/错误页)
# 方案3:缓存预热(系统启动时预先加载热点数据)
# 方案4:互斥锁(只允许一个线程重建缓存,其他等待)
lock = redis.set(f"lock:{key}", 1, nx=True, ex=3)
if lock:
value = db.query(key)
cache.set(key, value, ex=ttl)
redis.delete(f"lock:{key}")
缓存击穿
成因
单个热点 key 失效(区别于雪崩:多个 key)的瞬间,大量并发请求同时打到数据库。
解决方案
# 方案1:热点 key 永不过期(后台异步刷新)
cache.set(hot_key, value) # 不设 TTL
# 异步线程定期检查并刷新
# 方案2:互斥锁(双重检查)
def get_with_mutex(key):
value = cache.get(key)
if value:
return value
# 尝试加锁
lock = redis.set(f"lock:{key}", 1, nx=True, ex=3)
if lock:
try:
value = db.query(key)
cache.set(key, value, ex=300)
return value
finally:
redis.delete(f"lock:{key}")
else:
# 其他线程正在重建,稍等后重试
time.sleep(0.05)
return get_with_mutex(key)
# 方案3:SingleFlight(合并请求,见 Go 并发编程)
缓存穿透
成因
查询数据库中不存在的 key,缓存无法命中,每次都打到数据库。
常见场景:
- 恶意攻击(大量查询不存在的用户 ID)
- 业务 bug(查询了不存在的数据)
解决方案
# 方案1:缓存空值(最简单)
value = db.query(key)
if value is None:
cache.set(key, "NULL", ex=60) # 缓存空值,TTL 短一些
return None
cache.set(key, value, ex=300)
return value
# 方案2:布隆过滤器(更节省内存,适合数据量极大的场景)
# 写入时:将 key 加入布隆过滤器
bloom_filter.add(key)
# 查询时:先过布隆过滤器
if not bloom_filter.exists(key):
return None # 一定不存在,直接返回
value = cache.get(key)
...
| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空值 | 简单 | 占用内存,且数据存在后需及时更新 |
| 布隆过滤器 | 极省内存(1 亿数据约 100MB) | 有误判率(约 0.1%),不支持删除 |
缓存污染
成因
大量只访问一次的数据(如批量导出、全表扫描触发的缓存填充)占满缓存,导致真正的热数据被淘汰。
LRU 的不足
纯 LRU 只看"最近是否被访问",一次性扫描的数据会把真正的热数据挤出去。
LFU 的优势
**LFU(Least Frequently Used)**记录访问频率,频率低的优先淘汰。
Redis 实现的是近似 LFU:
- 每个 key 的 LFU 信息存储在
RedisObject.lru字段的后 8 位(频次计数器) - 访问时以一定概率递增计数器(防止暴增)
- 定期衰减计数器(体现"最近"的权重)
maxmemory-policy allkeys-lfu # 开启 LFU 淘汰
推荐:有热点数据访问模式时,用
allkeys-lfu比allkeys-lru效果更好。
Redis 变慢排查
8 类主要阻塞点
| 类型 | 典型操作 | 排查方式 |
|---|---|---|
| O(N) 集合操作 | HGETALL、SMEMBERS、全量 LRANGE | SLOWLOG |
| 大 key 删除 | DEL 含 10 万成员的集合 | SLOWLOG |
| AOF 刷盘 | always 模式 / 磁盘慢 | INFO persistence |
| 主从全量同步 | BGSAVE + RDB 传输 | INFO replication |
| 内存交换(SWAP) | 物理内存不足 | redis-cli --intrinsic-latency |
| 内存碎片率高 | 大量删除/更新操作 | INFO memory |
| 缓冲区溢出 | 输出缓冲区被强制关闭 | INFO clients |
| CPU 绑核问题 | NUMA 架构下跨 node 访问 | perf / taskset |
排查步骤
# 第一步:确认 Redis 本身变慢(排除网络因素)
redis-cli --intrinsic-latency 120 # 测试 120 秒内操作系统和硬件的基线延迟
# 第二步:查看慢查询日志
redis-cli SLOWLOG GET 20 # 查看最近 20 条慢查询
redis-cli SLOWLOG LEN # 慢查询总数
# 第三步:查看实例统计
redis-cli INFO stats | grep -E "total_commands|instantaneous_ops|keyspace_hits|keyspace_misses"
redis-cli INFO memory | grep -E "used_memory|mem_fragmentation_ratio|rdb_changes_since_last_save"
redis-cli INFO clients | grep -E "connected_clients|blocked_clients|client_recent_max_output_buffer"
# 第四步:扫描 bigkey
redis-cli --bigkeys -i 0.1 # 间隔 0.1 秒,降低对生产的影响
# 第五步:检查持久化状态
redis-cli INFO persistence | grep -E "rdb_last_bgsave_status|aof_current_rewrite_time_sec|loading"
关键指标参考
# 内存碎片率(mem_fragmentation_ratio)
# > 1.5:碎片严重,考虑在线整理
# < 1.0:内存不足,使用了 SWAP(危险!)
# 命中率(keyspace_hits / (keyspace_hits + keyspace_misses))
# 正常缓存命中率应 > 90%,过低说明缓存设计有问题
# 连接数
# connected_clients 接近 maxclients 时需要警惕
大 key 问题
什么是大 key
- String 类型 value > 10KB
- 集合类型成员数 > 5000
大 key 的危害
- 操作耗时长:序列化/反序列化大值,甚至 DELETE 大集合
- 内存分配不均:集群模式下导致数据倾斜
- 阻塞主线程:删除大 key 时释放内存耗时
异步删除(Lazy Free)
# 配置开启异步删除(不阻塞主线程)
lazyfree-lazy-eviction yes # 内存淘汰时异步删除
lazyfree-lazy-expire yes # key 过期时异步删除
lazyfree-lazy-server-del yes # 服务器删除时异步
lazyfree-lazy-user-del yes # 用户显式 DEL 命令时异步(6.0+)
# 手动异步删除(4.0+)
UNLINK key # 等价于 DEL,但异步执行(只是从 keyspace 摘除,实际删除在后台)
生产环境建议:开启
lazyfree-lazy-*所有选项,避免大 key 删除阻塞主线程。
大 key 的拆分
大 Hash(用户信息):
user:1 {name, age, address, bio, avatar_url, ...}
拆分为:
user:1:basic {name, age} # 高频访问
user:1:detail {address, bio} # 低频访问
user:1:media {avatar_url} # 独立存储
大 List(历史记录):
按时间分片:history:user:1:202401、history:user:1:202402 ...
内存碎片
成因
频繁的 SET/DELETE 操作导致内存分配器(jemalloc)产生碎片:
- 删除 100B 的 key,分配 120B 的新 key,剩余 20B 无法利用
检测
# mem_fragmentation_ratio = used_memory_rss / used_memory
# > 1.5 说明碎片率高(RSS 远大于实际使用)
redis-cli INFO memory | grep mem_fragmentation_ratio
在线整理(4.0+ 支持)
# 开启主动碎片整理(在线,对性能有轻微影响)
activedefrag yes # 总开关
active-defrag-ignore-bytes 100mb # 碎片 > 100MB 才开始整理
注意:碎片整理会消耗额外 CPU,
active-defrag-cpu-pct可限制 CPU 使用比例(默认 25%)。
缓冲区
Redis 有三种主要缓冲区,溢出会导致连接断开或数据丢失:
客户端输出缓冲区
# 客户端缓冲区溢出 → 客户端被强制断开连接
client-output-buffer-limit normal 0 0 0 # 普通客户端:不限制
client-output-buffer-limit slave 256mb 64mb 60 # 从库:硬限制 256MB,或 60 秒内超过 64MB
client-output-buffer-limit pubsub 32mb 8mb 60 # 发布订阅客户端
# 排查:输出缓冲区大的客户端
redis-cli CLIENT LIST | grep -v "omem=0"
主从复制缓冲区
主库为每个从库维护的 replication buffer(非 repl_backlog):
replication buffer:保存 RDB 传输期间 + 传输完成后的增量命令
大小 = RDB 传输时间 × 写命令 QPS × 命令平均大小
如果这个 buffer 溢出(超过 client-output-buffer-limit slave 限制)
→ 主库强制断开该从库连接 → 从库重新全量同步 → 恶性循环
解决:增大 client-output-buffer-limit slave 的限制,或降低主库的写入 QPS。
AOF 重写缓冲区
AOF 重写期间,新写入命令暂存在 aof_rewrite_buf:
- 重写结束后需要将缓冲区内容追加到新 AOF 文件
- 如果重写期间写入量很大,缓冲区会很大,追加时可能短暂阻塞主线程
参考资料
- 《Redis 核心技术与实战》— 第 11-28 讲(蒋德钧)
- Redis Keyspace Notifications
- Redis Memory Optimization
评论 (0)