Redis知识点总结(四)——如何保证缓存与数据库中的数据一致性
- 更新缓存
- 删除缓存
- 先删除缓存后更新数据库
- 先更新数据库后删除缓存
- 使用canal
- 总结
面试会经常遇到这种问题:你们如何保证缓存与数据库中的数据一致性?或者是:你们如果保证缓存与数据库的双写一致性?
其实这种问题本身就很矛盾,使用缓存的目的,本身就是牺牲一定的强一致性,追求性能的提升,如果真有场景是要保证缓存和数据库一致的,就不适合用缓存。但是既然面试官问到了,我们还是要答一下。
在使用缓存的时候,如果发生更新数据库的操作,我们可以删除缓存,也可以更新缓存。
更新缓存
我认为更新缓存是不可取的,原因有二:
- 如果这个缓存是个冷门数据,后续都不会查询到,那么这个更新就是多余的,还会占用内存空间
- 缓存和数据库都要更新,还要保证一致性,那么只能用事务,这样性能就会下降,违背了使用缓存的初衷
但是有一种情况是可以使用更新缓存这种策略的,就是一致性要求不高但是更新的数据是一个热点数据,那我们可以在更新数据库后,马上更新缓存,这样就不会因为有缓存缺失而造成大量的请求打向数据库,也就是缓存击穿的问题。即使更新数据库成功了,但是缓存更新失败也没关系,因为前提是一致性要求不高。
但是面试官问的是缓存与数据库如果保证一致性,那就不符合一致性要求不高的前提,所以更新缓存这种策略就不用考虑了。
删除缓存
再来看下删除缓存这种策略,我们可以先删缓存在更新数据库,也可以先更新数据库再删除缓存。
先删除缓存后更新数据库
先删缓存后更新数据库这种方案,有两种失败的情况:
- 删缓存失败
- 删缓存成功,更新数据库失败
如果删除缓存失败,那么就返回更新失败,此时数据库与缓存还是一致的;如果删缓存成功,但是更新数据库失败,那么也返回更新失败,此时只是缓存中没数据,下次查询的时候发现缓存缺失,从数据库中查询加载到缓存中,缓存和数据库还是一致的。这样,看起来好像没什么问题。
但是这种做法有一个潜在的问题,当缓存的数据的并发访问量比较大的时候,有可能出现如下这种情况:
线程1发起了一个更新操作,而线程2发起了以查询操作,目标都是同一份数据。线程1首先删除了缓存,但是迟迟没有更新数据库成功,而此时线程2进来了,线程2查询缓存发现缓存缺失,于是查询数据库并加载到缓存,但由于线程1没来得及更新数据库,所以此时缓存中的数据是旧的。随后线程1才更新数据库成功,此时缓存与数据库的数据就不一致了。
解决这种问题的办法,就是使用延迟双删的机制:
线程1在更新数据库成功后,sleep一段时间,然后再次删除缓存,就能把线程2加载到缓存中的旧值给删掉。但是这个sleep的时间长度不好把握,如果时间短了,可能在线程2加载旧值到缓存前,线程1就醒来了,那么缓存中的旧值还是没删,如果时间长了,又会影响数据更新的性能,数据已经更新成功了,但更新结果迟迟未返回。因此,一般情况下这种延时双删的机制也很少使用,进而先删缓存的这种做法也很少使用。
先更新数据库后删除缓存
再来看看先更新数据库,后删除缓存的做法。
如果更新数据库成功,然后删除缓存成功,那么缓存和数据库是能保证一致性的,但是在更新数据库成功之后,在删除缓存之前,其他线程查询到的还是旧值。
但是缓存不一致只是在更新数据库成功后,删除缓存成功之前的这一段时间内,如果不是要求强一致性的场景,都是可以接受的。
再看一下更新数据库或删除缓存不成功的情况:先更新数据库,如果更新失败了,那么就返回更新失败,此时缓存与数据库中的数据还是一致的;如果更新数据库成功,但是删除缓存失败,那么缓存与数据库的值是不一致。
但是我们可以给缓存添加一个过期时间,时间到了也会自动删除,那么我们就容忍一段时间的不一致,这种做法适用于一致性要求不高,只需要保证最终一致性的场景。
但是如果一致性要求非常高,不能容忍这一段时间的数据不一致,那么我们可以把要删除的key添加到队列中,然后起一个线程异步监听该队列,不断重试删除缓存,直到成功为止。
这种做法虽然还是没有保证绝对的一致性,但是容忍的不一致性时间更短了,更适合一致性要求较高的场景,但是引入了异步线程和队列,复制度就提升了。
如果再变态一点的,就是要保证绝对的一致,不能容忍一点点的不一致,并且连更新数据库成功后删除缓存成功之前的这一段时间的不一致也不能容忍,那么只能通过事务去保证。
但是这样一来,性能就会急剧下降,违背了使用缓存的初衷。
使用canal
还有一种保证缓存与数据库一致性的方案就是使用canal。
canal是基于MySQL的binlog日志进行数据同步的一个工具,它伪装成MySQL主从同步中的一个Slave节点,向MySQL主节点发起binlog同步的请求,接收到binlog后,canal就可以把接收到的binlog进行解析与处理,然后把数据同步到下游。
我们配置canal监听MySQL的binlog,然后同步数据到redis,这样就可以实现缓存与数据库的一致性。
当然,这种方案也不是强一致的,因为从数据库写成功,dump出binlog,到canal成功同步到redis,是存在一点点时差的。
总结
最后总结一下比较靠谱的几种做法。
如果是强一致性的场景,是不适合使用缓存的,那么最好就不要使用缓存了。如果遇到了即要求强一致性,又追求高性能的场景,那就太变态了。
如果是对一致性要求较高,但又不是强一致性,那可以使用canal同步的方案,正常情况下canal读取binlog同步数据到redis这个过程,处理是非常快的。
如果对一致性要求不高,可以采用先更新数据库,后删除缓存的机制,并且在查询数据库加载数据到缓存时,给缓存设置一个过期时间。这样,即使出现不一致,也只需容忍一段时间的不一致,等缓存过期时间到,缓存就会失效,可以到达最终一致性。