Bootstrap

高并发下如何保证数据的一致性

拿转账来说,在高并发下场景下,对账户余额操作的一致性,是非常重要的。如果代码写的时候没考虑并发一致性,就会导致公司亏损。所以本篇主要聊一下,如何在并发场景下,保证账户余额的一致性。

扣款流程

伪代码

public void transfer(Long id, Double payAmount){
   # 查询账户总额
   SELECT amount FROM account WHERE id=${id};

   #更新余额  
   UPDATE account SET amount=amount-payAmount WHERE id=${id};
}

以上流程如果并发量非常低的情况下是没问题的,但是如果在高并发下是很容易出现问题的。

在高并发下会出现什么问题?

假设 订单A 和 订单B 在同一时间都查询到了,账户余额为1000

订单A 扣款200,订单B 扣款 100,都满足1000-减去扣款金额大于0

执行扣款,订单A 修改账户余额为800,订单B 修改为账户余额为900

此时就出现问题了,如果 订单A 先执行更新,订单B 后执行,那么账户余额最终为900,反之为 800,都不正确,正确余额应该是700,那怎么处理呢?

并发扣款怎么处理?

使用悲观锁

在执行扣款时使用分布式锁或者数据库的 for update 对账户数据进行行级锁,使执行并发操作串型化操作。在这里使用 for update 做操作,这是数据库最简单的,但不推荐。

使用 for update 的方式,可能会带来很多问题,因为他是一个行级锁,高并发的情况下可能会导致死锁、客户端连接超时等问题。

如果一定要使用 for update,要注意 where 条件是唯一索引,否则会导致多行数据被锁,同时必须要开始事务,否则 for update 没效果,使用分布式数据库中间件还要注意,for update 可能会路由到读节点上。

使用乐观锁(CAS)

乐观锁的方式也就是是CAS的方式,适合并发量不高情况,如果并发量高大概率都失败在重试,开销也不比悲观锁小。

增加版本号方式

在查询余额时,加上版本号。

SELECT amount,version FROM account WHERE id=${id}

每次更新余额时,必须版本号相等,并且版本号每次要修改。

UPDATE account SET amount=余额,version=newVersion WHERE id=${id} AND version=${oldVersion}

使用原有金额值比对更新

在执行账户余额更新时,where 条件中增加第一次查出来的账户余额,即初始余额,如果在执行更新时,初始余额没变则更新成功,否则肯定是更新了,同时数据库也会返回受影响的行数,来判断是否更新成功,如果没成功就再次重试,同时还要考虑幂等性。

UPDATE account SET amount=余额 WHERE id=${id} AND amount=${oldAmount}

订单A 执行

UPDATE account SET amount=800 WHERE id=${id} AND amount=1000;

订单B 执行

UPDATE account SET amount=900 WHERE id=${id} AND amount=1000;

以上两笔执行只有一笔能成功,因为 amount 变了。

注意

在使用乐观锁时需要注意 ABA 的问题。拿上述场景举例:

订单A:获取出账户余额为 1000,期望余额是 1000 的时候,才能修改成功。

订单B:取了 100,将余额修改成了 900。

订单C:存进去了100,将余额修改成了 1000。

订单A:检查账户余额为 1000,进行扣款 200,账户余额变成了 800。

以上场景账户资金损失吗?没有,不过为了避免产生误解,推荐还是使用版本号的方式!

总结

在并发量高的情况下推荐使用悲观锁的方式,如果并发量不高可以考虑使用乐观锁,但是要注意幂等性,推荐使用版本号方式。同时乐观锁场景要注意 aba 的问题。

;