如果由于代码Bug导致缓存中存入了大量的脏数据,业务在线且不能停机,如何平滑清理并恢复数据一致性?
在业务在线且不能停机的情况下,处理大量缓存脏数据是一个典型的高可用架构应急场景。直接清空缓存(如 FLUSHALL 或批量 DEL)会导致缓存雪崩,巨量请求直接打垮数据库(DB)。
为了“平滑清理并恢复一致性”,必须遵循“控制爆炸半径、避免DB击穿、渐进式修复”的原则。以下是标准的操作指南和修复策略:
第一阶段:止血与评估(紧急操作)
- 切断污染源:
- 立即回滚代码或发布Hotfix,确保新的请求不再产生脏数据。如果污染源不切断,后面的清理毫无意义。
- 评估影响面:
- 脏数据的特征是什么?(是有特定的 Key 前缀?还是某个时间段写入的数据?还是全局污染?)
- 脏数据对业务的影响程度?(是只影响显示,还是会引起资损?)
- 监控水位:
- 检查当前 DB 的 CPU、IO负载,以及 Redis 的内存和网络情况,确定接下来修复任务的“可用性能余量”。
第二阶段:选择平滑修复策略
根据脏数据的特征和范围,选择以下三种策略之一:
策略一:缓存 Key 版本升级(适用于全局污染、无法区分脏数据)
如果几乎所有缓存都被污染,或者很难通过规则筛选出脏数据,最好的方式是废弃老缓存,启用新版本缓存。
- 做法:
- 修改代码中的 Cache Key 生成规则,增加版本号前缀。例如从
user:info:{uid}改为v2:user:info:{uid}。 - 绝对不能直接上线新代码! 否则会导致 100% 缓存未命中,瞬间击穿 DB。
- 后台预热:写一个后台任务,从 DB 中全量读取数据,写入到
v2版本的缓存中(控制写入 QPS,保护 DB)。 - 灰度切换:预热完成后,通过配置中心(如 Apollo、Nacos)动态下发开关,将应用读取缓存的逻辑从
v1切换到v2(可以按用户ID哈希做灰度,比如先放量10%,观察DB压力,再逐步放到100%)。 - 清理老数据:切换完成后,老缓存不再被访问,等待其自然过期,或者在低峰期使用脚本异步清理。
- 修改代码中的 Cache Key 生成规则,增加版本号前缀。例如从
策略二:异步扫描 + 强制覆盖(覆盖优于删除,适用于特定范围污染)
如果你能明确知道哪些 Key 是脏数据(例如特定前缀,或已知受影响的用户ID列表),应采用异步修复。
- 做法:
- 提取脏 Key 列表:通过应用日志提取,或者通过 Redis
SCAN命令(绝不能用KEYS)匹配出受影响的 Key。 - 编写修复脚本(或任务):读取 DB 中的正确数据,重新
SET到缓存中。 - 为什么是“覆盖”而不是“删除”? 如果你把脏数据
DEL掉,接下来的并发请求会发现缓存为空,同时去查 DB(缓存击穿)。直接用 DB 的正确数据覆盖 Redis 中的脏数据,可以保证前端请求始终命中缓存。 - 限流执行:脚本必须加入限流(Rate Limiter),例如限制每秒修复 500 个 Key,持续监控 DB 的 CPU 和慢查询,一旦 DB 压力上升立刻降低修复速率。
- 提取脏 Key 列表:通过应用日志提取,或者通过 Redis
策略三:懒加载 + 强制失效(适用于长尾数据、非热点数据)
如果脏数据量极大,且大部分是冷数据,全量查 DB 覆盖成本太高,可以结合过期时间进行处理。
- 做法:
- 拦截业务读取逻辑:如果业务允许,可以在代码里加个短期的“补丁”。
- 当读到旧格式的脏数据时(可以通过数据结构特征判断,比如缺少某个新字段),代码主动判断其为无效数据。
- 触发常规的“缓存未命中”逻辑:去 DB 查最新数据,并回写缓存。
- 防击穿保护:如果该数据是热点,必须在代码中加入单机锁(如 Golang 的
singleflight或 Java 的ConcurrentHashMap+synchronized)或分布式锁,确保同一个 Key 并发失效时,只有一个线程去查 DB 回写。
第三阶段:执行细节与防雷指南(Crucial Details)
在执行上述策略时,必须注意以下技术细节,防止引发二次事故:
Redis SCAN 操作规范:
bash# 错误做法:导致 Redis 阻塞 KEYS user:info:* # 正确做法:游标迭代,每次取少量 SCAN 0 MATCH user:info:* COUNT 100打散过期时间(TTL Jitter):
在后台任务重新覆盖缓存时,千万不要给所有 Key 设置相同的过期时间。否则几个小时后,这批数据同时过期,会引发延迟的缓存雪崩。java// 伪代码:基础过期时间 + 随机打散 int baseTtl = 3600; // 1小时 int randomJitter = new Random().nextInt(1800); // 0~30分钟随机 redis.set(key, value, baseTtl + randomJitter);双写一致性保障:
在后台修复脚本运行期间,业务层可能也在正常修改数据。为了防止“修复脚本拿到的 DB 旧数据”覆盖了“业务刚刚写入缓存的新数据”:- 方案 A(时间戳/版本对比):缓存 Value 中带上更新时间,覆盖前检查,只允许新时间戳覆盖老时间戳。
- 方案 B(利用 Redis 的 Lua 脚本):在修复脚本中,先查一下当前的 Redis 数据,如果已经被业务更新(数据正确),则跳过不修复。
- 方案 C(简单粗暴延后修复):修复任务只处理发生 Bug 那个时间段之前的数据。
利用低峰期:
如果脏数据不导致严重的资损(例如仅仅是用户昵称显示错误、点赞数错误),尽量将大规模的修复任务(预热、扫表)延后到凌晨业务低峰期执行。
总结:标准处理 SBP (Standard Business Procedure)
- 发现问题 -> 代码止血(发版修复BUG) -> 脏数据不再增加。
- 评估DB抗压能力,开启 DB/Redis/网络 监控大盘。
- 如果是核心热点数据 -> 采用策略一(Key版本升级+后台平滑预热+灰度切换)。
- 如果是特定已知数据 -> 采用策略二(游标SCAN + DB读取 + 限流强制覆盖缓存)。
- 在操作过程中,严格控制并发请求量,给重置的缓存加上随机TTL。
- 修复完毕后,复盘并引入机制(如缓存写入校验、重要数据的对账机制)。