insert SQL并发插入导致的阻塞问题
小轲

在一个正常的业务中,MySQL报错了阻塞。第一反应是死锁,但是发现是一条insert语句,语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO make_card_info (
id,
open_id,
md_order_no,
apollo_user_id,
project_name,
task_type,
task_pass_time,
amount,
app_mark
) VALUES (
1876546101549854799,
'1851452288917311489',
1870999825732784129,
'1859075883175706625',
'模糊处理',
'模糊处理',
'2025-01-07 16:27:09.737',
10,
'mark-cloud'
);

此时有点懵圈,手动执行了下这条SQL,发现确实被阻塞了。思路步骤如下

  1. 分析表结构
  2. 查看MySQL状态
  3. 查看代码,分析调用链

现在咱们挨个分析一下

分析表结构

一般出现这种问题,要么就是ID重复,要么就是索引里面存在唯一索引。ID重复倒是可以排除了,这个表的id是自增的,那么就是唯一索引的问题

image

看完这个表结构,猜想大概率是唯一索引问题,造成阻塞的想必也一定是这个唯一索引。

我的思路是mk_order_no字段难道重复了吗?走了一个这个sql,发现并没有查询出来

1
select * from make_card_info where mk_order_no=1870999825732784129

问题已经出来了,应该是并发插了。这时候去看看MySQL的状态就行了,看看可不可以kill掉它

查看MySQL状态

1
2
3
4
SHOW PROCESSLIST;
#这将显示当前所有连接的状态,包括正在运行的 SQL 语句。
SELECT * FROM information_schema.innodb_trx;
#这个查询将返回当前所有活动的 InnoDB 事务,包括事务 ID、开始时间、状态等信息。

查看代码,分析调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Override
@Transactional
public String uploadChain(MakeCardInfoDTO dto) {
if(dto.getMdOrderNo() == null){
throw new RuntimeException("订单id不能为空");
}
//查看用户是否创建过钱包
UserWalletDTO userWalletDTO = ConvertUtils.sourceToTarget(dto, UserWalletDTO.class);
dto.setApolloUserId(createUserWallet(userWalletDTO).getUserId());

//防重查看制卡任务是否存在
MakeCardInfoEntity makeCardInfoEntity = makeCardInfoDao.selectOne(new QueryWrapper<MakeCardInfoEntity>()
.lambda()
.eq(MakeCardInfoEntity::getMdOrderNo, dto.getMdOrderNo()));
if(makeCardInfoEntity != null){
if(makeCardInfoEntity.getTradeStatus().compareTo(WalletStatusEnum.SUCCESS.getStatus()) == 0){
return makeCardInfoEntity.getTaskId();
}else{
return StringUtils.EMPTY;
}
}

//构建上链请求体
String uploadChainBody = getUploadChainBody(dto);
//先落库
MakeCardInfoEntity entity = ConvertUtils.sourceToTarget(dto, MakeCardInfoEntity.class);
makeCardInfoDao.insert(entity);

//请求上链
JSONObject userWallet = walletRequestInfo.uploadChain(uploadChainBody);
//上链任务ID
String taskId = userWallet.getStr("taskId");
entity.setTaskId(taskId);
entity.setReqBody(uploadChainBody);
makeCardInfoDao.updateById(entity);
return taskId;
}

大概能猜出来了,该方法问题点如下:

  1. 该方法开启了事务,未手动提交,交给spring进行管理
  2. 该方法的执行流程为,先insert拿到id,后http请求一个接口,得到结果后再进行修改
    1. 注意: 该步骤也是在事务下进行的,会有可能出现A事务没提交,B事务又开启的情况
  3. 该接口未做任何幂等性的限制

综上所述,做个复盘,从请求侧确实能看出来两次请求(MQ重试)

也就是A线程在执行未提交事务前的逻辑,已经insert。B线程又进来请求insert,两个线程虽主键不冲突,但有唯一索引限制,A事务无法提交update SQL,B事务则被insert SQL阻塞,故出现死锁。

image

解决方案

kill掉,让业务正常运转

找到阻塞的SQL,kill掉就ok了。kill掉后通知业务方进行重试 and 人工介入处理

trx_id trx_state trx_started trx_requested_lock_id trx_wait_started trx_weight trx_mysql_thread_id trx_query trx_operation_state trx_tables_in_use trx_tables_locked trx_lock_structs trx_lock_memory_bytes trx_rows_locked trx_rows_modified trx_concurrency_tickets trx_isolation_level trx_unique_checks trx_foreign_key_checks trx_last_foreign_key_error trx_adaptive_hash_latched trx_adaptive_hash_timeout trx_is_read_only trx_autocommit_non_locking
19739684

RUNNING

2025-01-07 14:41:15

(NULL) (NULL)
3

269085273

(NULL) (NULL)
0

1

2

1
KILL 269085273;

改造代码

改造代码的落地方式分为多种,简单说下两种比较实用的思路

  1. 给订单号做分布式锁,防止单订单号重复提交,也就是单订单号串行化处理
  2. 这段代码核心就是需要得到insert的id,后修改造成的。 可以改造为不需要数据库返回id,使用雪花等id的分配方式,这样还可以减少一次修改
 评论
评论插件加载失败
正在加载评论插件