缓存双写问题:先操作数据库还是缓存?

缓存双写问题:先操作数据库还是缓存?

在很多高并发系统中,引入 Redis 缓存是为了缓解数据库(DB)的压力并提升读取效率。然而,引入缓存后,我们不得不面对一个棘手的问题:当数据发生变更时,如何保证数据库与缓存的一致性?

通常我们有四种更新策略,分别涉及“操作顺序”和“操作对象(更新或删除)”的组合:

  1. 先更新数据库,再更新缓存

  2. 先更新缓存,再更新数据库

  3. 先删除缓存,再更新数据库

  4. 先更新数据库,再删除缓存

本文将结合并发场景下的执行时序,对这四种方案进行详细推演,找出最优解。

注意,以下情况的造成并发问题卡顿都是发生在更新数据库/缓存之前/之后。 如果是在更新时卡顿的话,因为有的机制,是可以保证并发安全的。

一、 为什么不建议“更新”缓存?

首先,我们先看前两种策略,它们的共同点是:当数据变更时,选择直接更新缓存中的值

1. 先更新数据库,再更新缓存

场景推演

假设有两个写请求 A 和 B 同时并发执行:

  1. 请求 A 先更新数据库(将值改为 1)。

  2. 请求 B 随后更新数据库(将值改为 2)。

  3. 请求 B 比较快,先更新了缓存(缓存值为 2)。

  4. 请求 A 因为网络等原因卡顿了一下,后来才更新缓存(缓存值为 1)。

结果分析
  • 数据库值:2(新值,符合预期)

  • 缓存值:1(旧值,脏数据)

结论:由于网络传输的不确定性,请求 A 和 B 的操作顺序在数据库和缓存中可能不一致,导致缓存中保留了旧数据。并且,如果业务属于“写多读少”,频繁更新缓存却很少被读取,也是一种性能浪费。

2. 先更新缓存,再更新数据库

场景推演

同样是两个写请求 A 和 B 并发:

  1. 请求 A 先更新缓存(将值改为 1)。之后更新数据库时发生了网络卡顿的现象。

  2. 请求 B 随后更新缓存(将值改为 2)。

  3. 请求 B 先更新数据库(数据库值为 2)。

  4. 请求 A 卡顿后恢复,更新数据库(数据库值为 1)。

结果分析
  • 缓存值:2(新值)

  • 数据库值:1(旧值,脏数据)

结论:同样会因为并发时序问题导致数据不一致。更严重的是,数据库作为底层的持久化存储,存储了错误的数据(旧值),这比缓存脏数据更危险。

二、 为什么选择“删除”缓存?(核心讨论)

既然“更新缓存”由于并发时序问题不可靠,业界通常采用删除缓存(Invalidate) 策略:即更新 DB 时,直接把缓存删掉,下次查询时再回填。

那么,是先删缓存,还是先更 DB 呢?

3. 先删除缓存,再更新数据库

场景推演(读写并发)

假设数据库原值为 20。此时来了一个写请求 A(更新为 21)和一个读请求 B。

  1. 请求 A:先删除缓存。

  2. 请求 A:准备更新数据库,但发生网络卡顿(此时 DB 仍为 20)。

  3. 请求 B:发现缓存未命中(已被 A 删了),去读取数据库,读到了旧值 20。

  4. 请求 B:将旧值 20 写入缓存。

  5. 请求 A:卡顿结束,将数据库更新为 21。

结果分析
  • 数据库:21(新值)

  • 缓存:20(旧值)

结论:这导致了严重的数据不一致。因为“读+写缓存”的速度通常快于“更新数据库”的速度,这种时序在并发量大时非常容易发生。

补充方案:针对此问题,业界有“延时双删”策略(删缓存 -> 更 DB -> 休眠 -> 再删缓存),但休眠时间难以精准控制,实现复杂。

4. 先更新数据库,再删除缓存(推荐方案)

这是目前业界最主流的方案(Cache Aside Pattern)。

场景推演(极端并发)

假设数据库原值为 20,且缓存刚好失效。此时来了一个读请求 A 和一个写请求 B(更新为 21)。

  1. 请求 A:读缓存未命中,去查询数据库,读到了旧值 20

  2. 请求 A:准备把 20 写入缓存,但发生卡顿,没有立刻写入。

  3. 请求 B:更新数据库为 21。

  4. 请求 B:删除缓存(此时缓存本就是空的)。

  5. 请求 A:卡顿结束,把旧值 20 写入缓存。

结果分析
  • 数据库:21(新值)

  • 缓存:20(旧值)

  • 发生条件:需要“读操作在写缓存前卡顿”且“卡顿时间 > 写操作更新 DB + 删缓存的时间”。

结论:虽然理论上存在不一致的可能,但概率极低理由:缓存的写入速度(内存操作)远快于数据库的更新速度(磁盘 I/O)。请求 A 要在请求 B 完成“更 DB + 删缓存”这一整套慢动作的时间里一直卡顿,这种情况在生产环境中极其罕见。

因此,“先更新数据库,再删除缓存”是保证一致性的最优解。

三、 兜底方案:如果删除缓存失败了怎么办?

虽然策略 4 是最优解,但如果第二步“删除缓存”因为网络抖动失败了,缓存中就会残留旧数据。为了保证最终一致性,我们需要兜底方案。

方案 1:消息队列重试机制

思路:如果应用删除缓存失败,将需要删除的 Key 发送到消息队列中,由消费者进行重试删除。

  • 流程

    1. 更新数据库。

    2. 删除缓存。如果不成功,发送消息到 MQ。

    3. 消费者接收消息,再次尝试删除缓存。

    4. 如果删除成功,确认消息;如果失败,继续重试。

  • 缺点:业务代码需要侵入大量的重试逻辑,耦合度高。

方案 2:订阅 Binlog + Canal(推荐)

思路:利用 MySQL 的 Binlog 日志,将“数据变更”与“缓存操作”完全解耦。

  • 流程

    1. 业务层:只负责更新数据库,不关心缓存。

    2. MySQL:更新成功后产生 Binlog。

    3. Canal:伪装成 MySQL 从节点,订阅并解析 Binlog,将变更推送到消息队列。

    4. 消费者:监听 MQ,执行删除缓存操作。

  • 优点:代码零侵入,依靠数据库日志保证变更一定会被捕获,可靠性高。

四、 总结

策略

结果

评价

先更 DB,再更缓存

不一致

并发时可能保留旧缓存;浪费性能。

先更缓存,再更 DB

不一致

并发时可能 DB 存旧值(严重)。

先删缓存,再更 DB

不一致

读写并发时,读请求容易回填旧值到缓存。

先更 DB,再删缓存

一致 (推荐)

理论上有微小概率不一致,但工程实践中最为可靠。

最终建议: 在实际项目中,推荐使用 “先更新数据库,再删除缓存” 的策略。如果对一致性要求极高,建议配合 Canal + MQ 方案来处理“删除失败”的极端情况,确保数据的最终一致性。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值