前几天业务量暴涨,核心交易系统在批量生成批次订单号时,突然报出了大量的 Deadlock found when trying to get lock; try restarting transaction 错误。后台监控显示大量的 Insert 操作超时回滚,MySQL 数据库 CPU 瞬间拉满,应用服务器的线程池也因为事务阻塞被消耗殆尽。
按常理说,我们的业务逻辑仅仅是简单的“判断是否存在 -> 不存在则插入新记录”。在主键不冲突的情况下,多个并发插入操作怎么会演变成严重的死锁事故?
排查完锁日志(SHOW ENGINE INNODB STATUS)后,罪魁祸首终于浮出水面——InnoDB 的**间隙锁(Gap Locks)**和 Next-Key Locks。
在 InnoDB 的默认隔离级别 可重复读(Repeatable Read) 下,为了防止幻读(Phantom Read),查询会使用 Next-Key Locks(记录锁 + 间隙锁)。
当并发事务 A 执行 SELECT ... WHERE order_no = 'TX100' FOR UPDATE 时,如果 order_no 字段上没有索引,或者只命中了非唯一索引,MySQL 就会将对应的区间锁住。如果此时没有找到记录,MySQL 会在对应的“间隙”(Gap)上加共享间隙锁(S-Gap Lock)。
接着,并发事务 B 运行相同的查询,同样因为记录不存在,也成功获取了该间隙的共享间隙锁。
致命的一幕发生在随后:事务 A 尝试执行 INSERT INTO ... 插入这条数据,由于要插入间隙,必须等待事务 B 释放其 S-Gap Lock,因此事务 A 进入阻塞并等待插入意向锁(Insert Intention Lock);几乎同时,事务 B 也执行 INSERT,也要等待事务 A 释放 S-Gap Lock。两个事务互相等待对方释放间隙锁,瞬间触发死锁,MySQL 只能强制回滚其中一个事务。
这是我们当时发生死锁的简化版表结构:
CREATE TABLE `t_order_batch` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`batch_no` varchar(64) NOT NULL COMMENT '批次号',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- 致命伤:只建了普通索引,没有建唯一索引
KEY `idx_batch_no` (`batch_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为了彻底根治这个高并发下的死锁大坑,我们从索引设计和事务粒度上做了双重重构:
- 建立唯一索引:将
batch_no改为唯一索引(UNIQUE KEY)。在唯一索引上进行等值查询且记录不存在时,Next-Key Lock 会退化为单纯的间隙锁,而不会锁住无辜的更大范围。 - 乐观锁/防重表设计:不再使用
SELECT ... FOR UPDATE这种强锁动作,而是直接利用唯一键约束进行INSERT。如果报了唯一键冲突错误(ErrOuterKey / Duplicate entry),再执行更新或直接返回,从心智模型上彻底避开了间隙锁的重叠持有。 - 引入死锁重试机制:即使优化了索引,在复杂的长事务中死锁依然无法 100% 避免。我们在 Go 的 DB 操作层引入了退避重试机制。
以下是重构后的 Go 语言事务安全插入与重试机制代码实现:
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"math/rand"
"strings"
"time"
)
type OrderBatchRepository struct {
db *sql.DB
}
func NewOrderBatchRepository(db *sql.DB) *OrderBatchRepository {
return &OrderBatchRepository{db: db}
}
// SaveBatchWithRetry 带死锁重试的高并发批次保存
func (r *OrderBatchRepository) SaveBatchWithRetry(ctx context.Context, batchNo string) error {
const maxRetries = 3
var err error
for i := 0; i < maxRetries; i++ {
err = r.executeInsertTransaction(ctx, batchNo)
if err == nil {
return nil
}
// 识别 MySQL 死锁错误 (Error 1213: ER_LOCK_DEADLOCK)
if strings.Contains(err.Error(), "1213") || strings.Contains(err.Error(), "deadlock") {
// 渐进式随机退避,打散并发冲突
backoff := time.Duration(50+rand.Intn(100)) * time.Millisecond
fmt.Printf("[DB-Warning] 检测到死锁,正在进行第 %d 次重试,等待 %v...\n", i+1, backoff)
time.Sleep(backoff)
continue
}
// 其他非死锁错误,不需要重试,直接抛出
return err
}
return fmt.Errorf("在 %d 次重试后依然发生死锁失败: %w", maxRetries, err)
}
func (r *OrderBatchRepository) executeInsertTransaction(ctx context.Context, batchNo string) error {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
return err
}
defer tx.Rollback()
// 1. 利用唯一索引直接插入,放弃前置 SELECT FOR UPDATE 的黑盒间隙锁动作
query := "INSERT INTO t_order_batch (batch_no, status) VALUES (?, ?)"
_, err = tx.ExecContext(ctx, query, batchNo, 1)
if err != nil {
// 检查是否为唯一键冲突
if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") {
// 唯一键冲突说明已存在,直接返回,不影响业务
return nil
}
return err
}
// 2. 模拟后续的其他事务操作,以体现事务完整性
updateQuery := "UPDATE t_order_batch SET status = 2 WHERE batch_no = ?"
_, err = tx.ExecContext(ctx, updateQuery, batchNo)
if err != nil {
return err
}
return tx.Commit()
}
改完唯一索引并加上重试机制后,生产环境的死锁警报终于彻底安静了。这次踩坑让我们深切体会到:在 RR 隔离级别下,任何没有走唯一索引的范围或等值锁查询,都是极其容易翻车的隐形炸弹。
你们在遇到类似的并发冲突死锁时,是硬着头皮改隔离级别为 Read Committed (RC),还是像我们一样在业务层死磕唯一索引与重试机制?