跳至主要內容

缓存和数据库一致性问题


概述

缓存作为持久化存储(如数据库)的辅助存在,毕竟属于两套系统。理想情况下是缓存数据与数据库中数据完全一致,但是业务最常使用的旁路缓存架构下,在一些分布式或者高并发的场景中,可能会出现缓存不一致的情况。

在分布式系统中,数据一致性是一个核心问题。根据系统的设计与需求,可以选择实时强一致性(Strong Consistency)或最终一致性(Eventual Consistency)。

实时强一致性

定义:实时强一致性保证了任何时刻,所有的客户端看到的数据都是一样的。在分布式系统中实现强一致性意味着,一个操作一旦完成,所有的客户端立即都能看到这个操作的结果。

适用场景:事务性强、对数据一致性要求高的系统,如银行系统或任何财务系统。

保障策略

  1. 三阶段提交(3PC)等分布式事务协议:在分布式系统中保证操作要么全部成功,要么全部失败。
  2. 分布式锁:通过在操作前获取全局锁,保证同一时刻只有一个操作可以修改数据,从而保障数据一致性。
  3. 强一致性算法:如Paxos或Raft算法,通过一系列严格的消息传递和确认机制,确保分布式系统中的多个副本能够达到一致状态。

最终一致性

定义:最终一致性是指,系统会保证在没有新的更新操作的情况下,经过足够的时间后,数据将达到一致的状态。在这种模型下,数据的副本之间可能会暂时存在不一致。

适用场景:对实时性要求不高,可以容忍短时间内数据不一致的场景,如社交网络、推荐系统等。

保障策略:

  1. 异步复制:当数据更新发生时,首先更新主副本,然后异步地将更新同步到其他副本,例如使用消息队列来完成。
  2. 读取修复(Read Repair):在读取数据的时候检测副本之间的不一致,并在后台异步修复不一致的数据。
  3. 后台一致性修复进程:定期在后台运行的进程检查和同步数据副本之间的差异,以达到最终一致性。
  4. 版本控制:每次更新数据时附加一个时间戳或版本号,用于解决更新冲突和保持数据的最终一致性。

常见缓存更新/失效策略与一致性解决方案

缓存更新策略

  • Write through cache(直写缓存):首先将数据写入缓存,然后立即将新的缓存数据复制到数据库。这种方式可以保证写操作的一致性,但可能会影响写操作的性能。
  • Write back cache(写回缓存):数据首先写入缓存,然后由缓存异步写入数据库。这种方式可以提高写操作的性能,但增加了数据丢失的风险。
  • Write around cache(饶写缓存):绕过缓存,直接写数据库,然后依据需要更新缓存或使缓存失效。这适用于更频繁读取操作的场景。

缓存失效策略

  • 主动更新:当数据库数据变化时,主动更新缓存中的数据。这可以保持缓存数据的实时性,但可能会增加系统的复杂性。
  • 定时失效:为缓存数据设置一个过期时间。定期从数据库中重新加载数据,以保持数据的新鲜度。但这无法解决数据在两次加载之间变化导致的一致性问题。
  • 惰性加载:只有在请求特定数据且发现缓存失效或缓存中没有该数据时,才去数据库加载该数据。这种策略简单,但在高并发场景下可能会导致缓存击穿。

使用缓存一致性协议

  • 基于订阅的更新:使用消息队列(如Kafka,RabbitMQ)来发布数据库更新,然后相关服务订阅这些更新消息来同步更新缓存。
  • 最终一致性:采用最终一致性模型,允许系统在一段时间内是不一致的,但保证经过足够的时间后,系统中的所有复制数据最终将达到一致的状态。

分布式缓存系统

使用如Redis Cluster、Apache Ignite、Tair等分布式缓存系统,这些系统内置了处理缓存一致性的机制,(但是无法解决缓存和数据库之间的数据一致性问题)。

最终一致性

针对如何保证缓存和数据库一致性,引出以下几个问题:

  1. 到底是更新缓存还是删缓存?
  2. 如果是删缓存,那选择先更新数据库,再删除缓存,还是先删除缓存,再更新数据库?
  3. 为什么要引入消息队列保证一致性?
  4. 延迟双删会有什么问题?到底要不要用?
  5. ……

更新缓存

由于引入了缓存,那么在数据更新时,要想保证缓存和数据库最终一致性,就不仅要更新数据库,还要更新缓存

那么数据库和缓存都需要更新,就存在先后的问题:

  1. 先更新缓存,后更新数据库
  2. 先更新数据库,后更新缓存

由于操作分为了两步,那么就有可能出现 第一步成功,第二步失败 的情况

第一步成功,第二步失败

先更新缓存,后更新数据库

如果缓存更新成功了,但数据库更新失败了,那么此时缓存中是最 新值,但数据库中是 旧值。虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。

此时数据没有更新成功,显然就会对业务产生影响

先更新数据库,后更新缓存

如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是旧值。之后的读请求读到的都是旧数据,只有当缓存 失效 后,才能从数据库中得到正确的值。

这时就会发现,自己刚刚修改了数据,但却看不到修改后的值,一段时间过后,数据才变更过来,显然也会对业务产生影响。

可见,无论谁先谁后,但凡后者发生异常,都会对业务造成影响。那怎么解决这个问题呢?后面详细描述

并发场景

先更新缓存,后更新数据库

在并发场景下,假设有线程 A 和线程 B 两个线程,需要更新 X这条 数据,会发生这样的场景:

  1. 线程 A 更新缓存(X = 1)
  2. 线程 B 更新缓存(X = 2)
  3. 线程 B 更新数据库(X = 2)
  4. 线程 A 更新数据库(X = 1)

此时,数据库中的 X=1,而缓存中的 X=2 ,出现了缓存和数据库中的数据不一致的现象。

先更新数据库,后更新缓存

同样以上的场景,

  1. 线程 A 更新数据库(X = 1)
  2. 线程 B 更新数据库(X = 2)
  3. 线程 B 更新缓存(X = 2)
  4. 线程 A 更新缓存(X = 1)

此时,数据库中的 X=2,而缓存中的 X=1 ,出现了缓存和数据库中的数据不一致的现象。

所以,无论是先更新数据库,再更新缓存,还是先更新缓存,再更新数据库,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,较大概率会出现缓存和数据库中的数据不一致的现象。

解决方案:分布式锁

通常的解决方案是:加分布式锁

两个线程要修改「同一条」数据,那么每个线程在修改之前,先申请分布式锁,拿到锁的线程才允许更新数据库和缓存,拿不到锁的线程,返回失败,等待下次重试。

使用分布式读写锁可以完美解决缓存数据不一致的问题,但是想要读数据必须等待写数据整个操作完成。因此,这种方案造成的性能开销有可能会超过引入缓存带来的性能提升。

缓存利用率的角度来看更新缓存的方案:当每次数据发生变更,都去更新缓存,但是缓存中的数据实际上并不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。

因此更新缓存的方案在未引入分布式锁的情况下,不仅缓存利用率不高、浪费缓存资源,还会造成数据不一致的问题。所以可以考虑删除缓存的方案

删除缓存

同样的,删除缓存方案也有两种:

  1. 先删除缓存,后更新数据库
  2. 先更新数据库,后删除缓存

由于操作分为了两步,那么也有可能出现 第一步成功,第二步失败 的情况

第一步成功,第二步失败

先删除缓存,后更新数据库

如果缓存删除成功了,但数据库更新失败了,数据库没有更新成功,那下次读缓存发现不存在,则从数据库中读取,并重建缓存,此时数据库和缓存都是旧数据。

此时数据没有更新成功,显然就会对业务产生影响

先更新数据库,后删除缓存

如果数据库更新成功了,但缓存删除失败,那么此时数据库中是最新值,缓存中还是是旧值。之后的读请求读到的都是旧数据,只有当缓存 失效 后,才能从数据库中得到正确的值。

这时就会发现,自己刚刚修改了数据,但却看不到修改后的值,一段时间过后,数据才变更过来,显然也会对业务产生影响。

可见,无论谁先谁后,但凡后者发生异常,都会对业务造成影响。那怎么解决这个问题呢?后面详细描述

并发场景

先删除缓存,后更新数据库

开始时 X=1,在并发场景下,假设有线程 A 和线程 B 两个线程,A想要将 X 这条数据修改为 X = 2,B想要读 X 这条数据,会发生这样的场景:

  1. 线程 A 先删除缓存
  2. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
  3. 线程 A 将新值写入数据库(X = 2)
  4. 线程 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

  1. 线程 A 读取数据库,得到旧值(X = 1)
  2. 线程 B 更新数据库(X = 2)
  3. 线程 B 删除缓存
  4. 线程 A 将旧值写入缓存(X = 1)

最终,X 在缓存中是 1(旧值),在数据库中是 2(新值),缓存和数据库数据不一致。

显然先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。出现这个场景,要同时满足三个条件:

  1. 缓存刚好已失效
  2. 读请求 + 写请求并发
  3. 更新数据库 + 删除缓存的时间(步骤 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);
}

而对于分布式项目,要保证第二步执行成功,则有两种方案:

  1. 重试
  2. 订阅MySQL binlog,再操作缓存

重试方案

同步重试

同步重试会一直占用这个线程资源,无法服务其它客户端请求。因此同步重试不可取

异步重试

异步重试就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。

消息队列也有可能失败?实际上由于消息队列的特性,不会失败:
消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失。
消息队列能保证消息的成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者

方案过程如下:

订阅MySQL binlog,再操作缓存

在Mysql中,当一条数据发生修改时,MySQL 就会产生一条变更日志(binlog),binlog日志用于复制,在主从复制中,从库可以利用主库上的binlog进行重放,实现主从同步。

那么我们就可以伪装成从服务器,对binlog日志进行订阅,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

小结

至此,可以得出结论,想要保证数据库和缓存的最终一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列订阅变更日志的方式来做

实时强一致性

其实在这前面的提到的方案中,说的是 最终一致性,这个最终一致性是能保证缓存和数据库的最终一致性的,并且接进实时。

但是想让缓存和和数据库强一致,是很难的。最有效的方案先更新数据库,再删除缓存也是存在不一致性的可能的,只是概率较低。

要想做到强一致性,那就可以加锁,分布式锁可以完美解决缓存数据不一致的问题,但想要读取数据就必须等待写数据整个操作完成。这就会造成并发上的性能问题

但是,引入缓存的目的就是为了提升性能,决定了使用缓存,那必然就要面临数据一致性问题。性能和一致性无法做到都满足要求。只能尽量降低问题出现的概率,减小对业务的影响

seven97官方微信公众号
seven97官方微信公众号