MySQL + Redis 协同架构下的数据一致性与缓存防护实战指南
·
在高并发系统中,MySQL 作为持久化数据库负责数据的可靠存储,Redis 作为高性能缓存层承担读请求压力,二者搭配已成为业界标准架构。然而,这种组合也带来了三大经典挑战:
- 数据不一致(缓存与数据库内容不同步)
- 缓存穿透(大量请求查询不存在的数据)
- 缓存击穿(热点 key 过期瞬间引发 DB 雪崩)
若处理不当,轻则性能下降,重则系统崩溃。本文将从原理出发,结合生产实践,提供一套可落地、高可用的解决方案。
一、数据一致性:缓存与数据库如何同步?
常见策略对比
| 策略 | 描述 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside(旁路缓存) | 读:先查缓存,未命中查 DB 并回填;写:先更新 DB,再删除缓存 | 简单、通用 | 存在短暂不一致窗口 | 绝大多数业务 |
| Read/Write Through | 缓存层代理读写,自动同步 DB | 应用无感知 | 实现复杂,Redis 不原生支持 | 中间件封装场景 |
| Write Behind(异步写) | 先写缓存,异步批量刷入 DB | 写性能极高 | 数据丢失风险高 | 日志、监控等非关键数据 |
✅ 推荐方案:Cache-Aside + 删除缓存(而非更新)
为什么“删除缓存”优于“更新缓存”?
- 避免无效计算:若缓存数据由多个 DB 字段聚合而成(如用户详情 = 用户表 + 订单数 + 积分),每次字段变更都更新缓存成本高。
- 防止并发覆盖:A、B 两个线程先后更新 DB,若都去更新缓存,可能 B 先完成导致缓存为旧值。
经典问题:先删缓存还是先更新 DB?
方案 A:先更新 DB,再删缓存(推荐)
# 伪代码
update_mysql(data)
delete_redis(key)
- 优点:即使删除失败,下次读会回源重建缓存,最终一致。
- 极端情况:删缓存后、新请求回源前,有旧请求写入旧缓存?概率极低,可通过延迟双删缓解。
方案 B:先删缓存,再更新 DB
delete_redis(key)
update_mysql(data)
- 风险:删缓存后、DB 更新前,若有请求回源,会把旧数据重新写入缓存 → 脏读。
🔒 增强版:延迟双删(Delay Double Delete)
delete_redis(key)
update_mysql(data)
sleep(500ms) # 等待可能的旧读请求完成
delete_redis(key)
适用于对一致性要求极高的场景(如金融),但引入延迟,需权衡。
二、缓存穿透:防御“查无此物”的恶意攻击
什么是缓存穿透?
- 攻击者或 bug 导致大量请求查询根本不存在的数据(如
user_id = -1)。 - 缓存无命中 → 直接打到 MySQL → DB 压力剧增甚至宕机。
解决方案
1. 空值缓存(Null Cache)
- 查询 DB 无结果时,将
null或特殊标记(如"EMPTY")写入缓存,设置较短 TTL(如 1~5 分钟)。
user = redis.get(key)
if user is None:
user = mysql.query(key)
if user:
redis.setex(key, 3600, user)
else:
redis.setex(key, 60, "EMPTY") # 防穿透
⚠️ 注意:TTL 不宜过长,避免真实数据插入后无法及时生效。
2. 布隆过滤器(Bloom Filter)
- 在缓存前加一层布隆过滤器,快速判断 key 是否可能存在。
- 若布隆过滤器返回“不存在”,直接拒绝请求,不查缓存和 DB。
- 优点:内存占用小,查询快;缺点:存在误判(假阳性),但不会漏判。
✅ 组合使用:布隆过滤器(粗筛) + 空值缓存(兜底) = 高效防穿透。
三、缓存击穿:保护“热点 key”过期瞬间
什么是缓存击穿?
- 某个高并发访问的 key 在 Redis 中过期。
- 大量请求同时发现缓存失效,瞬间涌向 MySQL → DB 承压。
解决方案
1. 逻辑过期(Logical Expiration)
- 缓存中不仅存数据,还存一个“逻辑过期时间”。
- 请求发现逻辑过期后,不立即回源,而是尝试获取一个分布式锁,由一个线程去重建缓存,其他线程继续使用旧数据。
{
"data": { ... },
"expire_time": 1700000000 // 逻辑过期时间戳
}
2. 互斥锁(Mutex Lock)重建缓存
- 使用 Redis 的
SET key mutex EX 10 NX实现分布式锁。 - 只有抢到锁的线程去查 DB 并更新缓存,其他线程等待或重试。
def get_user(user_id):
key = f"user:{user_id}"
user = redis.get(key)
if user:
return user
# 尝试获取锁
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", ex=10, nx=True):
try:
user = mysql.query(user_id)
if user:
redis.setex(key, 3600, user)
else:
redis.setex(key, 60, "EMPTY") # 防穿透
finally:
redis.delete(lock_key)
else:
# 等待一小段时间后重试(或返回旧数据/默认值)
time.sleep(0.1)
return get_user(user_id)
3. 永不过期 + 后台刷新
- 热点 key 设置为永不过期(或超长 TTL)。
- 启动后台任务定期刷新缓存(如每 30 分钟)。
- 适用:数据变化不频繁的场景(如配置、排行榜)。
四、生产环境最佳实践清单
| 场景 | 推荐做法 |
|---|---|
| 缓存更新 | 采用“先更新 DB,再删缓存” + 延迟双删(高一致性要求) |
| 缓存穿透 | 布隆过滤器 + 空值缓存(TTL 1~5 分钟) |
| 缓存击穿 | 互斥锁重建 + 逻辑过期(二选一) |
| 缓存雪崩 | 随机 TTL(如基础 TTL ± 10%),避免大量 key 同时失效 |
| 监控告警 | 监控 Redis 命中率、MySQL QPS 突增、空值缓存比例 |
| 降级策略 | 当 Redis 故障时,可临时关闭缓存,直连 DB(需限流) |
结语
MySQL 与 Redis 的协同不是简单“加个缓存”就完事,而是一套涉及一致性模型、故障防御、并发控制的系统工程。没有银弹,只有权衡:
- 强一致? → 牺牲性能,用分布式锁+双删。
- 高可用? → 接受短暂不一致,用 Cache-Aside + TTL 随机化。
- 抗攻击? → 布隆过滤器 + 空值缓存筑起第一道防线。
真正的高手,不是避免问题,而是在问题发生前就布好局。通过本文的策略组合,你可以在性能、一致性与稳定性之间找到属于你业务的最佳平衡点。
更多推荐




所有评论(0)