一、前言:为什么"先更新 DB 还是先删缓存"成了经典面试题?
你是否遇到过这些场景?
- 用户修改了头像,但页面刷新后还是旧图
- 商品价格已调低,购物车仍显示原价
- 后台改了配置,前端却没生效
根本原因 :缓存与数据库数据不一致!
在高并发系统中,缓存(如 Redis)和数据库(如 MySQL)是两个独立的存储系统。一旦更新操作设计不当,就会导致短暂甚至长期的数据不一致。
本文将深入剖析四大主流缓存更新策略,并给出生产环境推荐方案,助你彻底解决"缓存一致性"难题。
二、核心矛盾:CAP 理论下的取舍
在分布式系统中,强一致性(Consistency)与高可用(Availability)难以兼得。
- 缓存追求 高性能 + 高可用
- 数据库追求 持久性 + 一致性
因此,缓存一致性通常是"最终一致性" ------ 允许短暂不一致,但必须在合理时间内收敛。
📌 目标 :在可接受的时间窗口内,让缓存与数据库保持一致
三、四大缓存更新策略详解
策略 1:Cache Aside(旁路缓存)------ ✅ 最常用
读流程:
- 先读缓存
- 命中 → 返回
- 未命中 → 查数据库 → 写入缓存 → 返回
写流程:
- 先更新数据库
- 再删除缓存
java
public void updateUser(User user) {
// 1. 更新 DB
userMapper.updateById(user);
// 2. 删除缓存(下次读时自动重建)
redisTemplate.delete("user:" + user.getId());
}
✅ 优点:
- 简单、直观、易于实现
- 读写分离,符合大多数业务场景
⚠️ 潜在问题:
- 并发场景下可能短暂不一致(见下文分析)
策略 2:Read/Write Through(读写穿透)
- 应用只与缓存层交互
- 缓存层负责同步读写数据库(类似代理)
写流程:
- 应用 → 缓存层
- 缓存层 → 更新自身 + 同步更新 DB
🔧 通常由中间件实现(如 ORM 框架),应用无感知。
✅ 优点:
- 应用逻辑简单
- 一致性由缓存层保证
❌ 缺点:
- 实现复杂
- 缓存层成为单点瓶颈
📌 实际项目中较少手动实现,多用于框架内部
策略 3:Write Behind(写回/异步写)
- 先更新缓存
- 异步批量刷入数据库
写流程:
- 应用 → 更新缓存(立即返回)
- 后台线程定期将缓存变更写入 DB
✅ 优点:
- 写性能极高(如日志、计数器场景)
❌ 缺点:
- 数据可能丢失(缓存宕机)
- 一致性差
📌 仅适用于允许丢失、非核心数据
策略 4:Double Delete(延迟双删)------ 高并发兜底方案
针对 Cache Aside 在高并发下的不一致问题 的增强方案。
写流程:
- 删除缓存
- 更新数据库
- 延迟 N 毫秒后,再次删除缓存
java
public void updateUser(User user) {
// 1. 先删缓存(防旧数据残留)
redisTemplate.delete("user:" + user.getId());
// 2. 更新 DB
userMapper.updateById(user);
// 3. 延迟双删(兜底)
Thread.sleep(500); // 或用 MQ 异步延迟
redisTemplate.delete("user:" + user.getId());
}
✅ 作用:
- 解决"旧缓存重建"问题(见下文并发分析)
⚠️ 注意:
sleep会阻塞线程,建议用 RocketMQ / RabbitMQ 延迟消息 替代
四、深度剖析:为什么"先更新 DB 再删缓存"也可能不一致?
场景:高并发下的"旧缓存重建"
时间线:
T1: 线程A 查询 user:1001 → 缓存 miss → 查 DB(值=张三)
T2: 线程B 更新 user:1001 → DB 改为"李四" → 删除缓存
T3: 线程A 将"张三"写入缓存 ← ❌ 旧数据覆盖!
💥 结果:缓存中是"张三",DB 中是"李四" → 不一致持续到缓存过期
解决方案:
- 延迟双删(如上)
- 给缓存设置较短 TTL(如 5~10 分钟),限制不一致窗口
- 使用 Canal / Binlog 监听 DB 变更,主动失效缓存(最终一致性强)
五、生产环境推荐方案
| 场景 | 推荐策略 |
|---|---|
| 普通 Web 应用 | Cache Aside + 短 TTL(如 10 分钟) |
| 高并发核心业务 | Cache Aside + 延迟双删(MQ 实现) |
| 强一致性要求 | 不使用缓存,或采用 本地缓存 + 分布式锁 |
| 数据变更通知 | 监听 MySQL Binlog(Canal / Debezium)→ 自动删缓存 |
📌 终极建议 :
80% 场景用 Cache Aside + 合理 TTL 即可 ,
20% 极端场景再上延迟双删或 Binlog 方案
六、Spring Boot 实战:优雅实现 Cache Aside
java
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String USER_KEY = "user:%d";
// 读:自动填充缓存
public User getUser(Long id) {
String key = String.format(USER_KEY, id);
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, User.class);
}
User user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
}
return user;
}
// 写:先更新 DB,再删缓存
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
redisTemplate.delete(String.format(USER_KEY, user.getId()));
// 【可选】发送 MQ 延迟消息,500ms 后再次删除
// mqProducer.sendDelayMessage("cache.delete", key, 500);
}
}
七、避坑指南:常见误区
❌ 误区 1:先删缓存,再更新 DB
风险:
- 删缓存成功
- 更新 DB 失败
- 缓存空,后续请求查到旧 DB 数据 → 永久不一致
正解 :先更新 DB,成功后再删缓存
❌ 误区 2:更新缓存而不是删除
问题:
- 需要序列化/反序列化,成本高
- 若缓存结构复杂(如 Hash),易出错
正解 :删除缓存,让下次读自动重建
❌ 误区 3:追求强一致性而放弃缓存
后果 :系统性能下降,得不偿失
正解 :接受短暂不一致,用 TTL + 监控兜底
八、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!