【Java】缓存与数据库:双写一致性、缓存问题

这里我讲的是缓存和数据库,以redis和mysql举例,实际上缓存包括不限于浏览器缓存、redis、memcache、本地缓存guava等等,数据库也有很多种,这里我们仅仅以较常见的redis和mysql举例。

一,缓存与数据库的查询、写入

1,双写一致性

在以数据库作为查询终点的项目里,一般在查询的时候,使用被动更新,即查询不到数据,从数据库获取数据,将其更新到缓存中。而在进行写操作的时候,如果只是依靠缓存的过期,会存在比较大的一致性问题,因此需要使用主动更新,即需要主动对缓存进行更新。而主动更新,存在双写一致性问题,下面是4种主动更新的方案。

更新缓存、更新数据库
基本上更新缓存的方案都存在一致性的问题,虽然不一致的原因不一样,但是结果都是数据一致性问题比较严重。比如这个先更新缓存,再更新数据库。如果更新数据库失败,直到数据库回滚后,将缓存数据更新为旧值之前,缓存中被更新的值都存在一致性问题,数据库写操作和回滚比较耗时,这期间缓存数据一直是不一致的,因此这个方案一般不采用。

更新数据库、更新缓存
这个方案的一致性问题是因为多线程导致的。假设a线程更新数据库后,还没有更新缓存,此时b线程更新了数据库与缓存,最后a线程更新了缓存。b线程后更新的数据库,但是最后缓存里的数据是先更新的a线程的数据,如果后面再有新的线程来查询,并且读多写少,那么数据会在相当一段时间内存在一致性问题。

删除缓存、更新数据库
这个方案相比“更新缓存、更新数据库”,即使a线程更新数据库失败了,也不会存在一致性问题,因为b线程查询时缓存没有数据会从数据库中去读数据,读到的快照都是更新成功的数据,a线程更新失败的数据并不会被读到。

这个方案的问题是,更新数据库相比查询数据库(mysql更新基于LBCC,需要加锁锁定;查询在可重复读和读已提交是基于MVCC,是非锁定的快照读,读的undolog,往往都很快)往往是比较耗时的操作,因此在a线程删除了缓存,并更新数据库未完成期间,b线程查询旧快照,并将旧数据更新到了缓存中。在客户端看来,先去更新的数据后查询的数据,但是查出来的是旧数据,因此造成一致性问题。

这种一致性问题是“删除缓存、再更新数据库”方案的固有问题,如果一定要选择这种方案,只能选择“延迟双删”方案,即在更新数据库后,进行第二次删除缓存。删除第二次缓存之前,需要先休眠 业务读取数据库时间 + 读写分离同步时间 + 少量用于保险的时间。这个方案的问题在于,评估这三个时间,比较麻烦,而且因为需要休眠影响吞吐量,不可避免的要用到线程池。如果有人硬要考虑到第二次删除可能失败,那么还需要用mq来处理第二次删除缓存,如果第二次删除缓存失败,就由mq重新推送并消费,直到重试失败,进入死信队列,由人工手动处理。

或者由canal监听mysql的binlog,来处理第二次删除缓存。由监听canal的项目来循环处理缓存,直到更新成功为止。到此,一致性终于得到了解决,但是因此带来的业务侵入、开发复杂度、架构复杂度,实在是太大了。因此这种方案一般也不考虑。在我看来这种延迟双删更接近水多了加面、面多了加水的一种方案。

如果真的对一致性有极致的要求,可以直接使用Read/Write Through或者Write Behind 架构。

更新数据库、删除缓存
这种方案也叫做cache aside模式。这种方案一定要挑刺的话,就是1、删除缓存有可能失败,2、在缓存无数据,数据库有数据的情况下,a线程从数据库查询到旧值,b线程更新数据库并删除缓存,a线程最后将旧值更新到缓存中。

对于第一个问题,由于项目的瓶颈一般是数据库,mysql数据库更新失败的可能性远大于简单快速的kv形式基于内存的redis。因此redis更新失败的可能性很小。对于第二个问题,不同于”删除缓存、更新数据库“,因为mysql查询速度远快于写入速度,所以查询线程阻塞的时间大于写入线程阻塞时间的可能性很小,再加上前提缓存无数据数据库有数据,第二个问题可能性就更小了。也就是说,这两个问题出现的概率都很小,因此一般就不会考虑可能性很小的问题,即使真的出现了这样的问题,需要我们容忍缓存数据ttl期间的一致性问题。还是那句话,如果对于一致性有着很高的要求,就不应该使用cache aside架构了。

2、Read/Write Through、Write Behind
这两种架构适用于流量比较高的场景,以缓存作为最终的查询终点。

Read/Write Through
这种架构方案,查询时,到缓存为止,不会在缓存没查到时再去查数据库,缓存就是查询的终点了;写入时,将写缓存、写入数据库放在一个事务中,确保缓存先被更新,以及数据库和缓存的一致。这种方案在数据库写入失败时,需要将缓存数据回滚为旧值,这期间数据可能被读取,出现脏读问题。这种方案适合读多写少的场景。

Write Behind
这种架构方案,查询和Read Through医院,区别在于写入时将写入数据库的操作异步化,通过最大努力通知方案,达到最终一致性,优点在于提高了系统的吞吐量、减小了RT,相比Read/Write Through写入性能更高,缺点在架构方案要更加复杂,mq虽然能够保证数据的最终一致性,但是加入了新的中间件,提高了架构复杂度。

二、缓存清理机制

时效性清理
时效性清理,以redis过期key举例,redis对于过期key,存在被动和主动两种方式。主动方式为定时任务随机抽取一定数目的key进行检测并剔除过期key,如果超过25%就立即进行下一次抽取并剔除。被动方式为被客户端调用到过期key会将过期key剔除并返回nil。

数目阈值式清理
同样以redis举例,对应redis的内存淘汰策略,当使用内存超过阈值时,进行内存淘汰,注意此时淘汰的key不一定过期。

三、缓存问题

下面的缓存问题有不少人总结过,但是大多是从单一的角度来看待的,这里我将从双写、缓存清理两个角度来一起总结这些问题。

1,缓存穿透
客户端请求了数据库中没有的数据,导致请求穿透了缓存打到了数据库。

分析:这种情况一般是客户端恶意请求,解决方案有两个:1、数据库查询为空时缓存空数据。2、使用布隆过滤器,redisson自带布隆过滤器。个人比较推荐缓存空数据,布隆过滤器存在一定错误率,且只能增加数据不能减少数据,不方便使用。

2,缓存雪崩
大量缓存突然失效,导致大量的请求,倾泻到数据库上,引起数据库压力骤增。

分析:可能是批量缓存定期ttl,同时过期导致的缓存雪崩;也有可能是内存淘汰导致批量缓存失效导致雪崩。如果是前一种情况,在业务允许的范围内,在ttl的基础上+随机一定时间即可。如果是后一种情况,建议使用lru或者lfu淘汰策略,防止热key被淘汰。

3,缓存击穿
高频率的缓存,突然失效,大量请求倾泻到数据库上。

分析:可能是热key过期了,或者是cache aside模式删除了缓存。前一个问题,可以设置key永不过期+配合缓存架构或者在重建热key时使用分布式锁。需要注意的是如果设置key永不过期,一定要需要配合read wirte through/ write behind架构使用或者基于cache aside架构在删除缓存后,重建热key时使用分布式锁。如果是后一个问题,则只能使用分布式锁。永不过期不能解决cache aside架构下击穿的问题。

参考文章:
[1],缓存模式(Cache Aside、Read Through、Write Through、Write Behind)
[2],三种缓存策略分析:Cache aside,Read/Write through,Write Back

  • 17
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值