Skip to content

秒杀期间唯一索引冲突导致数据库连接池打满的雪崩事故复盘

事故现象

秒杀活动期间,服务突然出现大量超时,健康检查接口无响应,Kubernetes 探针连续失败,Pod 被 Kill 后重启,重启后再次被 Kill,形成循环重启。

云监控同时告警:实例 market_online 的 MDL 锁数量从阈值 20 飙升到 75,持续触发。

第一反应是「服务崩了」,但真正的问题出在数据库层。

一句话根因

高并发秒杀请求同时 INSERT 相同的唯一键(如订单号、用户+商品维度的防重字段),InnoDB 的唯一性检查触发行锁升级,大量事务在锁等待队列中死等。

由于 innodb_rollback_on_timeout = OFF,锁超时后只回滚单条语句而非整个事务,导致连接长期不释放,连接池打满,健康检查线程也拿不到连接,最终容器被 Kill。

实例关键参数

以下是华为云 RDS MySQL 实例中与本次事故直接相关的参数:

参数当前值说明
transaction_isolationREAD-COMMITTEDRC 隔离级别,锁行为与 RR 不同
innodb_lock_wait_timeout5 秒行锁等待超时
lock_wait_timeout31536000 秒(约 1 年)MDL 锁等待超时,极端长
innodb_rollback_on_timeoutOFF锁超时时只回滚单条语句,事务不回滚
max_connections8000最大连接数
innodb_flush_log_at_trx_commit1每次事务提交刷盘
innodb_buffer_pool_size约 9.6 GBInnoDB 缓冲池
threadpool_enabledON线程池开启
wait_timeout28800 秒(8 小时)空闲连接超时

完整雪崩链路

① 秒杀请求涌入,大量 INSERT 并发写入同一张表

② 多个请求 INSERT 相同唯一键值,InnoDB 触发唯一性检查

③ 行锁升级:后到的事务被阻塞,进入锁等待队列

④ innodb_lock_wait_timeout = 5 秒到期
   但 innodb_rollback_on_timeout = OFF
   只回滚了单条 INSERT 语句,事务整体未回滚
   连接依然被事务持有,不归还连接池

⑤ 新请求持续涌入 → 新连接申请 MDL 锁 → MDL 锁堆积到 75+
   lock_wait_timeout = 1 年,MDL 锁几乎永远不会超时释放

⑥ 连接池打满 → 健康检查拿不到连接 → 探针失败 → 容器 Kill

下面逐一拆解关键机制。

关键机制拆解

一、RC 隔离级别下的锁行为

当前实例的事务隔离级别是 READ-COMMITTED(RC),与默认的 REPEATABLE-READ(RR)在锁机制上有本质区别。

特性READ-COMMITTEDREPEATABLE-READ
Gap Lock不使用(除外键和唯一键检查)使用
Next-Key Lock大部分场景退化为 Record Lock完整的 Next-Key Lock
幻读允许防止
唯一键冲突锁行为仍需加锁等待,但范围更小加锁范围更大

在 RC 级别下,INSERT 遇到唯一键冲突时:

  • 如果冲突行已经 COMMIT:直接返回 Duplicate key 错误,不长时间等待
  • 如果冲突行尚未 COMMIT:仍然需要加锁等待,等待前面事务释放锁

秒杀场景下,大量并发请求几乎同时到达,第一个 INSERT 成功但还没来得及 COMMIT,后续的 INSERT 发现冲突,被迫进入等待队列。

二、innodb_rollback_on_timeout = OFF 的致命影响

这是本次事故中最关键的一个参数配置。

innodb_lock_wait_timeout = 5 秒到期时,等待行锁的事务会收到 Lock wait exceeded 错误。但 innodb_rollback_on_timeout = OFF 意味着只回滚当前语句,事务继续存活:

配置超时后的行为对连接池的影响
innodb_rollback_on_timeout = ON整个事务回滚,连接释放连接快速归还,影响可控
innodb_rollback_on_timeout = OFF只回滚当前语句,事务继续存活连接不释放,继续占用连接池
sql
-- 事务 T1 的实际执行过程(innodb_rollback_on_timeout = OFF 时)
BEGIN;
INSERT INTO orders ... ;   -- 这条语句等了 5 秒后超时,语句回滚
UPDATE stock ... ;         -- 这条语句还能继续执行
COMMIT;                    -- 事务继续占用连接,直到自行提交或回滚

超时后事务没有结束,连接没有归还,而事务内可能还有后续操作在继续。一个「卡了 5 秒超时」的连接,实际上还能再占几分钟甚至更久。

连接池被占满的本质:不是 5 秒等超时的问题,而是超时后连接仍然不释放的问题。

三、lock_wait_timeout = 1 年的 MDL 锁陷阱

这是监控告警中 MDL 锁数量从 20 飙升到 75 的直接原因。

lock_wait_timeout 控制获取元数据锁(MDL Lock)的超时时间,当前值是 31536000 秒(约 365 天)。

MDL 锁的雪崩逻辑:

  1. 每条 INSERT 在执行时,都会在 Server 层申请该表的 MDL 共享读锁(SHARED_WRITE)
  2. MDL 锁必须等到事务提交或回滚才释放
  3. 由于 innodb_rollback_on_timeout = OFF,事务在行锁超时后不回滚,继续持有 MDL 读锁
  4. lock_wait_timeout = 1 年 意味着即使有 DDL 操作想申请 MDL 写锁,它会排队一年都不会超时退出
  5. 新的 INSERT 请求继续涌入,每个新请求申请一个新的 MDL 锁

旧的不释放,新的不停叠加,监控中 MDL 锁数量持续攀升。

更致命的衍生场景:如果在 MDL 锁堆积期间,有人执行了 DDL 操作(加字段、加索引、甚至工具自动查表结构):

  • DDL 申请 MDL 排他写锁,排在几百个 MDL 读锁后面
  • MySQL 的 MDL 队列写锁优先级高于读锁
  • 一旦 DDL 排队,后续所有正常的 SELECT 和 INSERT 全部被阻塞在 DDL 后面
  • lock_wait_timeout = 1 年 意味着这个队列永远不会自己解除

全表瞬间物理封锁,数据库彻底不可用。

四、为什么 max_connections = 8000 也没能救场

8000 个连接看似很多,但秒杀期间的连接消耗模式决定了它不堪一击:

  • 秒杀 QPS 可能在短时间内达到几千甚至上万
  • 每个请求至少占用一个数据库连接
  • 连接被行锁卡住 5 秒,超时后事务不结束,连接继续占用
  • 应用侧连接池(如 HikariCP)通常配 50~200 个连接,这才是真正的瓶颈
  • 8000 是数据库侧最大值,但应用侧连接池先打满

五、innodb_flush_log_at_trx_commit = 1 的额外影响

当前值为 1,表示每次事务提交时都要把 redo log 从缓存写入文件并刷盘,这是最严格的持久性保证,但也是最慢的配置。

在秒杀高并发场景下,每个事务的提交操作都要等待磁盘刷盘完成,拉长了事务的整体生命周期,间接加重了锁持有时间和连接占用时长。

解决方案

短期止血:删掉唯一索引

事故正在发生时,最快的止血手段是直接删掉引发冲突的唯一索引:

sql
-- 找到引发冲突的唯一索引,直接删除
ALTER TABLE seckill_order DROP INDEX uk_user_product;

唯一索引删除后,INSERT 不再触发唯一性检查,不再有行锁升级和锁等待,连接池压力瞬间释放,服务恢复。

删除唯一索引后,数据库层面的唯一性约束不再生效,需要由应用层来保证数据不重复。

长期方案:Redis 锁 + 布隆过滤器

删掉数据库唯一索引后,唯一性校验的职责从数据库转移到应用层。使用 Redis 锁 + 布隆过滤器的组合方案来替代数据库唯一索引。

第一层:布隆过滤器快速拦截

布隆过滤器在内存中高效判断某个值「一定不存在」或「可能存在」,对于秒杀场景下的大量重复请求,能过滤掉绝大部分:

java
// 初始化布隆过滤器(RedisBloom 或本地 Guava BloomFilter)
// 预加载已存在的唯一键值到布隆过滤器中

// 请求进来时,先经过布隆过滤器
if (!bloomFilter.mightContain(userProductKey)) {
    // 布隆过滤器说「一定不存在」→ 这是新请求,放行
} else {
    // 布隆过滤器说「可能存在」→ 可能是重复请求,进入第二层校验
}

布隆过滤器的特点是:

  • 不会漏判(说不存在就一定不存在)
  • 会误判(说存在的值可能实际不存在,但概率可控)
  • 内存占用极小,1 亿条数据大约只需 120MB

第二层:Redis 分布式锁保证精确去重

对于布隆过滤器放行的请求,用 Redis 分布式锁做精确的唯一性校验:

java
// 用 Redis SETNX 做分布式锁,保证同一时刻只有一个请求能拿到锁
String lockKey = "seckill:lock:" + productId + ":" + userId;
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

if (!Boolean.TRUE.equals(locked)) {
    // 没拿到锁,说明有其他线程正在处理,直接返回
    return Result.fail("请勿重复提交");
}

try {
    // 拿到锁后,检查是否已经处理过
    String dedupKey = "seckill:dedup:" + productId + ":" + userId;
    Boolean exists = redisTemplate.hasKey(dedupKey);
    if (Boolean.TRUE.equals(exists)) {
        return Result.fail("请勿重复提交");
    }

    // 标记为已处理
    redisTemplate.opsForValue().set(dedupKey, "1", 24, TimeUnit.HOURS);

    // 执行数据库 INSERT(此时不再有唯一索引,不会有锁冲突)
    orderMapper.insert(order);

    // 将新数据加入布隆过滤器
    bloomFilter.put(userProductKey);
} finally {
    redisTemplate.delete(lockKey);
}

方案优势:

  • 布隆过滤器承担第一道防线,拦截绝大多数重复请求,Redis 压力极小
  • Redis 分布式锁保证精确去重,不会出现重复插入
  • 数据库层面不再有唯一索引,INSERT 永远不会触发锁冲突
  • 即使 Redis 不可用,降级为直接插入数据库(容忍少量重复,业务可接受)

线上排查:华为云 RDS 控制台看板

事故正在发生时,通过华为云 RDS 控制台的监控看板快速定位问题,无需手动执行 SQL。

关键看板指标

看板指标正常值事故时表现判断依据
MDL 锁数量< 10飙升到 75+有大量未提交事务堆积 MDL 读锁
活跃连接数远小于 max_connections接近或达到 8000连接池已被打满
锁等待超时次数0持续增长大量事务在等待行锁释放
事务活跃数稳定持续升高不回落事务卡住无法提交
慢 SQL 数量0大量 INSERT 慢查询唯一键冲突导致锁等待
CPU 使用率< 50%可能飙升锁等待队列引发上下文切换

排查步骤

第一步:看 MDL 锁数量趋势

在 RDS 控制台的「锁分析」或「实时监控」面板中,查看 MDL 锁数量的实时曲线。如果 MDL 锁数量持续上升且不回落,说明有大量事务未提交,MDL 读锁堆积。

第二步:看活跃连接数和锁等待

在「连接管理」面板查看当前活跃连接数。如果接近 max_connections(8000),且大量连接状态为 Waiting for table metadata lockLock wait,说明连接池已被锁等待的线程占满。

第三步:定位阻塞事务

在 RDS 控制台的「锁分析」或「慢 SQL」面板中,查看当前的锁等待关系。华为云 RDS 会直接展示「阻塞会话」和「被阻塞会话」的对应关系,找到持有锁时间最长的那个事务。

第四步:Kill 阻塞会话

在「会话管理」面板中,找到持有锁的阻塞会话,直接点击 Kill。阻塞事务被终止后,其持有的行锁和 MDL 锁瞬间释放,堆积的等待队列清空,服务恢复。

日常监控建议

  • 对 MDL 锁数量设置告警阈值(本次事故阈值为 20,已提前发现)
  • 对活跃连接数设置告警(如达到 max_connections 的 70%)
  • 定期查看慢 SQL 面板,关注 INSERT 语句的锁等待情况
  • 开启华为云 RDS 的「锁分析」功能,保留锁等待的历史记录便于事后复盘

总结

这次事故的根因不是「数据库扛不住」,而是参数配置在高并发场景下的组合效应:

  1. innodb_rollback_on_timeout = OFF — 锁超时不回滚事务,连接不释放,是连接池被打满的直接原因
  2. lock_wait_timeout = 1 年 — MDL 锁永远不会超时,堆积后无法自行恢复
  3. 普通 INSERT 遇唯一键冲突 — 触发行锁等待,是所有问题的起始点
  4. 健康检查依赖数据库连接 — 数据库异常直接拖垮容器

四个因素叠加,完成了从「单行数据锁冲突」到「容器被 Kill」的完整雪崩。

止血靠删索引,长期靠应用层去重——把唯一性校验的职责从数据库(悲观锁)迁移到 Redis(无锁),是高并发场景下的正确方向。

Last updated: