秒杀期间唯一索引冲突导致数据库连接池打满的雪崩事故复盘
事故现象
秒杀活动期间,服务突然出现大量超时,健康检查接口无响应,Kubernetes 探针连续失败,Pod 被 Kill 后重启,重启后再次被 Kill,形成循环重启。
云监控同时告警:实例 market_online 的 MDL 锁数量从阈值 20 飙升到 75,持续触发。
第一反应是「服务崩了」,但真正的问题出在数据库层。
一句话根因
高并发秒杀请求同时 INSERT 相同的唯一键(如订单号、用户+商品维度的防重字段),InnoDB 的唯一性检查触发行锁升级,大量事务在锁等待队列中死等。
由于 innodb_rollback_on_timeout = OFF,锁超时后只回滚单条语句而非整个事务,导致连接长期不释放,连接池打满,健康检查线程也拿不到连接,最终容器被 Kill。
实例关键参数
以下是华为云 RDS MySQL 实例中与本次事故直接相关的参数:
| 参数 | 当前值 | 说明 |
|---|---|---|
transaction_isolation | READ-COMMITTED | RC 隔离级别,锁行为与 RR 不同 |
innodb_lock_wait_timeout | 5 秒 | 行锁等待超时 |
lock_wait_timeout | 31536000 秒(约 1 年) | MDL 锁等待超时,极端长 |
innodb_rollback_on_timeout | OFF | 锁超时时只回滚单条语句,事务不回滚 |
max_connections | 8000 | 最大连接数 |
innodb_flush_log_at_trx_commit | 1 | 每次事务提交刷盘 |
innodb_buffer_pool_size | 约 9.6 GB | InnoDB 缓冲池 |
threadpool_enabled | ON | 线程池开启 |
wait_timeout | 28800 秒(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-COMMITTED | REPEATABLE-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 | 只回滚当前语句,事务继续存活 | 连接不释放,继续占用连接池 |
-- 事务 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 锁的雪崩逻辑:
- 每条 INSERT 在执行时,都会在 Server 层申请该表的 MDL 共享读锁(SHARED_WRITE)
- MDL 锁必须等到事务提交或回滚才释放
- 由于
innodb_rollback_on_timeout = OFF,事务在行锁超时后不回滚,继续持有 MDL 读锁 lock_wait_timeout = 1 年意味着即使有 DDL 操作想申请 MDL 写锁,它会排队一年都不会超时退出- 新的 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 从缓存写入文件并刷盘,这是最严格的持久性保证,但也是最慢的配置。
在秒杀高并发场景下,每个事务的提交操作都要等待磁盘刷盘完成,拉长了事务的整体生命周期,间接加重了锁持有时间和连接占用时长。
解决方案
短期止血:删掉唯一索引
事故正在发生时,最快的止血手段是直接删掉引发冲突的唯一索引:
-- 找到引发冲突的唯一索引,直接删除
ALTER TABLE seckill_order DROP INDEX uk_user_product;唯一索引删除后,INSERT 不再触发唯一性检查,不再有行锁升级和锁等待,连接池压力瞬间释放,服务恢复。
删除唯一索引后,数据库层面的唯一性约束不再生效,需要由应用层来保证数据不重复。
长期方案:Redis 锁 + 布隆过滤器
删掉数据库唯一索引后,唯一性校验的职责从数据库转移到应用层。使用 Redis 锁 + 布隆过滤器的组合方案来替代数据库唯一索引。
第一层:布隆过滤器快速拦截
布隆过滤器在内存中高效判断某个值「一定不存在」或「可能存在」,对于秒杀场景下的大量重复请求,能过滤掉绝大部分:
// 初始化布隆过滤器(RedisBloom 或本地 Guava BloomFilter)
// 预加载已存在的唯一键值到布隆过滤器中
// 请求进来时,先经过布隆过滤器
if (!bloomFilter.mightContain(userProductKey)) {
// 布隆过滤器说「一定不存在」→ 这是新请求,放行
} else {
// 布隆过滤器说「可能存在」→ 可能是重复请求,进入第二层校验
}布隆过滤器的特点是:
- 不会漏判(说不存在就一定不存在)
- 会误判(说存在的值可能实际不存在,但概率可控)
- 内存占用极小,1 亿条数据大约只需 120MB
第二层:Redis 分布式锁保证精确去重
对于布隆过滤器放行的请求,用 Redis 分布式锁做精确的唯一性校验:
// 用 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 lock 或 Lock wait,说明连接池已被锁等待的线程占满。
第三步:定位阻塞事务
在 RDS 控制台的「锁分析」或「慢 SQL」面板中,查看当前的锁等待关系。华为云 RDS 会直接展示「阻塞会话」和「被阻塞会话」的对应关系,找到持有锁时间最长的那个事务。
第四步:Kill 阻塞会话
在「会话管理」面板中,找到持有锁的阻塞会话,直接点击 Kill。阻塞事务被终止后,其持有的行锁和 MDL 锁瞬间释放,堆积的等待队列清空,服务恢复。
日常监控建议
- 对 MDL 锁数量设置告警阈值(本次事故阈值为 20,已提前发现)
- 对活跃连接数设置告警(如达到 max_connections 的 70%)
- 定期查看慢 SQL 面板,关注 INSERT 语句的锁等待情况
- 开启华为云 RDS 的「锁分析」功能,保留锁等待的历史记录便于事后复盘
总结
这次事故的根因不是「数据库扛不住」,而是参数配置在高并发场景下的组合效应:
innodb_rollback_on_timeout = OFF— 锁超时不回滚事务,连接不释放,是连接池被打满的直接原因lock_wait_timeout = 1 年— MDL 锁永远不会超时,堆积后无法自行恢复- 普通 INSERT 遇唯一键冲突 — 触发行锁等待,是所有问题的起始点
- 健康检查依赖数据库连接 — 数据库异常直接拖垮容器
四个因素叠加,完成了从「单行数据锁冲突」到「容器被 Kill」的完整雪崩。
止血靠删索引,长期靠应用层去重——把唯一性校验的职责从数据库(悲观锁)迁移到 Redis(无锁),是高并发场景下的正确方向。