项目开发中 Redis 缓存和数据库一致性问题及解决方案
引入Redis缓存提高性能
如果公司的项目业务处于起步阶段,流量非常小,那无论是读请求还是写请求,直接操作数据库即可,这时架构模型是这样的:
但随着业务量的增长,你的项目业务请求量越来越大,这时如果每次都从数据库中读数据,那肯定会有性能问题。这个阶段通常的做法是,引入缓存来提高读性能,架构模型就变成了这样:
如何提高Redis缓存利用率?
想要缓存利用率最大化,我们很容易想到的方案是,缓存中只保留最近访问的热数据。
具体步骤如下:
- 写请求依旧只写数据库
- 读请求先读缓存,如果缓存不存在,则从数据库读取,并重建缓存
- 同时,写入缓存中的数据,都设置失效时间
缓存中不经常访问的数据,随着时间的推移,都会逐渐过期淘汰掉,最终缓存中保留的,都是经常被访问的热数据,缓存利用率得以最大化。
什么是Redis 缓存和数据库数据的一致性问题
Redis 缓存和数据库数据的一致性问题,就是 Redis 缓存的数据和数据库中保存的数据出现不相同的现象。导致这种现象的发生一般有两种情况:
- 在 Redis 缓存和数据库进行数据同步时出现了异常,导致数据同步失败
- 在高并发的情况下,有多个线程同时操作 Redis 缓存和数据库,导致数据不一致性
Redis 缓存和数据库数据的一致性问题解决方案
方案一:先更新Redis缓存,再更新数据库
这个方案一般不考虑。
原因是当数据同步时,更新 Redis 缓存成功,但更新数据库出现异常时,会导致 Redis 缓存数据与数据库数据完全不一致,而且这很难察觉,因为 Redis 缓存中的数据一直都存在。
方案二:先更新数据库,再更新Redis缓存
这种方案一般不考虑。
原因是当数据同步时,数据库更新成功,但 Redis 缓存更新失败,那么此时数据库中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。
该方案还存在并发引发的一致性问题
假设同时有两个线程进行数据更新操作,如下:
流程如下:
- 线程1 更新了数据库
- 线程2 更新了数据库
- 线程2 更新了Redis缓存
- 线程1 更新了Redis缓存
线程1 虽然先于 线程2 发生,但 线程2 操作数据库和缓存的时间,却要比线程1 的时间短,执行时序发生错乱,最终这条数据结果是不符合预期的。如果是写多读少的场景,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
方案三:先删除Redis缓存,后更新数据库
这种方案只是尽可能保证一致性而已,极端情况下,还是有可能发生数据不一致问题。
原因是当数据同步时,如果删除 Redis 缓存失败,更新数据库成功,那么此时数据库中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。
该方案还存在并发引发的一致性问题
假设同时有两个线程进行数据更新操作,如下:
从上图可见,先删除 Redis 缓存,后更新数据库,当发生读/写并发时,还是存在数据不一致的情况。
如何解决呢?最简单的解决办法就是延时双删策略
步骤如下:
-
先淘汰 Redis 缓存
-
再写数据库
-
休眠 1 秒,再次淘汰 Redis 缓存 (这么做,可以将 1 秒内所造成的缓存脏数据,再次删除)
那么,这个 1 秒怎么确定的,具体该休眠多久呢?针对上面的情形,自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的 Redis 缓存脏数据。
方案四:先更新数据库,后删除Redis缓存
实际使用中,建议采用这种方案。
这种方案其实一样也可能有失败的情况。
原因是当数据同步时,如果更新数据库成功,而删除 Redis 缓存失败,那么此时数据库中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。
你有没有发现,这个问题其实在删除 Redis 缓存类的方案都是存在的,那么此时再读取缓存的时候每次都是错误的数据了。
此时解决方案有两个:
方案一:利用消息队列进行删除的补偿
步骤如下:
- 应用系统先对数据库进行更新操作
- 再对 Redis 进行删除操作的时候发现报错,删除失败
- 此时将 Redis 的 key 作为消息体发送到消息队列中
- 应用系统接收到消息队列发送的消息后
- 再次对 Redis 进行删除操作
方案二:订阅数据库变更日志,再操作缓存
具体来讲就是,我们的应用系统在修改数据时,只需修改数据库,无需操作 Redis 缓存。那什么时候操作缓存呢?这就和数据库的变更日志有关了。
拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的 Redis 缓存。
该方案还存在并发引发的一致性问题
从上图可见,线程1 在做查询操作,刚好 Redis 缓存失效,然后从数据库获取数据,并写入 Redis 缓存中。这时,线程2 在做更新操作,先更新数据库,然后删除 Redis 缓存。由于线程1 查询数据库操作在线程2 更新数据库操作之前,所以导致获取的数据是数据库的旧值,而线程1 写入缓存操作由在线程2 删除缓存之后,导致写入到 Redis 缓存中的数据也是数据库的旧值。
其实出现以上情况的概率是非常低的,这是因为它必须满足 3 个条件,如下:
- Redis 缓存刚好已失效
- 读请求和写请求并发
- 更新数据库和删除 Redis 缓存的时间,要比读数据库和写 Redis 缓存时间短
因为写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。这么来看,先更新数据库,后删除 Redis 缓存的方案,是可以保证数据一致性的。
综上所述,想要保证数据库和 Redis 缓存一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志的方式来做。
总结
引入 Redis 缓存后,需要考虑 Redis 缓存和数据库一致性问题,可选的方案有:
-
先更新数据库再更新 Redis 缓存
-
先更新数据库在删除 Redis 缓存
先更新数据库再更新 Redis 缓存方案,在并发场景下无法保证缓存和数据一致性,且存在缓存资源浪费和系统性能浪费的情况发生。而在先更新数据库再删除 Redis 缓存的方案中,在并发场景下依旧有数据不一致问题,解决方案是延迟双删,但这个延迟时间很难评估,所以推荐用先更新数据库再删除 Redis 缓存的方案。
而在先更新数据库再删除 Redis 缓存方案下,为了保证两步都成功执行,需配合消息队列或订阅变更日志的方案来做,本质是通过重试的方式保证数据一致性。
在先更新数据库,再删除 Redis 缓存方案下,MySQL 的读写分离和主从库延迟也会导致缓存和数据库不一致,缓解此问题的方案是延迟双删,凭借经验发送延迟消息到队列中,延迟删除 Redis 缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。
掌握缓存和数据库一致性问题,核心问题有 3 点:缓存利用率、并发、缓存和数据库同步问题。
对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
如果不能容忍缓存数据不一致,可以通过 Redission 实现 分布式读写锁保证并发读写或写写的时候按顺序排好队,而只读的时候相当于无锁。