为了减轻数据库压力,提高读数据效率,通常使用Redis来做关系型数据库的缓存。很多人的用法是用Spring cache整合Redis然后在service层方法上用注解的方式做缓存,可以说非常的方便。然而,你确定真的用对缓存了吗?在高并发下又怎样保证缓存中的数据和数据库中的数据一致呢?现在我们就来唠一唠。
|双一致性问题
这里的双一致性指的是redis缓存中的数据和数据库中的数据要一致,如果数据不一致,从缓存中获取的数据则为脏数据,这将会造成不可预估的风险。下面从对数据库的增、删、改、查操作来分析数据一致性问题。
注:以下场景的分析不考虑缓存过期时间设置问题,设置一个短的缓存过期时间是一种解决数据不一致的方法,但不太优雅。当然了,实际项目中为了节省内存,是需要为缓存设置一个过期时间的!
读取数据
读取数据时,先在缓存中查找,如果缓存中有数据,直接返回;如果缓存中没有数据则从数据库中查找,最后把查询结果缓存到Redis中。
新增数据
新增数据时要先对数据库做新增操作,再将缓存数据放入Redis,便不会产生数据不一致问题(或者什么都不干,只是新增数据到数据库,待到读取数据的时候再缓存)。有人可能会问为什么不先把数据缓存到redis再对数据库做新增操作,这是因为要考虑往数据库插入数据失败的情况。
更新数据
这里有3种值得讨论的情况:
先更新数据库再更新缓存(应该不会有人问为什么不先更新缓存再更新数据库吧!)
先删除缓存,再更新数据库(直接删除,待到读取的时候再做缓存)
先更新数据库再删除缓存(同上)。
先更新数据库再更新缓存:
针对同一时刻对同一条记录的更新,大概率会出现数据不一致的情况。假设线程A和线程B同时对数据库里的同一条记录进行更新操作,则下面的情况是有可能出现的:

根据我们的图示分析,当两个线程同时操作同一条数据时,可能存在线程A更新数据库的速度快于线程B但线程A更新缓存的速度慢于线程B的情况,这种情况下就会导致数据不一致。除了加锁,似乎没有更好的解决方案,但加锁又会降低性能,所以这种方式我们直接PASS掉!
先删除缓存,再更新数据库:
针对同一时刻对同一条记录的更新、读取,有可能出现数据不一致的情况。假设线程A更新某条数据,线程B读取同一条数据,则下面的情况是有可能出现的:

线程A要进行更新数据操作,先删除了缓存。同时,线程B要读取同一数据,发现缓存中已经没有数据了,于是线程B从数据库中读取,并将读取到的数据写入缓存中。此时线程A还未将新数据写入数据库,线程B读取到的数据是旧数据。于是就造成了写线程A删除了缓存中的旧数据,但却又被读线程B重新写入缓存的问题。因为本身写数据库操作就大大慢于读数据库操作,所以这个问题在高并发场景下是很容易出现的。
针对先删后更新的方式,是有办法解决缓存不一致问题的,那就是使用延时双删!直接看下面的图:

在实际编程中,为了保证程序执行效率,延时删除操作需要做成异步的。可以使用JDK提供的延时队列DelayQueue + 若干个线程在本地做延迟删除,也可以使用RabbitMQ做延迟队列,实现通过一个统一的远端服务做延迟删除。当然了,实现的方式有很多,要视实际情况而定。
在这里可能会有人问了:如果延时删除失败怎么办?答案是不断重试,在重试一定次数后还不成功就通过发送邮件或短信的方式告知维护人员来处理。
先更新数据库再删除缓存:
针对同一时刻对同一条记录的更新、读取,小概率会出现数据不一致的情况。假设线程A更新某条数据,线程B读取同一条数据,则下面的情况是有可能出现的,但出现的概率很小:

为什么说出现的概率很小呢?是因为要满足以下几个条件:
缓存刚好失效
在同一时刻有读线程和写线程操作同一条数据,且读线程读到了更新之前的旧数据
写线程的写操作快于读线程的读操作,写线程先于读线程写完数据库和缓存
并且还有缓存过期时间的加持,即使出现小概率不一致问题了,在到达过期时间后问题也会被解决。
当然了,要想解决小概率不一致问题,也不是不可以。答案同样是使用延迟删除。关于延迟删除,在上面解释过了,下面就只贴个图吧!

异步延迟删除逻辑

删除数据
删除数据和更新数据所带来的问题一样,有了我前面的分析,不信你可以自己分析一下。

|Spring cache双一致性问题
使用Spring cache整合redis来做缓存是很多人的做法儿,但往往只是去用它,而没有考虑到双一致性问题。然而经过我们前面对双一致性的分析后,再来分析一下Spring cache,你可能就会有不同的看法儿。
Spring cache针对增删改查数据库提供了以下几个注解:
Cacheable,将结果放入缓存
CachePut,更新/新增缓存
CacheEvict,删除缓存
先来说一说Cacheable,Cacheable通常用来注解读数据方法,是针对读操作的。被Cacheable注解的方法会在目标方法执行之前,先从缓存中获取数据,如果缓存没有,再从数据库中获取数据,最后将获取到的数据写入缓存中。Cacheable的用法在这里没有任何异议。
再来说一说CachePut,CachePut通常用来注解新增数据方法。CachePut用于注解新增数据方法时,是不会出现双不一致问题的。切记不要将此注解用于更新数据的方法之上,因为很明显,会导致不一致问题!
最后再来说一说CacheEvict,顾名思义,CacheEvict是用来删除缓存的。前面我们也分析了,更新/删除数据库里的数据都需要删掉相关的缓存。我们来看看CacheEvict支持的删除方式:

将beforeInvocation设置为true,则先删缓存再操作数据库,将beforeInvocation设置为false则先操作数据库,成功后,再删缓存。
很显然,如果在更新/删除数据的方法上使用CacheEvict,我们应该将beforeInvocation设置为false(默认值)。这样可以减小数据不一致的概率。当然了,我们也可以看到,CacheEvict注解并不支持延迟删除策略,并且也没有对删除缓存失败做处理。基于这一点,应该酌情使用此注解。
|我的建议
下面说一说我对缓存使用的建议,当然了,只是建议。鉴于个人水平有限,本文如有错误,还望指出。
设置合理的过期时间
读、新增操作可以使用Spring cache提供的注解
删除、更新操作建议自己实现(要求不高也可以用CacheEvict)
这篇文章到这儿就写完了,相信你一定会有所收获的。如果有什么疑问,可以直接给我的公众号发消息,我看到后会回复的。





