以下内容力求在技术细节和实践深度方面尽可能面面俱到,从根源、核心流程、并发问题、解决策略到大规模落地方案进行剖析,帮助读者理解为什么缓存与数据库之间会出现不一致、出现在哪些地方、以及在高并发高可用场景下有哪些成熟的最佳实践可供参考。
1. 为什么会出现缓存与数据库不一致
在 MySQL + Redis 的常见架构中,数据存储分为两个层次:
- MySQL 数据库(主存 / 永久存储):具备事务能力、支持复杂查询、保证数据落盘等。
- Redis 缓存(读写加速 / 减少DB压力):存放热点数据、副本数据,以高吞吐和低延迟应对读/写请求。
不一致本质上是因为同一份逻辑数据同时存在于两个不同的存储系统中,而更新和读取可能不同步。常见原因包括:
-
更新时序和并发冲突
- 多个并发请求,先后/交错地对数据库和缓存进行写/读操作,会导致缓存被回填“旧值”、更新丢失等情况。
-
异步 / 延迟机制
- 典型的“先写 DB,后删/更新缓存”或者“写回缓存后异步刷 DB”在短时内无法保证强一致,往往只保证最终一致。
-
故障 / 网络抖动
- 写请求发生网络中断、服务重启、Redis 宕机等意外,写操作可能只成功执行了部分阶段,或重试导致数据重复写入。
-
缓存过期策略/TTL
- Redis 中 Key 过期时间设置不当(过长/过短),或者过期更新时机不一致,也可能造成持久层与缓存层数据的短期不一致。
在互联网大厂的高并发场景下,实际情况远比简单的“读写顺序问题”更复杂。为此,需要一套尽量减少不一致发生、并在出现时迅速解决的机制。
2. 常见的读写交互模式
先了解几种与缓存交互的典型模式,结合并发场景分析各自的问题与适用性。
2.1 Cache Aside(旁路缓存)模式
-
读流程
- 应用先访问 Redis,如果命中则直接返回;
- 如果缓存未命中,则从 MySQL 查询,然后把结果写回 Redis,再返回给应用。
-
写流程
- 先更新数据库(写 MySQL)
- 再删除缓存(让缓存失效,下次读时再回源数据库并刷新)
这也是最普遍使用的模式,称为“ Cache Aside Pattern ”或者“Lazy Loading Pattern”。
优点:对应用层改造较小、实现简单。
缺点:并发更新/读取时,依旧可能出现“回填旧数据”问题。
2.2 Read/Write Through(直读/直写)模式
- 读流程:如果缓存未命中,由缓存服务本身负责回源数据库,再把数据返回给应用和缓存。
- 写流程:应用直接对缓存写,然后由缓存异步或同步地写数据库(或写完缓存后,再写数据库)。
优点:对应用而言,数据库操作被“缓存层”屏蔽。
缺点:缓存服务需要实现复杂的读写数据库逻辑;如果缓存挂了,数据库可能也无法正常更新。
2.3 Write Behind / Write Back(异步写回)模式
- 写流程:应用只写缓存,缓存异步批量地将变更合并写回数据库。
- 读流程:大部分读从缓存中获取,如果缓存没有则回源数据库。
优点:写性能最高(几乎所有写请求都只落到缓存)。
缺点:有较大的数据丢失风险(缓存挂了且未写回时),并且在真实生产环境对一致性要求高时实现成本很大,不太常见于核心业务。
3. 核心一致性问题与典型“并发写”场景
以Cache Aside中最常用的“先更新数据库,后删除缓存”方案为例,最常见的并发一致性问题有两种时序:
-
读取脏数据(回填旧值)
-
时序场景:
- 线程 A 执行写操作,先更新 MySQL。
- 线程 B 立刻来读数据,发现 Redis 中还是旧值或空值,可能会去数据库读取到更新前的旧数据(如果还没提交,或者主从延迟),再将这份“旧数据”回填到缓存。
- 线程 A 再执行“删除缓存”操作,结果 B 已经用旧数据回填 Redis,造成不一致。
-
-
删除缓存失败
-
时序场景:
- 线程 A 更新数据库成功。
- 线程 A 删除缓存时,因网络/Redis 宕机等问题失败了。
- 缓存中一直保留旧数据且不会过期(或 TTL 设置很长),导致长期不一致。
-
要想彻底避免上述问题,可以加上同步锁、版本号校验、二次删除、监听 binlog 等手段,但都意味着性能、复杂性或一致性的取舍。
4. 大厂常用的解决策略
以下策略往往是组合拳,并不存在“唯一绝对正确”的答案,更多是根据业务访问模式、并发量、容忍延迟程度、运维成本来做平衡。
4.1 经典的“延迟双删”策略
核心思想:
- 更新数据前:先删除缓存;(某些实现是更新 DB 后,再删缓存;也有逆序做法,关键看如何避免并发回填)
- 更新数据库;
- 短暂等待,比如
sleep(50-200ms); - 再次删除缓存;
动机:
- 第一次删除缓存可以让后续读请求走数据库,缓存中不会有旧值。
- 但是,在“数据库更新完成”与“缓存删除操作”之间,可能有新的并发请求读到了旧数据并写回缓存。所以等待一小段时间后再次删一次缓存,最大程度降低“旧值再次回填”风险。
优点:
- 实现难度不算高,大型互联网公司(如淘宝等)也使用类似思路,能显著减少并发下的回填旧值问题。
缺点:
sleep的时间难以精确估算:过小无法覆盖大多数场景,过大就拉长不一致窗口,并且浪费线程资源。- 无法保证绝对强一致,但对“最终一致”来说一般够用。
深度细节
- 重试与幂等:删除缓存可能失败,需要保证重复删除是幂等操作(删除同一个 Key 多次不影响正确性)。
- 异步队列:有时第二次删除操作不是靠
sleep,而是在数据库更新后发一条消息到队列,再由队列消费方负责再次删除缓存,减少同步等待。
4.2 加分布式锁 / 乐观锁保证更新原子性
分布式锁(互斥锁)机制
在更新/删除缓存前,对目标 Key 先获取一个分布式锁,示例伪代码:
String lockKey = "lock:data:" + dataId;
boolean lockAcquired = tryLock(lockKey, 5000); // 设置过期时间,防死锁
if (lockAcquired) {
try {
// 1. 更新数据库
updateDB(dataId, newData);
// 2. 删除缓存
redis.del(dataKey);
} finally {
unlock(lockKey);
}
} else {
// 未获取到锁,可重试或直接返回
}
这样在同一时刻对同一个数据的写操作只有一个线程能进行,避免了写-读并发导致的回填冲突。
缺点:
- 牺牲一定的并发性能,高并发场景下锁竞争激烈。
- 如果不同 Key 的更新频率非常高,需要精细化地控制锁粒度(行级 / 业务级),否则容易造成性能瓶颈。
版本号(乐观锁)机制
- 在 MySQL 表中增加一个
version字段,每次更新时WHERE version = oldVersion,并将version更新为oldVersion+1。 - 在缓存中同样保留一个版本号,写操作时对比版本号决定是否回填或删除。
- 若检测到版本冲突,则说明在并发场景下已有别的线程成功更新,需要放弃本次更新或重试。
优点:
- 不会像互斥锁那样阻塞其他读操作或写操作。
- 能精确识别冲突,保证数据更新有序。
缺点: - 复杂度提升,需要改造数据库结构和业务更新逻辑,在极高并发下也会频繁出现乐观锁冲突,必须处理重试。
4.3 基于 Binlog / MQ 的异步校验 & 最终一致
Binlog 监听(MySQL-Canal / Maxwell / Debezium / Flink CDC)
-
原理:MySQL 的更新记录会写入 Binlog,对 Binlog 做增量监听,可以实时获取数据库里哪些字段更新了。
-
做法:一旦检测到某条记录发生更新,即可通知缓存服务或触发相应的“删除/刷新缓存”逻辑。
-
优势:
- 对业务应用零侵入,不用改写应用的增删改操作,只要能获取 Binlog 即可。
- 大厂常采用 Canal + Kafka/ RocketMQ 将 binlog 投递到下游做数据同步或缓存刷新。
-
不足:
- 存在消息传播和消费的延迟,不会是强一致。
- 如果 binlog 监听程序或队列出现故障,也会有时长不一致风险,仍需补偿机制。
业务消息队列方案
- 写操作时,应用在更新数据库后,将“更新事件”发送到消息队列(如 Kafka、RocketMQ)。
- 由专门的“缓存更新服务”消费此消息,执行对应的缓存删除/更新操作。
- 好处:解耦应用与缓存更新流程,可做到异步、削峰。
- 问题:消息丢失、延迟或重复消费等,需要靠消息幂等、重试机制和死信队列来保证最终一致。
4.4 定期校验和补偿任务
再完善一点,大厂在落地中常会有“定期对账”或“补偿任务”,在后端批量巡检数据:
-
思路:
- 定期(比如 1 小时 / 半天)去数据库和缓存对比部分核心数据(例如库存、余额、计数),若发现不一致则以数据库为准,更新或删除缓存。
- 对核心场景甚至会实施实时校验,当写操作发生时同步记录到审计表中,定期做比对。
-
场景:
- 特别适用于极其重要又必须对一致性有硬性要求的场景,如交易、账户、支付等。
- 在这些场景下,通常数据库是“可信源”,而缓存只做性能加速,出现差异时务必以数据库为准,立刻修正。
5. 大规模场景下的综合方案
互联网大厂往往会组合多种策略,以兼顾性能和一致性:
-
核心路径:
- 主流程采用“先更新数据库,后删除缓存” (或“延迟双删”)的 Cache Aside 模式;
- 尽量保证写操作的幂等,删除缓存不成功则多次重试或记录补偿。
-
分布式锁 / 乐观锁:
- 对热点数据,或者读一致性要求特别严格的关键字段,可以加行级分布式锁,保证一个时刻仅有一个线程更新数据和缓存;
- 或者使用版本号,在超高并发写场景下实现乐观锁冲突检测与重试。
-
Binlog / MQ 异步更新:
- 监听 MySQL Binlog,一旦检测到对应数据更新,就向缓存层下发“删除 / 刷新”指令;
- 作为补充手段,可解决应用删除缓存失败或延迟过久等情况,也能处理跨服务更新带来的一致性问题。
-
定期校验与自动修复:
- 为防止极端异常(如 MQ 丢消息、Redis 宕机导致删除失败),会定期对关键数据库表和 Redis Key 做对比校验,发现异常及时修补。
- 线上往往会做采样校验,或对核心业务做全量校验。
-
监控 & 告警体系:
- 对缓存更新操作的错误率、延迟、MQ 堆积、Redis 命中率异常波动等都进行实时监控,一旦出现异常,运维或值班人员会第一时间处理。
- 大厂会有完善的 APM(应用性能监控)和日志体系(ELK / Loki / Prometheus + Grafana),对缓存写/删操作进行闭环追踪。
6. 一些细节与注意事项
-
避免大 Key
- 如果一个 Redis Key 映射了很多数据库行数据(如一次性缓存一大段),更新时需要小心并发导致整段回填;大 Key 的更新和传输也影响性能。可考虑将数据拆分为多个细粒度 Key 管理。
-
热点 Key 优化
- 如果某些 Key 是超高并发热点,频繁更新会导致极大锁争用或写压力,需要采用单线程排队或分布式队列模型,对这种 Key 做特殊处理。
- 或者通过一致性哈希或分区将热点负载打散到多个节点,减少单点压力。
-
短 TTL vs 强一致
- 将缓存设为较短的 TTL,即便出现脏数据,也会在短时间内过期刷新。
- 适用于读多写少、且对数据准确性要求中等的场景;真正对准确性极度敏感的交易场景则应使用更强的同步机制(锁/消息队列/Binlog等)。
-
多级缓存
- 大厂在内部往往还有“本地缓存 + Redis + 数据库”三级结构,本地缓存读取速度更快,但是一致性维护更复杂,需要处理Cache Penetration和Cache Avalanche问题。
- 这时,需要对本地缓存设置更短的过期时间或结合监听机制,避免缓存层级越多数据越滞后。
-
高可用故障切换
- Redis 可能采用主从、集群、哨兵等模式,故障切换会带来短暂的不可用或数据回放延迟,需要在应用端做重试或快速感知切换,并保证写操作的幂等性。
7. 总结:大厂的一致性“组合拳”
-
Cache Aside:先更新数据库,后删除缓存
- 社区和大多数公司最常用的模式,简单且有效,重点在于删除缓存这一步的重试、幂等、监控。
-
延迟双删 + 分布式锁
- 针对高并发可能出现的脏回填问题,用“二次删除”+“等待”来弥补;
- 在读一致性非常严格的场景,可以加分布式锁或版本号来防并发写冲突。
-
消息队列 / Binlog 监听,异步校正
- 用于应对跨服务、分库分表、高级别数据同步等更复杂的场景;
- 解决应用在高峰期删除缓存失败或延迟过久的问题,并能追溯修复。
-
定期校验 & 自动修复
- 不可或缺的“最后一道防线”,防止缓存与数据库长期不一致,尤其是关键数据。
- 辅以完善的日志、监控、报警,在出现异常时及时发现。
-
因地制宜做性能与一致性的平衡
- 不可能所有场景都用最严格的分布式锁或多次删除,否则系统性能和开发复杂度都会爆炸;
- 大多数场景下,最终一致 + 短期数据误差 是可接受的,只要对用户体验或业务逻辑影响不大。对严谨场景(金融、交易)再使用更严格策略。
结语
Redis + MySQL 缓存架构本身并不能天生保证 100% 强一致,尤其在高并发、分布式、多服务的互联网业务中,出现缓存和数据库不一致是常态。最核心的思路在于识别关键数据的更新流程并发,精心设计更新/删除缓存时序,再辅以分布式锁/版本号/异步队列/定期校验,多重手段下保证“一般场景最终一致、核心场景强一致或快速修正”。
在实际生产中,没有一招鲜吃遍天;一线大厂往往是结合自身业务(是否高频写?对一致性要求到何种级别?能否容忍微小延迟?)来选用不同的技术方案。能支撑业务需求且维护成本可控,才是真正的最佳实践。