The source code for this blog is available on GitHub.

Bowen's Blog.

钱包系统: 余额更新

Bowen
Bowen
mysqlpayment

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

高并发扣减思路

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

直接扣减的问题

假设我们有下面这样一个 wallet_tab 表;用来存储用户的余额数据。

-- 用户钱包表
CREATE TABLE `wallet_tab` (
  `wallet_id` bigint(20) unsigned NOT NULL,
  `wallet_type` tinyint(3) unsigned NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `balance` bigint(20) NOT NULL,
  `create_time` bigint(20) unsigned NOT NULL,
  `update_time` bigint(20) unsigned NOT NULL,
  PRIMARY KEY (`wallet_id`),
  UNIQUE KEY `uniq_user_id_wallet_type` (`user_id`, `wallet_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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

SELECT * FROM wallet_tab WHERE uid=$uid;

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

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

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

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

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

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

悲观行锁 - TCC

为了支持TCC,可以稍微调整表结构,示例如下:

-- 用户钱包表
CREATE TABLE `wallet_tab` (
  `wallet_id` bigint(20) unsigned NOT NULL,
  `wallet_type` tinyint(3) unsigned NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `balance` bigint(20) NOT NULL,
  `frozen` bigint(20) NOT NULL, -- 新增字段,存储冻结的金额
  `create_time` bigint(20) unsigned NOT NULL,
  `update_time` bigint(20) unsigned NOT NULL,
  PRIMARY KEY (`wallet_id`),
  UNIQUE KEY `uniq_user_id_wallet_type` (`user_id`, `wallet_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

TCC每一步可基于用户级别并发锁行锁。可以把下面TCC的每一步都放在一个事务里面执行:先根据uid锁定了数据,再执行更新。

// try
SELECT balance FROM wallet_tab WHERE uid=$uid for Update
UPDATE wallet_tab SET balance=$balance-20, frozen=$frozen+20 where wallet_id=$wallet_id;

// confirm
SELECT balance FROM wallet_tab WHERE uid=$uid for Update
UPDATE wallet_tab SET frozen=$frozen-20 where wallet_id=$wallet_id;

// cancel
SELECT balance FROM wallet_tab WHERE uid=$uid for Update
UPDATE wallet_tab SET balance=$balance+20, frozen=$frozen-20 where wallet_id=$wallet_id;;

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

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

乐观锁 - CAS

CAS方案

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

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

UPDATE wallet_tab SET balance=$new_balance 
WHERE  where wallet_id=$wallet_id;

升级为:

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

并发操作发生时:

P1执行:

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

P2执行:

UPDATE wallet_tab SET balance=70 
WHERE wallet_id=$wallet_id 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。新的表结构示例如下:

-- 用户钱包表
CREATE TABLE `wallet_tab` (
  `wallet_id` bigint(20) unsigned NOT NULL,
  `wallet_type` tinyint(3) unsigned NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `balance` bigint(20) NOT NULL,
  `create_time` bigint(20) unsigned NOT NULL,
  `update_time` bigint(20) unsigned NOT NULL,
  `version` bigint(20) unsigned NOT NULL, -- 新增一个版本号
  PRIMARY KEY (`wallet_id`),
  UNIQUE KEY `uniq_user_id_wallet_type` (`user_id`, `wallet_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

如果被其他事务更新到 新的version 了,就 select 新的 balance 和 version 出来,然后基于新 version 做判断,新 balance 做更新。 更新余额时,必须版本号相同,并且版本号要修改。

SELECT balance,version FROM wallet_tab WHERE uid=$uid

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

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

PS:本例是用version举例而已,实际操作时也可以用更新时间戳来对比。