一、为什么双写一致性这么难?(并发时序漏洞剖析)
在电商、内容社区以及金融交易系统中,“缓存 + 数据库”是抗住高并发流量的标准读写分离架构。然而,在引入 Redis 作为加速层后,系统面临最棘手的问题就是:当数据库发生更新时,如何保证 Redis 缓存与 MySQL 数据的一致性?
如果在面试中或者实际技术方案设计时,你只回答了“先更数据库,再删缓存”,那么在并发量达到数千 QPS 时,你的系统大概率会出现严重的脏数据问题。
1. 先更新数据库,再更新缓存(❌ 严禁使用)
时序漏洞:线程A更新DB为1,线程B更新DB为2并写入Cache为2;随后网络延迟的线程A将旧值1写入Cache,造成永久脏数据。
2. 先删除缓存,再更新数据库(❌ 不推荐)
时序漏洞:线程A删除缓存;线程B读未命中查库拿旧值1回写Cache;线程A随后更新DB为2,导致缓存滞留旧数据。
二、业界主流的 4 种解决方案深度对比
| 方案 | 一致性保证 | 系统复杂度 | 适用并发场景 | 生产推荐度 |
|---|---|---|---|---|
| 1. 延迟双删 + TTL 兜底 | 最终一致(存在短暂时间窗口) | 低 | 中低并发(< 3000 QPS) | ⭐⭐⭐ |
| 2. 异步消息队列重试 (MQ) | 最终一致(保证重试成功) | 中 | 中高并发系统 | ⭐⭐⭐⭐ |
| 3. Canal + Binlog 监听 | 最终一致(解耦业务代码) | 较高(需维护中间件) | 生产级分布式核心链路 | ⭐⭐⭐⭐⭐(推荐) |
三、方案一:延迟双删优化版(轻量级方案代码)
@Service public class ProductService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private ProductMapper productMapper; @Autowired private ThreadPoolTaskExecutor asyncExecutor; public void updateProduct(Product product) { String cacheKey = "product:info:" + product.getId(); // 1. 第一次删除缓存 redisTemplate.delete(cacheKey); // 2. 更新数据库 productMapper.updateById(product); // 3. 异步延迟第二次删除(避免阻塞写请求耗时) asyncExecutor.execute(() -> { try { Thread.sleep(500); redisTemplate.delete(cacheKey); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } }四、方案二:生产级标准解法 —— Canal 监听 Binlog 异步解耦
在大中型分布式系统中,业务代码只负责操作数据库,缓存的失效完全解耦交给 Canal 监听 Binlog + MQ 消费:
- 零业务侵入:业务开发人员只需要操作 MySQL,不用到处写删除缓存代码;
- 天然重试保证:Redis 出现网络抖动时,MQ 自动触发退避重试,绝不丢失失效事件;
- 写性能极致:写库操作完全不占用业务接口 RT 耗时。
五、生产环境必备的 4 大防暴毙军规
- 绝对不要省略 TTL 兜底:所有缓存 Key 必须设置合理的过期时间,作为极端异常下的安全兜底;
- 防缓存穿透:查询不存在的 ID 时向 Redis 写入空值并设 60s 短期过期,防止恶意攻击打崩数据库;
- 加随机抖动防止缓存雪崩:TTL = 基础时间 + 随机时间(1~5分钟),避免同一时间集中失效;
- MySQL 开启 ROW 模式 Binlog:确保 Canal 能精确获取每行被修改数据的主键 ID。