缓存与数据库的一致性问题是每个使用Redis的团队都绕不开的坎。业务代码看似没问题,压测或高峰期一过,缓存里却悄悄躺进了脏数据:页面显示的库存和数据库对不上,用户改完昵称刷新后还是旧名字。这类问题的麻烦之处在于它往往不是必现的,而是概率性出现,排查起来非常费劲。要治理脏数据,得先搞清楚它是怎么进到缓存里的,再针对不同场景选择合适的清理和预防手段。

脏数据是怎么产生的:三个典型场景
最常见的场景是读写并发时的时序错乱。假设线程A负责写数据,线程B负责读数据。B先查缓存未命中,于是去数据库读到旧值;此时A完成数据库更新并删除了缓存;紧接着B把之前读到的旧值写回缓存。结果就是数据库已经是新值,缓存里却长期保留着旧数据,而且这个旧数据会一直存活到过期时间结束。这个经典的"先读后写"竞态,在低并发下几乎不会触发,一旦QPS上来了就变成常态化问题。
第二个场景是更新顺序设计不合理。比如先删缓存再更新数据库:删除缓存后、数据库还没提交的窗口期内,其他读请求会把数据库的旧值重新加载进缓存,之后数据库才更新完成,缓存就成了脏数据。反过来,先更新缓存再更新数据库也有风险,如果数据库更新失败,缓存里的新值就是一条凭空捏造的数据,比旧值危害更大。
第三个场景是主从延迟导致的"脏读回填"。读写分离架构下,读请求打到从库,主从同步存在毫秒到秒级的延迟。写请求更新主库并删除缓存后,读请求从未同步完的从库读到旧值,又把这个旧值写回缓存。此外,使用keys命令误操作、批量任务写错key、不同业务复用同一个key存不同结构的数据,也都会制造出各种形态的脏数据。
常见的清理与预防方案对比
延迟双删是目前应用最广的方案。它的思路是:写操作先删除缓存,再更新数据库,休眠一小段时间后进行第二次删除,把延迟窗口期内被读请求回填的旧值清掉。第二段延迟的时长要大于一次读请求从数据库取数到写回缓存的耗时,一般设为几百毫秒到一秒。它的优点是实现简单、改动小,缺点是那个休眠时间只能拍脑袋估算,极端场景下仍然可能残留脏数据。
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// 第一次删除缓存
redisTemplate.delete(key);
// 更新数据库
productMapper.update(product);
try {
// 延迟一段时间,等读请求把旧值回填后
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 第二次删除,清掉可能被回填的脏数据
redisTemplate.delete(key);
}
基于binlog的异步失效方案更可靠。通过Canal或Debezium监听MySQL的binlog,一旦数据库发生变更,由独立的服务去删除或更新对应缓存。这种方式把缓存失效逻辑从业务代码中剥离出来,天然保证"数据库更新成功才删缓存",不存在更新数据库失败但缓存已被误删的问题。代价是多维护一套中间件,链路变长,消息积压时缓存失效会有延迟。
如果对一致性要求极高,可以引入版本号机制或分布式锁。给每条缓存数据附带一个版本号,数据库更新时版本号自增,读请求写回缓存前校验版本号是否仍然匹配,不匹配就放弃写入,从根上阻断旧值回填。分布式锁则是在写操作期间对key加锁,读请求在锁存在时不回填缓存。两种方式一致性最强,但吞吐量都会受到明显影响,一般只用在资金、库存这类核心数据上。
脏数据已经产生,如何安全地批量清理
发现脏数据后的第一反应往往是执行keys prefix:*,这在生产环境是大忌。keys是阻塞命令,key数量达到百万级时一次执行可能卡住Redis好几秒,期间所有请求排队,等于制造一次线上事故。正确的做法是用scan命令游标式遍历,它每次只取一小批key,分多次执行,不会阻塞主线程。用Java代码配合scan实现按前缀删除的示例如下:
public long deleteByPattern(String pattern) {
long deleted = 0;
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
try (Cursor<String> cursor = redisTemplate.scan(options)) {
List<String> batch = new ArrayList<>();
while (cursor.hasNext()) {
batch.add(cursor.next());
if (batch.size() >= 500) {
redisTemplate.delete(batch);
deleted += batch.size();
batch.clear();
}
}
if (!batch.isEmpty()) {
redisTemplate.delete(batch);
deleted += batch.size();
}
}
return deleted;
}
批量删除时还有两个细节要注意。一是尽量用unlink代替del,unlink是异步回收内存,大key删除时不会阻塞主线程,Redis 4.0以上版本都支持。二是控制删除速率,如果是全量清理几十万个key,建议在批次之间加几十毫秒的间隔,避免瞬间大量删除导致Redis内存陡降、主从复制缓冲区暴涨,甚至引发缓存雪崩式的数据库冲击。
对于结构复杂、来源不明的脏数据,写一个离线对账脚本往往比盲目删除更稳妥。脚本从数据库读出正确数据,与缓存值逐条比对,不一致的记录下来,可以选择直接修正缓存或先出报表人工确认。对账任务放在低峰期执行,配合日志留存,清理过程完全可控可回溯,比一次性的批量删除命令安全得多。
从架构层面减少脏数据的长效机制
清理只是补救,防患于未然要靠机制。首先给所有缓存key设置合理的过期时间,并叠加随机偏移量,比如基础过期时间1小时加上0到10分钟的随机值。过期时间是脏数据的兜底防线,即使一致性方案失效,脏数据最多存活到过期为止,随机偏移量还能顺带防止缓存雪崩。
其次,规范key的命名和业务边界。不同业务、不同数据结构使用独立的前缀,比如user:info:、order:detail:,避免key被跨业务复用导致数据互相覆盖。团队内建立key登记制度,谁创建的key、存什么结构、过期策略是什么都有据可查,这对后续的批量清理和问题排查价值极大。
最后,把缓存更新动作收敛到统一入口。不要让每个业务模块各自写一套"更新数据库加删缓存"的逻辑,而是封装成公共组件或切面,统一采用延迟双删加binlog兜底、极端场景加锁的组合策略。统一的入口意味着策略升级时只需要改一处,出问题时也只需要排查一处。配合上监控告警,对缓存与数据库的抽样比对结果、缓存命中率突降等指标做实时观测,脏数据从产生到被发现的时间可以压缩到分钟级。
总的来说,脏数据治理没有银弹,延迟双删解决大部分场景,binlog异步失效提供兜底,版本号或分布式锁守护核心数据,过期时间做最后防线。几层策略叠加使用,才能在性能与一致性之间找到适合自己业务的平衡点。