一、前言
背景:近期主站经常出现死锁,一天能发生30+笔,最后导致资金归集失败。
首先线上问题处理:下游失败可重放,人工补偿 / 自动补偿,保证业务流程正常运行,避免客诉。
排查问题:从死锁日志开始排查
找 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、 接收方可从专用账户提现至自己的收益账户。
信息流:
备注:托管时发起方生成交易订单,出账明细;接收方生成入账明细。
- 托管操作:按发起方费率收取手续费 / 不换汇或者外币间换汇
- 提现操作:不收取提现手续费,其他费用按需 / 支持换汇
- 结汇订单:接收方专用进行提现操作时,使用发起方授权托管店铺的订单额度和订单结汇
资金流:
二、实验复现
数据库: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;
触发顺序:
- 会话 A 先执行第 1 条(锁 PRE)
- 会话 B 执行第 1 条(锁 AVL)
- 会话 A 执行第 2 条(等待)
- 会话 B 执行第 2 条(等待)
预期结果:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
实验结果如下:符合预期。
-- 事务隔离级别: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;
三、小结
解决:
- 解决一:业务流程理解加强,入账流程,无需对预入账账户加锁。
- 解决二:联合索引,将 type 字段也加入索引树中,例如:
(clientId, bizId, subAccountType)
Tips:支付业务中,需要注意
- 扣钱:先扣钱再处理业务
- 加钱:先处理业务再加钱
小结:
-
InnoDB 的行锁本质是“索引记录锁”,不是脱离索引单独锁行。
-
SQL 走哪个索引,就先锁哪个索引树上的记录:
- 走主键(聚簇索引)→ 先锁主键索引记录
- 走二级索引 → 先锁二级索引记录,必要时再回表访问并可能锁主键记录
-
SELECT ... FOR UPDATE / UPDATE / DELETE属于当前读,会对命中的索引记录加 X 锁。