Administrator
发布于 2022-09-19 / 479 阅读
2

Redis 缓存一致性终极方案:订阅 Binlog 更新

缓存和数据库又双叒不一致了

我们的商品详情页用"先更新 DB,再删缓存"的策略。大多数时候没问题,但运营一次批量改价后,部分用户看到的价格还是旧的,持续了十几分钟。根因是并发下的时序问题:线程 A 更新 DB、还没删缓存时,线程 B 读了旧 DB 值并写回缓存,导致 A 的删除"删了个寂寞",脏数据留在缓存里。为了彻底解决,我上了订阅数据库 Binlog 来删除缓存的方案。

为什么"删缓存"本身不可靠

经典做法是 Cache Aside:

// 写路径
updateDB(data);      // 1. 先改库
deleteCache(key);    // 2. 再删缓存

// 读路径
data = getCache(key);
if (data == null) {
    data = getDB(key);
    setCache(key, data);   // 缓存回填
}

问题在并发:A 做 updateDB 后、deleteCache 前,B 来读,发现缓存空,从 DB 读(此时读到 A 改之前的旧值),回填缓存。随后 A 的 deleteCache 执行,但 B 已经把旧值写回去了——缓存里是脏数据,且再也不会被删,直到过期。

方案:Canal 订阅 Binlog,异步删缓存

把"删缓存"的动作从业务写路径移到独立的 Binlog 消费链路。数据库的任何变更都会写 Binlog,用 Canal(或 Debezium)伪装成 MySQL 从库,订阅 Binlog,解析出"哪张表哪行变了",然后发消息去删对应缓存。

# Canal 伪代码:订阅商品表变更
CanalConnector connector = CanalConnectors.newSingleConnector(
        new InetSocketAddress("canal-host", 11111), "example", "", "");
connector.connect();
connector.subscribe("shop_db\\.product_price");

while (true) {
    Message msg = connector.get(1000);
    for (Entry entry : msg.getEntries()) {
        if (entry.getEntryType() != EntryType.ROWDATA) continue;
        RowChange rc = RowChange.parseFrom(entry.getStoreValue());
        for (RowData rd : rc.getRowDatasList()) {
            Long pid = rd.getAfterColumnsList().get(0).getValue();
            // 发到 MQ,让缓存删除消费者处理
            mq.send("cache-evict", "product:" + pid);
        }
    }
}

消费端拿到消息就删缓存,且删的是"数据库已经确认变更之后"的缓存,时序上保证"库改完 → 缓存被删",绕开了业务路径里的竞态。

为什么要过 MQ,不直接删

我们中间加了一层 Kafka,而不是直接在 Canal 回调里删 Redis:

  • 削峰:运营批量改价一次改 10 万行,Binlog 洪峰,MQ 缓一下,缓存删除消费者按自己节奏消费。
  • 重试与可靠:删除失败可以重投,避免"Binlog 丢了就再也删不了"。
  • 解耦:缓存删除逻辑独立部署,不影响 Canal 采集。

最终一致的边界:必须想清楚

这个方案是最终一致,不是强一致。要接受两个现实边界:

  • 延迟窗口:Binlog 采集 + MQ + 删除,链路有几百毫秒到几秒的延迟。期间读可能命中旧缓存。我们对"价格"这类要求高的,额外把缓存 TTL 设短(如 30 秒),即使漏删也能快速自愈。
  • 删除失败要兜底:MQ 消费失败反复重试仍不行时,依赖 TTL 过期,不能让脏数据永久驻留。所以任何缓存都必须有过期时间,这是最后一道防线

效果

上线后我们做了对照:用脚本并发模拟"改价 + 立刻读",老方案脏读率约 0.4%(每千次 4 次命中旧值),新方案降到 0.02% 以下,且残留脏数据都在 30 秒 TTL 内自愈。

小结

  • Cache Aside 在并发下存在"删缓存前被回填"的竞态,导致永久脏缓存。
  • Canal/Debezium 订阅 Binlog,把删缓存移到独立的变更消费链路,从时序上根治竞态。
  • 经 MQ 异步删除,获得削峰、重试、解耦,别忘了失败兜底。
  • 这是最终一致方案,TTL 是必不可少的安全网,不要追求"绝对一致"。

这套方案上线后,缓存不一致从"偶发故障"变成了"可观测、可自愈的指标"。但我一直跟团队强调:Binlog 方案解决的是"时序竞态",不是"强一致"——如果你的业务真的要求读到必是新值,那缓存本就不该出现在写后读的关键路径上。

参考