彻底理解如何保证Redis和数据库数据一致性问题
一.背景
系统中缓存最常用的策略是:服务端需要同时维护 DB 和 Cache 并且是以 DB 的结果为准,那么就可能出现 DB 和 Cache 数据不一致的问题。
二.读数据
逻辑如下:
当客户端发起查询数据的请求,首先回去Redis中查看没有没该数据,有就返回,没有的话就需要去数据库中查询该数据,并且将该数据缓存在Redis中,然后设置一个过期时间(过期时间的作用就是为了保证一些冷数据能及时被清理,不长时间保存在Redis中,占用Redis的空间),保证在Redis后,返回数据到客户端,这是最常见的一个数据读取场景。
如果我们只读的话,肯定不会出现数据不一致问题,一般是读和写并发的时候才会出现数据不一致问题。
三.写数据
分如下两种方式:
先更新缓存再更新数据库,还是,先删除缓存再更新数据库;
先更新数据库再更新缓存,还是,先更新数据库再删除缓存。
1.删除缓存还是更新缓存?
答:删除!这是因为更新操作相对来说更为复杂,涉及到数据的一致性和同步性等问题;而删除操作相对简单,只需要将缓存中的对应键值对删除即可;在实际的缓存场景中,我们经常会遇到数据更新的频繁性和数据量的大规模,如果每次更新操作都需要同步更新缓存,那么会增加系统的复杂性和开销,而采用只删不更新的方式,可以避免这些问题,当数据被修改时,我们只需要删除缓存中的对应键值对,下一次访问时再重新从数据库或其他数据源中获取最新的数据,并将其存储到缓存中,这种方式可以减少数据同步的开销,并且保证缓存数据的一致性。
2.先操作数据库还是缓存?
(1)先操作缓存
写的场景:先把Redis的数据删除,然后再写入数据库。
读写并发场景,如何导致数据不一致:
1)线程1发起修改操作,然后删除Redis中的缓存,此时网络出现卡顿;
2)线程2由于是并发进来,他执行的是一个查询的请求,他会去Redis中查数据,查不到数据,所以会去数据库中查询,此时它查询的是老数据,并且会把数据缓存到Redis中;
3)线程1网络卡顿结束,并把最新数据更新到数据库中;
4)此时数据库是新数据,Redis是老数据,后面再有线程查询请求,请求到的都是老数据,必须等到老数据的过期时间到了,才能重新查询数据库获取到新数据,这个过程之间,一直都会数据不一致。
解决方案:
线程1把数据库修改成新的后,再执行一遍删除Redis缓存操作,但是线程2查询到的肯定是老数据,所以只会出现数据的一次不一致。
那能不能保证不一致一次都不出现?
这就需要引入强一致性概念,如果数据库和Redis是强一致性的话,就必须要保证他们的操作是原子性,因为我们的Redis和数据库是在不同服务器上的,如果要保证他们的操作原子性,就需要去上一把锁,但是加锁后会影响系统的性能,然而我们用Redis就是为了提高系统性能,此时又为了保证强一致性,又去加锁,这样就得不偿失了,所以说,强一致性和性能我们只能保证一种,AP和CP只能保证一种,我们在AP的基础上,就可以采用双删方式保证数据的一致性,虽然会出现一次数据不一致,但是Redis和数据库他们最终的数据都是最新的,所以我们要保存数据一致性的时候会采用这种最终一致性。
双删的时候为什么要采用延迟删除?
如果不进行延迟删除,在线程1删除数据后,此时线程2才把老数据放入Redis中,此时的删除相当于没删,同样会出现数据不一致问题,所以需要采用延迟双删方式。
延时双删过程:
1)先删除缓存;
2)写数据库;
3)休眠500毫秒,在删除缓存;
这样子最多其他线程在这500毫秒获取到脏数据,这是无法避免的,这个500毫秒不是固定的,需要结合项目的具体业务进行判定(线程2查数据库到放数据到Redis的时间来判定)。
(2)先操作数据库
写的场景:先写入数据库,然后再把Redis的数据删除。
读写并发场景,如何导致数据不一致:
1)线程1发起修改操作,写入数据到数据库,数据库为最新数据;
2)线程2同时并发进来,进行一个查询操作,查询到时老数据,直接返回;
3)线程1然后把Redis的老数据删除,其他线程在进来,查询Redis没有,就会直接查询数据库,并缓存最新数据到Redis。
这样操作下来,也能保证数据的最终一致性,所以推荐先操作数据库,再删除缓存,只是在操作数据库时,其他线程在这个过程中查询的是脏数据。
但是这个操作是否还有问题?
有!比如说数据库插入了最新数据后,删除缓存失败,后续线程进来查询都是老数据,也是需要等待Redis缓存时间过期才能查询到最新的,虽然这是比较极端的情况,但是还是有可能出现,针对删除失败的情况,我们可以采用删除缓存重试机制。
可以在删除缓存失败后,异步发送需要删除的Key到MQ中,然后监听MQ,进行重试删除, 如果在设定的重新次数后,还是删除失败,就需要记录日志,让开发人员进行排查问题。
但是重试操作带来的后果就是加入MQ,所以逻辑都放在业务代码中,增加了代码的耦合性,那么要想解耦,可以使用另外一个组件Canal。
(3)Canal解耦
Canal是阿里的一款开源框架,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费,Canal提供了各种语言的客户端,当Canal监听到binlog变化时,会通知Canal的客户端,如下图:
我们可以利用Canal提供的Java客户端,监听Canal通知消息,当收到变化的消息时,完成对缓存的更新。
不管前面先删除缓存,还是先写数据库,最终在数据进行修改的时候,canal会从订阅到binlog日志中将变更的消息通知到canal客户端(Springboot应用),然后进行删除操作,如果删除失败,发送消息到MQ,进行重复删除,这些操作都是在canal客户端进行的,从而个根业务代码进行了解耦。