缓存与数据库的“双写悖论”:一致性的常见陷阱与破局之道

写在前面

"缓存不是很简单吗?读请求先查缓存,查不到再查数据库,然后把数据写进缓存。"

这大概是每个后端开发者最初对缓存的认知。然而一旦你的系统有了数据更新操作------用户修改了个人资料、订单状态变更、库存扣减------事情就会瞬间变得棘手起来。先更新数据库还是先删除缓存?删除缓存还是更新缓存?如果并发请求同时到达怎么办?缓存与数据库之间的不一致窗口,正是无数线上事故的温床。我曾经见过一个团队,因为没处理好缓存一致性问题,导致商品价格显示错误,用户以低价下单后公司不得不承担巨额损失。

今天,我们就来剖析缓存一致性问题的本质,盘点业界常用的几种解决方案,并告诉你每种方案的适用场景与代价。

一、问题本质:为什么缓存会"不一致"?

缓存(如 Redis)和数据库(如 MySQL)是两套独立的存储系统。在一次更新操作中,我们希望两者中的数据保持同步。

然而现实是:无法同时原子地更新两个系统。无论你采用哪种顺序,总有一个时间窗口,其中一个系统是旧数据,另一个是新数据。如果有并发读写请求落在这个窗口内,用户就会读到不一致的数据。

html 复制代码
时间线:
  写请求:更新DB → 删除缓存
  读请求:                 查缓存(无) → 查DB(旧数据) → 写缓存(旧)
  结果:缓存中永远残留旧数据

核心矛盾在于:数据库操作和缓存操作无法在一个事务中完成(除非使用分布式事务,但代价高昂)。

二、四种经典模式及陷阱

模式1:先更新数据库,再更新缓存

java 复制代码
// 伪代码
db.update(data);
redis.set(key, data);

问题:如果更新缓存失败,数据库是新值,缓存是旧值,后续读请求读到脏数据。另外,并发写场景下,后写的请求可能先更新缓存,导致缓存被"旧值"覆盖。

适用场景:几乎不适用。除非你的缓存可以允许一定时间不一致(如非关键数据),且有补偿机制。

模式2:先更新数据库,再删除缓存

这是最常用的模式,也是Cache-Aside Pattern的标准写法。

java 复制代码
db.update(data);
redis.del(key);

问题:在删除缓存之前,如果有并发读请求,会读旧数据并回填缓存,导致缓存再次被污染。但这个时间窗口非常短(数据库更新 → 删除缓存),概率较低。

适用场景:读多写少、容忍极短时间不一致的场景。这是大多数业务系统的首选,简单高效。

模式3:先删除缓存,再更新数据库

java 复制代码
redis.del(key);
db.update(data);

问题 :删除缓存后、数据库更新完成前,有读请求进来,会发现缓存缺失,进而读取数据库中的旧值并写回缓存。然后数据库才被更新为最新值------结果缓存中又变成了旧数据,且可能长期存在。

适用场景:只有在写入非常不频繁,且能接受短暂脏读的场景下才考虑,一般不推荐单独使用。

模式4:先删除缓存,再更新数据库,再延迟双删

为了解决模式3的并发问题,有人提出延迟双删策略:

java 复制代码
redis.del(key);
db.update(data);
Thread.sleep(500); // 等待可能存在的并发读请求把旧数据写回缓存
redis.del(key);

第二次删除确保将并发读请求可能写回的旧缓存再次清除。

问题:等待时间难以精确确定(取决于读请求的耗时),且会阻塞写请求线程。虽然可以用异步延迟删除(如消息队列),但整体复杂度上升。

适用场景:对一致性要求较高,且可以接受写请求延迟增加。

三、进阶方案:订阅数据库变更日志(Canal + Binlog)

有没有一种方式,能够确保缓存绝对一致?答案是:利用数据库的 binlog,由外部组件监听变更并异步更新缓存

这种方案将缓存更新逻辑从业务代码中剥离,彻底避免了并发竞态。因为 binlog 是数据库的"操作日志",只要数据库提交了事务,binlog 就一定会产生,Canal 保证至少一次推送。

优点 :最终一致性有保障,业务代码无侵入。缺点:架构复杂,引入额外组件,缓存更新有秒级延迟。

适用场景:对一致性要求极高,且能容忍一定延迟(如账务系统、库存系统)。阿里内部大量使用此方案。

四、对比总结:一张表看清所有方案

核心建议 :绝大多数业务系统采用 "先更新数据库,再删除缓存" 就足够了。不要过度设计。

五、实践中的常见错误与避坑

错误1:删除缓存失败不做处理

如果删除缓存操作因为网络超时或 Redis 异常失败了,缓存中就会残留旧数据。补救措施:将删除失败的 key 发送到消息队列,由异步任务重试删除。

错误2:以为"更新缓存"比"删除缓存"更好

有些人会想:"我直接把缓存更新成新值,不就避免缓存缺失了吗?" 但是并发写场景下,后写入的线程可能以错误的顺序更新缓存,导致缓存永久脏数据。删除缓存让下一次读去数据库拉取,更安全

错误3:在事务内操作缓存

java 复制代码
@Transactional
public void update() {
    db.update();
    redis.del(); // 如果在事务内,可能回滚吗?不能,Redis不支持事务回滚
}

数据库回滚时,Redis 的删除操作无法回滚,导致缓存被错误删除。应该先提交数据库事务,再操作缓存(或将缓存操作放在事务外)。

六、总结:选择方案前,先问自己三个问题

  1. 业务允许短暂不一致吗?

    允许 → 先DB后删缓存即可。不允许 → 考虑 Canal。

  2. 并发写冲突严重吗?

    不严重 → 基础方案足够。严重 → 需要分布式锁或版本号机制。

  3. 团队愿意引入额外组件吗?

    愿意 → Canal 优雅。不愿意 → 延迟双删作为折中。

没有完美的缓存一致性方案,只有合适的方案。最终一致性往往是性价比最高的选择。除非你的系统是金融交易、库存扣减这类强一致性场景,否则为了追求强一致而引入分布式事务或 Canal,可能得不偿失。

假设你的系统是"电商秒杀库存扣减",每次扣减需要同步减少 MySQL 中的库存数量,同时更新 Redis 中的库存缓存。如果采用"先更新数据库,再删除缓存"模式,在删除缓存之前的那一刻,另一个读请求进来了,它读到的是旧缓存中的库存(比如还有10件),而实际上数据库已经扣减到9件,导致用户误以为有货而下单。这种不一致窗口虽然极短,但在高并发下可能造成超卖。你会如何优化这个场景?欢迎在评论区分享你的思路。

相关推荐
hannnnn1 小时前
从 Prompt 到 Harness,为什么 Agent 工程的重点变了
后端·agent
XovH1 小时前
Django 视图(View)与路由(URL):处理用户请求的完整流程
后端
卷无止境1 小时前
Polars 多 DataFrame 合并操作全指南
后端
祀爱1 小时前
定时任务之BackgroundService的详细教程
后端·c#·asp.net
超梦dasgg1 小时前
Sentinel生产环境实战全解
java·微服务·sentinel
青云计划1 小时前
MySQL技术文档
java·mysql
fengxin_rou1 小时前
Feed 三级缓存架构详解:分层设计、缓存一致性与高性能实战
spring·缓存·架构
qq_2518364571 小时前
基于java 汽车检修管理系统设计与实现 论文
java·开发语言·汽车
爱编程的小新☆1 小时前
redis缓存
redis·分布式·缓存