缓存数据一致性问题

Redis 缓存与数据库的 数据不一致

以 Tomcat 向 MySQL 中写入和删改数据为例,数据的增删改操作具体是如何进行的,如下图所示:

img

新增数据

如果是新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时,缓存中本身就没有新增数据,而数据库中是最新值,等后续查询请求将数据库中的值写入缓存后,缓存和数据库的数据就是一致的了。

删改数据

如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,也就是说,要不都完成,要不都没完成,此时,就会出现数据不一致问题了。这个问题比较复杂,我们来分析一下。

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

我们假设应用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。

img

数据库更新失败,这种会影响业务流程的,我们需要进行重试,如果重试超过的一定次数还是失败,应该向业务层发送报错信息。

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

如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。

img

为什么是删除,而不是更新缓存?

我们以先更新数据库,再删除缓存来举例。

举个例子:如果数据库1小时内更新了100次,那么缓存也要更新100次,但是这个缓存可能在1小时内只被读取了1次,那么这100次的更新有必要吗?

如果是删除缓存的话,就算数据库更新了100次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载。

删除或更新失败场景

在更新数据库和删除缓存值的过程中,只要有一个操作失败了,就会导致客户端读取到旧值

img

问题发生的原因我们知道了,那该怎么解决呢?

方法:重试机制

具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka 消息队列)。

  • 当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
  • 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作;

如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。


并发请求场景下

刚刚说的是在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作执行时都没有失败,当有大量并发请求时,应用还是有可能读到不一致的数据。

情况一:先删除缓存,再更新数据库。

假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:

  • 线程 B 去数据库读取到了旧值;
  • 线程 B 把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。

等到线程 B 从数据库读取完数据、更新了缓存后,线程 A 才开始更新数据库,此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。

时间线程A线程B问题
T11.删除缓存
T21.查询缓存, 未命中
2.从数据库查询数据值,更新缓存
1. 线程A尚未更新数据库的值,导致线程B读取到旧值
2.线程B把旧值写入缓存,导致其他线程读取到旧值
T32. 更新数据库缓存中是旧值,数据库中是新值,两者数据不一致

img

应对方案:

在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,等线程 B 执行完读取数据 和写入缓存后,再进行一次缓存删除操作。

问题:
  • 需确保 线程 A sleep 的时间 大于 线程B读数据和写缓存的操作时间;

    业务程序运行的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进行估算,设置合理的sleep 时间

解决:

缓存的所有数据都设置过期时间,等数据过期下一次查询时触发主动更新

情况二:先更新数据库值,再删除缓存值。

如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。

不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,等其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

时间线程A线程B问题
T11.更新数据库
T21.查询缓存, 缓存命中
2.从缓存读取,读到旧数据值
1. 线程A尚未删除缓存值,导致线程B读取到缓存的旧值
T32. 删除缓存

img

小结
执行顺序潜在问题现象应对方案影响分析
先删除缓存,再更新数据库缓存删除后,尚未更新数据库,有并发读请求过来并发请求从数据库读到旧值,并且更新到缓存中,导致后续请求都读取到旧值缓存的所有数据都设置过期时间,数据过期下一次查询时触发主动更新这种情况如果该数据的更新频率比较低,会导致在下一次更新该缓存值前,其他线程一直都是读取到错误的旧值;
先更新数据库,再删缓存更新数据库后,尚未缓存删除,有并发读请求过来并发请求从缓存中读到旧值等待缓存删除,期间会存在短暂的数据不一致这种情况造成的不一致时间很短,等其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值,对业务造成的影响比较小
  • 删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
  • 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案 是给缓存的所有数据都设置过期时间,数据过期下一次查询时触发主动更新。

缓存更新失效问题

如果缓存的数据值是热点数据,更新频率较高,有可能出现并发更新缓存值被覆盖的情况

在这里插入图片描述

假设 线程A需要查询数据值,线程B 需要将数据值更新为V1,线程C需要将数据值更新为V2;

线程A在线程B执行完数据更新后,查询缓存,发现缓存缺失,然后查询数据库,得到线程B 写入的数据值V1

在线程A使用查询到的数据值V1写入缓存前,线程B将该数据值更新为了V2,最终缓存中保存的是线程B更新后的旧值V1

如果是对缓存数据一致性要求高的业务场景,这种情况可以用读写锁,让查询操作线程 和 更新操作线程 串行执行;

等 更新线程完成 (数据库更新 + 删除缓存值)后,再让查询线程 执行(查询数据库 + 更新缓存值),或者查询线程 完成(查询数据库 + 更新缓存值)后 ,再让更新线程执行 (数据库更新 + 删除缓存值),从而保证数据一致性;

时间线程A(查询线程)线程B(更新线程)线程C(更新线程)
T11.更新数据库值=V1
2. 删除缓存
T21.查询缓存, 未命中
2.查询数据库,更新缓存值=V1
T31.更新数据库值=V2
2. 删除缓存

Redisson客户端提供的ReadWriteLock读写锁 ,读锁 和 读锁兼容,写锁和其他锁互斥 ,允许同时有多个读锁 或者 只有一个写锁 处于加锁状态;

一个查询线程成功获取读锁后,执行(查询数据库 + 更新缓存值),另一个更新线程等查询线程执行完成释放读锁后,获取写锁执行(数据库更新 + 删除缓存值)

	/**
	 * 查询商品库存数量
	 * @param itemNo 商品编号
	 * @return
	 */
	@RequestMapping("/getstock")
	public String getStock(@RequestParam("itemNo") String itemNo) {
		// 锁的粒度 具体缓存的是某个数据 例如: 11-号商品 item-lock-11
		String lockKey = "item-lock"+itemNo;
		// 获取可重入读写锁对象
		RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(lockKey);
		RLock readLock= readWriteLock.readLock();
		// 获取读锁
		readLock.lock();
		logger.info("获取读锁成功");
		// 查询缓存
		String stock = stringRedisTemplate.opsForValue().get(itemNo);
		// 缓存为空
		if(StringUtil.isEmpty(stock)){
			//查询数据库
			stock = itemService.getDataFromDB();
			// 更新缓存
			stringRedisTemplate.opsForValue().set(itemNo,stock);
		}
		readLock.unlock();
		logger.info("释放读锁成功");
		return stock;
	}


	/**
	 * 更新商品库存数量
	 * @param itemNo 商品编号
	 * @return
	 */
	@RequestMapping("/updatestock")
	public String updateStock(@RequestParam("itemNo") String itemNo,@RequestParam("stock") String stock ) {
		// 锁的粒度 具体缓存的是某个商品 例如: 11-号商品 item-lock-11
		String lockKey = "item-lock"+itemNo;
		// 获取可重入读写锁对象
		RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(lockKey);
		RLock writeLock= readWriteLock.writeLock();
		// 获取写锁
		writeLock.lock();
		logger.info("获取写锁成功");
		//更新数据库
		itemService.updateDBData(itemNo,stock);
		// 删除缓存
		stringRedisTemplate.delete(itemNo);
		writeLock.unlock();
		logger.info("释放写锁成功");
		return stock;
	}

总结:

  1. 对实时性、一致性要求不高的场景,缓存的所有数据都设置过期时间,数据过期下一次查询触发主动更新即可
  2. 对于实时性,一致性要求高的场景,使用读写锁控制并发读写,让查询操作线程 和 更新操作线程 串行执行;

参考:

https://time.geekbang.org/column/article/295812

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值