这是我参与「第五届青训营 」笔记创作活动的第7天
人生没有白走的路,每一步它都算数——考研政治老师孔昱力
1. 概述
无论是在Redis还是在其他缓存中间件中,只要使用到缓存,就会出现缓存与数据库之间的数据同步问题,导致数据不一致。一致性的定义如下:
指任何一个读操作总能读到之前完成的写操作的结果。也就是说在我们分布式环境中,多点的数据必须是一致的。所有节点在同一时间要具有相同的数据。
不一致会导致线程并发时出现问题。为了更清晰明了的举例子,我们在此定义三个并发线程:写线程A、写线程B、读线程C。三个线程均操作price字段。接下来就是线程并发操作缓存和数据库的情形了,根据操作缓存和数据库的先后顺序及操作类型可以分为四种策略:
2. 线程并发操作的顺序问题
在上述四种操作顺序的前两种,存在着严重的问题,是没人会使用的。其实,所有的问题都是由于某一线程的两步操作无法原子完成,问题就出现在该线程两步操作中间,其他线程(要么是读线程、要么是写线程)侵入了正常的操作顺序。接下来我们逐个分析其问题所在及解决方案。
2.1 先更新数据库,后更新缓存
2.1.1 读线程侵入❗
写线程A更新数据库,数据库更新为price=100。此时状态:数据库price=100;缓存price=?;读线程C读缓存,读走了price=?⁉️
写线程A更新缓存,缓存更新为price=100。 此时状态:数据库price=100;缓存price=100;
读线程C本应该读走数据库中最新的值,但是由于同步延迟,却读到了过期值。
还有就是,如果更新数据库成功了,但是更新缓存失败了,缓存中的值直到下次更新就都是过期值。
其实,先更新数据库的策略都会存在读线程侵入的问题。
2.1.2 写线程侵入❗
-
写线程A更新数据库,数据库更新为price=100。 此时状态:数据库price=100;缓存price=?;写线程B更新数据库,数据库更新为price=200。此时状态:数据库price=200;缓存price=?;写线程B更新缓存,缓存更新为price=200。 此时状态:数据库price=200;缓存price=200;
-
写线程A更新缓存,缓存更新为price=100。 此时状态:数据库price=200;缓存price=100;⁉️
写线程B后发生,最终数据库和缓存的状态应该是B影响的,本应为:数据库price=200;缓存price=200。但是,由于并发问题导致了脏数据被刷新到了缓存中。
2.2 先更新缓存,后更新数据库
2.2.1 读线程侵入✅
因为是先更新的缓存,读线程C读到的也是最新的值。因此,该策略不会存在读线程侵入的问题。
2.2.2 写线程侵入❗
写线程A更新缓存,缓存更新为price=100。 此时状态:数据库price=?;缓存price=100;写线程B更新缓存,缓存更新为price=200。 此时状态:数据库price=?;缓存price=200;写线程B更新数据库,数据库更新为price=200。此时状态:数据库price=200;缓存price=200;
写线程A更新数据库,数据库更新为price=100。 此时状态:数据库price=100;缓存price=200;⁉️
写线程B后发生,最终数据库和缓存的状态应该是B影响的,本应为:数据库price=200;缓存price=200。但是,由于并发问题导致了脏数据被刷新到了数据库中。
2.3 先删除缓存,后更新数据库
2.3.1 读线程侵入❗
写线程A删除缓存,缓存更新为price=___。 此时状态:数据库price=?;缓存price=___;读线程C读缓存失败,于是读数据库并更新缓存。 此时状态:数据库price=?;缓存price=?;⁉️
写线程A更新数据库,数据库更新为price=100。 此时状态:数据库price=100;缓存price=?;
本来写线程A已经把缓存删了,等到再把数据库更新后,其他线程读到的就是最新写到数据库中的值。谁知道,还没等写线程A更新数据库呢,读线程C这个老六,把人家删了的值又给刷新到缓存了。此时,就出现了缓存脏数据问题。**这样以后无论谁读到的就都是脏数据,**就会存在读脏数据问题。
缓存脏数据解决:延迟双删
刚才缓存中的数据是脏数据了,那咋办呀。回顾线程并发的流程可以发现,是写线程A删了缓存,读线程B又弄脏了缓存。那就让写线程A等一会,例如1ms,再删除一次缓存。这样就可以擦除这1ms内的缓存脏数据。
但是如果数据库使用的是主从读写分离的架构的话,数据库主从之间也会存在数据不同步情况。这仍然可能导致缓存脏数据。情形如下:
写线程A删除缓存读线程C读缓存失败,于是读数据库并更新缓存,缓存脏了。
写线程A更新数据库(主库)写线程A删除缓存读线程C读缓存失败,于是从未同步主库的从库读数据并更新缓存,缓存又脏了。
此时的解决办法就是如果是对 缓存进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询。
读脏数据解决:更新与读取操作进行异步串行化
异步串行化
我在系统内部维护n个内存队列,更新数据的时候,根据数据的唯一标识,将该操作路由之后,发送到其中一个内存队列中(对同一数据的请求发送到同一个队列)。读取数据的时候,如果发现数据不在缓存中,并且此时队列里有更新库存的操作,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也将发送到同一个内存队列中。然后每个队列对应一个工作线程,每个工作线程串行地拿到对应的操作,然后一条一条的执行。
这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新的时候,如果此时一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,排在刚才更新库的操作之后,然后同步等待缓存更新完成,再读库。
读操作去重
多个读库更新缓存的请求串在同一个队列中是没意义的,因此可以做过滤,如果发现队列中已经有了该数据的更新缓存的请求了,那么就不用再放进去了,直接等待前面的更新操作请求完成即可,待那个队列对应的工作线程完成了上一个操作(数据库的修改)之后,才会去执行下一个操作(读库更新缓存),此时会从数据库中读取最新的值,然后写入缓存中。
如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那就是另外一个问题了。可以进行降级嘛。
2.3.1 写线程侵入✅
因为写线程第一步操作都是删除缓存,然后再更新数据库,所以并不会使得缓存和和数据库中存在两个值。因此,写线程侵入不会导致数据不一致问题。
2.4 先更新数据库,后删除缓存
问题同2.1 先更新数据库,后删除缓存是一样的