缓存数据库双写不一致

82 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1. 最初级的缓存不一致问题及解决方案

问题

先修改数据库,在删除缓存,如果删除缓存失败了,那么导致数据库是新数据,而缓存中是旧数据,数据不一致

解决

先删除缓存,再修改数据库,如果修改数据库失败了,数据库中仍是旧数据,缓存中是空的,数据不会不一致,因为读的时候缓存没有,则读数据库中旧数据,然后更新到缓存中

例如

image.png

2. 比较复杂的数据不一致问题分析

数据发生了变更,先删除了缓存,然后要去修改数据库

此时还没有修改,一个请求过来,去都缓存,发现缓存空了,去查数据库,查到了修改前的旧数据,放到了缓存中

而数据库更新完成,此时数据又出现不一致

例如

image.png

3. 原因

只有在对一个数据在并发的进行读写时,才可能会出现这种问题,如果比能发量很低,特别是读并发很低,很少情况会出现2中不一致的情况。

但如果每天是上亿的流量,每秒并发读是几万,同时每只要有数据更新的请求,就可能会出现上述的不一致情况。

4. 数据库与缓存更新与读取操作异步串行化

  • 更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个jvm内部的队列中
  • 读数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个jvm内部的队列中
  • 一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行

这样的话,回到2中的问题:一个数据变更的操作,先执行,删除缓存,然后再去更新数据库,但是还没完成更新,此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到jvm内部的队列中,此时会在队列中积压,然后同步等待缓存更新完成

这里有一个优化点:去重操作

一个队列中,其实多个读请求串在一起是没意义的,因此可以做过滤,如果发现队列中已经有一个读的请求了,那么就不用再放多个读请求操作进去了,直接等待前面的更新操作请求完成即可。

待那个队列对应的工作线程完成了上一个操作的数据库的修改之后,才会去执行下一个操作,也就是读的操作(同时也是缓存更新的操作),此时会从数据库中读取最新的值,然后写入缓存中

如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值

5. 高并发场景下,上述解决方案需要注意的问题

5.1 读请求长时阻塞

由于读请求进行了非常轻度的异步化,所以要注意读超时的问题,每个读请求必须在超时时间范围内返回
该解决方案,最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库
另外一点,因为一个队列中,可能会积压针对多个数据项的更新操作,例如下图

image.png

因此需要根据自己的业务情况进行测试,例如进行压力测试和模拟线上环境测试,可能需要部署多个服务,每个服务分摊一些数据的更新操作,也就让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少,但一般来说数据的写频率是很低的,因此实际上正常来说,在队列中积压的更新操作应该是很少的

另外大部分的情况下,应该是这样的:大量的读请求过来,都是直接走缓存取到数据的,少量情况下,可能遇到读和更新冲突的情况,如上所述,那么此时更新操作如果先入队列,之后可能会瞬间来了对这个数据大量的读请求,但是因为能了去重的优化,所以也就一个读的操作跟在它后面,等数据更新完了,读请求触发的缓存更新操作也完成,然后临时等待的读请求全部可以读到缓存中的数据

5.2 读请求并发量过高

这里需要做好压力测试,确保恰巧碰上上述情况的时候,还有一个风险,就是突然间大量读请求会在几十毫秒的延时阻塞在服务上,看服务是否抗的住,需要多少机器才能抗住最大的极限情况的峰值

但是因为并不是所有的数据都在同一时间更新,缓存也不会同一时间失效,所以每次可能就是少数数据的缓存失效了,然后那些数据对应的读请求过来,并发量应该也不会特别大

5.3 多服务实例部署的请求路由

可能某个服务部署了多个实例,必须保证执行数据更新操作,以及执行缓存更新操作的请求,负载均衡到相同的服务实例上,例如nginx的hash路由功能

5.4 热点商品的路由问题,导致请求倾斜

这个问题也是5.3衍生出来的,万一某个商品的读写请求特别高,全部达到相同的队列中,可能造成某台机器的压力过大

但只有在商品数据更新的时候,才会清空缓存,才会导致读写并发,所以更新频率如果不高,这个问题的影响并不大

6. 延时双删

6.1 延时策略

image.png

针对读卡顿问题,在Cache Aside Pattern的基础上,延时地再删一次缓存,能有效的缩短缓存不一致时长,下面是伪代码

public void write(String key,Object data){
    redis.delKey(key);
    db.updateData(data);
    Thread.sleep(1000);
    redis.delKey(key);
}

image.png

假设在延迟删除(2s)期间,有人请求过来,还是会得到更新之前的数据,只不过过了2s之后,这个缓存就会被删除,业务上来说也基本不影响。这个2S就是可能数据不一致的出现时间。

6.2 此时的问题

问题一:延时双删,实际上演变成了:先更新数据库,再删除缓存

比如:

1、A删除缓存

2、B查询数据库获取旧值

3、B更新了缓存,此时A还没有修改db,所以执行到这里数据库和缓存是一致的

4、A更新数据库,此时数据库和缓存不一致

5、A延时删缓存,此时缓存删除,不一致情况消失

1~3步执行后,数据库和缓存是一致的,相当于没删除。

4~5步:先更新数据库,再删缓存。

所以延时双删演变成了:先更新数据库,再删除缓存,这显然与Cache Aside Pattern思想是不一致的,会导致问题。

为什么?假设,此时,在第4步执行之前,又来了个查询C,C查询到旧值。第6步:C将旧值插入缓存。此时出现缓存和数据库不一致。

延时并不能解决:C插入缓存的操作在第5步后面执行,比如C遇到网络问题、GC问题等。当然这是小概率,但并不代表不存在。

当然,延时越长,这个问题越能规避。如果业务需求不是非常严格,是可以忽略的。

问题二:吞吐量

问题三:数据库更新后,无法保证下一次查询,从缓存获取的值和数据库是一致的