缓存一致性问题

缓存一致性问题

    • 一.场景
    • 二.问题
    • 三.定义
    • 四.分析
      • 1.顺序异常分析
        • 1.1 先A后B
        • 1.2 先B后A
        • 1.3小结
      • 2.方案分析
        • 2.1先A后B
        • 2.2先B后A
        • 2.3小结
        • 2.4方案改进
    • 五.项目中的实践
      • 普通缓存(查询并发量少)
      • 秒杀缓存(查询并发量大)
      • 普通缓存(查询并发量大)
    • 六.总结

一.场景

项目中为了对dao(data access object)层数据做缓存,设计了一层das(data access service),在das上采用了aop的方式作统一缓存处理。这里做的是缓存删除操作。

二.问题

数据库和缓存的一致性问题。

三.定义

A: 删除缓存
B: 提交数据库事务
C: 读取缓存(如果缓存数据为空,会从数据库读取旧数据)
A-B: A和B同步
A–B: A和B异步

四.分析

1.顺序异常分析

1.1 先A后B

注意:删除缓存的操作在数据库事务中执行的情况也属于先A后B
(1)A失败了,B不会执行,不影响一致性。
(2)A成功了,B失败了。因为是删除缓存操作(读请求会从数据库读取旧数据),所以不影响一致性。
(3)先A,后B,存在时间间隙,如果在这段间隙中有其它读请求C,那么就会出现数据不一致。

1.2 先B后A

(1)B失败,A不会执行,不影响一致性。
(2)B成功,A失败,会出现数据不一致。

1.3小结

从上面分析可以发现,两种顺序的执行都会存在导致数据不一致的问题。

2.方案分析

2.1先A后B

解决问题(3)
(1)阻塞加锁的方式,A-B。优点:保证强一致,缺点:性能不高,高并发情况不适合,缓存读写阻塞不好实现。
(2)在B操作中加入数据库持久化日志,使用定时任务补偿,A-B–AA-B同步(并发不高情况下,可以降低C执行的概率),B–A异步(补偿)。优点:基本不影响缓存性能,缺点:增加日志数据和定时任务的开销。

2.2先B后A

解决问题(2)
(1)在B操作中加入数据库持久化日志,使用定时任务补偿,B-A–A,原理跟上面第(2)点一样。
(2)事务消息,生产者事务生产和消费者事务消费,B操作中需要加入数据库持久化日志,异步A操作,定时任务做MQ补偿,B–A。优点:异步队列处理性能高,操作解耦,适合A操作量大的情况,缺点:转移到解决MQ一致性的问题上,而且还增加日志数据、定时任务和MQ的开销。

2.3小结

考虑用户体验的话,A-B–A方案比较合适;A操作复杂耗时的情况下,B–A的方案比较合适。

2.4方案改进

(1)B–A方案可以通过失败重试的方式来提高一致性的效率,即B-A(n)–A,意思是在A同步重试n次失败后再发出消息作补偿,优点:提高同步成功率,避免异步开销,缺点:业务线程操作耗时,需要适当设置重试时间策略。
(2)B–A的方案不一定要用分布式MQ,还可以用本地消息队列,优点:不需要处理MQ一致性问题,提升效率,缺点:业务应用程序需要增加本地消息队列的开销。

五.项目中的实践

普通缓存(查询并发量少)

业务代码

	@Transactional
	@DelCache
	public void updateStock(Long itemId, Integer num){
		//删除缓存
		deleteCache(itemId, num);
		//打印日志
		printLog(itemId, num);
		//更新数据库
		updateDb(itemId, num);

	}

@DelCache aop 在@Transactional 事务提交后执行

	public void afterReturning(JoinPoint point, Object result) throws Exception{
		try{
			//再一次删除缓存
	    	deleteCache(itemId, num);
		} cache (Exception){
			//数据库中插入补偿日志,后面由定时任务轮询重试删除缓存
			insertCompensateLog(itemId, num);
		}
	}

小结:项目中的这种做法相对简单,只有在出现异常的时候才需要把数据持久化,交给定时任务做重试,即A-B-A,缓存双删。

秒杀缓存(查询并发量大)

业务代码

	public void deductStock(Long itemId, Integer num){
		//扣减库存+校验超卖(原子操作)
		decrStock(itemId, num);
		//打印日志
		printLog(itemId, num);
		try{
			//更新数据库
			updateDb(itemId, num);
		} catch (Exception e){
			//打印日志
			printLog(itemId, num);
			//异常缓存回滚
			incrStock(itemId, num);
			//把异常抛给上层
			throw e;
		}
	}

小结:项目秒杀场景,这里考虑到并发量会很大,所以选择更新缓存的操作,先缓存扣减库存,以抵抗高并发压力。注意,这里的 deductStock 不需要加事务。
还有一个情况:就是缓存回滚会可能失败,因为这种情况出现的概率是非常低的,这里会采用定时任务轮询作补偿。具体方案:
1.在 updateDb 的时候需要同时在同一个事务插入一条库存扣减记录。
2.实际业务中,每个订单都有一个自动取消时间,如果在规定时间内不进行支付就会取消,并且作库存回滚。
3.如果更新库存的数据库操作失败了,那么,订单是不会创建的,库存的记录时间如果超过了自动取消时间,并且订单不存在或者订单状态未支付,那么就把这个库存记录的数量加到缓存中。

普通缓存(查询并发量大)

这种情况的一致性要求一般不高,但是查询量大,如果使用删除缓存的操作,作缓存更新,后面读缓存的时候需要使用同步加载数据到缓存,也就是会出现阻塞的情况。为了避免出现阻塞,可以把这种查询请求量大,但是一致性要求不高的操作,做缓存读写分离,也就是把这种操作分离出来,读库的数据不作失效设置,通过写库进行数据写入,然后通过同步机制进行读库数据的更新。这个过程是有一定的时间差的,但可以解决出现阻塞的情况。

六.总结

在分布式场景下,解决一致性的问题往往需要耗费很多资源。需要根据业务场景适当取舍。

参考:
B-A(n)–A 失败重试

你可能感兴趣的:(一致性)