缓存与数据库一致性
缓存是数据库的一份副本,只要有两份数据,就有不一致的可能。问题不在于"能不能做到强一致",而在于业务能接受多长的不一致窗口,以及在这个窗口内会不会出问题。本文先分析并发读写下的经典竞态,再对比四种更新策略,最后给出延迟双删和 binlog 订阅两套可落地的方案。
一、为什么会出现不一致
先看最常见的 Cache Aside 模式:读时先查缓存,未命中查库并回填;写时先更新数据库,再删除缓存。
问题出在并发时序上。考虑两个线程:
| 时刻 | 线程 A(写) | 线程 B(读) |
| --- | --- | --- |
| t1 | 更新数据库为 10 | |
| t2 | | 读缓存未命中 |
| t3 | | 读数据库,得到 10 |
| t4 | 删除缓存 | |
| t5 | | 把 10 写入缓存 |
这个顺序没问题。但如果 B 在 t3 读到的是旧值 5(A 尚未提交),而 B 的写缓存操作被调度到 A 删除之后:
| 时刻 | 线程 A(写) | 线程 B(读) |
| --- | --- | --- |
| t1 | 更新数据库为 10 | |
| t2 | | 读缓存未命中,读库得 5 |
| t3 | 删除缓存 | |
| t4 | | 把 5 写入缓存(脏数据) |
缓存里留下了旧值 5,且没有过期时间,就长期不一致了。这个窗口要求"读线程在写线程更新前后恰好完成查库与回填",概率不高但确实存在,尤其在读 QPS 很高时。
为什么是"删缓存"而不是"更新缓存":更新缓存有两个问题。一是并发写时,后写库的线程可能先更新缓存,导致缓存是旧值;二是如果缓存值需要复杂计算(多表 join、聚合),每次写都重算成本太高,而删除是惰性的,只有被读到才重建。
二、四种策略对比
| 策略 | 做法 | 一致性 | 复杂度 | 适用 |
| --- | --- | --- | --- | --- |
| 先删缓存再写库 | 删除后更新 DB | 差,读请求容易回填旧值 | 低 | 不推荐 |
| 先写库再删缓存 | Cache Aside 标准做法 | 较好,仍有极小竞态 | 低 | 绝大多数场景 |
| 延迟双删 | 写库后删一次,延迟再删一次 | 较好 | 中 | 读写并发高 |
| 订阅 binlog | 监听 DB 变更异步删缓存 | 最终一致,窗口可控 | 高 | 多数据源、跨服务 |
先删缓存再写库为什么差:删除后、写库前,任何读请求都会回源读到旧值并回填,写库完成后缓存里就是脏数据,且这个窗口比"先写库再删缓存"大得多。
先写库再删缓存是推荐的基础方案,不一致窗口只有"读线程查库到回填"这一小段,且可以通过给缓存设 TTL 兜底。
三、延迟双删
延迟双删的思路是:写库后立即删一次缓存,再延迟一段时间删第二次,把并发读回填的脏数据也清掉。
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// 1. 先删一次,减少读到旧值的概率
redis.delete(key);
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延迟再删一次,清掉期间可能回填的旧值
delayExecutor.schedule(() -> redis.delete(key),
500, TimeUnit.MILLISECONDS);
}
延迟时间怎么定:应大于「一次读请求查库 + 回填缓存」的耗时,通常取业务读接口 P99 的 2~3 倍,500 ms 到 1 秒是常见值。
延迟双删的问题:
- 延迟任务在 JVM 内存中,应用重启会丢;多实例部署时只有执行写操作的那个实例会删,其他实例的本地缓存管不到。
- 延迟时间靠猜,无法覆盖慢查询或 GC 停顿导致的长尾。
- 第二次删除仍可能被更晚的回填覆盖,只是概率更低。
因此延迟双删是"降低概率",不是"消除问题"。真正稳的方案是给缓存设 TTL,并把延迟删除作为补充。
四、订阅 binlog
更彻底的做法是让缓存删除与业务代码解耦:监听数据库的 binlog,解析出变更行,异步删除对应缓存。常用工具是 Canal(阿里开源)或 Debezium。
# canal.properties 关键配置
canal.instance.master.address=10.0.0.21:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.filter.regex=shop\\.product,shop\\.order
canal.mq.topic=canal-topic
Canal 伪装成 MySQL 从库,向主库发送 dump 请求,收到 binlog 后解析成结构化事件,投递到 Kafka 或 RocketMQ,由消费者删除对应缓存。
@KafkaListener(topics = "canal-topic")
public void onBinlogEvent(String message) {
CanalMessage msg = JSON.parseObject(message, CanalMessage.class);
if (!"shop".equals(msg.getDatabase()) || msg.getData() == null) {
return;
}
for (Map<String, Object> row : msg.getData()) {
String table = msg.getTable();
Object id = row.get("id");
if ("product".equals(table)) {
redis.delete("product:" + id);
} else if ("order".equals(table)) {
redis.delete("order:" + id);
}
}
}
优点:
- 业务代码不需要关心缓存删除,避免"某处更新忘了删缓存";
- 所有写入路径(包括后台脚本、其他服务)都能触发;
- 可以按表配置过滤,只处理关心的表。
缺点:
- 链路变长,binlog → MQ → 消费者,延迟通常在几十到几百毫秒;
- 需要处理消费失败与重复消费,删除操作要幂等(
DEL本身幂等); - 运维成本高,需要额外部署 Canal 和 MQ。
注意:binlog 方案要求 MySQL 的
binlog_format=ROW,且binlog_row_image=FULL,否则拿不到完整的行数据。
五、先更新缓存还是先更新库
有些场景确实需要更新缓存而不是删除,比如缓存值计算成本极高、或者缓存是唯一的读取来源。此时要处理并发写的顺序问题:
// 用版本号或时间戳保证缓存只被更新的数据覆盖
public void updateWithVersion(Product product) {
product.setVersion(System.currentTimeMillis());
productMapper.updateById(product);
String key = "product:" + product.getId();
// Lua:仅当新版本大于缓存中版本时才写入
redis.execute(new DefaultRedisScript<>(UPDATE_IF_NEWER, Long.class),
Collections.singletonList(key),
JSON.toJSONString(product), String.valueOf(product.getVersion()));
}
local cached = redis.call('GET', KEYS[1])
if cached then
local old = cjson.decode(cached)
if tonumber(old.version) >= tonumber(ARGV[2]) then
return 0
end
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', 1800)
return 1
这能避免"旧数据覆盖新数据",但仍然无法解决"数据库已更新、缓存更新失败"的问题,所以 TTL 兜底依然必要。
六、读多写少场景的取舍
大部分业务是读多写少,实践建议:
- 默认用 Cache Aside:读时回填,写时先更新库再删缓存。
- 缓存必须设 TTL,作为最终兜底,即使删除失败也能在 TTL 后自愈。
- 对一致性敏感的接口(如余额、库存),读路径不要走缓存,或缓存只做展示、扣减走数据库。
- 读写并发极高的热点数据,用延迟双删或 binlog 订阅降低不一致概率。
- 不要追求强一致:跨两个存储的强一致需要分布式事务,成本和延迟都不划算。
常见坑 / 最佳实践
- 删除缓存失败必须重试。 网络抖动会导致
DEL失败,应放入重试队列或依赖 binlog 补偿,不能只打一条日志。 - 缓存 TTL 是不可省略的兜底。 所有删除策略都可能失效,只有过期能让脏数据最终消失,TTL 建议按业务容忍度设置。
- 不要在事务提交前删缓存。
@Transactional方法内删除缓存,事务回滚后缓存已被清空,虽不会产生脏数据但会造成缓存击穿;正确做法是事务提交后删除。 - binlog 消费者要幂等。 MQ 至少一次投递,重复消费时
DEL是幂等的,但如果逻辑是"更新缓存"就必须带版本号。 - 主从延迟会放大不一致。 写完主库立即读从库可能读不到,缓存回填了旧值;强一致读要路由到主库。
小结
- 不一致的根源是并发读写下的时序交错,"先写库再删缓存"能把窗口压到最小,但无法完全消除。
- 四种策略中,Cache Aside 是默认选择,延迟双删降低概率,binlog 订阅解耦且覆盖面最广。
- 延迟双删的延迟时间应大于读接口 P99 的 2~3 倍,且必须配合 TTL 使用。
- binlog 方案依赖
ROW格式,链路延迟在百毫秒级,适合多写入源和跨服务场景。 - 工程上接受最终一致,用 TTL 兜底,强一致需求下沉到数据库层解决。
- 点赞
- 收藏
- 关注作者
评论(0)