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