前几天业务量暴涨,核心交易系统在批量生成批次订单号时,突然报出了大量的 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;

为了彻底根治这个高并发下的死锁大坑,我们从索引设计和事务粒度上做了双重重构:

  1. 建立唯一索引:将 batch_no 改为唯一索引(UNIQUE KEY)。在唯一索引上进行等值查询且记录不存在时,Next-Key Lock 会退化为单纯的间隙锁,而不会锁住无辜的更大范围。
  2. 乐观锁/防重表设计:不再使用 SELECT ... FOR UPDATE 这种强锁动作,而是直接利用唯一键约束进行 INSERT。如果报了唯一键冲突错误(ErrOuterKey / Duplicate entry),再执行更新或直接返回,从心智模型上彻底避开了间隙锁的重叠持有。
  3. 引入死锁重试机制:即使优化了索引,在复杂的长事务中死锁依然无法 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),还是像我们一样在业务层死磕唯一索引与重试机制?