缓存和数据库又双叒不一致了
我们的商品详情页用"先更新 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 方案解决的是"时序竞态",不是"强一致"——如果你的业务真的要求读到必是新值,那缓存本就不该出现在写后读的关键路径上。