支付系统: 余额更新
支付系统的一大挑战:高并发下怎么做余额扣减;并发扣款,如何保证数据的一致性?
高并发扣减思路
- 分库分表
- 合并请求,在保证事务的前提下,将多个扣款请求合并操作,这样只需要做一次锁操作和写操作。
- 合并记账后余额不足的怎么处理,可能拆分时有些还能成功?
- 拆分账户,将热点账户的余额账户拆分成多个子余额账户,以此来降低单个账户扣减操作的并发度。
- 多个账户如何协同管理?
- 使用内存数据库扣减,并异步写日志,所有日志结果可以回溯账户余额结果,和内存数据库做对账。
- 最终还是会碰到热点账户问题,当然效率比起数据库来说要好很多了
- 怎么保证数据最终一致?
直接扣减的问题
直接扣减的方法来进行余额扣减:
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举例而已,实际上本例可以用余额“值”比对。或者更新时间戳对比。