缓存一致性终极方案:双删、binlog 订阅与最终一致性架构

Redis 缓存一致性终极方案:缓存与 DB 不一致来源、Cache Aside 双删问题(时序)、延迟双删、订阅 binlog(Canal/CDC)主动失效、写后更新 vs 删除、带版本号/时间戳校验、最终一致性架构、Redis 与消息队列配合、案例与取舍

“缓存与数据库到底先写谁?“是分布式系统里争论最多、踩坑最深的问题。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 写在最后:一致性成本

缓存一致性的每一档提升,都要付出吞吐、延迟或架构复杂度的代价。工程上的正确姿势不是"追求最强一致",而是为每类数据定义可接受的一致性等级,然后选择性价比最高的方案。先问业务"能容忍多久的脏数据",再谈方案。


结语

缓存一致性没有一劳永逸的银弹,核心要点回顾:

  1. 根源是竞态:读线程把旧值写回缓存是脏数据的主因,纯 Cache Aside 无法杜绝
  2. 双删是补偿:延迟双删清除竞态窗口的旧值,但延迟时长需大于读路径耗时
  3. binlog 是根本:Canal/CDC 让 DB 变更必然触发缓存失效,与应用代码解耦
  4. 版本号是护栏:版本/时间戳校验拒绝旧值写回,与删除叠加可达最强一致
  5. MQ 是解耦器:DB 提交后发消息异步删缓存,配合重试与 TTL 形成最终一致闭环
  6. 组合拳是常态:Cache Aside + TTL + 主动失效 + 版本校验,四者叠加让脏数据窗口趋近于零

给每类数据定义一致性等级,用最经济的方案满足它:商品详情用双删就够了,库存用原子扣减 + binlog,点赞用异步落库。一致性是设计出来的权衡,不是堆出来的配置。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. Redis 向量检索实战:RediSearch、HNSW 与 Embedding 管道集成
  2. 高级数据结构实战:Bitmap、HyperLogLog、GEO、布隆过滤器与 Stream
  3. 过期键淘汰与内存回收深入:expire cycle、驱逐策略与碎片整理