当前位置: 首页 > 后端技术 > Java

浅析如何保证缓存和数据库的一致性

时间:2023-04-02 09:18:19 Java

1.前言最近在网上遇到缓存和数据库数据不一致的问题。经过排查,发现我们写操作采用的策略是先写数据库,再删除缓存。如果在写操作后立即读取,因为缓存被删除了,你会去数据库中查看数据。并且由于数据库主从延迟,数据库中的旧数据会被加载到缓存中。于是碰巧数据库已经修改为新数据,但是缓存还是旧数据。这次网上发生的事情引出了一个老生常谈的话题,如何保证数据库和缓存的一致性?今天我们就来说说这个问题。二、不一致场景分析1、没有并发时,先看最简单的情况。当写请求想要将数据从D1更改为D2时。因为我们写请求需要执行两个操作:1.写入数据库;2.更新/删除缓存。这两个操作不是一个事务,所以难免有一个成功,一个失败(通常是后一个,因为如果前一个操作失败,第二个操作一般不会执行)。这导致以下两种情况。先修改D1到D2的DB数据,然后删除或更新缓存(这一步出现异常,即失败)。最后数据库数据是D2,缓存数据还是D1。先从D1更新缓存数据到D2更新数据库,出现异常。最后数据库数据为D1,缓存数据已经修改为D2。2.并发时,接下来的讨论就比较复杂了,就是读写并发时可能出现的不一致。首先要明确的是,当一个读没有命中缓存时,我们的做法一般是去查数据库,然后把查到的值写入缓存。但是在写数据的时候,策略往往是不同的。我们经常考虑两个问题。1)先操作缓存还是先操作数据库;2)删除缓存或更新缓存。1)先DB,后缓存1.写入数据库后,首先更新缓存是并发读写操作遗漏的场景。线程A读取缓存,未命中线程A读取DB,获取Data1,线程B写入DB,将数据从Data1更新到Data2,线程B写入缓存,更新到Data2,线程A写入缓存,更新到之前读取的Data1final,DB值为Data2,但缓存中的值为Data1。二是并发写操作的场景。线程A写DB,写Data1,线程B写DB,写Data2,线程A更新缓存,写Data1,线程B更新缓存,写Data2,最后DB值为Data2,缓存值为Data1.2。写入数据库后删除缓存写入数据库后删除缓存似乎可以解决上述问题,如下图所示。但这不是万灵药。比如我一开始提到的线上问题就是写DB+删除缓存的策略。因为我们项目的读QPS很大,写QPS却不高。因此采用读写分离的主从架构。写请求在主库上执行,而读请求访问从库,依赖主从同步来保证数据的一致性。由于主从同步需要时间,可能会出现以下情况,导致DB与缓存数据不一致。2)先缓存再DB1。删除缓存,然后写入数据库。线程A写入请求,先删除缓存,线程B读取缓存,错过线程B读取DB,获取D1,线程A写入数据库,D1更新D2,线程B写入缓存D1,最后,DB中的数据为D2,缓存为D1。2、更新缓存后,线程B读取缓存并写入数据库,错过了线程A的写请求,缓存从D1更新到D2。线程B读取DB并获取D1。线程A写DB,有D1更新到D2线程B写缓存D1最后DB里面的数据是D2,缓存的数据是D1。3、不一致的处理缓存和数据库不一致后,如果过期时间很长,期间没有写操作,读的时候数据会很长时间出错。那么如何纠正或尽量保证一致性呢?1、延迟双删顾名思义,延迟双删就是写完数据库后,经过很短的\(\Delta(T)\)时间,再次删除缓存。当然,第二次删除缓存是异步进行的。对于以下两种情况,采用延迟双删除策略后,可以保证缓存中的脏数据在一段时间后被删除。即实现了最终一致性。但是期间可能会有读取脏数据的请求。应该如何取这个短时间\(\Delta(T)\)的值呢?首先,在\(\Delta(T)\)之后删除的目的是删除并发未读产生的脏数据。所以一般比读请求略大,比主从同步的延时略大。2、删除缓存重试机制删除缓存这一步可能会出现异常。为了保证缓存删除成功,可以引入重试机制。对于删除缓存失败的操作,进入重试队列。重试队列的选择可以是Kafka,也可以是Redis中的列表。对于一致性要求不高的,队列甚至可以存放在单机内存中。3、读取binlog校对缓存,使用组件/中间件获取数据库的binlog。如果binlog采用的是Row模式,解析后数据行中一般会有最新数据的信息。使用此信息检查缓存,如果发现不一致,则删除缓存;如果一致,则不处理。4.总结其实到底使用哪种策略来写数据,要根据自身服务的特点来选择。没有通用的策略(除非使用序列化或者很多限制来保证数据的强一致性,这会降低系统的可用性。)。业界经常使用的CacheAside策略,即写请求先更新数据库,再删除缓存的做法,在我们的服务中会遇到很多问题。所以最后改成了先更新数据库再更新缓存。对于线上的情况,可以尝试不同的策略,在后台做数据库和缓存的一致性统计,根据业务特点选择最合适的方案。