如何解决缓存一致性问题
引入缓存,我们的很大原因是为了让经常访问而不常修改的数据快速响应,提高系统性能。除此之外还有一些对及时性、数据一致性不高的场景。
使用缓存我们还有一个问题就是,缓存的数据一致性问题,即保证数据库的数据与我们缓存的数据一致,如何解决,我们常用的解决方式有以下两种。
1.双写模式
双写就是,写入数据库的时候,也更新缓存中的数据。如果细分析下来这两个步骤不同顺序执行也会不同效果。
数据一致性考虑主要两点:在不考虑并发问情况的异常问题,在并发情况下的不安全问题。
不考虑并发问情况出现异常
在不考虑并发问情况下我们考虑:
- 先更新缓存,后更新数据库
- 先更新数据库,后更新缓存
这都可能会出现业务问题。比如:更新了缓存,但更新数据库出错了,导致不一致。那如何解决?
解决的办法就是重试,详细在后面讲到。
我们还能思考一层就是更新缓存和数据库的操作容易出错吗?是否需要保证这一层的高可用?我们引入缓存是为了性能,强一致性的场景是否需要缓存呢?
并发问题情况
而双写在并发情况下会出现以下问题
如果我们设置了缓存过期,即时出现上图的脏数据问题,数据不一致,等缓存过期,重新更新缓存,最终还是得到正确的数据,叫做最终一致性。这个过程时间不是立即的,所以适用于时效性不要求严格的场景。
当然我们也可以双写的时候加锁,避免脏数据的问题。这里的锁保证数据一致。写写互斥,读和写也要互斥关系。
缓存利用率的角度来评估这个方案,也是不太推荐的。这是因为每次数据发生变更,都更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
2.失效模式
指的是更新数据时,删除缓存。这样访问的时候先从数据库查再更新缓存。
不考虑并发问情况出现异常
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
这和双写模式一样,解决的办法还是重试,详细在后面讲到。
并发问题情况
1先删除缓存,后更新数据库
出现不一致的情况,推荐先更新数据库后删除缓存因为数据库数据保证最新,那么缓存过了有效期也会最终一致。
2先更新数据库,后删除缓存
这个问题也是一个脏数据问题,更新到第一个线程的数据,缓存数据不是最新的。这个问题如何解决呢?还是能够使用设置缓存过期,然后保证最终一致性。除此之外使用读写锁,把读和写锁住就能解决这个问题。
设置缓存的过期时间。缓存中不经常访问的数据,随着时间的推移,都会逐渐「过期」淘汰掉,最终缓存中保留的,都是经常被访问的「热数据」,缓存利用率得以最大化。
不同顺序的解决方法都一样,如下:
- 设置缓存过期,然后保证最终一致性。
- 加锁[读写锁]
3.保证两步都成功
不考虑并发问情况出现异常,如何保证两部都成功?
使用重试,但重试需要思考下面问题
- 立即重试很大概率「还会失败」
- 「重试次数」设置多少才合理?
- 重试会一直「占用」这个线程资源,无法服务其它客户端请求
更好的是异步重试,直接把缓存的操作放消息队列上通知操作,MQ保证消息可靠,异步释放当前线程。或者订阅数据库变更日志,再操作缓存。
缓存数据一致性解决-Canal
使用canal从MySQL的binlog获取数据,更新到缓存。
4.缓存数据一致性解决方案
无论是双写模式还是失效模式,都会导致缓存的不一致问题(脏数据)。即多个实例同时更新会出事。怎么办?
- 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。强一致性可加锁。
如何保证强一致性
双写模式和失效模式都一样,加上锁就能保证强一致,比如加上读写锁,通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。由于写操作是排他锁,所以会损耗一定性能,写时读就会降低并发量。我们要考虑加上了锁之后代价是否大于加上缓存的性能提升。
总结
- 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可,这是一个保险方案。
- 正常来说推荐失效模式使用先更新数据库。
- 我们不应该过度设计,增加系统的复杂性。遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
- 性能和一致性不能同时满足,性能与场景结合考虑,是否选择最终一致性,还是强一致性(加锁)。
- 失败场景下要保证一致性,常见手段就是重试,同步重试会影响吞吐量,所以通常会采用异步重试的方案