数据一致性问题
“数据一致”一般指的是:缓存中有数据,缓存的数据值 = 数据库中的值。一致性又分为几种程度:
- 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
- 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
- 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型
只读缓存情况
只读缓存:新增数据时,直接写入数据库;更新(修改/删除)数据时,先删除缓存。 后续,访问这些增删改的数据时,会发生缓存缺失,进而查询数据库,更新缓存。
读取数据流程:
更新数据流程:
在更新数据的流程中会有个时序问题:更新数据库与删除缓存的顺序,这里会发生数据不一致的问题
无并发情况下
先更新数据库再删除缓存:
- 更新数据库(成功)
- 删除缓存(失败)
如果第二步执行失败则会导致, 缓存中还是旧值, 数据不一致
反之如果是先删除缓存, 再更新数据库, 就第二步更新数据库失败了, 只是缓存被删除了, 读操作回源的时候还是会把数据库中的值load回缓存, 数据还是一致的
解决方案;消息队列 异步重试
高并发情况下
先删除缓存再更新数据库
两个线程同时做更新操作, 由于网络问题可能发生如下时序:
时序 | 线程A | 线程B |
---|---|---|
T1 | 删除数据X的缓存 | |
T2 | 读取X,缓存MISS | |
T3 | 从数据库Load X 的值到缓存 | |
T4 | 更新数据库中X的值 |
或者:
时序 | 线程A | 线程B |
---|---|---|
T1 | 删除数据X的缓存 | |
T2 | 读取X,缓存MISS | |
T3 | 更新数据库中X的值 | |
T4 | 从数据库Load X 的值到缓存 |
这种情况下会导致缓存中是旧值(线程B Load 进去的值)而数据库中是新值
解决方案: 设置缓存过期时间 延时双删, 时序如下:
时序 | 线程A | 线程C | 线程D |
---|---|---|---|
T5 | Sleep(N) | 读取到缓存旧值 | |
T6 | 删除缓存数据 | ||
T7 | 更新数据库中X的值 | 缓存miss, load数据库值到缓存 |
先更新数据库再删除缓存
- 线程A先更新了数据库还没来得及删除缓存,此时线程B读取了缓存中还未来得及更新的值 时序线程A线程BT1更新数据库中数据XT2读取X,命中缓存T3删除缓存X
- Mysql读写分离架构下如果产生主从延迟也会导致不一致
时序 | 线程A | 线程C | 线程D |
---|---|---|---|
T1 | 更新主库X=1 | ||
T2 | 删除缓存 | ||
T3 | 缓存miss,查询从库得到从库旧值(X=0) | ||
T4 | 旧值写入缓存 | ||
T5 | 此刻从库同步完成 |
解决方案:
(1) 针对读写分离的场景, 可以采取延迟消息删除缓存(这个延迟的时间要根据项目情况控制下), 另外也要控制主从延迟的时间
(2) 针对第一种情况, 其实只是小段时间的不一致, 一般业务可以接受这种情况, 保证最终一致即可。如果业务一定要强一致, 可以采取: a. 更新数据加写锁 b. 查询数据加读锁
(3) 采取数据库binlog来淘汰缓存, 这种方法成本较高
总结
优先使用“先更新数据库再删除缓存”的执行时序,原因主要有两个:
- 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
- 业务应用中读取数据库和写缓存的时间有时不好估算,进而导致延迟双删中的sleep时间不好设置。
读写缓存情况
读写缓存:增删改在缓存中进行,并采取相应的回写策略,同步数据到数据库中
- 同步直写:使用事务,保证缓存和数据更新的原子性,并进行失败重试(如果Redis 本身出现故障,会降低服务的性能和可用性)
- 异步回写:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库(没写回数据库前,缓存发生故障,会造成数据丢失。该策略在秒杀场中有见到过,业务层直接对缓存中的秒杀商品库存信息进行操作,一段时间后再回写数据库。
无并发情况
执行顺序性 | 潜在问题 | 结果 | 解决策略 |
---|---|---|---|
先更新缓存,后更新数据库 | 更新缓存成功,更新数据库失败 | 数据库中为旧值 | 消息队列 重试 |
先更新数据库,后更新缓存 | 更新数据库成功,更新缓存失败 | 请求命中缓存,读取缓存旧值 | 消息队列 重试机制;订阅Binlog日志 |
高并发情况
写 读并发
- 先更新数据库,再更新缓存 1.线程A先更新数据库 2.线程B读取数据,命中缓存,读取到旧值 3.线程A更新缓存成功,后续的读请求会命中缓存得到最新值 A 更新了数据库, 还没来得及更新缓存, 这个时候B Load 了缓存, 导致缓存是旧值
- 先更新缓存,再更新数据库 1.线程A先更新缓存成功 2.线程B读取数据,此时线程B命中缓存,读取到最新值后返回 3.线程A更新数据库成功 这种场景下,虽然线程A还未更新完数据库,数据库会与缓存存在短暂不一致,但在这之前进来的读请求都能直接命中缓存,获取到最新值对业务影响较小
解决方案: 保存请求对缓存的读取记录,延时消息比较,发现不一致后,做业务补偿
写 写并发
- 先更新数据库,再更新缓存
1.线程A和线程B同时更新同一条数据 2.更新数据库的顺序是先A后B 3.更新缓存时顺序是先B后A 这种场景下会导致缓存更新覆盖,缓存中其实是旧值(A的值, 应该是后更新数据库中B的值)
解决方案: 对于写请求,需要配合分布式锁使用。写请求进来时,针对同一个资源的修改操作,先加分布式锁,保证同一时间只有一个线程去更新数据库和缓存;没有拿到锁的线程把操作放入到队列中,延时处理。用这种方式保证多个线程操作同一资源的顺序性,以此保证一致性。