支付账户死锁:加锁顺序不一致

0 阅读4分钟

一、前言

背景:近期主站经常出现死锁,一天能发生30+笔,最后导致资金归集失败。

image.png

首先线上问题处理:下游失败可重放,人工补偿 / 自动补偿,保证业务流程正常运行,避免客诉。

排查问题:从死锁日志开始排查

找 DBA 获取对应的死锁日志,直接扔给 ChatGPT 来分析。

通过日志,发现非常关键的线索:

  • SQL 都是 SELECT ... FOR UPDATE
  • 命中索引 idx_bizId(非唯一索引)。
  • 同一个 bizId 下有两条记录(例如 subAccountType=01/02)。
  • 一个事务拿着记录 A 等 B,另一个事务拿着 B 等 A。

产生死锁的高概率根因(按优先级):

  • idx_bizId 选择性不足,导致同 bizId 多行被锁
  • 缺少能覆盖查询条件并保证定位顺序稳定的复合索引
  • 业务并发下对同一 bizId 高频 FOR UPDATE,冲突放大

通过死锁日志排查,可以确定是发生在入账和资金归集并发的场景下:

理清业务流程

1、入账业务

海外电商平台入账:平台收款

转存失败,建议直接上传图片文件

  • 各大银行通过 H2H方式/人工上传,解析入账文件,获得客户的银行入账流水信息
  • 根据店铺信息定期拉取账期数据
  • 根据亚马逊账单和银行流水,符合匹配规则后,进行入账

2、资金归集

店铺托管后,根据用户设置的策略:自动归集还是手动归集。

1、 支持客户开通多币种 “专用账户”,用于托管店铺账户资金;

2、 支持客户发起“店铺托管”,授权其他客户(接收方)托管自己名下绑定的店铺,审批通过后生效;

3、 托管期内,发起方店铺账户资金自动托管至接收方专用账户;

4、 接收方可从专用账户提现至自己的收益账户。

信息流:

image.png 备注:托管时发起方生成交易订单,出账明细;接收方生成入账明细。

  • 托管操作:按发起方费率收取手续费 / 不换汇或者外币间换汇
  • 提现操作:不收取提现手续费,其他费用按需 / 支持换汇
  • 结汇订单:接收方专用进行提现操作时,使用发起方授权托管店铺的订单额度和订单结汇

资金流:

image.png


二、实验复现

数据库:MySQL。

实验准备:表 / 数据

DROP TABLE IF EXISTS wallet;
CREATE TABLE wallet (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  trade_no VARCHAR(40) NOT NULL,
  type VARCHAR(8) NOT NULL COMMENT 'PRE=预入账户, AVL=可用账户',
  amount DECIMAL(18,2) NOT NULL DEFAULT 0,
  KEY idx_trade_no (trade_no)
) ENGINE=InnoDB;

INSERT INTO wallet(trade_no, type, amount) VALUES
('T20260214001', 'AVL', 100.00),
('T20260214001', 'PRE',  50.00);

-- 事务隔离级别:RC读提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

触发顺序:

  1. 会话 A 先执行第 1 条(锁 PRE)
  2. 会话 B 执行第 1 条(锁 AVL)
  3. 会话 A 执行第 2 条(等待)
  4. 会话 B 执行第 2 条(等待)

预期结果:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

image.png

实验结果如下:符合预期。

image.png

-- 事务隔离级别:RC读提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
---------------------------------------
---------------------------------------

---- 会话 A(模拟入账:先 PRE 后 AVL) ----
---------------------------------------
BEGIN;
SELECT * FROM wallet FORCE INDEX(idx_trade_no)
WHERE trade_no='T20260214001' AND type='PRE'
FOR UPDATE;

-- 第二步再执行
SELECT * FROM wallet FORCE INDEX(idx_trade_no)
WHERE trade_no='T20260214001' AND type='AVL'
FOR UPDATE;

---- 会话 B(模拟归集:先 AVL 后 PRE) ----
---------------------------------------
BEGIN;
SELECT * FROM wallet FORCE INDEX(idx_trade_no)
WHERE trade_no='T20260214001' AND type='AVL'
FOR UPDATE;

-- 第二步再执行
SELECT * FROM wallet FORCE INDEX(idx_trade_no)
WHERE trade_no='T20260214001' AND type='PRE'
FOR UPDATE;

三、小结

解决:

  1. 解决一:业务流程理解加强,入账流程,无需对预入账账户加锁。
  2. 解决二:联合索引,将 type 字段也加入索引树中,例如:(clientId, bizId, subAccountType)

Tips:支付业务中,需要注意

  • 扣钱:先扣钱再处理业务
  • 加钱:先处理业务再加钱

小结:

  • InnoDB 的行锁本质是“索引记录锁”,不是脱离索引单独锁行。

  • SQL 走哪个索引,就先锁哪个索引树上的记录:

    • 走主键(聚簇索引)→ 先锁主键索引记录
    • 走二级索引 → 先锁二级索引记录,必要时再回表访问并可能锁主键记录
  • SELECT ... FOR UPDATE / UPDATE / DELETE 属于当前读,会对命中的索引记录加 X 锁。