“缓存与数据库到底先写谁?“是分布式系统里争论最多、踩坑最深的问题。Cache Aside 模式人人会说,但高并发下"读线程读到旧值写回缓存、写线程更新 DB 删缓存"的时序竞争,依然会让缓存长期残留脏数据。
本文从不一致的根源出发,系统对比延迟双删、订阅 binlog(Canal/CDC)主动失效、带版本号校验三类主流方案,最后给出基于 Redis + 消息队列的最终一致性架构与取舍建议,帮你终结缓存一致性这个老大难。
一、缓存与 DB 不一致的来源
1.1 一致性问题本质
缓存与数据库是两套存储,无法用一次事务原子提交。只要"更新 DB"与"更新/删除缓存"不是原子的,中间任何一步失败或乱序,就会出现不一致。常见来源:
| 来源 | 场景 | 结果 |
|---|---|---|
| 更新顺序竞态 | 先更缓存后写 DB,写 DB 失败 | 缓存是脏数据 |
| 删除缓存失败 | 更新 DB 后删缓存抛异常 | 旧缓存长期残留 |
| 读后写回竞态 | 读线程读 DB 旧值,写线程已更 DB,旧值写回缓存 | 永久脏数据 |
| 多副本延迟 | 主从复制延迟期间读从库 | 读到旧值 |
| 网络超时重试 | 请求重放导致覆盖新数据 | 数据回退 |
1.2 为什么"先删缓存再写 DB"也有问题
先删缓存再写 DB 看似安全:删了缓存,读会 miss 从而回源 DB。但高并发下存在窗口:
线程A: 删除缓存(成功) -> 读 DB 旧值 -> 写缓存(旧值)
线程B: 更新 DB(新值)
结果: 缓存被线程A写回旧值,与 DB 不一致
这个问题在"删缓存 → 写 DB"的间隙,任何并发读都能把旧值写回缓存,且此后没有任何机制纠正。
二、Cache Aside 与双删问题的时序
2.1 标准 Cache Aside
// 读:缓存命中直接返回,未命中回源 DB 再写缓存
public User getUser(long id) {
String key = "user:" + id;
User user = redis.get(key);
if (user != null) return user;
user = db.getUser(id); // 回源
if (user != null) redis.setex(key, 3600, user);
return user;
}
// 写:先更新 DB,再删除缓存
public void updateUser(User user) {
db.updateUser(user); // 1. 更新 DB
redis.del("user:" + user.getId()); // 2. 删除缓存
}
2.2 双删问题的精确时序
写操作"更新 DB 后删缓存"也非绝对安全。危险时序为:读线程 T2 读到 DB 旧值 age=29(慢查询耗时较长)→ 写线程 T1 更新 DB 为 age=30 并删除缓存 → T2 将 age=29 写回缓存 → 缓存中长期残留旧值。关键竞争点是"读 DB → 写缓存"必须发生在"更新 DB"完成之后,才可能读到新值。
核心结论:“更新 DB → 删缓存"必须与"读 DB → 写缓存"竞争。读线程把旧值写回缓存的一瞬,就是脏数据的产生点。仅靠 Cache Aside 的删除动作,无法保证覆盖所有竞态。
2.3 何时真正需要一致性保证
| 场景 | 容忍度 | 处理 |
|---|---|---|
| 商品详情、列表页 | 可容忍秒级延迟 | Cache Aside + TTL 兜底 |
| 库存、余额、价格 | 不能脏读 | 双删/binlog/版本号 |
| 对账、审计 | 严格一致 | 不走缓存或读写串行 |
三、延迟双删:简单有效的补偿方案
3.1 算法与实现
延迟双删(Double Delete)的思路:删缓存、更新 DB、等待一段时间后再次删除缓存。第二次删除用于清除竞态窗口内被写回的旧值。
public void updateUserWithDoubleDelete(User user) {
String key = "user:" + user.getId();
redis.del(key); // 第一次删除
db.updateUser(user); // 更新 DB
// 延时第二次删除(清除竞态写回的旧值)
executor.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS);
}
import time, threading, redis
r = redis.Redis(decode_responses=True)
def update_with_double_delete(user):
key = f"user:{user['id']}"
r.delete(key) # 第一次删除
db_update(user) # 更新 DB
# 延迟 500ms 再删一次,清掉竞态窗口的旧缓存
threading.Timer(0.5, lambda: r.delete(key)).start()
3.2 延迟时长怎么定
延迟时间的下限取决于一次完整"读 DB → 写缓存"的耗时:必须大于读路径的最大耗时,否则第二次删除太早,旧值写回发生在删除之后依然残留。
# 估算读路径耗时:监控读命令延迟(p99)
redis-cli --latency
# min: 0, max: 12, avg: 0.8 (38166 samples)
redis-cli SLOWLOG GET 20
经验值:多数业务 300~800ms。延迟越久越安全,但缓存"假删除"窗口越长(期间缓存是空的,读压力打到 DB),需要权衡。延迟双删是"补偿"而非"杜绝”,无法 100% 消除极端竞态。
3.3 双删的失败兜底
| 失败点 | 影响 | 兜底 |
|---|---|---|
| 第二次删除失败 | 脏数据残留 | 删除操作重试 + TTL 兜底 |
| 删除重试阻塞 | 影响主链路 | 异步化(线程池/MQ) |
| DB 更新成功但删缓存超时 | 短暂不一致 | 依赖 TTL 或 binlog 方案 |
四、订阅 binlog 主动失效:Canal/CDC
4.1 思路:让数据库变更"通知"缓存
延迟双删是被动的补偿,而 binlog 订阅(CDC) 是主动的兜底:MySQL 主库把变更写入 binlog,中间件解析后主动删除/刷新缓存。无论应用层怎么写,只要 DB 变了,缓存一定会被清理。
4.2 Canal 架构与配置
Canal 是阿里巴巴开源的 binlog 解析工具,典型链路:
MySQL(binlog ROW) -> Canal Server -> Canal Adapter(Redis) 或 -> MQ -> 消费者
# MySQL 开启 ROW 格式 binlog
# my.cnf
server-id=1
log-bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL
# canal.properties
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.connectionCharset=UTF-8
canal.instance.filter.regex=app_db\\.user,app_db\\.order
# Canal Adapter: application.yml(Redis 适配器示例)
dataSourceKey: defaultDS
destination: example
outerAdapterKey: redis
redisMapping:
- key: user:{id}
field: name,age,level
# 数据库表变更时,自动更新 Redis Hash user:{id} 的对应字段
4.3 Debezium 方案(Kafka 生态)
不使用 Canal 时,可用 Debezium 将 binlog 事件投递到 Kafka:配置 MySQL connector(io.debezium.connector.mysql.MySqlConnector,指定 database.hostname、table.include.list、topic.prefix),消费者监听对应 topic(如 app_db.app_db.user),收到事件后解析主键并 redis.delete("user:" + key) 主动失效。Debezium 与 Canal 同属 CDC 思路,差异在 Kafka 生态集成与开箱即用的状态管理。
4.4 binlog 方案与双删的对比
| 维度 | 延迟双删 | binlog 订阅 |
|---|---|---|
| 实时性 | 秒级(有延迟窗口) | 近实时(ms~百 ms) |
| 侵入性 | 需改业务代码 | 无侵入(旁路解析) |
| 可靠性 | 删除可能失败 | binlog 天然可靠可重放 |
| 复杂度 | 低 | 高(引入 Canal/Debezium) |
| 一致性 | 大概率最终一致 | 确定性最终一致 |
binlog 方案的巨大优势是与应用代码解耦:任何途径写入 DB(后台任务、数据修复、报表工具),只要 binlog 有变更,缓存就会失效。代价是引入额外组件与运维成本。
五、写后更新 vs 删除:旁路缓存的更新策略
5.1 三种写策略对比
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 更新缓存 | DB 更新后直接 SET 新值 | 读命中率高 | 可能写脏(DB 成功、缓存失败) |
| 删除缓存 | DB 更新后 DEL | 实现简单、懒加载 | 删除失败残留旧值 |
| 更新+TTL | 更新后重写且设置短 TTL | 兼顾命中与自愈 | 复杂度略高 |
5.2 为什么推荐"删除"而非"更新”
- 写频繁场景:若 1 分钟内同一 Key 被更新 100 次,直接更新缓存也写了 100 次,而删除后读路径只会在真正需要时回源一次
- 一致性更强:删除让"旧值残留"的概率更低;更新则可能覆盖其他线程刚写入的新值(读后写回问题)
- 代码更简:删除不需要构造完整缓存对象,避免"缓存结构变化"导致的迁移问题
// 推荐:更新 DB 后删除缓存
db.updateUser(user);
redis.del("user:" + user.getId());
// 不推荐(写频繁场景):直接更新缓存
// db.updateUser(user);
// redis.set("user:" + user.getId(), JSON.toJSONString(user), 3600);
5.3 删除的可靠性增强
删除操作应支持重试:失败后间隔 50/100/150ms 递增重试,最后一次失败投递到重试队列/MQ 异步兜底。
删除失败的终极兜底有两个:TTL(缓存迟早过期,兜底最终一致)与 binlog(DB 变更必触发删除)。两者叠加后,即使删除偶发失败,也不会造成永久脏数据。
六、带版本号/时间戳的校验:防止旧值写回
6.1 原理
给每个缓存 Key 配一个版本号(或 DB 更新时间戳),写缓存时校验:只允许新版本覆盖旧版本。这样即使读线程拿着旧值晚到,也会因版本过低被拒绝写入。
# 缓存中同时存 value 与 version
redis-cli SET user:1001 '{"name":"张三","age":30}'
redis-cli SET user:1001:ver 2
6.2 Lua 原子版本校验
-- versioned_write.lua
-- KEYS[1]: 数据 Key
-- KEYS[2]: 版本 Key
-- ARGV[1]: 待写入版本号
-- ARGV[2]: 待写入 value
local key = KEYS[1]
local verKey = KEYS[2]
local newVer = tonumber(ARGV[1])
local value = ARGV[2]
local oldVer = redis.call('GET', verKey)
if oldVer and tonumber(oldVer) >= newVer then
return 0 -- 旧版本写入被拒绝,防止脏数据覆盖
end
redis.call('SET', key, value)
redis.call('SET', verKey, newVer)
return 1
# 写路径:携带 DB 记录的时间戳作为版本号
redis-cli EVALSHA <sha> 2 user:1001 user:1001:ver 1758979200 '{"age":30}'
6.3 版本号方案对比
| 方案 | 实现成本 | 一致性强度 | 适用 |
|---|---|---|---|
| 纯删除 | 低 | 弱(依赖 TTL 兜底) | 容忍短时脏读 |
| 延迟双删 | 中 | 中 | 高并发读多写少 |
| binlog 订阅 | 高 | 强 | 严格一致要求 |
| 版本号校验 | 中 | 强(配合删除最佳) | 更新频繁、竞争激烈 |
| 版本号+删除 | 中高 | 最强 | 核心数据(库存/价格) |
七、最终一致性架构:Redis + 消息队列
7.1 异步删缓存的最终一致
写入路径通过消息队列解耦,DB 更新成功后才发消息,消费者删除缓存;配合重试保证删除最终成功:
写请求 -> 更新 DB(事务提交) -> 发 MQ(删除缓存消息) -> 消费者 DEL
|
删除失败 -> 重试队列 -> 定时重试
// 写路径:DB 提交后发消息
@Transactional
public void updateUser(User user) {
db.updateUser(user);
// 事务提交后发送删除缓存消息(用 TransactionSynchronization)
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
mqSender.send("cache.invalid", "user:" + user.getId());
}
});
}
// 消费者:删除缓存,失败进入重试队列
@RabbitListener(queues = "cache.invalid")
public void invalidateCache(String key) {
try {
redis.del(key);
} catch (Exception e) {
retryQueue.send("cache.invalid.retry", key); // 重试
}
}
7.2 用 Redis Stream 当消息队列
不想引入 Kafka/RabbitMQ 时,Redis Stream 足够承担删缓存消息:生产者 XADD cache:invalid * key "user:1001";消费者 XGROUP CREATE 建组后 XREADGROUP 消费,成功 XACK 确认,失败靠 Pending + XAUTOCLAIM 重试,形成"至少一次投递 + 可重试"的最终一致闭环。
7.3 最终一致性架构的取舍
| 组件 | 职责 | 注意 |
|---|---|---|
| DB 事务 | 保证数据落库 | 消息发送必须在事务提交后 |
| 消息队列 | 解耦删除动作 | 保证至少一次投递 |
| 重试队列 | 兜底删除失败 | 带延迟的定时重试 |
| TTL | 最后防线 | 任何方案都建议保留 |
| binlog | 双保险 | 与 MQ 方案可叠加 |
一致性没有银弹,成熟架构通常是组合拳:Cache Aside 打底(读多写少场景命中率高)+ TTL 兜底(保证最终过期)+ binlog/MQ 主动失效(保证删除必达)+ 版本号校验(挡住极端竞态)。四者叠加,脏数据存活窗口从"永久"降到"毫秒级”。
八、实战案例与取舍
8.1 案例一:电商商品详情页
要求:高并发读,允许秒级延迟。方案:Cache Aside + TTL(5 分钟)+ 延迟双删。商品更新频率低,双删窗口足够;命中率 95%+,脏数据窗口 < 1s。
8.2 案例二:库存扣减
要求:不能超卖、不能脏读。方案:库存不走普通缓存,用 Redis 原子扣减(Lua 中先 GET 校验再 DECRBY,保证不超卖)作为前置校验,DB 落库对账;缓存删除走 binlog(Canal),保证任何 DB 变更都失效缓存。
8.3 案例三:评论点赞数
要求:更新极频繁(每秒钟千级),读也不低。方案:Redis 计数(INCR)异步批量回写 DB,缓存不删除而是维护 Redis 为准的计数,DB 只做持久化对账。这类"缓存为准、DB 异步落库"的模式,牺牲强一致换取吞吐。
| 案例 | 数据特性 | 方案 | 一致性目标 |
|---|---|---|---|
| 商品详情 | 读多写少 | Cache Aside + TTL + 双删 | 秒级最终一致 |
| 库存 | 强约束 | Redis 原子扣减 + binlog | 强一致(对账) |
| 点赞数 | 写极频繁 | Redis 计数 + 异步落库 | 最终一致 |
8.4 通用决策树
更新频率低? ---------------> Cache Aside + TTL(够用)
|
更新频繁、读可容忍秒级延迟? -> 延迟双删 / MQ 异步删
|
强一致 / 多写入口? --------> binlog(CDC) + 版本号
|
极端竞争(库存/金额)? -----> 缓存即真相 + DB 对账,或读写串行化
九、监控与验证
9.1 一致性监控
一致性需要"对账"来量化:定时任务随机抽取缓存 Key(redis-cli RANDOMKEY),按业务规则与 DB 值比对,不一致记录到 /tmp/inconsistency.log,从而持续观测脏数据窗口是否收敛。
# 监控指标建议
- 缓存命中率(redis_exporter: hit_rate)
- 删除缓存失败率(业务埋点)
- 版本冲突次数(版本号方案埋点)
- 不一致抽样命中数(对账任务)
9.2 验证步骤
- 压测验证:并发读写下抽样比对,确认脏数据窗口符合预期
- 故障演练:杀掉删除缓存的消费者,验证 TTL 兜底生效
- 双删验证:监控第二次删除是否在竞态窗口之后执行
- 对账任务:每日跑一次缓存与 DB 的抽样对账,量化残留脏数据
9.3 写在最后:一致性成本
缓存一致性的每一档提升,都要付出吞吐、延迟或架构复杂度的代价。工程上的正确姿势不是"追求最强一致",而是为每类数据定义可接受的一致性等级,然后选择性价比最高的方案。先问业务"能容忍多久的脏数据",再谈方案。
结语
缓存一致性没有一劳永逸的银弹,核心要点回顾:
- 根源是竞态:读线程把旧值写回缓存是脏数据的主因,纯 Cache Aside 无法杜绝
- 双删是补偿:延迟双删清除竞态窗口的旧值,但延迟时长需大于读路径耗时
- binlog 是根本:Canal/CDC 让 DB 变更必然触发缓存失效,与应用代码解耦
- 版本号是护栏:版本/时间戳校验拒绝旧值写回,与删除叠加可达最强一致
- MQ 是解耦器:DB 提交后发消息异步删缓存,配合重试与 TTL 形成最终一致闭环
- 组合拳是常态:Cache Aside + TTL + 主动失效 + 版本校验,四者叠加让脏数据窗口趋近于零
给每类数据定义一致性等级,用最经济的方案满足它:商品详情用双删就够了,库存用原子扣减 + binlog,点赞用异步落库。一致性是设计出来的权衡,不是堆出来的配置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。