Skip to content
Charles Shao
Go back

缓存一致性深挖:双删、Binlog 订阅与多级失效

views

引入缓存的那一刻,你就有了两份数据:缓存里一份、数据库里一份。只要它们可能不一致,就有了「缓存一致性」问题。这是 缓存系列 里最难、也最容易线上翻车的一块——它不像穿透击穿雪崩有标准答案,而是一连串「在一致性、性能、可用性、复杂度之间怎么取舍」的工程判断。

本篇把开篇一笔带过的 Cache-Aside 竞态、延迟双删、多级失效逐帧拆开,讲清每种方案的窗口、代价与适用边界。

缓存系列压轴:本篇是 缓存系列开篇 的一致性下钻,前四篇分别深挖了 本地缓存 与 Redis 的 数据结构持久化与高可用线程模型与性能。更一般的分布式一致性(幂等、事务、最终一致)见 幂等与一致性

TL;DR

Table of contents

Open Table of contents

1. 缓存一致性到底能到多强

先破除幻想:只要数据存在缓存和 DB 两处,就不可能有『缓存层的强一致』。 因为更新缓存和更新 DB 是两次独立操作,没有跨系统的原子事务把它们绑在一起;任何一步失败、任何并发交错,都会让两者短暂背离。

所以缓存一致性的目标不是「永远一致」,而是「把不一致窗口压到业务可接受的范围,并保证最终收敛」。做方案前先回答三个问题:

  1. 能接受多长的不一致窗口?(毫秒?秒?分钟?)
  2. 读写比多少、是否有热点?(决定值不值得为一致性加复杂度)
  3. 是单层缓存还是多级(L1+L2)?(多级的失效联动难度陡增)

答案不同,选型天差地别。下面从最常用的 Cache-Aside 开始。

2. 为什么写时删缓存,而不是更新缓存

Cache-Aside 写路径有两个选择:更新 DB 后更新缓存,还是删除缓存?主流是删除,原因:

所以标准写法是 Cache-Aside 写时删除缓存。但「删」也有顺序和失败问题,见下节。

3. Cache-Aside 竞态逐帧拆解

标准写法长这样——读时回填、写时「先更新库、再删缓存」:

// 读:miss 回源并回填
public Product get(long id) {
    String key = "product:" + id;
    Product p = cacheGet(key);
    if (p != null) return p;
    p = db.findById(id);
    if (p != null) cacheSet(key, p, Duration.ofMinutes(10));
    return p;
}

// 写:先更新 DB,再删缓存(不是更新缓存,见 §2)
public void update(Product p) {
    db.update(p);                       // ① 先落库
    cacheDelete("product:" + p.id());   // ② 再失效缓存
}

看似简单的两步,并发下仍有竞态。逐帧拆开。

3.1 先更新库,再删缓存(主流)

这是推荐顺序,但仍有一个低概率窗口:

Cache-Aside 竞态时序:读请求 B 查缓存 miss(key 恰好过期)→ 读 DB 得到旧值 v0;此时写请求 A 更新 DB v0→v1、删除缓存(此刻本就为空);最后读请求 B 把旧值 v0 写回缓存 → 结果缓存=v0 旧、DB=v1 新,不一致且会持续到下次失效

读 miss 读到旧值、写请求随后更新库并删缓存、读请求最后把旧值回填污染缓存。概率低(读的两步通常快于写的两步),但一旦发生窗口很长。

拆解:① B 读缓存 miss;② B 读 DB 拿到旧值 v0;③ A 更新 DB v0→v1;④ A 删缓存(此刻缓存本就空,删了个寂寞);⑤ B 把 v0 写回缓存。结果缓存=v0(旧)、DB=v1(新),且会一直错到 key 下次被失效。

为什么概率低:步骤 ②→⑤(读 DB + 写缓存)通常远快于 ③→④(写 DB + 删缓存),B 一般在 A 删除前就回填完了。但「低概率」不等于「不会」——一旦命中,脏值会长期存在,所以一致性敏感的场景要叠加补偿(§4 双删或 §5 binlog)。

3.2 先删缓存,再更新库

这个顺序问题更大:删缓存后、更新库前,并发读会 miss → 读到旧 DB 值 → 回填缓存;然后写请求才更新库 → 缓存留着旧值。窗口比 3.1 更容易命中,一般不推荐单独使用。

3.3 删缓存失败怎么办

无论哪种顺序,「删缓存」这一步本身可能失败(Redis 抖动、网络超时)。删失败 = 脏值残留。补偿手段:

public void update(Product p) {
    db.update(p);
    try {
        cacheDelete("product:" + p.id());
    } catch (Exception e) {
        // 删失败别吞掉:发一条失效消息,消费端重试直到成功(可重放)
        mq.send("cache-invalidate", "product:" + p.id());
    }
}

一旦你开始为「删失败」兜底,本质上就是在自建一条「变更 → 失效消息 → 消费端保证删成功」的管道——这恰恰是 §5 binlog 订阅的雏形,只是那里用数据库日志替代了业务代码里手写的这条消息,更不容易漏。

4. 延迟双删

针对 §3 的竞态,工程上常用延迟双删做补偿:

延迟双删时序:写请求 A 第一次删除缓存;并发读 B miss、读到旧值 v0;A 更新 DB v0→v1;B 把旧值 v0 回填缓存(污染);A 延迟 Δt(> 一次读DB+写缓存耗时)后第二次删除缓存,清掉回填的旧值;注:更新DB 到第二次删除之间仍是残余不一致窗口,延迟时长靠拍脑袋、跨主从复制延迟难覆盖

第二次删除专门清除「第一次删除→更新DB」窗口内被并发读回填的旧值;延迟需 > 一次「读DB+写缓存」耗时,通常异步执行。

流程:删缓存 → 更新 DB → 延迟 Δt → 再删一次缓存。第二次删除的作用是清掉「第一次删除到更新 DB 之间」被并发读回填的旧值。

public void update(Product p) {
    cacheDelete("product:" + p.id());        // ① 第一次删
    db.update(p);                            // ② 更新 DB
    // ③ 延迟 Δt 后再删一次,清掉窗口内被并发读回填的旧值
    delayQueue.schedule(Duration.ofMillis(500), () -> {
        try { cacheDelete("product:" + p.id()); }
        catch (Exception e) { mq.send("cache-invalidate", "product:" + p.id()); }
    });
}

第二次删务必异步(延迟队列 / MQ 定时消息)执行,别用 Thread.sleep 阻塞业务线程;且第二次删同样要有失败兜底,否则「延迟删」本身失败又回到 §3.3 的问题。

关键与局限

延迟双删是「够用但不优雅」的补偿——Δt 靠经验、跨复制延迟难覆盖。更可靠的做法是让数据库变更本身来驱动失效,即 binlog 订阅。

5. Binlog / CDC 订阅驱动失效

与其让应用「双写」(既写 DB 又管缓存,容易漏、容易乱序),不如订阅数据库的变更日志,由变更事件驱动缓存失效:

Binlog/CDC 订阅驱动缓存失效架构:MySQL 主库数据变更 → Canal/Debezium 订阅 binlog → Kafka Topic(变更事件有序)→ 缓存失效消费者(按 key 幂等处理)→ 向右扇出到 L2 Redis(DEL/更新缓存)和 L1 各节点本地缓存(MQ 广播失效)

MySQL → binlog → Canal/Debezium → Kafka → 失效消费者,再扇出失效 L2 Redis 与广播失效各节点 L1。相比应用双写:解耦、不漏、可重放、有序。

链路:MySQL 主库变更 → binlog → Canal/Debezium 订阅 → Kafka(变更事件,见 Kafka 核心原理)→ 缓存失效消费者 → 删/更新 L2 Redis + 广播失效各节点 L1。

相比应用层双写,它的四个优势:

消费端的关键是幂等 + 防乱序:同一行可能被多次投递(重放/重试),也可能因分区/重试导致旧事件晚到,所以用行里的版本(或 binlog 位点、updated_at)做「新值才写、旧值只删」:

// canal/Kafka 消费端:按 key 幂等失效
public void onChange(RowChange c) {
    long id = c.after("id");
    String key = "product:" + id;
    long ver = c.after("version");          // DB 行自带的单调版本号

    // 删缓存永远安全:下次读自然回填最新值
    cacheDelete(key);

    // 若选择「主动回写」而非单纯删除,必须比对版本防旧事件覆盖新值
    // cacheSetIfNewer(key, c.after(), ver);
}

优先「删」而非「写」——删除天然幂等、不怕乱序;只有为扛回源尖峰才主动回写,此时务必带版本号做 setIfNewer。代价:多一套 Canal/Debezium + MQ 的基础设施与运维;失效有「DB 提交 → binlog → 消费」的传播延迟(通常几十毫秒到秒级)。对一致性要求较高、又不想污染业务代码的中大型系统,这是当前最主流的做法。

6. 多级缓存的失效联动

引入 L1 本地缓存(见 本地缓存深挖)后,一致性难度陡增:DB 一次更新,要同时失效 L2 Redis + 所有应用节点各自的 L1。L2 好办(一个 DEL),难的是广播失效散落在 N 个节点上的 L1。四种方案:

方案可靠性延迟复杂度说明
Redis Pub/Sub发布失效消息各节点订阅;节点宕机/断连期间漏消息不可重放
MQ 广播Kafka/RabbitMQ 广播失效事件,可重放、可靠;多一套 MQ
极短 TTL—(TTL 窗口)极低L1 TTL 设 1–5s,接受这段不一致;简单粗暴、最常用
版本号 / 逻辑时钟读 L1 时带版本号,落后则主动失效重拉;需维护版本

实践中常组合使用:L1 用极短 TTL 兜底(保证最终收敛的上界),再叠加 MQ 广播 做主动失效(把窗口从「TTL 秒级」压到「广播毫秒级」),一致性极敏感的字段再加版本号校验。Pub/Sub 因不可重放,只适合能容忍偶发漏失效的场景。

版本号校验的读路径:L1 命中也不盲信,拿一个「很轻量的版本源」(如 Redis 里一个 ver: 计数)比一下,落后就丢弃 L1、回 L2 重拉:

public Product get(long id) {
    String key = "product:" + id;
    long latest = redis.get("ver:" + key);          // 轻量版本号,写侧每次变更 INCR
    Cached<Product> l1 = caffeine.getIfPresent(key);
    if (l1 != null && l1.version() >= latest) {      // L1 未落后才用
        return l1.value();
    }
    Product p = redisGet(key);                       // 回 L2
    if (p == null) { p = db.findById(id); redisSet(key, p); }
    caffeine.put(key, new Cached<>(p, latest));      // 带版本回填 L1
    return p;
}

代价是每次读多一次 ver: 查询(可再叠一层极短 TTL 的本地版本缓存降低开销),换来「即使 L1 广播失效漏了,也不会用超过一个版本的旧值」。只对最敏感字段开这条路径,别对全部缓存一刀切。

7. 强一致的特殊手段

当业务确实要更强的一致(金额、库存、审计),别硬在缓存层堆补偿,换思路:

8. 选型决策与 AdTech 落地

一切选型的总纲只有一句:实时性要求越低,越适合缓存。能容忍的不一致窗口越大,方案就可以越简单(长 TTL 足矣)、命中率越高、成本越低;窗口要求越窄,越要往下面的复杂方案走,直到窗口要求归零时干脆绕过缓存。所以下面的选型路径本质就是按「能接受多大的不一致窗口」从松到紧排列:

选型路径(按能接受的不一致窗口):

  1. 能容忍秒级不一致 → L1/L2 加极短 TTL 即可,最省事。
  2. 要更稳、不想污染业务代码Binlog/CDC 订阅驱动失效(§5)。
  3. 多级缓存 + 更敏感 → 在 2 的基础上叠 MQ 广播失效 L1 + 版本号校验。
  4. 强一致 → 关键链路绕过缓存直查主库(§7)。

AdTech 落地:广告竞价对不同数据的一致性要求分层——

参考


views
Share this post on:

Previous Post
Redis 布隆过滤器实现:位图、RedisBloom 与缓存穿透防线
Next Post
Redis 深挖(三)· 线程模型与性能