线上环境Redis缓存与数据库不一致的合理解决方案
问题
当前线上环境遇到一个偶现问题,Redis缓存跟第三方数据库不一致,问题出现的概率为每隔三四天或者一周出现一两次,且Redis缓存没有设置过期时间,代码中更新第三方数据和缓存的逻辑遍布在工程各个角落且写法不一,然后一开始想着直接给Redis缓存中的数据加上过期时间,但是又担心线上环境Redis缓存数据突然全部失效导致缓存穿透和雪崩,进而请求全部打到第三方接口导致第三方接口限流,该场景问题应该如何分析并彻底解决?
分析
这是一个非常经典且棘手的线上问题,它混合了技术债务、架构缺陷和稳定性风险。处理起来需要分阶段、有策略地进行,绝不能盲目地一刀切(比如直接给所有缓存加上过期时间)。
核心问题分析
1、根本原因:代码中遍布的、写法不一的更新逻辑是万恶之源。这导致无法保证“更新第三方数据”和“更新/删除Redis缓存”这两个操作的原子性和最终一致性。某个地方可能只更新了数据库忘了删缓存,另一个地方可能删了缓存但数据库更新失败了。由于缓存没有过期时间,这些不一致一旦产生就会永久存在,直到下一次“写对”的操作覆盖它。
2、“偶现”为何:因为只有某些特定的、不常执行的代码路径或罕见的并发竞争条件会触发这种不一致的操作序列,所以问题三四天才出现一次。
3、你的担忧非常正确:如果直接给存量缓存加上TTL,会导致它们在某个时间点大规模同时失效。大量请求无法命中缓存,会直接穿透到第三方接口,极易导致:
- 第三方接口被打挂或触发限流,返回大量错误。
- 自身服务因等待第三方响应而线程池占满,引发整体服务雪崩。
解决方案:分阶段治理
彻底解决此问题需要一个系统性的工程方案,不能只靠一个“银弹”。建议分为以下四个阶段实施:
阶段一:止血与监控(短期,立即执行)
目标:发现现有不一致的数据,并修复它们,同时建立监控以防恶化。
1、编写数据校对脚本:
- 写一个离线脚本,定期(比如每小时一次)跑批。
- 脚本逻辑:扫描Redis中的关键缓存Key,用Key还原出查询参数,去第三方数据库查询最新数据,与缓存值进行比对。
- 一旦发现不一致,记录日志并自动触发缓存更新(用第三方数据覆盖Redis)或删除。
- 这个脚本能帮你自动修复历史遗留的脏数据,是 immediate action。
2、增强日志和告警:
- 在所有“更新第三方数据并操作缓存”的地方,加固日志。记录操作前、后的数据状态,以及是否成功。
- 一旦发现更新DB成功但删除缓存失败的情况,立即记录错误日志并发送告警(如发到钉钉/Slack/短信),让研发人员可以人工介入排查。
阶段二:架构与流程优化(中期,下一版本)
目标:在不引发雪崩的前提下,引入过期机制,并开始统一缓存操作模式。
1、为所有【新】缓存设置【随机】过期时间:
绝对不要直接给存量缓存加TTL。而是修改代码,所有新写入的缓存都必须设置TTL。
TTL时间不应固定。采用基础时间 + 随机偏移量的策略(例如:3600 + Random.nextInt(600) 表示1小时±5分钟)。这能确保缓存不会在同一时刻失效,从而避免雪崩。
2、引入本地缓存(Guava Cache/Caffeine)作为二级屏障:
- 在应用层(如Spring Boot服务中),使用内存级的本地缓存(设置一个短的过期时间,如1-2分钟)来缓存第三方接口的数据。
- 好处:即使Redis缓存同时失效,请求会先打到本地缓存。由于每个节点的本地缓存失效时间点是随机的,打到第三方接口的流量会变得非常平缓,完美解决了雪崩和穿透问题。
- 流程:请求 -> 查本地缓存 -> 查Redis缓存 -> 查第三方DB。
3、开始代码重构的准备:
- 创建一个统一的
CacheService
类,将所有散落的缓存操作(get
,put
,delete
)收拢到这个类中。 - 先让这个类成为所有缓存操作的唯一入口,为下一阶段的彻底重构做准备。
阶段三:彻底重构(长期,重要版本)
目标:从根本上解决一致性问题。
1、收敛写操作:
- 这是最治本但最困难的一步。将“更新第三方数据”的逻辑也收拢到少数几个服务或方法中(如用Facade模式或领域服务)。
- 在这些统一的入口,采用标准的缓存更新模式:
- 推荐模式:先更新第三方数据库,再删除缓存。 (Write-Through + Cache-Aside)
- 在删除缓存时,必须加入重试机制。如果删除失败,将删除操作抛入消息队列(如RocketMQ/Kafka)进行异步重试,直到成功。这保证了最终一致性。
2、考虑更强大的解决方案:
- 如果第三方数据库是MySQL等,可以引入Canal或Debezium,通过订阅数据库的binlog来感知数据变化。
- 然后由一个独立的服务来消费binlog事件,并负责删除或更新Redis中的缓存。这样,应用代码就不再需要关心缓存删除问题,从根本上杜绝了因代码写法不一导致的不一致。这是最彻底、最解耦的方案。
阶段四:容量规划与降级(持续)
- 压测与评估:评估在极端情况下(如缓存完全失效),第三方数据库和自身服务的最大承压能力。做到心中有数。
- 配置降级开关:准备一个开关,在发现第三方接口响应变慢或大量报错时,可以手动或自动开启。降级策略可以是:
- 返回本地缓存的旧数据(并标记为陈旧)。
- 直接返回默认值或友好提示。
- 这样做的目的是保住自身服务的可用性,避免被拖垮。