Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

frogman

路过地球,暂作停留 🍃

1、场景介绍

场景1:扣费,企业账户送流量或者红包,用户签到领取。此场景下就是多用户对某一个账号的并发扣款;

场景2:充值,打赏给主播,这种场景是多用户对同一个账号进行打款,但是方案和问题和场景1是一致的。

2、场景举例

假设有两个业务操作同一个账号,账号余额为100,业务1扣除50,业务2扣除40,如果顺利应该是剩余100-50-40=10,那么我们看如下并发操作的场景:

img

通过两个业务的并发操作,最后账户余额为60(是业务2最后修改后的余额值)。

3、解决方案

3.1 悲观锁

概念:

要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。

悲观锁的实现
  1. 传统的关系型数据库使用这种锁机制,比如行锁表锁读锁写锁等,都是在操作之前先上锁。
  2. Java 里面的同步 synchronized关键字的实现。
悲观锁主要分为共享锁和排他锁
  • 共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。

  • 排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。

在查询语句加 for update,行记录加上排它锁,这样后来的事务会阻塞查询,这样就避免了数据不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
//开始事务
begin;
//消费金额
$spend = 10;
//查询用户余额
$user = select id,money from t_account where id = #{id} for update;
//计算金额
$newMoney = $user['money']-$spend;
//.. 这里检查余额是否足够
//更新余额
update t_account set money = $newMoney where id = #{id} ;
//确认成功之后 提交事务
commit

3.2 乐观锁

概念

乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。

乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:

  1. CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
  2. 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

通过CAS操作,即旧值和预期值相同时执行修改,例如:

update t_account set money=#{new_money} where id=#{id};

修改为:

Update t_account set money=#{new_money} where id=#{id} and money=#{old_money};

这样在并发情况下,只能有一个修改成功,affect row为1;其他事务由于money不等于旧值,修改失败,affect row为0。

3.3 为什么不使用减等于的sql操作

例如 :

update t_account set money= money - $spend where id = #{id} ;

这里要再加上余额的判断避免出现负数金额:

Update t_account set money= money- $spend where id = #{id}} and money>= $spend ;

稍微改一下这里的更新语句也能完成正确的更新就算是并发也都将正常。

但是这样做将产生一个问题不幂等。

什么是不幂等 ?

在相同的条件下,执行同一请求,得到的结果相同才符合幂等性。

如果是money= money- $spend的操作在多次操作场景下就会产生重复扣款。

3.4 ABA问题

ABA问题是由CAS衍生来的,在并发极端情况下会产生,那么什么是ABA问题?

标准的描述如下:

并发1(上):获取出数据的初始值是A,后续计划实施CAS乐观锁,期望数据仍是A的时候,修改才能成功;

并发2:将数据修改成B;

并发3:将数据修改回A。

并发1(下):CAS乐观锁,检测发现初始值还是A,进行数据修改。

上述并发环境下,并发1在修改数据时,虽然还是A,但已经不是初始条件的A了,中间发生了A变B,B又变A的变化,此A已经非彼A,数据却成功修改,可能导致错误,这就是CAS引发的所谓的ABA问题。

举一个游戏充值例子,如下:

img

按照业务的诉求,事务3是最终将数据库结果值恢复为100,但是通过cas比较更新最终结果出现了40,这个就是ABA导致的问题。

我们还是以前面的sql作为例子讲解ABA问题如何解决?

1、数据库表结构由(id、money)修改为(id、money、version)

2、数据库查询由之前的:

select money from t_account where id=#{id}

修改为:

select money,version from t_account where id=${id}

3、数据库修改除了cas比较外,还需要版本相同,并且进行版本修改:

update t_account set money=#{new_money} where id=#{id} and money=#{old_money};

修改为:

update t_account set money=#{new_money} where id=#{id} and money=#{old_money} and version=#{version_old}

4、总结

select&set业务场景,在并发时会出现一致性问题;

基于“值”的CAS乐观锁,可能导致ABA问题;

不能采用减等于的sql操作,由于此操作不幂等;

CAS乐观锁,必须保证修改时的“此数据”就是“彼数据”,应该由“值”比对,优化为“版本号”比对。

评论