https://mp.weixin.qq.com/s/4W7vmICGx6a_WX701zxgPQ
缓存利用率
希望缓存中是都是热点数据,而不是冷数据占用缓存中的内存。具体做法体现在访问缓存的交互上:
- 写请求写数据库
- 读请求先读缓存,如果缓存不存在,从数据库中读取并且重建缓存。
- 写入缓存数据库中的数据,设置失效时间。
这样缓存中不经常访问的数据,随着时间推移都会逐渐过期淘汰掉,最终缓存中保留的都是经常访问的热数据。 当然上述过程存在数据一致性问题。
缓存一致性问题
缓存和数据库绝对一致是不可能实现的,只能尽可能的保证缓存和数据库的最终一致性。这里要更新数据库同时去维护缓存。
第二步失败带来的问题
这里先去考虑异常场景,即第二步操作发生异常的场景。
1.先更新缓存,后更新数据库
缓存更新成功,数据库更新失败,此时缓存中是最新值,数据库是旧值。后续请求在缓存未失效的场景会读到缓存中最新的值,但是如果缓存失效,会从数据库中读到旧值,那么对业务影响。
2.先更新数据库,再更新缓存
更新缓存失败,数据库中是新值,数据库是旧值,那么此时后续请求读到的是旧值,对业务有影响。
读写并发带来的问题
再来考虑并发带来的问题。
1. 双写不一致情况
2. 读写并发不一致问题
这里线程1库存更新之后删除缓存,线程3读取到为空读数据库,再线程2写数据库之后删更新缓存,之后线程3才去更新缓存。线程2是最新的数据,但是缓存中不一致。
更新缓存换为删除缓存
从缓存利用率的角度考虑,如果在更新数据时都去更新缓存,也是不太合适的。
因为每次数据发生变更之后,都无脑更新缓存,这里缓存在之后不一定就会被读到,所以导致缓存中存放了可能不被访问的冷数据,浪费缓存资源。
且重建缓存可能是一个耗时和复杂的过程,因为可能经历一系列计算才能写入缓存。造成机器的浪费。所以更新缓存应该换为删除缓存。让之后的读请求去重建缓存。
更新在写写并发下也有上面覆盖的问题。
所以写数据流程应该选择删除缓存,而不是直接更新缓存。
删除缓存中存在的问题
1. 先删除缓存,后更新数据库
- 线程A是要去更新数据X=2 (原X=1)
- 线程A先删除缓存
- 线程B读缓存不存在,读数据库(x=1)
- 线程A写入数据库(X=2)
- 线程B重建缓存写入旧值。(X=1)
这里还是存在不一致的场景。因为先删除缓存之后,之后更新数据库的时间内缓存都是没有这个值的,所以其他线程可能因为策略去读到了数据库旧值去重建缓存。
2. 先更新数据库,后删除缓存
- 缓存中不存在数据,线程A读数据库旧值(X=1)
- 线程B更新数据库(X=2)
- 线程B删除缓存
- 线程A旧值重建缓存(X=1)
这里理论上是有不一致的问题存在的,但看下这里必须是更新数据库 + 删除缓存的时间比读书库+写缓存的时间短。这个条件发生概率还是比较低的。
所以在顺序上要采用这种方案,更新数据库比删除缓存慢,要把删除缓存放到后边执行,降低缓存中数据不存在的时间,也就降低了缓存不一致的概率。
当然这里不是不存在不一致,只不过调整了删除缓存在后,降低概率。
缓存不一致处理
可以看到缓存不一致的主要原因是:
- 第二步缓存删除失败。
- 并发操作导致写入了旧数据。
保证第二步成功的方式
要想保证第二步成功,可以去在应用内重试,但是重试应该解耦,也不应该在应用内执行,所以通常会引入MQ去删除缓存,
- 消息队列保证可靠性:写入队列的消息,成功消费之前不会丢失。
- 消息队列保证成功投递:消费失败MQ会有重新投递的策略。
而在项目中引入发送MQ又显得成本比较高。这里可以把MQ转移为订阅数据库binlog来完成。也就是订阅binlog变更,再操作缓存。这样对于更新数据侧:
- 无需自己写入MQ,binlog产生也在Mysql事务中保证
- 订阅方可以通过canal收到MQ投递的消息来操作删除缓存。
所以对于缓存一致性,我们可以采取先更新数据库,再删除缓存方案,对于删除缓存,可以配合消息队列和订阅binlog日志方式来做保证重试成功。
采用Canal方式还有个好处是一个公共服务,可以解耦业务操作,不需要去为每个更新数据的地方写删除缓存或者主动发MQ的操作。
延时双删除策略。
如果采用的是先删除缓存,再删除数据库,在删除缓存之后其他读线程可能读到旧值来写入到缓存中。这个是前面也讨论过的问题;
还有个场景是数据库做了主从同步和读写分离。如果主从同步完成时间大于删除缓存之后新的读请求的时间,那么读请求会读到旧的值(从库)然后写入缓存。
这两个问题其实就是写入旧值到缓存,那么可以针对这个场景去做一个延时双删除。
问题1:可以采用更换删除缓存的顺序,或者在更新完缓存之后,去延迟一定时间去再次删除缓存。
问题2:也一样,需要生成延时消息来完成对缓存的第二次删除。
这里想一下,其实在极端情况下,这个延时如果不够还是会出现不一致的场景,这里无法避免。且这个时间是一个经验值,在极端情况下可能还是不够用,还是要去从降低主从同步这方面来入手去降低这个问题的发生概率。
缓存不一致的兜底
设置合理的缓存失效时间是最后的兜底方案,缓存按照规定时间失效会重建缓存,所以针对写数据频率比较高的缓存key也可以尝试去设置较短的失效时间。
或者对于可见不会更改的数据,可以在流量大的场景下设置长一点的失效时间,避免并发问题出现的概率,等流量小的时候再重建缓存。