缓存与数据库一致性

举报
yd_216253085 发表于 2026/11/06 09:11:32 2026/11/06
【摘要】 对比四种更新策略,给出延迟双删与 binlog 订阅的落地实现

缓存是数据库的一份副本,只要有两份数据,就有不一致的可能。问题不在于"能不能做到强一致",而在于业务能接受多长的不一致窗口,以及在这个窗口内会不会出问题。本文先分析并发读写下的经典竞态,再对比四种更新策略,最后给出延迟双删和 binlog 订阅两套可落地的方案。

一、为什么会出现不一致

先看最常见的 Cache Aside 模式:读时先查缓存,未命中查库并回填;写时先更新数据库,再删除缓存。

问题出在并发时序上。考虑两个线程:

| 时刻 | 线程 A(写) | 线程 B(读) |

| --- | --- | --- |

| t1 | 更新数据库为 10 | |

| t2 | | 读缓存未命中 |

| t3 | | 读数据库,得到 10 |

| t4 | 删除缓存 | |

| t5 | | 把 10 写入缓存 |

这个顺序没问题。但如果 B 在 t3 读到的是旧值 5(A 尚未提交),而 B 的写缓存操作被调度到 A 删除之后:

| 时刻 | 线程 A(写) | 线程 B(读) |

| --- | --- | --- |

| t1 | 更新数据库为 10 | |

| t2 | | 读缓存未命中,读库得 5 |

| t3 | 删除缓存 | |

| t4 | | 把 5 写入缓存(脏数据) |

缓存里留下了旧值 5,且没有过期时间,就长期不一致了。这个窗口要求"读线程在写线程更新前后恰好完成查库与回填",概率不高但确实存在,尤其在读 QPS 很高时。

为什么是"删缓存"而不是"更新缓存":更新缓存有两个问题。一是并发写时,后写库的线程可能先更新缓存,导致缓存是旧值;二是如果缓存值需要复杂计算(多表 join、聚合),每次写都重算成本太高,而删除是惰性的,只有被读到才重建。

二、四种策略对比

| 策略 | 做法 | 一致性 | 复杂度 | 适用 |

| --- | --- | --- | --- | --- |

| 先删缓存再写库 | 删除后更新 DB | 差,读请求容易回填旧值 | 低 | 不推荐 |

| 先写库再删缓存 | Cache Aside 标准做法 | 较好,仍有极小竞态 | 低 | 绝大多数场景 |

| 延迟双删 | 写库后删一次,延迟再删一次 | 较好 | 中 | 读写并发高 |

| 订阅 binlog | 监听 DB 变更异步删缓存 | 最终一致,窗口可控 | 高 | 多数据源、跨服务 |

先删缓存再写库为什么差:删除后、写库前,任何读请求都会回源读到旧值并回填,写库完成后缓存里就是脏数据,且这个窗口比"先写库再删缓存"大得多。

先写库再删缓存是推荐的基础方案,不一致窗口只有"读线程查库到回填"这一小段,且可以通过给缓存设 TTL 兜底。

三、延迟双删

延迟双删的思路是:写库后立即删一次缓存,再延迟一段时间删第二次,把并发读回填的脏数据也清掉。

public void updateProduct(Product product) {
    String key = "product:" + product.getId();
    // 1. 先删一次,减少读到旧值的概率
    redis.delete(key);
    // 2. 更新数据库
    productMapper.updateById(product);
    // 3. 延迟再删一次,清掉期间可能回填的旧值
    delayExecutor.schedule(() -> redis.delete(key),
            500, TimeUnit.MILLISECONDS);
}

延迟时间怎么定:应大于「一次读请求查库 + 回填缓存」的耗时,通常取业务读接口 P99 的 2~3 倍,500 ms 到 1 秒是常见值。

延迟双删的问题:

  • 延迟任务在 JVM 内存中,应用重启会丢;多实例部署时只有执行写操作的那个实例会删,其他实例的本地缓存管不到。
  • 延迟时间靠猜,无法覆盖慢查询或 GC 停顿导致的长尾。
  • 第二次删除仍可能被更晚的回填覆盖,只是概率更低。

因此延迟双删是"降低概率",不是"消除问题"。真正稳的方案是给缓存设 TTL,并把延迟删除作为补充。

四、订阅 binlog

更彻底的做法是让缓存删除与业务代码解耦:监听数据库的 binlog,解析出变更行,异步删除对应缓存。常用工具是 Canal(阿里开源)或 Debezium。

# canal.properties 关键配置
canal.instance.master.address=10.0.0.21:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.filter.regex=shop\\.product,shop\\.order
canal.mq.topic=canal-topic

Canal 伪装成 MySQL 从库,向主库发送 dump 请求,收到 binlog 后解析成结构化事件,投递到 Kafka 或 RocketMQ,由消费者删除对应缓存。

@KafkaListener(topics = "canal-topic")
public void onBinlogEvent(String message) {
    CanalMessage msg = JSON.parseObject(message, CanalMessage.class);
    if (!"shop".equals(msg.getDatabase()) || msg.getData() == null) {
        return;
    }
    for (Map<String, Object> row : msg.getData()) {
        String table = msg.getTable();
        Object id = row.get("id");
        if ("product".equals(table)) {
            redis.delete("product:" + id);
        } else if ("order".equals(table)) {
            redis.delete("order:" + id);
        }
    }
}

优点:

  • 业务代码不需要关心缓存删除,避免"某处更新忘了删缓存";
  • 所有写入路径(包括后台脚本、其他服务)都能触发;
  • 可以按表配置过滤,只处理关心的表。

缺点:

  • 链路变长,binlog → MQ → 消费者,延迟通常在几十到几百毫秒;
  • 需要处理消费失败与重复消费,删除操作要幂等(DEL 本身幂等);
  • 运维成本高,需要额外部署 Canal 和 MQ。

注意:binlog 方案要求 MySQL 的 binlog_format=ROW,且 binlog_row_image=FULL,否则拿不到完整的行数据。

五、先更新缓存还是先更新库

有些场景确实需要更新缓存而不是删除,比如缓存值计算成本极高、或者缓存是唯一的读取来源。此时要处理并发写的顺序问题:

// 用版本号或时间戳保证缓存只被更新的数据覆盖
public void updateWithVersion(Product product) {
    product.setVersion(System.currentTimeMillis());
    productMapper.updateById(product);
    String key = "product:" + product.getId();
    // Lua:仅当新版本大于缓存中版本时才写入
    redis.execute(new DefaultRedisScript<>(UPDATE_IF_NEWER, Long.class),
            Collections.singletonList(key),
            JSON.toJSONString(product), String.valueOf(product.getVersion()));
}
local cached = redis.call('GET', KEYS[1])
if cached then
    local old = cjson.decode(cached)
    if tonumber(old.version) >= tonumber(ARGV[2]) then
        return 0
    end
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', 1800)
return 1

这能避免"旧数据覆盖新数据",但仍然无法解决"数据库已更新、缓存更新失败"的问题,所以 TTL 兜底依然必要。

六、读多写少场景的取舍

大部分业务是读多写少,实践建议:

  1. 默认用 Cache Aside:读时回填,写时先更新库再删缓存。
  2. 缓存必须设 TTL,作为最终兜底,即使删除失败也能在 TTL 后自愈。
  3. 对一致性敏感的接口(如余额、库存),读路径不要走缓存,或缓存只做展示、扣减走数据库。
  4. 读写并发极高的热点数据,用延迟双删或 binlog 订阅降低不一致概率。
  5. 不要追求强一致:跨两个存储的强一致需要分布式事务,成本和延迟都不划算。

常见坑 / 最佳实践

  1. 删除缓存失败必须重试。 网络抖动会导致 DEL 失败,应放入重试队列或依赖 binlog 补偿,不能只打一条日志。
  2. 缓存 TTL 是不可省略的兜底。 所有删除策略都可能失效,只有过期能让脏数据最终消失,TTL 建议按业务容忍度设置。
  3. 不要在事务提交前删缓存。 @Transactional 方法内删除缓存,事务回滚后缓存已被清空,虽不会产生脏数据但会造成缓存击穿;正确做法是事务提交后删除。
  4. binlog 消费者要幂等。 MQ 至少一次投递,重复消费时 DEL 是幂等的,但如果逻辑是"更新缓存"就必须带版本号。
  5. 主从延迟会放大不一致。 写完主库立即读从库可能读不到,缓存回填了旧值;强一致读要路由到主库。

小结

  • 不一致的根源是并发读写下的时序交错,"先写库再删缓存"能把窗口压到最小,但无法完全消除。
  • 四种策略中,Cache Aside 是默认选择,延迟双删降低概率,binlog 订阅解耦且覆盖面最广。
  • 延迟双删的延迟时间应大于读接口 P99 的 2~3 倍,且必须配合 TTL 使用。
  • binlog 方案依赖 ROW 格式,链路延迟在百毫秒级,适合多写入源和跨服务场景。
  • 工程上接受最终一致,用 TTL 兜底,强一致需求下沉到数据库层解决。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。