主题色
250
壁纸模式
壁纸设置
特效设置
固定导航栏
字体选择
文章列表布局
6869 字
18 分钟
金融开发实践:金融并发控制,先守住不变量再谈吞吐

两个请求同时扣同一个账户,单线程测试全部通过,线上却可能把余额扣成负数。两个退款请求各自看到“可退款金额还剩 100”,最后合计退款 160。两个审批请求分别通过了自己的校验,组合起来却突破了授信上限。

这些问题的共同点不是“锁没加好”这么简单,而是并发请求分别看到了一个局部快照,做出了看似正确的决定,却没有共同维护业务不变量。金融开发中的并发控制,目标也不是让所有请求排队,而是让任何合法的并发交错,都不能产生非法的资金状态。

本文是“金融开发实践”系列的第三篇。前一篇讨论了本地事实如何可靠地外发并最终收敛;本文只处理并发这一条线:并发扣款、退款、冻结和解冻如何不超扣、不覆盖、不重复。计算错误、渠道不一致、权限与安全问题仍需要单独的设计和校验。

一、先定义要守住的业务不变量#

并发控制的第一步不是选择 SELECT ... FOR UPDATE 还是 Redis 锁,而是把“什么结果绝对不能出现”写成可验证的不变量。没有不变量,锁只是凭经验堆出来的同步代码。

以账户和支付交易为例,常见不变量包括:

  • 可用余额不能小于零,除非产品明确允许透支,并且透支额度也有独立约束。
  • 账户余额、冻结金额和可用金额之间的关系必须成立,例如“可用 = 余额 - 冻结”。
  • 每个业务请求号最多产生一笔扣款效果;重试不能产生第二笔分录。
  • 一笔原交易的累计退款不能超过可退款金额;部分退款和全额退款都要遵守同一约束。
  • 冻结、解冻、扣减、入账的状态流转必须符合状态机,终态不能被迟到请求任意改回中间态。
  • 借贷分录必须平衡,分录、流水、余额视图和业务状态要在正确的本地事务中一起提交。

这些约束分布在不同层次:

约束层次典型问题主要手段
请求层用户重复点击,同一请求并发到达业务幂等键、唯一约束
行级状态两个请求同时扣同一个账户条件更新、行锁、版本号
聚合级规则多笔退款合计不能超限,多个账户合计不能超授信同一聚合串行化、锁定相关行、串行化事务
账务级规则分录借贷不平、余额与流水漂移本地事务、数据库约束、对账

请求幂等和并发控制不是同一件事。幂等解决“同一业务操作被重复提交”;并发控制解决“不同操作同时修改共享状态”。同一个扣款请求用两个不同请求号重试,幂等键看不出重复,余额条件更新或账户行锁仍然必须阻止超扣。反过来,两个不同订单同时扣款时,版本号可以防止覆盖,却不会判断它们是否属于同一个业务请求。

二、四种典型竞态,以及普通代码为什么会失效#

2.1 丢失更新:最后一次写入覆盖了前一次写入#

最常见的错误是把“读、计算、写”拆成了三个独立动作:

-- 两个事务都先读取到 balance = 100
SELECT balance FROM account WHERE account_id = :id;
-- 请求 A 计算 100 - 80,写入 20
-- 请求 B 也计算 100 - 80,写入 20
UPDATE account SET balance = :new_balance WHERE account_id = :id;

两个请求都返回成功,账户只减少了 80,而业务上实际接受了两笔 80 的扣款。若后续分录各自写入,余额和分录合计会漂移。

2.2 超扣或超卖:校验和扣减之间被插入了另一个请求#

即使把代码写成“先判断余额,再更新”,只要判断和更新不是同一个原子操作,就存在窗口:

T1: 读取余额 100,判断 100 >= 80
T2: 读取余额 100,判断 100 >= 80
T1: 扣减 80
T2: 扣减 80

库存、额度、优惠券和可退款金额都具有同样的形态。它们不是“库存服务”或“账户服务”的特例,而是一个共享数量被并发消耗的问题。

2.3 写偏差:每个事务更新不同的行,但组合结果违反规则#

写偏差比丢失更新更隐蔽。假设一个商户必须始终保留至少一名值班审批人,两个事务分别读取到“还有两名值班人”,然后各自把不同的人设为休假:

T1: 读取 Alice、Bob 都值班,更新 Alice 为休假
T2: 读取 Alice、Bob 都值班,更新 Bob 为休假
T1、T2: 都提交
结果:没有值班人

支付领域的例子包括“账户余额和冻结表分开存储但总额不能为负”“多个子账户共享同一个授信额度”“多笔退款的累计上限”。每个事务可能只更新一行,却共同破坏了跨行约束。单行版本号不够,需要锁定整个业务聚合、使用约束可表达的数据库检查,或在更高隔离级别下让其中一个事务失败。

2.4 乱序与重复:不是锁能单独解决的问题#

扣款成功回调和退款请求可能以任意顺序到达。即使每次更新都拿到了正确的行锁,状态机如果允许“退款成功后又回到扣款处理中”,仍然会产生错误状态。重复回调也可能来自消息重投或调用方超时,它需要业务幂等键和合法状态转移,而不是无限加锁。

三、先用数据库原子操作:最小范围、最容易验证#

当不变量只涉及一行时,优先把判断和变更合并成一条带条件的 UPDATE。数据库在执行这条语句时负责竞争同一行,应用只根据影响行数判断结果。

3.1 并发扣款:条件更新和流水同事务#

BEGIN;
-- 先占位,重复 request_id 直接走已有结果
INSERT INTO debit_request (request_id, account_id, amount, status)
VALUES (:request_id, :account_id, :amount, 'PROCESSING')
ON DUPLICATE KEY UPDATE request_id = request_id;
-- 只有余额足够的请求才能成功扣减
UPDATE account
SET balance = balance - :amount,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = :account_id
AND balance >= :amount;
-- affected_rows = 1 才能插入扣款分录
INSERT INTO ledger_entry (request_id, account_id, amount, direction)
SELECT :request_id, :account_id, :amount, 'DEBIT'
WHERE ROW_COUNT() = 1;
COMMIT;

这段示例表达的是约束关系,不是可以直接复制到所有数据库的模板。生产代码需要明确处理以下情况:

  • 幂等占位已存在时,读取原请求状态和结果,不要再次执行扣减。
  • 条件更新影响行数为零时,区分账户不存在、余额不足和版本冲突。
  • 扣减、分录、请求状态在同一个本地事务内提交;任何一步失败都回滚。
  • 不要先在应用内读取余额再把计算后的新值写回去,余额计算应由数据库基于当前值完成。

退款、解冻和额度扣减也可以用同样的模式:

UPDATE payment
SET refundable_amount = refundable_amount - :refund_amount,
version = version + 1
WHERE payment_id = :payment_id
AND refundable_amount >= :refund_amount
AND status IN ('PAID', 'PARTIALLY_REFUNDED');

条件更新的优点是事务短、锁范围小、吞吐稳定,且失败结果容易通过影响行数判断。它的边界是:一条语句表达不了复杂的跨行规则,或者需要先读取多张表才能计算决策时,就要使用显式锁、版本检查或更高隔离级别。

3.2 唯一约束是并发防线,不只是数据整洁#

业务唯一键应在数据库中建立唯一索引,例如 debit_request(request_id)、refund_request(refund_id) 和 ledger_entry(request_id, direction)。应用层的“先查再插”有竞态,两个请求都可能查到不存在;唯一索引把最后的并发判断交给持久化层。

唯一键要表达业务语义。只对消息 id 去重,无法识别同一笔退款被重新封装成另一条消息;只对账户和金额做唯一约束,又可能把两个合法的同额交易误判为重复。应先定义业务操作的身份,再选择唯一键。

四、悲观锁:需要读取后作决定时锁住业务行#

当一个事务要读取当前余额、冻结金额、状态和额度,再根据这些值执行多步更新,可以在事务中使用行级排他锁:

BEGIN;
SELECT account_id, balance, frozen_amount, status
FROM account
WHERE account_id = :account_id
FOR UPDATE;
-- 在持锁期间检查余额、状态和业务规则
UPDATE account
SET balance = balance - :amount,
frozen_amount = frozen_amount + :amount
WHERE account_id = :account_id;
INSERT INTO ledger_entry (...);
COMMIT;

SELECT ... FOR UPDATE 的关键不是“查出来的数据更新”,而是让其他事务在同一行上等待,直到当前事务提交或回滚。锁必须包住完整的本地决策和写入过程,但不能包住 HTTP、RPC、文件或人工等待。远程调用放在锁内,会把网络抖动放大成锁等待和连接池耗尽。

行锁有效的前提是存储引擎支持行级锁、查询走到预期索引,并且事务真正持有锁到提交。以 InnoDB 为例,官方文档说明锁定读、UPDATE 和 DELETE 会锁住执行计划扫描到的索引记录;缺少合适索引时,扫描范围扩大,锁冲突和等待也会扩大。参见 MySQL InnoDB 锁定模型 和 不同语句设置的锁。

4.1 多行锁的顺序必须固定#

转账、账户合并和跨账户冻结经常要锁多行。所有代码路径必须按同一顺序获取锁,例如按 account_id 排序后依次锁定:

转出账户 ID = 10,转入账户 ID = 20
所有流程都先锁 10,再锁 20

如果一条路径先锁 10 后锁 20,另一条路径先锁 20 后锁 10,就形成经典的 ABBA 死锁。数据库发现死锁后会回滚其中一个事务,应用必须把整个事务重新执行,而不是只重发最后一条 SQL。

4.2 SKIP LOCKED 是调度工具,不是资金正确性保证#

定时任务领取待处理记录时,可以用 FOR UPDATE SKIP LOCKED 让多个 worker 分片处理,避免互相等待。这适合任务队列和补偿扫描,但不适合用来“跳过”一个正在被处理的账户扣款。资金操作如果跳过锁,只能把记录留给下一轮任务,不能把未确认的状态当成成功。

五、乐观锁:冲突可接受时用版本号拒绝覆盖#

乐观锁不预先阻塞其他事务,而是在更新时带上读取到的版本号:

-- 读取 account.balance = 100, version = 7
UPDATE account
SET balance = balance - :amount,
version = version + 1
WHERE account_id = :account_id
AND version = :expected_version
AND balance >= :amount;

影响行数为 1 表示版本仍然匹配,更新成功;为 0 表示余额不足、记录不存在或已经被其他请求修改。应用应重新读取最新状态,重新计算并决定是否重试。不能把旧对象直接再次保存,也不能无条件把“版本冲突”当成业务失败。

乐观锁适合读多写少、冲突率低、允许一次请求返回“请重试”的场景,例如修改交易备注、推进低竞争状态、更新聚合版本。热点账户每秒有大量扣款时,所有请求都争同一个版本号,冲突重试会放大数据库和 CPU 压力,此时应考虑串行化或队列化。

版本号只能防止同一行的覆盖,不能自动解决以下问题:

  • 两个不同请求都通过了各自的业务幂等检查,但合计金额超过额度。
  • 账户表更新成功,分录表写入失败,跨表事实不一致。
  • 外部渠道已经扣款,本地版本冲突后重试又发起一次扣款。

因此,乐观锁通常要和业务幂等、条件约束、本地事务以及外部结果查询一起使用。

六、事务隔离级别:隔离的是观察和写入,不是业务语义#

数据库隔离级别描述并发事务如何观察彼此的读写。它不能替代业务状态机,也不会自动知道“累计退款不能超过原交易金额”。

隔离级别主要保证仍需警惕金融代码中的用法
READ UNCOMMITTED允许读到未提交数据脏读、不可重复读、幻读不用于资金决策
READ COMMITTED只读已提交版本同一事务两次普通读取可能不同,写偏差仍可能发生许多 OLTP 的默认选择,配合条件更新或显式锁
REPEATABLE READ同一事务普通读取保持快照快照读不是锁定读,跨行约束仍需显式设计MySQL InnoDB 默认级别,不能据此认为所有读都已串行化
SERIALIZABLE让可冲突事务表现得像串行执行锁等待、冲突和吞吐成本更高小范围、高价值、确实需要串行语义的事务

不同数据库的实现细节不同。MySQL InnoDB 的普通 SELECT 通常是非锁定一致性读,而 SELECT ... FOR UPDATE 属于当前读;PostgreSQL 使用 MVCC,并在 SERIALIZABLE 下通过可串行化冲突检测让事务失败重试。选择隔离级别时应阅读具体引擎文档,而不是把同一个名称当成跨数据库的完全相同承诺。

最容易犯的错误是把普通读取当成“已经锁住”:

BEGIN;
-- 普通 SELECT 读到快照,不阻止另一个事务修改
SELECT balance FROM account WHERE account_id = :id;
-- 应改为条件更新,或者在需要多步决策时使用 FOR UPDATE
UPDATE account SET balance = balance - :amount
WHERE account_id = :id AND balance >= :amount;
COMMIT;

对于只涉及一行的扣减,条件更新比提高整个事务隔离级别更精准。对于跨行的约束,提高隔离级别也不一定足够,仍需明确锁定哪些行或让数据库约束表达不变量。

七、分布式锁的边界:协调入口,不证明资金正确#

服务扩容后,同一个账户可能被不同节点同时处理,团队往往会先想到 Redis、ZooKeeper 或 etcd 分布式锁。分布式锁可以减少同一逻辑资源的并发进入,但它不是数据库事务,也不是外部渠道的幂等保证。

7.1 数据库拥有事实时,优先用数据库并发控制#

如果账户余额和分录都在同一个数据库中,数据库行锁、条件更新和唯一约束距离事实最近,故障时也由数据库事务统一回滚。额外加一把 Redis 锁,会增加锁服务、租约、时钟、网络分区和释放异常等新的失败面。

分布式锁更适合以下边界:

  • 多个服务实例要协调一个不适合直接落在同一数据库行上的调度任务。
  • 需要在访问外部非幂等资源前减少并发入口,但外部调用仍有自己的查询、幂等和补偿方案。
  • 选择一个短时、可丢失、丢失后不会破坏资金事实的互斥区。

7.2 Redis 锁至少要有令牌、租约和失效预案#

单实例 Redis 常见的加锁形式是带随机令牌的 SET key token NX PX ttl,释放时用脚本比较令牌,只有持有者才能删除。不能直接 DEL key,否则旧持有者超时后可能删除新持有者的锁。

租约会带来一个不可忽略的边界:持锁进程暂停、网络延迟或 GC 超过 ttl 后,另一个进程可以获得新锁,旧进程却可能继续执行。锁的“互斥”因此不能作为最后正确性证明。需要强一致资源时,可以把单调递增的 fencing token 一并传给资源端,由资源端拒绝过期 token;如果资源端没有这种校验,就必须依靠数据库条件更新和外部幂等。

Redis 官方的分布式锁文档讨论了互斥、释放和故障容忍边界。文档中的算法不能自动覆盖业务事务、数据库提交和支付渠道效果,接入前应先写清楚锁丢失、续租失败和节点分区时的行为。

7.3 锁的键、范围和等待策略要可审计#

锁键应对应明确的业务聚合,例如 account:{account_id},不要用“整个支付服务”这样过粗的锁。锁持有时间只覆盖需要互斥的短逻辑;获取不到锁时应快速失败、排队或由补偿任务重试,不能无限等待占满线程池。

无论是否使用分布式锁,数据库仍应保留唯一约束、条件更新或版本号。锁是降低冲突的优化层,数据库约束才是事实边界。

八、热点账户:正确的串行化可能成为吞吐瓶颈#

单账户余额天然是一个串行化点。一把账户行锁可以保证正确,但一个高流量商户、平台总账户或结算账户会让大量请求排队。把锁换成更大的分布式锁只会扩大排队范围,不会提高可安全处理的资金并发度。

热点场景要先确认业务是否真的允许拆分:

  • 按账户建立串行队列或 Actor:同一账户的扣款、退款、冻结按顺序处理,不同账户并行。队列消息仍需幂等,消费者崩溃后可从持久化日志恢复。
  • 缩短数据库临界区:在进入事务前完成参数校验、风控和路由;事务内只做锁定、条件判断、分录和状态更新。
  • 拆分可独立结算的子账户:只有当产品和账务规则允许子账户独立核算时才可拆分,不能把一个共享余额机械切成多个桶。
  • 追加分录、异步汇总余额:适合只追加事实、不需要每次请求都读取一个强一致余额的场景。若扣款必须实时检查可用余额,最终汇总不能替代授权时的单一事实源。
  • 分片并不能消除单聚合串行点:按用户、商户或账户分片能提高整体吞吐,但同一个热点账户仍需要一个有序的决策点。

热点账户应监控锁等待、事务排队、版本冲突、队列延迟和单键流量。盲目增加重试次数,往往只是把一个可见的排队问题变成数据库重试风暴。

九、死锁、锁等待与重试:重试整个决策,不是重放扣款#

死锁不是“数据库坏了”,而是并发事务的锁依赖形成了环。InnoDB 默认会检测死锁并回滚其中一个事务;官方建议统一多行操作的锁顺序、缩短事务、为条件查询建立合适索引,并让应用处理被回滚的事务。InnoDB 死锁文档还说明,死锁与读取隔离级别不是同一个问题,写入顺序和锁范围才是主要因素。

应用应区分可重试错误和不可重试错误:

错误是否重试正确动作
死锁、序列化失败有限重试回滚完整事务,重新读取、重新计算、重新提交
短暂锁等待超时视负载有限重试缩短批次、检查锁顺序和索引,带退避
余额不足、状态不允许不重试返回业务结果或进入人工、补偿流程
唯一键冲突读取已有业务结果按幂等语义返回,不能再次执行副作用
外部调用超时不直接重发不可逆动作标记未知,查询外部状态后决定

重试必须带指数退避、随机抖动和上限,并记录原始请求号。每次重试都要重新开启事务和读取当前状态,不能复用已失效的 ORM 对象或在旧快照上继续计算。

特别要避免这种组合:事务持有账户行锁,调用外部支付接口,超时后框架自动重试。第一次调用可能已经成功,第二次调用却变成重复扣款。外部调用应在本地事务提交后由可恢复状态驱动,并使用渠道幂等键或主动查询结果。

十、测试和可观测性:并发正确性必须被主动制造#

并发 bug 依赖时序,普通的“调用接口两次”测试无法覆盖。测试应直接验证不变量,而不是只验证 HTTP 返回码。

10.1 最小测试矩阵#

  • 两个并发扣款,余额足够一笔、不足两笔,最终只能成功一笔。
  • 两个并发退款,累计金额不能超过可退款金额。
  • 扣款与退款、冻结与解冻并发,状态机不能出现非法逆转。
  • 同一请求号并发提交多次,只产生一条资金效果和一组分录。
  • 多账户转账以相反参数并发执行,不产生永久死锁;被回滚的事务可以有限重试。
  • 事务在余额更新后、分录写入前、提交后响应前分别崩溃,重试和对账都能恢复。
  • 数据库主从切换、连接断开、锁等待超时和序列化冲突下,结果仍满足不变量。

测试可以使用屏障让多个 worker 在“读取完成、准备写入”处同时放行,稳定复现丢失更新和写偏差。压测应按账户热点分布建模,均匀随机账户的结果不能代表单热点商户。

10.2 线上指标#

并发控制需要同时观测正确性和容量:

  • 余额不足、条件更新影响行数为零、版本冲突和唯一键冲突的数量与比例。
  • 行锁等待时间、锁等待超时、死锁次数、事务重试次数和最终失败数。
  • 同一账户的请求排队时间、队列积压、热点键 Top N。
  • 中间态超时、重复回调、未知结果、对账差异和负余额告警。
  • 事务持续时间、连接池占用、数据库 CPU、慢查询和被扫描的行数。

日志必须带业务请求号、账户号或聚合号、事务尝试次数、数据库错误码和 TraceID。对于金额,记录币种和最小单位,不要只记录格式化后的展示字符串。

十一、上线前 Checklist#

业务和模型#

  • 余额、冻结、可用金额、累计退款和授信额度的不变量是否写成公式或断言?
  • 扣款、退款、冻结、解冻和冲正的状态转移是否显式枚举?
  • 同一业务操作的幂等键是什么?不同业务操作是否可能误用同一个键?
  • 账户、分录、流水和请求状态是否在正确的本地事务内保持一致?

数据库和事务#

  • 能否用一条条件更新表达约束?不能时,锁定的行集合是什么?
  • SELECT ... FOR UPDATE 是否使用唯一键或合适索引?是否可能扫描和锁住大范围记录?
  • 多行操作是否有统一锁顺序?
  • 事务内是否包含 RPC、HTTP、消息等待、文件操作或长时间计算?
  • 当前数据库的隔离级别、普通读和锁定读语义是否被代码明确使用?
  • 死锁、锁等待超时和序列化失败是否会回滚并重试完整事务?

分布式协调#

  • 分布式锁失效、续租失败、进程暂停和网络分区时,数据库是否仍能拒绝过期写入?
  • 锁是否只作为协调优化,数据库唯一约束和条件写入是否仍然存在?
  • 热点账户是否有串行队列、限流或隔离方案?重试是否有上限和抖动?
  • 外部调用是否使用幂等键,并能查询未知结果?

测试和运行#

  • 是否测试了并发扣款、并发退款、写偏差、乱序回调和崩溃恢复?
  • 是否监控锁等待、死锁、版本冲突、重试风暴、热点账户和对账差异?
  • 是否有负余额、借贷不平、重复分录和状态逆转的自动告警?
  • 发生差异后,查询、冻结、冲正、补差和人工复核流程是否可执行?

十二、结语:并发控制守住的是不变量#

金融并发控制不是“给方法加一个锁”。它是一条从业务不变量到数据库写入、从事务边界到运行监控的完整链路:

明确不变量
-> 让数据库原子地判断和更新
-> 用幂等键区分重复请求
-> 用行锁、版本号或串行队列控制竞态
-> 用一致的锁顺序和有限重试处理冲突
-> 用对账和告警验证结果

条件更新、行锁和乐观锁解决的是本地共享状态的竞态;分布式锁只能协调进入顺序,不能证明资金效果;幂等解决的是重复业务,不是所有并发问题。越接近资金事实的约束,越应该放在数据库和账务模型里,而不是寄托在缓存锁、线程调度或一次成功的接口调用上。

上一篇分布式一致性文章解决“事件怎样可靠到达并最终收敛”;本篇解决“多个请求同时到达时,谁有资格改变事实”。两者叠加后,仍然需要独立的金额计算、外部渠道对账和权限安全防线,才能把资损风险控制在可发现、可限制、可修复的范围内。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

赞助
金融开发实践:金融并发控制,先守住不变量再谈吞吐
https://blog.souloss.cn/posts/financial-engineering/financial-concurrency-control/
作者
Tsukimi
发布于
2026-09-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时