缓存一致性的四种写法:从 Cache Aside 到 Binlog 订阅
缓存和数据库之间不存在「保证一致」,只存在「不一致的窗口有多大、出现概率有多高、业务能不能接受」。这篇把四种主流写法的不一致窗口摊开算一遍。
先明确一件事:只要数据存在两份,就不可能在没有分布式事务的前提下做到强一致。所以讨论方案时,唯一有意义的问题是——这个方案的不一致窗口是多长,什么情况下会出现。
一、Cache Aside:先更库,再删缓存
最常用的写法,也是绝大多数场景的正确答案。
@Service
public class ProductService {
private static final String KEY = "product:%d";
private static final Duration TTL = Duration.ofMinutes(30);
// 读:缓存未命中则回源,回填时带 TTL
public Product get(long id) {
String key = String.format(KEY, id);
Product cached = (Product) redis.opsForValue().get(key);
if (cached != null) {
return cached;
}
Product db = productMapper.selectById(id);
if (db != null) {
redis.opsForValue().set(key, db, TTL);
}
return db;
}
// 写:先落库,事务提交后再删缓存
@Transactional
public void update(Product p) {
productMapper.updateById(p);
// 注册事务同步器,确保 commit 之后才删,避免删早了读到旧数据
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() {
redis.delete(String.format(KEY, p.getId()));
}
});
}
}两个细节决定这段代码对不对:
为什么是删缓存,不是更新缓存。 两个并发写请求 A(值=1)和 B(值=2),如果都用「更新缓存」,数据库里的最终值和缓存里的最终值取决于两次操作各自的先后顺序,完全可能一个是 1 一个是 2。删除是幂等的,不存在这个问题。另外缓存值往往是聚合后的结果,更新它可能要重新查一遍关联数据,成本比删掉更高。
为什么必须在事务提交后删。 如果在事务内删,删除和提交之间有个窗口,此时别的请求读缓存未命中、回源查到的还是未提交前的旧值,然后把旧值写回缓存。事务提交后,缓存里躺着一个旧值且没人再去删它,脏数据会一直留到 TTL 过期。实测在删除之后、提交之前插入一次读请求:提交后库里是 200,缓存里是 100。
那 Cache Aside 的不一致窗口在哪?在这个交错序列里:
出现条件是:读请求的回源比写请求的整个「更新库 + 删缓存」还慢。实测用闩锁让读请求在回源之后停住,等写请求更新为 200 并删完缓存再回填,结束后库里 200、缓存里 100,这个旧值会一直留到 TTL 到期。不加控制、让读写同时开始跑 2,000 轮,一次也没有出现。所以它的概率很低,但不是零:GC 停顿、网络抖动、读从库都可能让读请求恰好在回源之后变慢。兜底手段就是 TTL——脏数据最多存活一个 TTL 周期。
绝大多数业务到这里就够了。下面三种方案都是为了缩小这个窗口,代价各有不同。
二、延迟双删:用一次额外删除换更小的窗口
思路很朴素:写完之后过一会儿再删一次,把上面 t5 写进去的脏数据清掉。
@Transactional
public void updateWithDoubleDelete(Product p) {
productMapper.updateById(p);
String key = String.format(KEY, p.getId());
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() {
redis.delete(key);
// 延迟第二次删除,延迟时间需大于「一次读请求回源 + 写缓存」的耗时
delayedDeleteScheduler.schedule(
() -> redis.delete(key), 500, TimeUnit.MILLISECONDS);
}
});
}要老实说清它的问题:
- 延迟时间只能靠猜。 定 500ms 是因为观测到 p99 回源耗时在 200ms 以内,但 p999 可能是 2s。你没法给出一个覆盖所有情况的值。
- 延迟任务本身会丢。 用
ScheduledExecutorService的话,进程重启就丢了。要可靠就得把延迟任务放到 MQ 或者 Redis 的延迟队列里,复杂度上来了。 - 它只是缩小窗口,不是消除窗口。 实测延迟 500ms 双删:读请求在第一次删除后 200ms 回填,旧值被第二次删除清掉;800ms 才回填,旧值照样留在缓存里。
所以延迟双删是一个「感觉更安全但说不清安全多少」的方案。既然都要引入延迟任务的基础设施,不如直接上第四种。真要用,至少把延迟任务做成可靠的。
三、写入标记:读走从库时阻止回填旧值
前面两种方案都假设「更新完数据库,立刻回源就能读到新值」。一旦读走从库,这个假设就破了——主从延迟通常几十毫秒到几秒,回源读到旧值的概率大幅上升。
这时候需要的不是更多次删除,而是在写入期间阻止回源回填:
private static final String LOCK = "lock:product:%d";
public Product getWithLock(long id) throws InterruptedException {
String key = String.format(KEY, id);
Product cached = (Product) redis.opsForValue().get(key);
if (cached != null) {
return cached;
}
String lockKey = String.format(LOCK, id);
// 写入进行中,不回填缓存,直接读主库返回本次结果
if (Boolean.TRUE.equals(redis.hasKey(lockKey))) {
return productMapper.selectByIdFromMaster(id);
}
// 单飞:只允许一个线程回源,避免缓存击穿
String loadKey = "load:" + key;
boolean acquired = Boolean.TRUE.equals(
redis.opsForValue().setIfAbsent(loadKey, "1", Duration.ofSeconds(3)));
if (!acquired) {
// 别人正在回源:短暂等几次缓存,仍然没有就直接读库,不无限等待
for (int i = 0; i < 5; i++) {
TimeUnit.MILLISECONDS.sleep(20);
cached = (Product) redis.opsForValue().get(key);
if (cached != null) {
return cached;
}
}
return productMapper.selectById(id);
}
try {
Product db = productMapper.selectById(id);
// 二次确认:回填前再查一次锁,防止回源期间开始了写入
if (db != null && !Boolean.TRUE.equals(redis.hasKey(lockKey))) {
redis.opsForValue().set(key, db, TTL);
}
return db;
} finally {
redis.delete(loadKey);
}
}
@Transactional
public void updateWithLock(Product p) {
String lockKey = String.format(LOCK, p.getId());
// 标记「正在写入」,存活时间要覆盖主从延迟
redis.opsForValue().set(lockKey, "1", Duration.ofSeconds(3));
productMapper.updateById(p);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() {
redis.delete(String.format(KEY, p.getId()));
// 注意:不主动删 lockKey,让它自然过期,覆盖主从延迟窗口
}
});
}这里的 lockKey 只是一个「正在写入」的标记,不是读写锁:读请求不会被阻塞,只是跳过回填。实测同样让读请求在回源后停住,回填前发现标记,跳过了这次回填,结束时缓存为空,下一次读取会重新回源。这套写法把不一致窗口压到了「主从延迟 < lockKey TTL」的范围内,代价是写入期间该 key 的读请求全部落到主库。所以它适合写少读多、但一致性要求较高的数据,比如商品价格、库存阈值、风控规则。
四、Binlog 订阅:把缓存失效从业务代码里摘出去
前三种方案有一个共同的结构性问题:删缓存这个动作写在业务代码里。这意味着:
- 任何一个漏写了删缓存的写入路径,都是一个长期脏数据源
- 运维直接改库、数据修复脚本、其他服务写同一张表,都绕过了缓存失效
- 业务代码里散落着缓存 key 的拼接逻辑
Binlog 订阅换了个思路:数据库变更是唯一事实来源,缓存失效由变更流驱动。
消费者大致这样:
@Component
public class CacheInvalidateConsumer {
// 表名 -> 缓存 key 模板,集中管理,不再散落在业务代码里
private static final Map<String, String> KEY_TEMPLATES = Map.of(
"product", "product:%s",
"sku", "sku:%s",
"category", "category:%s");
@KafkaListener(topics = "mysql-binlog", groupId = "cache-invalidator")
public void onChange(BinlogEvent event, Acknowledgment ack) {
String template = KEY_TEMPLATES.get(event.getTable());
if (template == null) {
ack.acknowledge();
return;
}
// INSERT 不需要删(本来没缓存),UPDATE/DELETE 需要
if (event.getType() == EventType.UPDATE || event.getType() == EventType.DELETE) {
String pk = event.getPrimaryKey();
redis.delete(String.format(template, pk));
// 主键变更(罕见但存在)时旧 key 也要删
String oldPk = event.getOldPrimaryKey();
if (oldPk != null && !oldPk.equals(pk)) {
redis.delete(String.format(template, oldPk));
}
}
ack.acknowledge(); // 手动提交,保证失败会重投
}
}这个方案的好处很实在:
- 业务代码里完全不需要写删缓存,写入路径漏掉的可能性消失了
- 运维改库、数据修复脚本同样会触发失效
- 缓存 key 规则集中在一处
- 删除操作幂等,重复消费无害,天然适配「至少一次」投递
代价也很实在:
- 多了 Canal(或 Debezium)+ Kafka 两个组件,都需要监控和运维
- 不一致窗口变成了「binlog 采集 + MQ 投递 + 消费」的链路延迟,正常几十毫秒,但 MQ 积压时可能到分钟级
- 消费者挂掉时,脏数据会持续累积——必须对消费延迟做告警
- 无法处理「缓存内容是多表聚合结果」的情况,那种要么按最粗粒度失效,要么退回业务代码里处理
五、四种方案的选择
| 方案 | 不一致窗口 | 额外组件 | 适用场景 |
|---|---|---|---|
| Cache Aside | 低概率,最长一个 TTL | 无 | 默认选择,绝大多数业务 |
| 延迟双删 | 比上面小,但仍看延迟时间猜得准不准 | 可靠延迟队列 | 不推荐;要做就直接上 Binlog |
| 写入标记 | 主从延迟范围内 | 无 | 读多写少 + 一致性要求高(价格、风控规则) |
| Binlog 订阅 | 采集+投递链路延迟 | Canal/Debezium + MQ | 写入路径多、有直接改库行为、缓存种类多 |
实际项目里最常见的组合是 Cache Aside 打底 + Binlog 订阅兜底:业务代码正常删缓存,Binlog 消费者作为第二道保险,捕获所有绕过业务代码的变更。两条路径都是删除操作,幂等,叠加没有副作用。
六、别忘了兜底的三件事
不管选哪种方案,这三件事都要有,否则一致性方案再精巧也会在生产上出事:
1. 所有缓存都必须有 TTL。 TTL 是所有一致性方案的最终兜底。永不过期的缓存意味着任何一次失效遗漏都是永久脏数据。反过来,缓存里的数据也会在业务不知情时消失:内存写满后的淘汰、Redis 故障切换时丢失的写入(见 Redis Sentinel 故障切换),都会让下一次读变成回源,或者读到切换前的旧值,所以回源路径要按「随时可能被触发」来设计容量。
2. TTL 要加随机抖动,避免批量回源打穿数据库:
Duration ttl = Duration.ofMinutes(30).plusSeconds(
ThreadLocalRandom.current().nextInt(300)); // 30min ± 5min
redis.opsForValue().set(key, value, ttl);3. 定期对账。 抽样比对缓存和数据库,把不一致率做成指标。这个数字会告诉你方案实际的效果,而不是你以为的效果:
private final AtomicReference<Double> mismatchRate = new AtomicReference<>(0.0);
CacheAuditor(MeterRegistry meterRegistry) {
// 只注册一次,gauge 读取的是这个字段
meterRegistry.gauge("cache.mismatch.rate", mismatchRate, ref -> ref.get());
}
@Scheduled(cron = "0 */10 * * * ?")
public void auditSample() {
List<Long> ids = productMapper.sampleIds(200);
long mismatch = ids.stream().filter(id -> {
Product cached = (Product) redis.opsForValue().get(String.format(KEY, id));
if (cached == null) return false; // 未缓存不算不一致
return !cached.equals(productMapper.selectById(id));
}).count();
mismatchRate.set((double) mismatch / ids.size());
}不要在定时任务里直接写 meterRegistry.gauge("cache.mismatch.rate", 比例)。gauge 同名只注册一次,之后的调用被忽略;它对传入的对象只持有弱引用,装箱的 Double 被回收后读数变成 NaN。用 Micrometer 1.17.1 实测:第二次传入的 0.2 没有生效(读数仍是 0.1),GC 之后读数是 NaN;改成上面的写法后读数随字段更新。
小结
缓存一致性没有银弹,只有窗口大小和运维成本之间的取舍。
务实的路径是:默认用 Cache Aside,把「事务提交后再删」和「TTL 加抖动」这两个细节做对;等到写入路径变多、或者出现绕过业务代码改数据的情况,再引入 Binlog 订阅兜底。延迟双删这种「看起来更保险但说不清保险多少」的方案,能不用就不用。
最后,把不一致率做成一个可观测的指标——这比任何方案设计都更能告诉你系统的真实状态。
配套实验
- codesphere-labs/cache/cache-aside-window:用闩锁控制读写交错,复现 Cache Aside 脏回填、事务内删缓存、延迟双删快慢两种情况、写入标记,统计 2,000 轮不加控制的自然并发,并验证不一致率指标的两种 gauge 写法(验证记录)