缓存和数据库一致性问题
概述
缓存作为持久化存储(如数据库)的辅助存在,毕竟属于两套系统。理想情况下是缓存数据与数据库中数据完全一致,但是业务最常使用的旁路缓存架构下,在一些分布式或者高并发的场景中,可能会出现缓存不一致的情况。
在分布式系统中,数据一致性是一个核心问题。根据系统的设计与需求,可以选择实时强一致性(Strong Consistency)或最终一致性(Eventual Consistency)。
实时强一致性
定义:实时强一致性保证了任何时刻,所有的客户端看到的数据都是一样的。在分布式系统中实现强一致性意味着,一个操作一旦完成,所有的客户端立即都能看到这个操作的结果。
适用场景:事务性强、对数据一致性要求高的系统,如银行系统或任何财务系统。
保障策略:
- 三阶段提交(3PC)等分布式事务协议:在分布式系统中保证操作要么全部成功,要么全部失败。
- 分布式锁:通过在操作前获取全局锁,保证同一时刻只有一个操作可以修改数据,从而保障数据一致性。
- 强一致性算法:如Paxos或Raft算法,通过一系列严格的消息传递和确认机制,确保分布式系统中的多个副本能够达到一致状态。
最终一致性
定义:最终一致性是指,系统会保证在没有新的更新操作的情况下,经过足够的时间后,数据将达到一致的状态。在这种模型下,数据的副本之间可能会暂时存在不一致。
适用场景:对实时性要求不高,可以容忍短时间内数据不一致的场景,如社交网络、推荐系统等。
保障策略:
- 异步复制:当数据更新发生时,首先更新主副本,然后异步地将更新同步到其他副本,例如使用消息队列来完成。
- 读取修复(Read Repair):在读取数据的时候检测副本之间的不一致,并在后台异步修复不一致的数据。
- 后台一致性修复进程:定期在后台运行的进程检查和同步数据副本之间的差异,以达到最终一致性。
- 版本控制:每次更新数据时附加一个时间戳或版本号,用于解决更新冲突和保持数据的最终一致性。
常见缓存更新/失效策略与一致性解决方案
缓存更新策略
- Write through cache(直写缓存):首先将数据写入缓存,然后立即将新的缓存数据复制到数据库。这种方式可以保证写操作的一致性,但可能会影响写操作的性能。
- Write back cache(写回缓存):数据首先写入缓存,然后由缓存异步写入数据库。这种方式可以提高写操作的性能,但增加了数据丢失的风险。
- Write around cache(饶写缓存):绕过缓存,直接写数据库,然后依据需要更新缓存或使缓存失效。这适用于更频繁读取操作的场景。
缓存失效策略
- 主动更新:当数据库数据变化时,主动更新缓存中的数据。这可以保持缓存数据的实时性,但可能会增加系统的复杂性。
- 定时失效:为缓存数据设置一个过期时间。定期从数据库中重新加载数据,以保持数据的新鲜度。但这无法解决数据在两次加载之间变化导致的一致性问题。
- 惰性加载:只有在请求特定数据且发现缓存失效或缓存中没有该数据时,才去数据库加载该数据。这种策略简单,但在高并发场景下可能会导致缓存击穿。
使用缓存一致性协议
- 基于订阅的更新:使用消息队列(如Kafka,RabbitMQ)来发布数据库更新,然后相关服务订阅这些更新消息来同步更新缓存。
- 最终一致性:采用最终一致性模型,允许系统在一段时间内是不一致的,但保证经过足够的时间后,系统中的所有复制数据最终将达到一致的状态。
分布式缓存系统
使用如Redis Cluster、Apache Ignite、Tair等分布式缓存系统,这些系统内置了处理缓存一致性的机制,(但是无法解决缓存和数据库之间的数据一致性问题)。
最终一致性
针对如何保证缓存和数据库一致性,引出以下几个问题:
- 到底是更新缓存还是删缓存?
- 如果是删缓存,那选择先更新数据库,再删除缓存,还是先删除缓存,再更新数据库?
- 为什么要引入消息队列保证一致性?
- 延迟双删会有什么问题?到底要不要用?
- ……
更新缓存
由于引入了缓存,那么在数据更新时,要想保证缓存和数据库最终一致性,就不仅要更新数据库,还要更新缓存
那么数据库和缓存都需要更新,就存在先后的问题:
- 先更新缓存,后更新数据库
- 先更新数据库,后更新缓存
由于操作分为了两步,那么就有可能出现 第一步成功,第二步失败 的情况
第一步成功,第二步失败
先更新缓存,后更新数据库
如果缓存更新成功了,但数据库更新失败了,那么此时缓存中是最 新值,但数据库中是 旧值。虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。
此时数据没有更新成功,显然就会对业务产生影响
先更新数据库,后更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是旧值。之后的读请求读到的都是旧数据,只有当缓存 失效 后,才能从数据库中得到正确的值。
这时就会发现,自己刚刚修改了数据,但却看不到修改后的值,一段时间过后,数据才变更过来,显然也会对业务产生影响。
可见,无论谁先谁后,但凡后者发生异常,都会对业务造成影响。那怎么解决这个问题呢?后面详细描述
并发场景
先更新缓存,后更新数据库
在并发场景下,假设有线程 A 和线程 B 两个线程,需要更新 X这条 数据,会发生这样的场景:
- 线程 A 更新缓存(X = 1)
- 线程 B 更新缓存(X = 2)
- 线程 B 更新数据库(X = 2)
- 线程 A 更新数据库(X = 1)
此时,数据库中的 X=1,而缓存中的 X=2 ,出现了缓存和数据库中的数据不一致的现象。
先更新数据库,后更新缓存
同样以上的场景,
- 线程 A 更新数据库(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 更新缓存(X = 2)
- 线程 A 更新缓存(X = 1)
此时,数据库中的 X=2,而缓存中的 X=1 ,出现了缓存和数据库中的数据不一致的现象。
所以,无论是先更新数据库,再更新缓存,还是先更新缓存,再更新数据库,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,较大概率会出现缓存和数据库中的数据不一致的现象。
解决方案:分布式锁
通常的解决方案是:加分布式锁
两个线程要修改「同一条」数据,那么每个线程在修改之前,先申请分布式锁,拿到锁的线程才允许更新数据库和缓存,拿不到锁的线程,返回失败,等待下次重试。
使用分布式读写锁可以完美解决缓存数据不一致的问题,但是想要读数据必须等待写数据整个操作完成。因此,这种方案造成的性能开销有可能会超过引入缓存带来的性能提升。
从缓存利用率的角度来看更新缓存
的方案:当每次数据发生变更,都去更新缓存,但是缓存中的数据实际上并不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
因此更新缓存
的方案在未引入分布式锁的情况下,不仅缓存利用率不高、浪费缓存资源,还会造成数据不一致的问题。所以可以考虑删除缓存的方案
删除缓存
同样的,删除缓存方案也有两种:
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
由于操作分为了两步,那么也有可能出现 第一步成功,第二步失败 的情况
第一步成功,第二步失败
先删除缓存,后更新数据库
如果缓存删除成功了,但数据库更新失败了,数据库没有更新成功,那下次读缓存发现不存在,则从数据库中读取,并重建缓存,此时数据库和缓存都是旧数据。
此时数据没有更新成功,显然就会对业务产生影响
先更新数据库,后删除缓存
如果数据库更新成功了,但缓存删除失败,那么此时数据库中是最新值,缓存中还是是旧值。之后的读请求读到的都是旧数据,只有当缓存 失效 后,才能从数据库中得到正确的值。
这时就会发现,自己刚刚修改了数据,但却看不到修改后的值,一段时间过后,数据才变更过来,显然也会对业务产生影响。
可见,无论谁先谁后,但凡后者发生异常,都会对业务造成影响。那怎么解决这个问题呢?后面详细描述
并发场景
先删除缓存,后更新数据库
开始时 X=1,在并发场景下,假设有线程 A 和线程 B 两个线程,A想要将 X 这条数据修改为 X = 2,B想要读 X 这条数据,会发生这样的场景:
- 线程 A 先删除缓存
- 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
- 线程 A 将新值写入数据库(X = 2)
- 线程 B 将旧值写入缓存(X = 1)
最终,X 在缓存中是 1(旧值),在数据库中是 2(新值),缓存和数据库的数据不一致。
- 延迟双删
实际上,先删除缓存,后更新数据库
方案导致缓存和数据库的数据不一致原因在于缓存被写回了旧值。而针对这个方案的解决方法就是延迟双删策略。
在线程 A 删除缓存、更新完数据库之后,先 休眠一会 ,再 删除 一次缓存。伪代码如下:
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)
加了个睡眠时间,主要是为了确保线程 A 在睡眠的时候,线程 B 能够在这这一段时间完成「从数据库读取到(旧的)数据,再把(旧的)数据写回缓存」的操作,然后线程 A 睡眠完,再删除缓存。
但是具体睡眠多久其实很难评估,所以这个方案也只是尽可能保证一致性,极端情况下,依然也会出现缓存不一致的现象。
因此,还是比较建议用以下 先更新数据库,再删除缓存
的方案。
先更新数据库,再删除缓存
同样的以上的场景:但 X 在缓存中不存在,在数据库中 X = 1
- 线程 A 读取数据库,得到旧值(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 删除缓存
- 线程 A 将旧值写入缓存(X = 1)
最终,X 在缓存中是 1(旧值),在数据库中是 2(新值),缓存和数据库数据不一致。
显然先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。出现这个场景,要同时满足三个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 2-3),要比读数据库 + 写缓存时间短(步骤 1 和 4)
实际上,写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。也就是说,通常情况下,更新数据库 + 删除缓存的时间(步骤 2-3),都是要比读数据库 + 写缓存时间 长的
因此,可以说,先更新数据库 + 再删除缓存
的方案,是可以保证数据一致性的。
如何保证两步都执行成功
前面的方案,都是两步执行的操作,都有可能出现第一步成功,第二步失败的情况,这种情况下都会导致数据问题,导致业务受到影响,因此,需要保证两步都执行成功。
对于单体项目,可使 第一步和第二步 都在同一个事务中执行,使更新数据库和删除缓存是原子性操作
@Transactional //同一个事务中执行,保证同时成功
public Result update(Shop shop){
Long id = shop.getId();
if(id == null){
return "商品id不能为空";
}
//更新数据库
updateById(shop);
//删除缓存
redisTemplate(id);
}
而对于分布式项目,要保证第二步执行成功,则有两种方案:
- 重试
- 订阅MySQL binlog,再操作缓存
重试方案
同步重试
同步重试会一直占用这个线程资源,无法服务其它客户端请求。因此同步重试不可取
异步重试
异步重试就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。
消息队列也有可能失败?实际上由于消息队列的特性,不会失败:
消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失。
消息队列能保证消息的成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者
方案过程如下:
订阅MySQL binlog,再操作缓存
在Mysql中,当一条数据发生修改时,MySQL 就会产生一条变更日志(binlog),binlog日志用于复制,在主从复制中,从库可以利用主库上的binlog进行重放,实现主从同步。
那么我们就可以伪装成从服务器,对binlog日志进行订阅,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
小结
至此,可以得出结论,想要保证数据库和缓存的最终一致性,推荐采用先更新数据库,再删除缓存
方案,并配合消息队列
或订阅变更日志
的方式来做
实时强一致性
其实在这前面的提到的方案中,说的是 最终一致性,这个最终一致性是能保证缓存和数据库的最终一致性的,并且接进实时。
但是想让缓存和和数据库强一致,是很难的。最有效的方案先更新数据库,再删除缓存
也是存在不一致性的可能的,只是概率较低。
要想做到强一致性,那就可以加锁,分布式锁可以完美解决缓存数据不一致的问题,但想要读取数据就必须等待写数据整个操作完成。这就会造成并发上的性能问题
但是,引入缓存的目的就是为了提升性能,决定了使用缓存,那必然就要面临数据一致性问题。性能和一致性无法做到都满足要求。只能尽量降低问题出现的概率,减小对业务的影响