Redis和MySql实现数据一致性
# Redis和MySql实现数据一致性
首先明确一个观点:缓存的写入通常要远远快于数据库的写入
# 问题的背景
在我们使用Redis来提高查询效率后,我们不可避免的会遇上这么一个问题————如何更新数据,才能更好的保证数据一致性。
Redis中的数据来自Mysql,一旦MySql中的数据变更后,那么Redis中的数据也应该要发生相应变化才对,或者说将变化的这部分数据同步到Redis中。这是问题就来了,先更新谁:
- 先更新数据库,再更新缓存;
- 先更新缓存,再更新数据库;
这两种操作,不考虑并发情况,正常情况下,不论先后,都可以让两者保持一致,但现在我们需要重点考虑【异常】情况。以下我们讨论的都是异常情况,或者少数情况可能会发生的。
这个两个操作不可能同时发生,必然存在先后顺序,这就导致了数据不一致的情况发生。因为我们获取数据是先从Redis中获取,如果先更新数据库如MySql等,那这时用户请求可能会获得旧数据。
所以答案是————先更新缓存吗?
先更新数据库还可能存在以下问题:
比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
A 请求先将数据库的数据更新为 1,然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。
此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。
# 更新策略
上文说了,先更新数据库好像存在一些明显的问题,特别是在更新数据库特别耗时的情况。那我们先更新缓存呢?
# 先更新缓存
如果缓存先更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库中是「旧值」。那么此时就会发生请求可以命中缓存,拿到正确的值。但是,一旦缓存【失效】,就会从数据库中读取到【旧值】,然后重建缓存也是这个旧值。这时用户会发现自己之前修改的数据又变回去了,对业务造成影响。
或者因为其他因素————比如求一个计算后的值,需要在数据库获取一堆值,其中就包括这个【旧值】,那么这样得出的最终值就是一个错误的。
这2种方案都不行。似乎到这里,解决方案就是:加分布式锁。但这种方式对性能影响有点大。
还有其他方案吗?
# 先删缓存再更新数据库
我们在访问数据的时候,其实本质都是访问的数据库的数据,缓存的数据其实都是复制的数据库的。那我们在更新之前,先把缓存中的数据给删掉,再更新数据库的数据。这样能解决问题吗?
先删除缓存,后更新数据库,假设第二步操作失败,数据库没有更新成功,那下次读缓存发现不存在,则从数据库中读取,并重建缓存,此时数据库和缓存依旧保持一致。
似乎一切大功告成。不要急,我们再来看看在【并发】的情况,是怎样的?
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
请求B可以读到用户年龄的原因是,此时请求A在删除缓存后,可能因为网络延迟了。如果请求B是一个写操作,那这种情况也会发生。
在这种并发操作下,缓存的数据仍然是旧的,出现业务不一致。那给数据库加锁吧?但加缓存不就是为了效率,而加锁又会降低效率,违背了初衷。再想想其他方案呢?
# 延迟双删
那我们请求B在更新完数据库后,再删除一次缓存,然后存储对应的值,这样不就行了吗?
是的,这样实现了数据一致性,虽然在中途出现了一些————请求到旧数据,但最终的情况是相同的。这种就叫做实现了最终一致性。
但请注意在第二次删除的时候,请延迟删除。这是为了避免发生————在你删除后,请求B再写入缓存。这样删除就没效果了。当然具体的时间根据你的业务而定。
其实就"更新缓存"来说,直接删除缓存比更新缓存还有一个优势:
删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。
在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。比如上面提到的商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,这个操作会非常耗时。
从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。
# 先更新数据库再删除
过程:
- 先更新数据库
- 再删除缓存
但这种情况,可能会发生以下情况:
- 短暂的数据不一致:在数据库更新成功后,缓存被删除,但在新的数据项被重新加载到缓存之前,其他请求可能会读取到旧的或不存在的数据。
它也实现了最终一致性。
似乎一切大功告成。不要急,我们再来看看在【异常、并发】的情况,是怎样的?
假如在删除缓存的时候,失败了?我们有哪些解决方案:
- 在删除缓存失败时,可以尝试多次删除,直到删除成功或达到最大重试次数。
- 在删除缓存失败时,将删除缓存的操作放入消息队列,由消息队列消费者异步处理。
那并发情况下呢?会发生什么?
1.缓存中x 不存在(数据库x=1)
2.线程 A 读取数据库,得到旧值(X=1)
3.线程 B 更新数据库(X=2)
4.线程 B 删除缓存
5.线程 A 将旧值写入缓存(X=1)
最终 X的值在缓存中是1(旧值),在数据库中是2(新值),也发生不一致。这种情况「理论」来说是可能发生的,但实际真的有可能发生吗?其实概率「很低」,这是因为它必须满足 3个条件:
1.缓存刚好已失效
2.读请求 +写请求并发
3.更新数据库 +删除缓存的时间(步骤 3-4),要比读数据库+写缓存时间短(步骤 2和 5)
仔细想一下,条件3发生的概率其实是非常低的。因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的,「先更新数据库 +再删除缓存」的方案,是可以保证数据一致性的。
# 总结
那么我们使用哪个方案呢?
推荐(供参考)——采用「先更新数据库,再删除缓存」方案。主要是因为,它在并发情况下,效果好于前者(先删缓存再更新)。为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据最终一致
其实使用延迟双删时,第二次操作,不就是一个【先更新数据库再删除】吗?