前言
本期探讨在常见的缓存—数据库模型中,如何在高并发的情况下保证双写的数据一致性。
正文
普遍使用的方案
使用缓存的主要目的是为了提升查询的性能。我们普遍这么使用缓存:
- 收到用户请求之后,先查缓存有没有数据,如果有则直接返回
- 如果缓存没数据,再继续查数据库
- 如果数据库有数据,更新缓存为查询到的数据,返回数据
- 如果数据库也没数据,直接返回空
有一个很常见的问题:如果数据库中的某一条数据,在放入缓存之后,几乎立刻被更新了,那么要如何更新缓存呢?
如果选择不更新缓存,那么在缓存有效期内,用户请求从缓存中获取的都有可能是旧的数据,不是数据库中的最新值,存在数据不一致问题。
面对这个问题,有四种更新缓存的方案:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删缓存,再更新数据库
- 先更新数据库,再删缓存
先更新缓存,再更新数据库
最直接的想法肯定是在写操作的时候直接把缓存更新,但是我们要先更新缓存,还是先更新数据库呢?
先更新缓存,再更新数据库存在的问题最严重:
如果更新缓存之后,在将要更新数据库的时候网络异常导致更新数据库失败。这样导致在缓存中的数据是最新的,但是数据库中的数据仍是旧数据,出现数据不一致问题。
并且,这两个步骤不是一个原子操作。这意味着在这两个步骤之间,会存在其他并发操作介入的可能。
所以这个方案是不应该采纳的。
先更新数据库,再更新缓存
那么我们把更新顺序对调一下,先更新数据库,再更新缓存会怎么样呢?
更新缓存失败
如果把这两个操作放在同一个事务当中,那么当更新缓存失败之后,我们可以把更新的数据库数据进行回滚。
但是在高并发的情况下,一般这两个操作不会放到一个事务之中,所以会出现更新缓存失败之后,数据库中已写入的数据不会回滚的问题。
这样就会出现,数据库是新数据,缓存是旧数据,数据不一致的情况又出现了。
高并发场景下出现的问题
当两个并发操作同时更新同一份数据时,可能会出现竞态条件,导致缓存中的数据不是最新的,或者数据库和缓存不一致。
假设针对同一个用户的同一条数据,出现两个更新数据请求:线程 1 和 线程 2,同时请求到业务系统。
- 线程 1:更新 X 为 20
- 更新数据库:DB.update(X, 20) // 数据库中 X 现在是 20
- 准备更新缓存:Cache.set(X, 20)
- 线程 2:更新 X 为 30 (在线程 1 的第二步执行之前介入)
- 更新数据库:DB.update(X, 30) // 数据库中 X 现在是 30
- 更新缓存:Cache.set(X, 30) // 缓存中 X 现在是 30
- 线程 1 继续执行
- 更新缓存:Cache.set(X, 20) // 缓存中 X 现在又变回 20
结果:
- 数据库:X = 30
- 缓存:X = 20
数据不一致! 此时缓存中保存的是旧值(线程 1 的值),而数据库已经是最新值(线程 2 的值)。如果此时有读取请求,会从缓存中读到脏数据。
资源浪费
如果每一次在更新完数据库之后都要更新缓存,可能存在比较浪费系统资源的现象。
如果更新缓存需要消耗大量的系统资源,那么每次更新操作都要经过一次大消耗。而且在写多读少的情况下,每次都要更新缓存有点得不偿失。
先删缓存,再更新数据库
如果直接更新缓存,会带来很多问题,那么要是删除缓存,下次查询的时候直接更新,会怎么样呢?
先分析先删缓存,再更新数据库的方案。
高并发状态下问题
假如有一个数据项 X
,其值为 V1
。
线程一与线程二同时对X进行操作,步骤如下
线程一请求读取 X
:
- 缓存未命中
- 线程一从数据库读取
X
的值V1
。 - 线程一将
V1
写入缓存。
线程二请求更新 X
的值为 V2
:
- 线程二删除缓存中的
X
- 线程二更新数据库中
X
的值为V2
如果线程一在线程二更新数据库后,但在线程二删除缓存之前完成了写入缓存操作,那么缓存中仍旧是旧值 V1
,而数据库中已经是新的值 V2
。那么直到缓存失效或下次更新,任何读取 X
的请求都将从缓存中获取到过期的 V1
。
缓存双删
为了解决或缓解这些问题,可以考虑以下策略:
**延迟双删 (Double Delete with Delay)**:
- 先删除缓存
- 更新数据库
- 等待一小段时间(例如,几百毫秒),再次删除缓存
等待时间的作用就在于给并发的读操作一个完成旧数据写入缓存的机会,确保第二次删除能够清除掉可能被写入的旧数据。
这可以尽量保证在第一次删除后有读操作将旧数据写入缓存的情况下,第二次删除能够将其清除。但仍不能保证100%一致。
先更新数据库,再删缓存
用户首先更新数据库中的数据,在数据库更新成功后,再删除缓存中对应的数据。
这个方案确实解决了「先删缓存,再更新数据库」方案中并发读写时序导致旧数据写入缓存的问题,但如果在更新数据库与删除缓存之间有读操作介入,或者删除缓存操作失败,会出现脏读现象。
脏读
问题场景:
- 请求 A 更新数据库成功。
- 请求 A 删除缓存。
- 请求 B 在请求 A 删除缓存之前读取缓存,命中旧值。
- 请求 B 将这个旧值重新写回缓存(可能在某些逻辑下,比如缓存穿透保护或者延迟加载),覆盖了数据库的新值。
时间线
1 | 时间点 操作 缓存状态 数据库状态 |
结果:缓存中是旧值,数据库中是新值 → 缓存和数据库不一致。
但这种情况毕竟少见,可以用延时双删或分布式锁来处理。
后记
选择合适的操作模式,可以尽量保证缓存与数据库的一致性。