去年我们团队做了一个电商项目,QPS峰值大概在5000左右,商品详情页用了Redis做缓存。
上线第一个月一切正常。第二个月开始,客服陆续收到用户反馈:订单状态显示不对,明明是已支付的订单,页面还显示待付款;库存扣减了,页面还显示有货。
查日志发现是缓存和数据库不一致了。这问题不致命,但足够恶心——用户投诉、客诉压力、研发背锅。
那段时间我每天下班都在想:到底怎么才能让Redis里的数据和MySQL保持一致?
三个月折腾下来,试了五六种方案,踩了无数坑。这篇笔记是我自己的总结,希望能帮同样被这问题折磨的人少走弯路。
一、先搞清楚:问题到底出在哪?
1.1 什么是缓存一致性?
简单说:数据库里存的是最新数据,Redis里存的也是最新数据,两者要同步。
问题是,更新操作不是原子的。你要么先改DB再改Redis,要么先改Redis再改DB,不管哪种顺序,中间都有一段时间窗口,另一个请求可能读到脏数据。
1.2 为什么这么难?
核心原因是两个存储系统的操作无法在同一个事务里完成。MySQL的事务和Redis的事务是两码事,没法保证同时成功或同时失败。
我们做个实验(表1):
| 访问方式 | 耗时(ms) | QPS |
|---|---|---|
| 直接查DB | 150-200 | 5000 |
| 查Redis(命中) | 5-10 | 10万+ |
| 查Redis(穿透) | 160-210 | 4800 |
看出问题了吗?缓存命中时性能起飞,但只要不一致,用户看到的就是错误数据。
二、常见的缓存更新策略(按坑深排序)
2.1 先写Redis,再写MySQL(千万别用)
做法:
java
redis.set(key, newValue); // 先更新缓存 db.update(sql, newValue); // 再更新数据库
我踩的坑:
有一次缓存更新成功了,数据库更新因为死锁报错,结果Redis里是新数据,MySQL里是旧数据。用户读到新数据,下单时发现库存不够(因为数据库里库存没扣成功)——灾难性后果。
更坑的是,如果写Redis成功、写DB失败,你要么不回滚Redis(数据就永远不一致),要么再写一次Redis恢复旧值。但恢复旧值的时候如果又有其他请求进来,又乱了。
结论:这个方案我直接拉黑,生产环境绝对不用。
2.2 先写MySQL,再写Redis(有并发坑)
做法:
java
db.update(sql, newValue); // 先更新数据库 redis.set(key, newValue); // 再更新缓存
问题:
假设两个并发请求:
- 请求A把age从18改成19
- 请求B把age改成25
正常顺序:A改DB→A改Redis→B改DB→B改Redis,最终age=25,没问题。
但实际可能是:A改DB→B改DB→B改Redis→A改Redis,结果age=25(DB)和age=26(Redis),因为A把19+1变成了20,但B已经把DB改成25了。
这就是缓存乱序问题——更新操作的先后顺序乱了。
2.3 先删缓存,再写MySQL(经典坑)
做法:
java
redis.del(key); // 先删缓存 db.update(sql, newValue); // 再更新数据库
血泪史:
某个版本我们上线了这个方案,结果线上出现严重问题:
| 时间 | 线程A(写) | 线程B(读) |
|---|---|---|
| T1 | 删除缓存 | |
| T2 | 读缓存未命中 | |
| T3 | 从DB读到旧数据 | |
| T4 | 把旧数据写回缓存 | |
| T5 | 更新DB为新数据 |
这个问题的关键是读请求在写请求更新DB之前把旧数据写回了缓存。后来我们统计,这种问题出现的概率大概在5%左右,但一旦出现,持续的时间可能很长。
2.4 先写MySQL,再删缓存(主流方案)
做法:
java
db.update(sql, newValue); // 先更新数据库 redis.del(key); // 再删除缓存
为什么这个相对靠谱?
因为删除缓存的操作是幂等的,而且即使删除失败,也只是多一次缓存穿透,不会导致数据永久不一致。
但仍有问题:
还是并发场景:
- T1:线程A更新数据库
- T2:线程B读缓存(命中旧数据)
- T3:线程A删除缓存
三、几种优化方案(我试过的)
3.1 延迟双删
代码:
java
redis.del(key); // 第一删 db.update(sql, newValue); // 更新DB Thread.sleep(500); // 延迟500ms redis.del(key); // 第二删
原理:等500ms,让可能发生的“读请求回写旧数据”操作完成,再删一次。
我们踩的坑:
- sleep 500ms 写死,结果主从延迟有时候超过1秒,还是会出问题
- 高并发下,500ms的等待对性能影响不小
改进:
java
// 动态计算延迟时间 int sleepTime = Math.max(50, getAverageReplicationDelay() * 1.3); Thread.sleep(sleepTime);
3.2 消息队列异步重试
做法:
java
db.update(sql, newValue);
mq.send("cache_clean", key); // 发送删除缓存消息
消费者收到消息后删除缓存,如果删除失败,MQ自带的重试机制会再试几次。
优点:
- 解耦,业务代码不用关心缓存删除结果
- 重试机制保证最终一致性
缺点:
- 引入MQ,架构变复杂
- 有短暂的不一致窗口(消息处理延迟)
3.3 基于Binlog的异步同步(Canal方案)
做法:
流程图解:
text
MySQL binlog → Canal → MQ → 消费者 → Redis更新
优点:
- 和业务代码完全解耦,代码里一行都不用改
- 没有并发乱序问题,binlog里的顺序就是真实的执行顺序
- 可以同时更新多个下游(ES、缓存、其他服务)
缺点:
- 引入Canal、MQ,架构重
- binlog解析有延迟,通常是秒级
- Canal挂了怎么办?需要高可用部署
适用场景:
四、方案对比(我自己的评分)
| 方案 | 一致性等级 | 复杂度 | 实时性 | 适用场景 |
|---|---|---|---|---|
| 先写DB再删缓存 | 中 | ★☆☆ | 高 | 大多数业务,读多写少 |
| 延迟双删 | 中上 | ★★☆ | 中 | 对一致性比较敏感的中小项目 |
| MQ异步删除 | 高(最终) | ★★★ | 中 | 高并发,能接受短暂不一致 |
| Canal+MQ | 高(最终) | ★★★★ | 低(秒级) | 大型系统,写多读少,需解耦 |
五、最佳实践:我现在怎么选?
经过三个月的折腾,我总结了一套按场景选型的策略:
5.1 中小型项目(QPS < 5000)
选:先更新数据库,再删除缓存 + 重试兜底
java
public void updateWithCacheClean(Product product) {
try {
// 1. 更新数据库
productDao.update(product);
// 2. 删除缓存
redisTemplate.delete("product:" + product.getId());
} catch (Exception e) {
// 3. 删除失败,扔进重试队列
retryQueue.offer(new CacheCleanTask(product.getId()));
log.error("缓存删除失败,已加入重试队列", e);
}
}
关键点:
5.2 高并发核心场景(QPS > 5000,如库存、订单)
选:先更新数据库,再删除缓存 + 延迟双删
java
public void updateStock(Stock stock) {
// 第一删
redisTemplate.delete("stock:" + stock.getId());
// 更新数据库
stockDao.update(stock);
// 动态计算延迟时间
int replicaDelay = getReplicaDelay(); // 从监控系统获取主从延迟
int sleepTime = Math.max(50, replicaDelay * 2);
// 延迟后第二删
scheduledExecutor.schedule(() -> {
redisTemplate.delete("stock:" + stock.getId());
}, sleepTime, TimeUnit.MILLISECONDS);
}
关键点:
5.3 大型微服务架构(多个下游依赖)
选:Canal + MQ
yaml
# 架构组件 - MySQL: 数据源 - Canal: 监听binlog - RocketMQ: 异步消息 - Consumer: 更新Redis、ES、其他服务
关键点:
六、避坑指南(我流过的血)
坑1:缓存乱序问题
现象:明明数据库顺序更新正确,缓存却乱了
解决:
坑2:主从延迟导致第二删失效
现象:延迟双删的sleep时间不够,读请求从从库读到旧数据写回缓存
坑3:缓存穿透引发雪崩
现象:删除缓存后,大量请求穿透到数据库
解决:
坑4:分布式锁性能崩盘
现象:为了强一致性加分布式锁,结果锁竞争把QPS干到200
七、写在最后:没有银弹
折腾了三个月,我最大的感悟是:缓存一致性没有完美的方案,只有适合业务的方案。
你要问我现在怎么选,我的回答是:
- 实时性要求高、并发中等:先更新数据库再删缓存 + 重试
- 一致性要求高、能接受短暂不一致:延迟双删 + 动态延迟
- 大型系统、多个下游:Canal + MQ
- 强一致性要求:别用缓存,直接查库
最后送自己一句话:缓存是性能的加速器,也是一致性的麻烦制造者。引入缓存之前,先想清楚你能不能接受它的副作用。