余额为什么会被扣两次?交易系统里的并发、幂等与账本

用户点了一次下单,服务端却可能处理两次。更棘手的是:接口超时了,钱到底有没有扣?

在虚拟资产交易系统里,余额扣减看起来只是一条 UPDATE。真正的难点,是让重试、并发下单、部分成交和撤单发生之后,资金仍然能对得上。

我理解这类系统时,会先暂时放下微服务拆分图,追问三件事:哪一笔业务改变了余额?这次改变能否重复执行?进程在中间退出之后,下一次请求凭什么知道结果?

先把“不允许发生什么”写清楚

余额设计至少需要守住几个不变量:可用余额不能为负;冻结余额不能为负;同一业务动作只能生效一次;余额变化必须能追溯到流水。

这里的“同一业务动作”比订单号更细。一个订单可能多次部分成交,之后还会撤销剩余数量。因此,下单冻结、每笔成交结算、剩余冻结释放,需要不同的业务事件 ID,不能共用一个“订单已处理”的标记。

币种之间也不能直接求和。BTC 的一笔减少和 USDT 的一笔增加,并不能证明账平。核对必须按资产分别进行:同一资产的借贷分录平衡,账户的可用与冻结变化可以和流水推导出的余额对齐,手续费有明确的归属账户。

一把 Redisson 锁挡不住所有重复扣款

分布式锁可以减少同一账户的并发竞争,但它不是资金正确性的最终依据。线程长时间停顿、锁租约失效、业务从其他入口写入,都可能让“我已经拿到锁”的假设失效。

更隐蔽的问题是事务提交时机。一个带 @Transactional 的方法,在方法体的 finally 中释放 Redis 锁之后,外层事务代理才可能执行提交。此时另一请求拿到锁,读到的仍可能是旧数据。

因此,锁适合放在入口做协作和限流,最终约束仍要落在数据库:业务唯一键、条件更新、事务内的流水写入。先保证不用 Redis 锁也不会扣成负数,再考虑锁能否减少冲突。

Spring 默认代理模式也有明确边界:同一个对象内部调用自己的事务方法,不会自动经过事务代理。写了注解并不等于事务已经生效,必须通过回滚用例验证。Spring 事务文档

冻结余额,把判断与写入放在一起

限价买单首先预留报价资产,卖单预留基础资产。买单还要明确手续费预留规则;价格改善、部分成交和撤单后,按原冻结记录释放剩余金额,不能拿当前价格反推。

下面是单库、InnoDB 场景的事务核心。balance 的主键为 (tenant_id, account_id, asset),balance_operation 对 (tenant_id, business_id) 建立唯一约束。所有参数都由服务端校验并使用预编译绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
START TRANSACTION;

INSERT INTO balance_operation
(tenant_id, business_id, account_id, asset, amount, payload_hash)
VALUES
(:tenant_id, :business_id, :account_id, :asset, :amount, :payload_hash);

UPDATE balance
SET available = available - :amount,
frozen = frozen + :amount,
version = version + 1
WHERE tenant_id = :tenant_id
AND account_id = :account_id
AND asset = :asset
AND available >= :amount;

-- 应用必须检查上一步恰好影响 1 行,否则回滚整个事务。
INSERT INTO balance_movement
(tenant_id, business_id, available_delta, frozen_delta)
VALUES
(:tenant_id, :business_id, -:amount, :amount);

COMMIT;

这不是一段可以忽略执行结果、直接批量发送的 SQL。插入业务键冲突时,必须终止并回滚当前事务,再通过新事务查询已提交操作:账户、资产、金额和规范化参数摘要都一致,才返回原结果;同一个键搭配不同参数,应返回冲突。其他数据库异常不能误判成“已经成功”。

条件更新解决“先查余额再扣款”的竞争窗口。若账户不存在、资产不匹配或余额不足,影响行数都不会是 1,后续流水不能继续提交。金额必须预先验证为正数,精度和最大值也必须受限,否则负数会反向增加可用余额。

balance_movement 记录账户内可用与冻结的变化,不等于一套完整的会计账本。成交结算还需要按资产生成各方与手续费账户的对应分录。涉及多个余额行时固定加锁顺序,遇到死锁回滚后以同一业务键进行有界重试。MySQL 锁定读

精度要在进入数据库前确定

法币可以用最小单位整数表示,虚拟资产则需要按币种定义数量精度、价格步长和金额上限。一个统一的“保留两位小数”规则不适用所有资产;整数表示还要检查乘法和累加是否溢出。

Java 使用十进制字符串构造 BigDecimal。下面这个入口校验拒绝超出资产精度的输入,不会默默截断用户金额:

1
2
3
4
5
6
7
8
static BigDecimal positiveAmount(String raw, int scale, BigDecimal maximum) {
BigDecimal value = new BigDecimal(raw)
.setScale(scale, RoundingMode.UNNECESSARY);
if (value.signum() <= 0 || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException("金额超出允许范围");
}
return value;
}

scale 和 maximum 来自服务端资产配置;入口还应限制字符串长度。成交额和手续费计算可能需要舍入,但应使用明确、独立的规则,并记录规则版本。把舍入散落在前端、服务端和数据库里,最后对账时很难解释那一点差额。

超时后的状态,不能靠猜

请求超时只说明调用方没有拿到结果。事务可能没有提交,也可能已经提交但响应丢失。客户端必须保留原业务键查询或重试;换一个新键,相当于发起新的资金操作。

链上充值和提现又多了一层外部状态。广播成功不等于最终到账,节点返回超时也不等于没有广播。提现记录需要关联网络、资产、原始业务请求和交易标识,进入待查询状态后先核对链上结果,不立即重新扣款或构造第二笔转账。充值的确认数与重组处理则需要按具体链的最终性策略设计。

这些动作不能放在长时间持有数据库行锁的事务里。数据库提交的是业务状态及待办事件,外部动作由执行器完成,再凭稳定标识推进状态。

优化应当围绕争用,而不是绕过账本

资金热点往往集中在少数账户。增加应用实例,并不会让同一个余额行同时安全地执行更多写入。先缩短事务、移出网络调用、统一加锁顺序,再观察锁等待和死锁频率。

撮合可以按交易对组织有序处理,但一个账户可能同时参与多个交易对,账户余额仍然需要独立的并发约束。行情、历史委托和账户首页可以走异步读模型;下单时是否有足够资金,必须回到权威写入路径判断。

验证时,我会保留下面这些故障点,而不只看正常下单的 QPS:

注入条件 必须观察到的结果
同一业务键并发提交 只生成一次余额变化,其他请求读取同一结果
两个不同订单竞争不足的余额 允许其中一个成功,不能出现负余额
更新余额后、写流水前抛异常 余额和操作记录一起回滚
提交完成后断开响应连接 使用原键重试不会再次冻结
部分成交与撤单竞争 已结算金额不被再次释放,冻结余额不为负

交易系统的技术深度,最终体现在每一个异常出口是否仍能说明:这笔钱为什么在这里,下一步可以做什么。

延伸阅读:支付回调与 RabbitMQ 的可靠处理。