保证Redis与数据库之间的数据一致性,是分布式系统中的一个经典挑战。没有“银弹”方案,需要根据业务场景(对一致性要求、数据读写比例、性能需求)进行权衡和设计。
核心思想是:放弃强一致性,追求最终一致性。
在分布式环境下,同时保证Redis和数据库的强一致(CP)代价极高,会严重损害性能,通常业务上可以接受短暂的数据不一致。
以下从设计模式、常见方案、高级实践和选择建议几个方面来系统阐述。
一、核心设计模式 (Cache-Aside Pattern)
这是最常用、最基础的缓存模式,也称为“懒加载”。流程如下:
- 读操作:
- 先读Redis,命中则返回。
- 未命中则读数据库,取出数据后写入Redis,再返回。
- 写操作:
- 更新数据库。
- 删除Redis中的对应缓存(而非更新)。
优点:逻辑简单,能覆盖大部分场景。删除缓存而非更新,避免了并发写操作导致的缓存数据错乱问题。
关键:该模式的一致性问题,就集中在 “更新数据库” 和 “删除缓存” 这两个操作的执行顺序和原子性上。
二、常见一致性方案与权衡
主要围绕两大核心问题:1. 先操作数据库还是先操作缓存? 2. 操作失败怎么办?
方案一:先更新数据库,再删除缓存 (Cache-Aside 标准做法)
这是最推荐的通用做法。
- 流程:
- 更新数据库。
- 删除Redis缓存。
- 不一致场景分析:
- A线程更新数据库后,删除缓存前:B线程读取到的是旧缓存(短暂不一致,等A删除后即恢复)。
- A线程更新数据库后,删除缓存失败:缓存永远为旧数据(严重问题)。
- 优点:出现不一致的时间窗口通常极短(因为数据库写操作一般比Redis删除慢,下一个读请求会在缓存缺失后从数据库加载新值)。
- 缺点:缓存删除失败会导致永久不一致。必须要有重试或补偿机制。
方案二:先删除缓存,再更新数据库
- 流程:
- 删除Redis缓存。
- 更新数据库。
- 不一致场景分析(概率较高):
- A线程删除缓存。
- B线程发现缓存缺失,读取数据库旧值,并写入缓存。
- A线程更新数据库。
- 结果:缓存中是旧数据,数据库是新数据,且直到该缓存下次被删除或过期前,都会不一致。
- 缺点:不一致发生的概率和窗口比方案一更大,因为读操作(步骤2)非常快。
针对“先删后更”的优化:延迟双删
为了解决上述问题,在方案二基础上增加一个延迟删除步骤。
- 删除Redis缓存。
- 更新数据库。
- 等待一小段时间(如几百毫秒,根据主从同步和业务读耗时估算)。
- 再次删除Redis缓存。
- 作用:第4步可以清除掉在步骤1和步骤2之间被其他线程写入的旧值缓存。
- 缺点:引入了延迟,降低了吞吐量,且时间难以精确估计。
三、保障可靠性的高级实践
无论采用哪种顺序,关键是要保证两个操作最终都能成功。以下是保障措施:
1. 缓存删除重试机制
- 同步重试:在应用代码中重试几次,简单但不推荐,会阻塞用户请求。
- 异步重试 - 消息队列:
- 更新数据库后,向消息队列发送一条删除缓存的消息。
- 消费者消费消息,执行删除。若失败,消息会自动重试,直到成功。
- 优点:解耦,可靠。需维护消息队列。
- 异步重试 - 订阅数据库Binlog:
- 使用 Canal、Debezium 等工具,订阅数据库的变更日志(Binlog)。
- 程序解析Binlog,识别出数据更新,然后触发缓存删除。
- 优点:完全解耦,对业务代码零侵入。可以统一处理所有缓存失效逻辑。
- 缺点:系统复杂度高。
2. 设置合理的缓存过期时间
- 在所有缓存Key上设置一个不太长的过期时间(如30秒到几分钟)。
- 这是最终一致性的兜底方案。即使之前的删除操作全部失败,缓存数据也会在一段时间后自动过期,下一个读请求会从数据库加载最新值。
- 作用:防止在补偿机制也失效的情况下,产生永久性不一致。
四、不同业务场景的选择建议
| 场景 | 一致性要求 | 推荐方案 | 关键点 |
|---|---|---|---|
| 读多写少,容忍秒级不一致(如用户信息、商品详情) | 最终一致性 | 先更新DB,再删除缓存 + 缓存过期 | 实施简单,配合短过期时间,可靠性足够。 |
| 写多或一致性要求稍高(如库存、优惠券) | 最终一致性(尽量短) | 先更新DB,再删除缓存 + 异步重试(MQ/Binlog) | 通过重试保证删除成功,不一致窗口极短。可考虑延迟双删。 |
| 金融、交易核心数据(如账户余额) | 强一致性 | 放弃读缓存,直接读DB 或 使用分布式锁 | 强一致性代价高。可直接读主库,或使用读写锁确保“读-写-读”顺序,但性能骤降。 |
| 极少变化的配置类数据 | 最终一致性 | 先更新DB,再删除缓存 或 设置较长过期时间,定时刷新 | 不一致影响小,甚至可以直接用过期时间驱动更新。 |
五、总结与最佳实践清单
- 首选标准模式:对于大多数互联网应用,采用 Cache-Aside(先更新数据库,再删除缓存)。
- 删除而非更新:写操作时,总是 删除(Invalidate) 缓存,而不是更新它。
- 必须设置过期时间:为缓存Key设置一个合理的、不算太长的过期时间,作为最后的安全网。
- 建立补偿机制:通过 消息队列 或 订阅Binlog 实现异步重试,确保缓存删除操作最终成功。
- 考虑并发场景:在高并发写入场景下,可以评估 延迟双删,但要权衡复杂度。
- 强一致性场景慎用缓存:对于必须强一致的业务,要么直接读数据库(可能从库有延迟,需读主库),要么引入分布式读写锁(性能代价大)。
- 监控与告警:监控缓存与数据库的差异(例如通过定期抽样对比),并设置告警。这是发现和解决问题的最后防线。
记住,一致性、可用性、分区容错性(CAP)无法同时完美满足。与产品、业务方明确“可接受的不一致时间”是技术方案设计的前提。通过技术手段将这个不一致窗口缩短到业务可接受的范围内,是工程实践的目标。