目录
正在加载目录…
专栏文章
专栏文章
Redis 专栏
1. Redis:核心数据结构与应用场景 2. Redis 数据结构:底层实现与性能取舍 3. Redis 持久化与复制:RDB、AOF 与主从同步 4. Redis 缓存设计:穿透、击穿与雪崩治理 5. Redis 高可用:哨兵、集群与故障转移 6. Redis 多级缓存:一致性、失效与性能优化

Redis 缓存设计:穿透、击穿与雪崩治理

发布于 2026-07-07 10:09 · 最后编辑于 2026-07-31 15:52 · 字数 2,034 👁 123 次阅读

覆盖 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短暂不一致(极小概率)
先删缓存,后更新 DBDELETE → 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. 再删一次缓存(兜底)

缓存雪崩

redis cache problems

成因

大量缓存 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-lfuallkeys-lru 效果更好。

Redis 变慢排查

8 类主要阻塞点

类型典型操作排查方式
O(N) 集合操作HGETALL、SMEMBERS、全量 LRANGESLOWLOG
大 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 文件
  • 如果重写期间写入量很大,缓冲区会很大,追加时可能短暂阻塞主线程

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论