在高并发系统中,缓存(如 Redis)与数据库(如 MySQL)配合使用是提升性能的关键手段。但若设计不当,会引发四类经典问题:双写不一致、缓存穿透、缓存雪崩、缓存击穿。下面逐一详解其原理、危害及解决方案。
一、缓存与 DB 双写不一致(Cache-DB Inconsistency)
🔍 问题描述
当数据更新时,先更新数据库,再操作缓存(删除或更新),但由于网络延迟、程序异常或并发操作,导致 缓存与数据库中的数据短暂或长期不一致。
🧩 典型场景
- 线程 A 更新 DB → 删除缓存
- 线程 B 在 A 删除缓存后、新数据写入前,查询 DB(旧值)并写入缓存
- 结果:缓存中是旧数据,DB 是新数据 → 不一致
1sequenceDiagram 2 participant A as 线程A(更新) 3 participant B as 线程B(读取) 4 participant DB 5 participant Cache 6 7 A->>DB: update user.name = "Alice" 8 A->>Cache: del user:123 9 B->>Cache: get user:123 (miss) 10 B->>DB: select name → "Bob" (旧值!) 11 B->>Cache: set user:123 = "Bob" 12 Note right of Cache: 缓存脏数据! 13
⚠️ 危害
- 用户看到过期数据(如余额、订单状态错误)
- 金融、电商等强一致性场景不可接受
✅ 解决方案
方案 1:Cache-Aside + 延迟双删(推荐)
1def update_user(user_id, new_name): 2 # 1. 删除缓存(第一次) 3 redis.delete(f"user:{user_id}") 4 5 # 2. 更新数据库 6 db.update("UPDATE users SET name=%s WHERE id=%s", (new_name, user_id)) 7 8 # 3. 延迟一段时间后再次删除缓存(防止步骤2期间有旧数据写入缓存) 9 time.sleep(0.5) # 或用消息队列异步执行 10 redis.delete(f"user:{user_id}") 11
💡 原理:第二次删除可清除在 DB 更新期间被写入的旧缓存。
方案 2:先更新缓存,再更新 DB(不推荐)
- 若 DB 更新失败,缓存已变脏,更难恢复
方案 3:使用 Binlog 订阅(最终一致性)
- 通过 Canal / Debezium 监听 MySQL Binlog
- 异步更新/删除缓存,保证最终一致(适合非强一致场景)
📌 最佳实践:
- 强一致场景:直接读 DB(牺牲性能)
- 普通场景:采用“先删缓存 → 更新 DB → 延迟再删缓存”
二、缓存穿透(Cache Penetration)
🔍 问题描述
大量请求查询 根本不存在的数据(如 user_id = -1),导致:
- 每次都 绕过缓存,直击数据库
- 数据库压力剧增,甚至宕机
🧩 典型场景
- 恶意攻击:遍历不存在的 ID
- 业务逻辑 bug:前端传入非法参数
⚠️ 危害
- DB QPS 暴涨,CPU 打满
- 正常服务不可用(雪崩前兆)
✅ 解决方案
方案 1:缓存空值(Null Cache)
1def get_user(user_id): 2 key = f"user:{user_id}" 3 cached = redis.get(key) 4 if cached is not None: 5 return None if cached == "" else json.loads(cached) 6 7 # 查询 DB 8 user = db.query("SELECT ... WHERE id=%s", user_id) 9 if user: 10 redis.setex(key, 300, json.dumps(user)) 11 else: 12 # ✅ 关键:缓存空结果(短 TTL) 13 redis.setex(key, 60, "") # 只缓存 60 秒 14 return user 15
方案 2:布隆过滤器(Bloom Filter)
- 在缓存前加一层 布隆过滤器
- 快速判断 key 是否“可能存在”
- 若布隆过滤器返回“不存在”,直接拒绝请求
1from pybloom_live import BloomFilter 2 3# 初始化布隆过滤器(预计 100 万用户) 4bf = BloomFilter(capacity=1000000, error_rate=0.001) 5 6# 预加载所有合法 user_id 7for uid in all_valid_user_ids: 8 bf.add(uid) 9 10def get_user_safe(user_id): 11 if user_id not in bf: 12 raise ValueError("Invalid user ID") # 直接拦截 13 return get_user(user_id) 14
📌 适用场景:
- 空值缓存:适用于少量无效请求
- 布隆过滤器:适用于海量无效请求(如爬虫攻击)
三、缓存雪崩(Cache Avalanche)
🔍 问题描述
大量缓存 key 在同一时刻失效,导致:
- 所有请求同时打到数据库
- DB 瞬间压力过大,可能崩溃
🧩 典型原因
- 缓存服务器宕机(全部失效)
- 所有 key 设置了相同的过期时间(如统一 2 小时)
⚠️ 危害
- 数据库连接池耗尽
- 服务大面积不可用
✅ 解决方案
方案 1:设置随机过期时间
1import random 2 3def set_cache_with_random_ttl(key, value, base_ttl=3600): 4 # 在基础 TTL 上增加随机值(如 ±5 分钟) 5 ttl = base_ttl + random.randint(-300, 300) 6 redis.setex(key, max(ttl, 60), value) # 确保至少 60 秒 7
方案 2:永不过期 + 后台异步更新
- 缓存不设 TTL
- 启动后台线程定期更新热点数据
- 请求时若发现数据“太旧”,触发异步刷新(Refresh-Ahead)
方案 3:高可用架构
- Redis 集群(Cluster)避免单点故障
- 多级缓存(本地缓存 + Redis)
📌 关键:避免所有 key 同时失效!
四、缓存击穿(Cache Breakdown)
🔍 问题描述
某个热点 key 过期瞬间,大量并发请求同时发现缓存失效,全部打到数据库。
🧩 与雪崩的区别
| 缓存击穿 | 缓存雪崩 | |
|---|---|---|
| 范围 | 单个热点 key | 大量 key 同时失效 |
| 原因 | 热点数据过期 | 统一过期 or 服务宕机 |
| 影响 | 单个接口压垮 DB | 整个系统瘫痪 |
⚠️ 危害
- 单个热门商品详情页查询打垮 DB
- 秒杀活动库存查询超载
✅ 解决方案
方案 1:热点 key 永不过期
- 对已知热点数据(如首页 banner)不设 TTL
- 通过后台任务定期更新
方案 2:互斥锁(Mutex Lock)
- 只允许一个线程重建缓存,其他线程等待
- 使用 Redis 分布式锁实现
1def get_hot_product(product_id): 2 key = f"product:{product_id}" 3 product = redis.get(key) 4 if product: 5 return json.loads(product) 6 7 # 尝试获取分布式锁 8 lock_key = f"{key}:lock" 9 if acquire_lock(lock_key, timeout=2): # 获取锁(见前文 Lua 脚本) 10 try: 11 # 双重检查(防止其他线程已加载) 12 product = redis.get(key) 13 if not product: 14 product = db.query("SELECT ...") 15 redis.setex(key, 300, json.dumps(product)) 16 finally: 17 release_lock(lock_key) 18 else: 19 # 未获得锁,短暂等待后重试(或返回旧数据) 20 time.sleep(0.01) 21 return get_hot_product(product_id) 22 23 return product 24
方案 3:逻辑过期(Logical Expiration)
- 缓存中存储 数据 + 逻辑过期时间
- 请求时若逻辑过期,则异步更新,但仍返回旧数据
1{ 2 "data": { ... }, 3 "expire_time": 1717020000 // 逻辑过期时间戳 4} 5
📌 适用场景:
- 允许短暂返回旧数据(如商品价格、文章内容)
- 不允许停顿(如高并发 API)
✅ 总结对比表
| 问题 | 原因 | 影响范围 | 核心解决方案 |
|---|---|---|---|
| 双写不一致 | 更新时序问题 | 单条数据不一致 | 延迟双删 / Binlog 订阅 |
| 缓存穿透 | 查询不存在的数据 | DB 被无效请求打垮 | 空值缓存 / 布隆过滤器 |
| 缓存雪崩 | 大量 key 同时失效 | 整个系统瘫痪 | 随机 TTL / 高可用架构 |
| 缓存击穿 | 热点 key 过期 | 单个接口压垮 DB | 互斥锁 / 永不过期 |
🛡️ 综合防御策略(生产环境推荐)
- 安全层:API 网关校验参数合法性(防穿透)
- 缓存层:
- 所有 key 设置 随机 TTL
- 热点 key 永不过期 + 后台刷新
- 不存在的数据 缓存空值(60秒)
- 更新层:
- 写操作采用 “先删缓存 → 更新 DB → 延迟再删”
- 关键数据使用 Binlog 异步修正
- 容灾层:
- Redis 集群 + 哨兵
- 本地缓存(Caffeine)兜底
- 熔断降级(Hystrix/Sentinel)
💡 记住:
没有银弹,只有组合拳。
根据业务场景选择合适策略,才能构建高可用缓存体系。
如果需要 具体代码实现(Go/Java/Node.js) 或 Redis 配置模板,欢迎继续提问!
《redis常见问题分析》 是转载文章,点击查看原文。