支付系统: 余额更新

Page content

支付系统的一大挑战:高并发下怎么做余额扣减;并发扣款,如何保证数据的一致性?

高并发扣减思路

  1. 分库分表
  2. 合并请求,在保证事务的前提下,将多个扣款请求合并操作,这样只需要做一次锁操作和写操作。
    1. 合并记账后余额不足的怎么处理,可能拆分时有些还能成功?
  3. 拆分账户,将热点账户的余额账户拆分成多个子余额账户,以此来降低单个账户扣减操作的并发度。
    1. 多个账户如何协同管理?
  4. 使用内存数据库扣减,并异步写日志,所有日志结果可以回溯账户余额结果,和内存数据库做对账。
    1. 最终还是会碰到热点账户问题,当然效率比起数据库来说要好很多了
    2. 怎么保证数据最终一致?

直接扣减的问题

直接扣减的方法来进行余额扣减:

SELECT balance FROM wallet_tab WHERE uid=$uid

UPDATE wallet_tab SET balance=balance-100 WHERE uid=$uid;

在分布式环境中,如果并发量很大,这种“查询+修改”的业务有一定概率出现数据不一致;甚至会将balance扣成负数。

例如,假设有两个进程,同时更新余额

-- P1, P2 get the same balance
P1: SELECT balance FROM wallet_tab WHERE uid=$uid (balance=100)
P2: SELECT balance FROM wallet_tab WHERE uid=$uid (balance=100)

-- P1 update first, then P2 update
P1: UPDATE wallet_tab SET balance=balance-20
WHERE uid=$uid;
P2: UPDATE wallet_tab SET balance=balance-30
WHERE uid=$uid;

-- balance is updated to 70, but it should be 50.

悲观行锁 - TCC

TCC每一步可基于用户级别并发锁行锁。

SELECT balance FROM wallet_tab WHERE uid=$uid for Update

// try
UPDATE wallet_tab SET balance=$balance-20, frozen=$frozen+20

// confirm
UPDATE wallet_tab SET frozen=$frozen-20;

// cancel
UPDATE wallet_tab SET balance=$balance+20, frozen=$frozen-20;

TCC 的一个作用就是把两阶段拆分成了两个独立的阶段,通过资源业务锁定的方式进行关联。 资源业务锁定方式的好处在于,既不会阻塞其他事务在第一阶段对于相同资源的继续使用,也不会影响本事务第二阶段的正确执行。

TCC 模型进一步减少了资源锁的持有时间。有助于提高并发能力? 同时,从理论上来说,只要业务允许,事务的第二阶段什么时候执行都可以,反正资源已经业务锁定,不会有其他事务动用该事务锁定的资源。

乐观锁 - CAS

CAS方案

Compare And Set(CAS),是一种常见的降低读写锁冲突,保证数据一致性的方法。 使用CAS解决高并发时数据一致性问题,只需要在进行set操作时,compare初始值,如果初始值变换,不允许set成功。

具体到扣款case,只需要将:

UPDATE wallet_tab SET balance=$new_balance 
WHERE uid=$uid;

升级为:

UPDATE wallet_tab SET balance=$new_balance 
WHERE uid=$uid AND balance=$old_balance;

并发操作发生时:

P1执行:

UPDATE wallet_tab SET balance=80 
WHERE uid=$uid AND balance=100;

P2执行:

UPDATE wallet_tab SET balance=70 
WHERE uid=$uid AND balance=100;

怎么判断哪个并发执行成功,哪个并发执行失败呢?

Set操作,其实无所谓成功或者失败,业务能通过affect rows来判断:

  • 写回成功的,affect rows为1;
  • 写回失败的,affect rows为0;

高并发“查询并修改”的场景,可以用CAS(Compare and Set)的方式解决数据一致性问题。对应到业务,即在set的时候,加上初始条件的比对即可。 优化不难,只改了半行SQL,但确实能解决问题。

CAS方案,会不会存在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问题。

余额操作,出现ABA问题并不会对业务产生影响,因为对于“余额”属性来说,前一个A为100余额,与后一个A为100余额,本质是相同的。

ABA问题可以怎么优化?

ABA问题导致的原因,是CAS过程中只简单进行了“值”的校验,在有些情况下,“值”相同不会引入错误的业务逻辑(例如余额),有些情况下,“值”虽然相同,却已经不是原来的数据了(例如堆栈)。

因此,CAS不能只比对“值”,还必须确保是原来的数据,才能修改成功。

常见的实践是,将“值”比对,升级为“版本号”的比对,一个数据一个版本,版本变化,即使值相同,也不应该修改成功。

在表里加一个version字段;使用CAS,更新时判断 version。 如果被其他事物更新到 version + 1 了,就 select 新的 balance 和 version 出来,然后基于新 version 做判断,新 balance 做更新。

设置余额时,必须版本号相同,并且版本号要修改。

SELECT balance,version FROM wallet_tab WHERE sid=$sid

UPDATE wallet_tab SET balance=38, version=$version_new 
WHERE uid=$uid AND version=$version_old

此时假设有并发操作,首先操作的请求会修改版本号,并发操作会执行失败。

PS:version通用,本例是强行用version举例而已,实际上本例可以用余额“值”比对。