缓存与数据库双写一致性

双写一致性是高并发分布式系统的核心痛点 ,指当缓存(如 Redis)和数据库同时存储同一份数据时,对数据进行更新操作后,两者的数据保持一致的特性。

在高并发场景下,缓存是缓解数据库压力的关键,但双写操作的顺序、并发竞争 会直接导致数据不一致,进而引发脏读、旧数据覆盖新数据等问题。以下内容从核心矛盾、常见策略、解决方案、面试考点四个维度展开,覆盖生产落地全方案。


一、 双写一致性的核心矛盾

双写一致性的问题根源在于 「操作顺序」「并发竞争」,我们先通过两个典型场景理解不一致的产生过程:

场景 1:并发更新导致的脏数据

假设存在两个请求 A 和 B,同时更新同一数据(商品 ID=1 的库存),采用 「先更新数据库,再更新缓存」 策略:

  1. 请求 A:更新数据库,库存从 100→99;
  2. 请求 B:更新数据库,库存从 99→98;
  3. 请求 B:更新缓存,缓存库存 = 98;
  4. 请求 A:更新缓存,缓存库存 = 99;最终结果:数据库库存 = 98,缓存库存 = 99 → 数据不一致,后续请求会读取到缓存中的旧数据。

场景 2:并发读写导致的缓存穿透 + 旧数据回写

采用 「先删除缓存,再更新数据库」 策略,存在请求 A(更新)和请求 B(查询)并发执行:

  1. 请求 A:删除缓存中商品 ID=1 的库存数据;
  2. 请求 B:查询商品 ID=1 的库存,缓存未命中,从数据库读取旧数据(库存 = 100);
  3. 请求 B:将旧数据写入缓存;
  4. 请求 A:更新数据库,库存从 100→99;最终结果:数据库库存 = 99,缓存库存 = 100 → 数据不一致,且短时间内无法自动恢复。

二、 三种经典双写策略对比(优缺点 + 适用场景)

双写一致性的核心是选择合理的操作顺序,以下是三种主流策略的详细对比,这是面试的核心考点:

策略 操作流程 优点 缺点 适用场景
先更新数据库,再更新缓存 1. UPDATE db SET ... 2. SET cache KEY VALUE 实现简单,无需额外操作 1. 并发更新导致脏数据;2. 写多读少场景下,缓存更新无效(更新后无请求读取),浪费资源 低并发、写少读多、对一致性要求低的场景
先删除缓存,再更新数据库 1. DEL cache KEY 2. UPDATE db SET ... 避免缓存更新的无效写入 1. 并发读写导致旧数据回写;2. 短暂缓存穿透(删除后更新前,请求直接查库) 写多读少、对一致性要求中等的场景
先更新数据库,再删除缓存 1. UPDATE db SET ... 2. DEL cache KEY 1. 并发问题影响最小;2. 无效缓存更新少 1. 存在短暂的缓存不一致(更新后删除前,缓存是旧数据);2. 删除失败会导致缓存脏数据 高并发场景首选,生产环境主流方案

✅ 核心结论:为什么「先更新数据库,再删除缓存」是最优解?

  1. 并发影响最小:即使并发更新,最终缓存会被删除,后续请求会从数据库加载最新数据;
  2. 无效操作少:只有当数据被查询时,缓存才会被重新加载,避免写多读少场景下的缓存无效更新;
  3. 修复成本低:若删除缓存失败,可通过重试、定时任务兜底,比其他策略更容易恢复一致性。

三、 生产级双写一致性解决方案(按优先级排序,层层兜底)

选择「先更新数据库,再删除缓存」的基础上,需通过额外机制 解决残留的一致性问题,以下方案按 「实现成本从低到高、保障强度从弱到强」 排序,组合使用可覆盖 100% 场景

🔥 方案 1:延迟双删(解决并发读写导致的脏数据,必做)

这是最简单、成本最低 的解决方案,针对「先删缓存再更库」或「先更库再删缓存」的并发问题,核心是通过延迟第二次删除,清理并发写入的旧数据

实现步骤(以「先更库再删缓存」为例)
  1. 执行 UPDATE db SET ... → 更新数据库;
  2. 执行 DEL cache KEY → 第一次删除缓存;
  3. 休眠 N 毫秒(根据业务耗时调整,如 500ms~1s);
  4. 执行 DEL cache KEY → 第二次删除缓存。
核心原理
  • 休眠的 N 毫秒,是为了等待并发的读请求完成「缓存未命中→查库→写缓存」的全过程
  • 第二次删除,会将并发读请求写入的旧数据删除,后续请求会从数据库加载最新数据。
关键配置
  • 休眠时间:需大于一次数据库查询 + 缓存写入的耗时 ,生产环境建议通过压测确定,一般设置为 500ms~2s
  • 实现方式:可通过 Thread.sleep() 实现,或用异步线程执行第二次删除,避免阻塞主线程。

🔥 方案 2:分布式锁(保证双写操作的原子性,高并发核心)

在高并发场景下,延迟双删仍可能存在极端情况的不一致,此时需通过分布式锁保证「数据库更新 + 缓存删除」的原子性,避免并发竞争。

实现步骤
  1. 请求到来时,先通过 Redis SETNXRedisson 获取分布式锁,锁的 Key 为业务唯一标识(如 lock:goods:1);
  2. 获取锁成功后,执行 UPDATE db SET ... → 更新数据库;
  3. 执行 DEL cache KEY → 删除缓存;
  4. 释放分布式锁;
  5. 获取锁失败的请求,等待重试或直接返回。
核心原理
  • 分布式锁保证同一时间只有一个请求能执行双写操作,彻底杜绝并发竞争导致的不一致;
  • 锁的过期时间需大于业务操作耗时,避免死锁(推荐用 Redisson 的自动续期锁)。
适用场景
  • 秒杀、库存扣减等超高并发写场景,对数据一致性要求极高的核心业务。

🔥 方案 3:版本号机制(避免旧数据覆盖新数据,进阶方案)

通过给数据添加版本号,在更新缓存时校验版本号,确保只有最新版本的数据能写入缓存,解决并发更新导致的脏数据问题。

实现步骤
  1. 数据库表中新增 version 字段,初始值为 0,每次更新时 version +1
  2. 更新数据库:UPDATE db SET ..., version = version +1 WHERE id = 1
  3. 查询数据库获取最新 version(如 version=5);
  4. 删除缓存时,将版本号写入缓存(如 SET cache:goods:1:version 5);
  5. 后续读请求从数据库加载数据时,需校验版本号:只有当数据库版本号 > 缓存版本号时,才写入缓存。
核心原理
  • 版本号是数据的「时间戳」,确保只有最新版本的数据能覆盖缓存,避免旧数据回写。

🔥 方案 4:基于 Canal 的异步更新(高并发终极方案,无侵入)

以上方案均为同步操作 ,在超高并发场景下会影响接口性能,此时推荐使用 Canal 监听数据库 binlog ,异步更新缓存,实现非侵入式的双写一致性

核心原理
  1. Canal 伪装成 MySQL 的从库,实时监听数据库的 binlog 日志;
  2. 当数据库发生更新操作时,Canal 捕获到 binlog 事件;
  3. Canal 将更新事件发送到消息队列(如 RocketMQ/Kafka);
  4. 消费端监听消息队列,根据 binlog 内容异步删除或更新缓存
优势
  • 非侵入式:无需修改业务代码,对业务无感知;
  • 高性能:异步操作不阻塞主线程,适合高并发场景;
  • 强一致性:基于 binlog 保证数据更新的最终一致性,支持跨服务、跨机房的缓存同步。
生产最佳实践
  • 结合「先更库再删缓存」的同步策略 + Canal 异步兜底:同步删除保证大部分场景的一致性,Canal 捕获遗漏的更新事件,确保最终一致性;
  • 适合大规模分布式系统,如电商、金融的核心业务。

🔥 方案 5:定时任务兜底(最终一致性保障,必做)

以上方案仍可能存在极端情况的不一致(如缓存删除失败、Canal 宕机),此时需通过定时任务做最终兜底,实现「数据对账 + 修复」。

实现步骤
  1. 编写定时任务(如每 5 分钟执行一次),批量查询数据库中的核心数据(如商品库存、订单状态);
  2. 对比缓存中的数据与数据库数据,若不一致,则以数据库数据为准,更新缓存;
  3. 记录不一致的数据日志,方便排查问题。
核心原则
  • 定时任务是最终兜底手段,不能替代前面的策略,只能作为补充;
  • 任务执行频率根据业务一致性要求调整,核心业务可设置为 1~5 分钟 ,非核心业务可设置为 30 分钟~1 小时

四、 面试高频问题 & 标准答案

1. 为什么不推荐「更新缓存」,而是推荐「删除缓存」?

  1. 避免并发脏数据:更新缓存会导致并发请求覆盖,删除缓存则让后续请求从数据库加载最新数据;
  2. 减少无效操作:写多读少场景下,更新缓存后可能无请求读取,造成资源浪费;
  3. 实现简单:删除缓存的逻辑比更新缓存更简单,无需关心缓存的序列化 / 反序列化。

2. 延迟双删的原理是什么?休眠时间如何确定?

  • 原理:通过两次删除缓存,第一次删除是正常操作,第二次删除是为了清理并发读请求写入的旧数据,保证后续请求读取最新数据;
  • 休眠时间确定 :需大于「一次数据库查询 + 缓存写入的耗时」,生产环境通过压测确定,一般设置为 500ms~2s,避免过长阻塞主线程。

3. 双写一致性的最终解决方案是什么?

:生产环境推荐 「同步策略 + 异步兜底 + 定时对账」 的组合方案:

  1. 同步策略:先更新数据库,再删除缓存 + 分布式锁,保证高并发下的一致性;
  2. 异步兜底:基于 Canal 监听 binlog,异步更新缓存,非侵入式;
  3. 定时对账:定时任务对比缓存和数据库数据,修复不一致,实现最终一致性。

4. 缓存穿透、缓存击穿、缓存雪崩和双写一致性的关系?

  • 双写一致性问题可能引发缓存穿透(如先删缓存再更库时,并发读直接查库)、缓存击穿(如热点数据的缓存被删除后,大量请求查库);
  • 解决双写一致性的过程中,也能缓解这些问题(如延迟双删减少旧数据回写,分布式锁减少并发查库)。

五、 生产最佳实践总结

  1. 低并发场景:先更新数据库,再删除缓存 + 延迟双删,成本最低;
  2. 高并发写场景:分布式锁 + 先更库再删缓存,保证原子性;
  3. 大规模分布式场景:Canal 异步更新 + 定时任务兜底,非侵入式,高性能;
  4. 核心铁律永远不要让缓存的更新逻辑依赖于业务代码,尽量通过异步、兜底机制保证一致性,减少业务耦合。
相关推荐
余道各努力,千里自同风2 小时前
node.js 操作 MongoDB
数据库·mongodb·node.js
oMcLin2 小时前
如何在 Fedora 34 上通过配置 MongoDB 集群,提升电商平台的用户数据存储与查询响应速度?
数据库·mongodb
green__apple2 小时前
Oracle普通分区表转自动分区表
数据库·oracle
MediaTea2 小时前
Python OOP 设计思想 13:封装服务于演化
linux·服务器·前端·数据库·python
极客先躯2 小时前
高级java每日一道面试题-2025年5月09日-基础篇[协议-注解-缓存]-JCache(JSR-107)是什么?它的主要目标是什么?
java·spring·缓存
清风拂山岗 明月照大江2 小时前
MySQL 基础篇
数据库·sql·mysql
古城小栈2 小时前
后端接入大模型实现“自然语言查数据库”
数据库·ai编程
IvorySQL2 小时前
拆解 PostgreSQL 连接机制:从进程模型到通信协议
数据库·postgresql
Dxy12393102162 小时前
MySQL连表更新讲解:从基础到高级应用
数据库·mysql