1. 一个压测事故让我重新理解并发控制
先说我实际遇到的一个问题。之前做电商库存扣减,接口逻辑写得非常简单:先 select stock 查询当前库存,代码里判断库存够不够,够就执行 update 扣减。单测、功能测试全部通过,结果上线前压测到 100 并发时出事了——库存从 5000 直接扣成了负数。
当时我的第一反应是扣减逻辑写错了,怀疑有重复请求或者补偿逻辑有 bug。但翻日志看不出任何异常,每一条请求都按正常流程走完了“查询-判断-更新”三个步骤。后来问了一位做数据库内核方向的朋友,他提了一句:你这两个事务并发的时候,查到的库存可能都是 5000,然后各自扣减、各自回写,后回写的把先回写的覆盖了。这就是典型的丢失更新。
这件事让我真正开始系统性地研究 MySQL 的锁机制。很多人把锁机制当成面试里的八股文来背,觉得 InnoDB 有行锁、有表锁、有间隙锁,知道死锁是什么就足够了。但你真到了并发场景下,会发现“知道概念”和“能定位问题、能设计避免方案”之间差了很远。这篇博文我不打算按教科书顺序从锁的类型开始罗列,而是按照我实际摸清这个问题的路径来讲:先看并发控制要解决什么,再理解锁是怎么配合隔离级别解决问题的,然后是 InnoDB 行锁到底怎么加锁,最后落到真实业务里的死锁定位和优化手段。
适合谁来读?两类人。一类是做后端开发、天天写 CRUD 但没认真想过并发更新会遇到什么问题的同学,另一类是准备 MySQL 面试、希望在锁机制这个问题上不只是背诵概念,而是能结合具体场景讲清楚原理的候选人。阅读之前最好对事务的基本概念有了解,比如 begin、commit、rollback 是干什么的,不用理解很深,文中涉及的关键点我会在讲原理时铺开。
文章里出现的所有业务示例,都以“商品库存表 + 用户余额表”为背景,这两类数据是互联网业务里最典型的并发热点数据,也是锁冲突最容易暴露的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并发事务的三个经典副作用,以及锁机制到底在防什么
讨论锁之前,先把问题定义清楚。为什么需要锁?不是因为 MySQL 想限制并发,而是因为多个事务同时读写同一份数据时会出现各种不一致的情况。事务并发不加控制时,主要有三类副作用:脏读、不可重复读、幻读。这三个名字几乎所有 MySQL 文章都会提,但它们的含义需要一个能落到实处的理解方式。
2.1 丢失更新:压测事故的直接元凶
丢失更新其实常常和脏读放在不同维度讨论。从严格意义来说,它不属于 SQL 标准里隔离级别要防范的“读异常”,但它在实际开发里出现的频率非常高,而且后果往往很严重。
你想象这样一个场景:商品 A 的库存是 100 件。事务 T1 扣减 1 件,事务 T2 扣减 2 件,两个事务同时开始。如果两个事务都先读到 100 件,T1 在自己会话里计算出 99 件并写回,T2 在自己会话里计算出 98 件并写回——关键问题在于,T2 写回的时间点在 T1 写回之后,那么最终库存会是 98 而不是 99。丢失的 1 件扣减消失了,就像没发生过一样。
我压测时遇到的就是这种情况。库存从 5000 扣成负数,因为 100 个请求并发进来,每个请求读到的初始库存可能都是 5000(或者接近 5000),各自做“库存减一”后写回,后面的写回把前面的覆盖掉了。
MySQL 默认的 REPEATABLE READ(可重复读)隔离级别下,普通 select 属于快照读,读取的是某个时间点的快照,不会加锁。因此这种“先查后改”的逻辑在并发下天然存在问题。要解决它,要么把「查询+判断+扣减」变成一条原子 SQL(比如 update ... set stock = stock - 1 where stock > 0),要么使用 select ... for update 对要修改的行加锁后再操作。
2.2 脏读、不可重复读、幻读的分界线
再来看三个经典的“读异常”。脏读是读到了其他事务未提交的数据;不可重复读是同一个事务内,同一行数据读到两次,结果不一样,第二次读到了其他事务已提交的修改;幻读比不可重复读更进一步,不仅是同一行数据变了,而是“多了一行”或“少了一行”。想象一下,事务 T1 第一次 select count(*) from product where stock = 0 得到 10 行,此时事务 T2 插入了一条新的 stock = 0 的记录并提交,T1 再执行同样的查询发现变成了 11 行。多出来的那一行就像幻觉一样。
这三个词在 READ COMMITTED(读已提交)和 REPEATABLE READ 下有不同的表现。READ COMMITTED 级别下,快照读每次执行都会生成新的快照,所以能读到其他事务已提交的修改,因此存在不可重复读;但它避免了脏读。REPEATABLE READ 级别下,快照在事务第一次读时生成,后面所有快照读都基于同一份快照,所以可重复读得到了保证。MySQL 的 InnoDB 引擎在 REPEATABLE READ 下还通过 next-key lock 解决了幻读问题,这一点和标准 SQL 不太一样:标准 SQL 认为 REPEATABLE READ 无法避免幻读,但 InnoDB 做到了。这也是很多 MySQL 面试题喜欢设陷阱的地方。
2.3 隔离级别与锁的关系:读不用锁、写要加锁
很多初学者会对“加锁”和“隔离级别”的关系感到困惑。简单拆解一下:事务隔离级别控制的是“读”的规则,锁控制的是“写”以及“当前读”的规则。
普通 select 是快照读,走的是 MVCC(多版本并发控制)机制——每个事务看到的是行的一个历史版本,所以不需要加锁,并发读性能很高。而 update、delete、select ... for update 这些属于当前读,它们必须读最新的已提交版本,并且要对命中的行加锁。加锁与隔离级别共同决定了某个当前读会锁住哪些范围。在 REPEATABLE READ 下,一个范围查询会通过间隙锁把查询范围里的“空隙”也锁住,防止其他事务往这个空隙插入数据,从而根除幻读。
由此可见,锁机制并不是一个孤立的概念,它是数据库事务语义的底层实现工具。你只有理解了隔离级别对事务行为的要求,才能理解为什么 InnoDB 会设计出这么多种锁,为什么加锁规则如此复杂。
3. MySQL 的锁体系远不是一张锁分类表那么简单
现在进入正题,来看看 MySQL 里到底有哪些锁。大多数文章会给你列一张表:全局锁、表级锁、行级锁、意向锁、间隙锁……分类没错,但如果不理解每种锁的定位和触发场景,这张表背下来也没用。我按照“作用范围从小到大”的顺序讲,同时在每个层级强调它们在实际业务里是怎么显现的。
3.1 全局锁:每次全库备份时的短暂阻塞
全局锁是对整个数据库实例加锁。MySQL 提供了一个专门的语句:FLUSH TABLES WITH READ LOCK(简称 FTWRL)。执行之后,整个库处于只读状态,所有更新操作都会被阻塞。它的典型使用场景是全库逻辑备份,尤其是早期使用 mysqldump 时,为了保证备份数据的一致性,需要在备份期间禁止写入。
我见过不少团队在使用 mysqldump 做备份时掉进过坑里——如果备份期间业务还在正常读写,备份出来的数据可能是一个不一致的状态:表 A 备份的是 10 点的数据,表 B 备份的是 10 点零 5 分的数据,两张表之间逻辑上就对不上。全局锁能解决这个问题,但副作用也明显:业务写入会中断。
实际生产中,InnoDB 引擎下 mysqldump 可以指定 --single-transaction,通过开启一个可重复读事务来完成一致性快照备份,从而避免长时间持有全局锁。但如果你使用的表是 MyISAM,因为没有事务支持,--single-transaction 不生效,仍会退化到 FTWRL。
提示:线上环境用
mysqldump --single-transaction备份 InnoDB 表时,要确认所有表都是 InnoDB。只要有一张 MyISAM 表,备份过程就可能获取全局读锁,影响线上写入。
3.2 表级锁:MDL 锁才是最需要关注的表锁
表锁分为两种:一种是显式的 LOCK TABLES ... READ/WRITE,另一种是隐式的 MDL(Metadata Lock,元数据锁)。显式 LOCK TABLES 在日常生活中用得很少,因为 InnoDB 有行锁,没必要锁整个表。真正需要警惕的是 MDL 锁。
MDL 锁由 MySQL 自动管理,作用是保护表结构定义。当你执行任何 SQL 语句时,MySQL 都会自动获取表的 MDL 读锁;当你执行 ALTER TABLE 修改表结构时,会获取 MDL 写锁。读锁之间不互斥,读锁和写锁之间互斥。
这里有一个非常经典的线上事故路径:某个长事务执行了很久(比如一个大数据量的 update 或者一个忘记提交的事务),它持有某张表的 MDL 读锁一直不释放。此时 DBA 执行了一条 ALTER TABLE 想加字段,ALTER 需要 MDL 写锁,发现读锁没释放,就进入等待状态。更麻烦的是,ALTER TABLE 在等待期间会阻塞后续所有对该表的读写请求——因为新的请求需要获取 MDL 读锁,而读锁与等待中的写锁互斥。MySQL 的 MDL 锁队列是写锁优先的,后续的读请求全部排队。最终结果是一条 ALTER TABLE 拖垮整张表的读写。
解决这个问题的常规手段是:在业务低峰期执行 DDL,使用 pt-online-schema-change 这类工具让 DDL 以拷贝数据的方式在线执行,同时监控长事务,及时 kill 掉已经“僵死”的事务。另外,MySQL 5.6 之后 DDL 支持 ALGORITHM=INPLACE,但 MDL 写锁的获取仍然会排队等待。
3.3 行锁与意向锁:InnoDB 并发能力的基石
InnoDB 真正引以为傲的是行级锁。它可以在事务更新某一行时只锁定这一行,其他行仍然可以被并发读写。这是它替代 MyISAM 成为默认引擎的重要原因。
但行锁带来一个新问题:某个事务持有表里某些行的行锁,另一个事务想对整张表加表锁,数据库怎么快速判断“这些行有没有被锁”?如果逐行扫描检查,开销太大。于是 InnoDB 引入了意向锁。
意向锁是表级锁,分为意向共享锁(IS)和意向排他锁(IX)。它的设计哲学很朴素:如果一个事务准备给某些行加共享锁,就先在表上声明“我打算对某些行做共享访问”,于是给表加上意向共享锁;如果准备加排他锁,就声明意向排他锁。这样,后来的事务想对整张表加表锁时,只需要检查表上是否存在意向锁,就能快速判断表中是否有行被锁定,不需要逐行扫描。意向锁之间互相兼容,因为它们表达的是“意图”,不是“实际锁定的行”。
理解意向锁对日常开发的实际帮助没有前面几类锁那么大,但在分析死锁日志时经常会看到 IS、IX 字样,如果你不知道它们的存在,日志看起来会非常困惑。
3.4 间隙锁与 next-key lock:可重复读下防幻读的关键
行锁只能锁住“已经存在的行”。但幻读问题在于“其他事务插入了新行”,原本不存在的行无法用普通行锁控制。InnoDB 的解决方案是把索引记录之间的“空隙”也锁起来,这就是间隙锁。
间隙锁锁的是一个范围而不是具体的行。比如表里有一列 id,已有记录 id 为 1、5、10。事务 A 执行 select * from product where id between 5 and 10 for update,InnoDB 不仅会锁住 id=5 和 id=10 的记录,还会锁住 5 到 10 之间的空隙。如果事务 B 想插入一条 id=7 的记录,会被阻塞——这个行为就是通过间隙锁实现的。
间隙锁有一个值得注意的特性:它在 READ COMMITTED 隔离级别下会被禁用,只保留行锁。这也是为什么有些团队为了提升并发性能把隔离级别调整为 READ COMMITTED——副作用是宽松的隔离级别下可能会出现幻读。业务是否接受,需要具体分析。
next-key lock 是行锁和间隙锁的组合,锁住的区间是“左开右闭”,比如 (5, 10]。InnoDB 在 REPEATABLE READ 级别下对范围查询默认使用 next-key lock,既锁住了已有的记录,也锁住了记录前的空隙,从而隔离了“往区间内插入记录”的可能。
4. InnoDB 行锁的内部视角:锁加在索引上,而不是“行”上
这一节我想写得偏原理一些,因为它直接关系到优化策略的推导。很多人虽然知道 InnoDB 有行锁,但不知道 InnoDB 的行锁实际上是通过索引实现的。不理解这一点,就不知道为什么“更新不走索引”会导致全表锁住。
4.1 为什么行锁必须落在索引上
InnoDB 的表是索引组织表,数据按照主键索引(也叫聚簇索引)的顺序物理存储。二级索引(普通索引)的叶子节点存储的是主键值。当你要更新某一行时,InnoDB 需要通过索引找到这一行对应的主键,然后在聚簇索引上对这条记录加锁。
如果 update 语句的 where 条件没有用到任何索引,InnoDB 只能全表扫描,逐行判断是否符合条件。扫描过程中,每一条被访问的记录都会被加上锁(具体是加行锁还是间隙锁,取决于隔离级别)。结果就是:一个本该只更新几行的 SQL,因为没走索引,把整张表的所有记录都锁住了,这等价于一次表锁。其他事务想更新任何一行,都会被阻塞。
我在诊断线上问题时见过一个具体案例。一张订单表有 500 万行,业务执行 update orders set status = 1 where merchant_order_no = 'xxx',而 merchant_order_no 列没有建索引。这条 SQL 执行频率不高,但它每次执行时都会把所有订单记录加锁,导致后续所有针对这张表的更新全部排队。后来给 merchant_order_no 加了索引,同样的 SQL 执行时间从秒级下降到毫秒级,锁影响范围也缩小到一条记录。
4.2 当前读与快照读:锁只出现在当前读场景
前文提过,普通 select 是快照读,不加锁。这条规则的意义再怎么强调都不过分。在线上数据库压力很大时,大量业务查询走的是快照读,因此不会被行锁阻塞。这也是 InnoDB 能够支撑高并发读的基础。
那什么操作会触发当前读?简单列举:
select ... for updateselect ... lock in share modeupdatedeleteinsert
这些语句都必须读到最新的已提交数据,并且对被操作的数据加锁。理解当前读和快照读的区别,有助于排查“为什么事务 A 更新了一行还没提交,事务 B 的普通 select 还能查到旧值”这类问题——因为事务 B 的快照读不需要等待事务 A 的锁释放,它直接读取了旧版本的数据。这也是 MVCC 与锁配合工作的精妙之处:读不阻塞写、写不阻塞读。
4.3 分析一个 update 语句到底加了哪些锁
理论铺得差不多了,我用一个具体的例子来演算加锁过程。假设有一张订单表,结构如下:
sql复制CREATE TABLE `order` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`status` tinyint NOT NULL DEFAULT '0',
`order_no` varchar(32) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB;
表中现有数据如下:
| id | user_id | status | order_no |
|---|---|---|---|
| 1 | 100 | 0 | A001 |
| 3 | 200 | 0 | A002 |
| 7 | 100 | 1 | A003 |
| 10 | 300 | 0 | A004 |
假设事务 T1 执行下面这条语句且尚未提交:
sql复制SELECT * FROM `order` WHERE user_id = 100 FOR UPDATE;
在 REPEATABLE READ 隔离级别下,这条语句会通过二级索引 idx_user_id 找到所有 user_id = 100 的记录,也就是 id 为 1 和 7 的两行。
这里加锁行为是这样的:首先,在二级索引 idx_user_id 上,凡是扫描过的索引记录都会被加锁。这个例子里,InnoDB 会沿着索引 B+ 树扫描,找到 user_id=100 的记录后,继续向后扫描,因为无法确定后面是否还有 user_id=100 的记录,所以扫描到第一条 user_id≠100 的记录(即 user_id=200 的 id=3)才会停止。这个过程中访问到的索引范围,包括(100, 200] 的左开右闭区间,都会被加上 next-key lock。与此同时,命中的主键记录 id=1 和 id=7 也会在聚簇索引上加行锁。
所以这条简单的 FOR UPDATE 语句实际锁住的范围,比开始想象的“user_id=100 的那两行”要宽得多。在区间 (100, 200] 之内,任何插入 user_id 介于 100 和 200 之间的新订单记录都会阻塞。
这就是为什么很多人写代码时想用 select for update 锁定一些行,结果发现其他业务操作被阻塞了很大范围,却查不到原因——本质上是因为没有预判到 next-key lock 的范围会扩大到“最后一次索引扫描的位置”。
提示:如果要减少
FOR UPDATE的锁定范围,最直接的办法是让 where 条件命中唯一索引或主键。比如WHERE id = 1 FOR UPDATE,InnoDB 只需要在聚簇索引上找到一条记录加锁即可。范围条件和普通索引查询都会因为 next-key lock 锁住更大的范围。
如果把隔离级别调整成 READ COMMITTED,情况会有所缓和,因为间隙锁被禁用,上面的语句只会对 id=1 和 id=7 两条记录加行锁,不会锁住 (100, 200] 的空隙。这也是很多高并发团队选择 READ COMMITTED 的原因之一。
5. 死锁定位的完整链路:从“卡住的接口”到锁定等待日志
并发场景下另一个绕不开的问题是死锁。死锁的本质是两个或多个事务在持有锁的情况下互相等待对方持有的锁,形成一个循环等待。MySQL 的 InnoDB 引擎会检测死锁,并自动回滚代价较小的事务,让另一个事务继续执行。但业务侧往往还是不愉快——你会在日志里看到 Deadlock found when trying to get lock; try restarting transaction 这样的报错。
5.1 一个经典的死锁形成场景
我用一张最简单的表来演示死锁的形成。假设有一个账户余额表:
sql复制CREATE TABLE `account` (
`id` int NOT NULL,
`balance` decimal(10,2) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
事务 T1 和事务 T2 按如下顺序执行:
| 时间点 | 事务 T1 | 事务 T2 |
|---|---|---|
| 1 | UPDATE account SET balance = balance - 100 WHERE id = 1; |
|
| 2 | UPDATE account SET balance = balance - 100 WHERE id = 2; |
|
| 3 | UPDATE account SET balance = balance + 100 WHERE id = 2; |
|
| 4 | UPDATE account SET balance = balance + 100 WHERE id = 1; |
在时间点 3,T1 想获取 id=2 上的锁,但 id=2 的锁被 T2 持有,因此 T1 等待。在时间点 4,T2 想获取 id=1 上的锁,但 id=1 的锁被 T1 持有,因此 T2 等待。两个事务互相等待,形成死锁。InnoDB 的死锁检测机制发现这个循环后,会选择回滚其中一个事务。
这类死锁在真实业务里非常容易出现在“转账”场景:两个账户互相转账,或者一个批量任务和另一个批量任务按不同顺序更新同一组账户。规避方式也很简单:所有事务内对多个账户的操作,都按固定顺序加锁,例如先锁小 id 再锁大 id,从根上消除循环等待。
5.2 实操:从死锁日志里还原现场
如果线上已经出现了死锁,第一件事不是改代码,而是把死锁现场抓出来。InnoDB 会把最近一次死锁的详细信息记录在错误日志里。你可以执行下面的 SQL 查看:
sql复制SHOW ENGINE INNODB STATUS;
在输出内容里定位到 LATEST DETECTED DEADLOCK 段落,里面会包含两个事务各自执行的 SQL、持有的锁和等待的锁。下面是一段简化后的典型输出结构:
text复制*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 100, OS thread handle 1234, query id 5678 updating
UPDATE account SET balance = balance + 100 WHERE id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 3 n bits 72 index PRIMARY of table test.account
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
...
UPDATE account SET balance = balance - 100 WHERE id = 1
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
...
*** WE ROLL BACK TRANSACTION (2)
日志里最关键的信息有三个:每个事务当前正在执行的 SQL、它在等待哪一行上的锁、最终被回滚的是哪个事务。拿到这个信息就能还原整个死锁的形成过程。
我在实际排查中还会配合一个辅助手段:在业务日志里对应时间窗口内搜索报错的事务 ID,找到触发死锁的所有相关 SQL,再人工拼接出执行时序——通常会发现是某个接口在同一个事务里调用了两次更新,而两次更新的加锁顺序与另一个事务相反。
5.3 哪些日常代码最容易被死锁缠上
根据我观察到的线上死锁案例,有几种高频模式值得特别警惕。
第一种是“同一事务内加锁顺序不一致”。比如转账场景,A 转账给 B 时先锁 A 后锁 B;另一个任务调度里又出现先锁 B 后锁 A 的逻辑,两个任务并发执行时就容易死锁。
第二种是“批量更新使用了范围条件”。一个批量任务执行 update orders set status = ... where merchant_id = ?,多个这样的任务并发处理同一个商户下的不同订单时,因为各自扫描索引的范围交错重叠,可能出现循环等待。这种场景要尽量把大事务拆成小事务,每条或每批订单单独提交。
第三种是“先查后改但查询没有锁定范围”。有些同学习惯先 select 出需要更新的主键列表,再逐条 update。如果两个事务同时查出了相似的主键列表,又在更新时以不同顺序执行,就可能死锁。建议对这类操作加一个 ORDER BY id 固定顺序,或者使用 select ... for update 提前把目标行按顺序锁好。
死锁定位的核心心法就一句话:死锁日志只告诉你“谁和谁撞了”,你要找出“为什么撞”,分析代码里加锁的先后顺序。只要事务之间加锁的顺序形成全序关系,死锁就不可能发生。
6. 锁竞争优化:控制事务粒度、拆解索引策略、缩窄加锁范围
理解了锁的基本行为之后,接下来聊优化。优化的本质是在保证数据一致性的前提下,尽量减少锁等待和锁竞争,让并发事务能同时推进。
6.1 索引设计对锁竞争的决定性影响
一个容易被忽略的事实是:索引设计不只影响查询性能,直接影响行锁的粒度和范围。
前面提到,如果更新操作无法命中索引,InnoDB 会锁住扫描过程中访问到的所有记录。在线上业务里,一个简单的规则应该是:凡是出现在 where 条件里的字段,如果它是高频更新条件,必须有合适的索引。否则一次无索引更新就能引发全局阻塞。
另外,联合索引的字段顺序也会影响加锁范围。比如有一张订单表常按 (merchant_id, status) 查询,而更新语句用 where merchant_id = ? and status = ? 执行。如果只针对 merchant_id 建了单列索引,InnoDB 在二级索引上扫描的范围可能覆盖该商户下的所有状态记录,加锁范围会扩大。如果把联合索引建成 (merchant_id, status),扫描到目标状态即可停止,加锁范围进一步收窄。
提示:给高并发更新的表增加索引时,要考虑索引本身的维护成本。写放大和锁范围是一个 trade-off,通常优先保障“高频更新语句的锁定范围足够小”,因为锁冲突对系统吞吐的影响往往比索引写入开销更致命。
6.2 事务时长与锁持有时间的数学关系
事务持有锁的时间越长,其他事务等待锁的概率越大。一个事务从 begin 到 commit 期间持有的所有锁都不会释放。有些业务代码里习惯在事务内做外部 API 调用、远程缓存删除甚至文件上传,这些操作让事务的存活时间被拉长到几百毫秒甚至秒级,锁的持有时间也随之变长。
从概率上想:假设某行记录平均每秒被更新 10 次,事务 A 持有这行锁 10 毫秒,那事务 B 到达时撞上锁的概率大约是 10%。如果事务 A 因为一次远程调用把持有时间拖到 500 毫秒,事务 B 撞锁的概率就飙升到超过 80%。锁竞争一旦超过阈值,数据库的并发能力会急剧下降,大量请求堆积在锁等待队列里,表现为吞吐量骤降、接口 RT 飙升。
因此,事务内只做数据库操作是极重要的原则。所有外部 I/O、耗时的业务逻辑都必须在事务提交之后执行。如果因为业务需求必须在一个大事务里处理大量数据,考虑分批提交,每批几百行就 commit 一次。
6.3 热点行的拆分:从“一个人买单”到“多人一起买单”
有些业务场景的高并发锁竞争不在索引设计上,而在“热点行”本身。最典型的就是秒杀场景下商品库存只有一行记录,所有用户同时扣减这行的库存。无论你把 SQL 写得多好,InnoDB 对这一行只能串行加锁,并发度撑不上去。
常用解法是“库存分桶”:把一行库存拆成多行,比如把 5000 件库存分成 50 个桶,每个桶 100 件,每行记录对应一个桶。用户的扣减请求通过 user_id 或者随机数映射到某个桶,只更新对应桶的库存。这样同一秒内的并发更新会被分散到 50 行不同的记录上,锁竞争从单点转成多点,吞吐量可以提升数十倍。
拆桶方案的代价是库存的判断逻辑变得更复杂:扣减时要判断当前桶是否还有库存,没有则尝试其他桶。此外,要保证桶的总和不超卖,扣减动作仍然要在事务里完成。这个方案不是银弹,但对热点行的抢购类业务是经过实践检验的有效优化。
6.4 设置合理的锁等待超时与监控指标
最后聊一聊参数配置。MySQL 提供了 innodb_lock_wait_timeout 参数,默认值是 50 秒。这个值表示一个事务等待行锁的最长时间,超时后事务会报错。线上很多接口出现“卡死 50 秒后报错”的现象,往往就是某张表对应的行锁被一个长事务一直持有,其他事务排队等待直到超时。
我见过不少团队在压测时遇到锁等待问题,第一反应是把 innodb_lock_wait_timeout 调大,比如调到 120 秒。这个方向其实是反的:锁等待时间设置得越长,请求堆积越严重,系统恢复越慢。更合理的做法是把超时时间控制在 5 到 10 秒内,让失败的请求快速失败、快速重试,不要让线程池被锁等待占满。
同时,监控端有两个核心指标要盯住:
information_schema.innodb_trx表里是否存在长时间未提交的事务(用trx_started时间判断)。information_schema.innodb_lock_waits表里是否有持续的锁等待记录。
实际运维时可以用如下的 SQL 快速找出阻塞源头:
sql复制SELECT
waiting_trx_id,
waiting_pid,
blocking_trx_id,
blocking_pid
FROM information_schema.innodb_lock_waits;
还可以通过 sys.innodb_lock_waits 视图拿到更直观的“谁阻塞了谁”的信息。定位到阻塞源头后,判断是长事务未提交、还是死锁残留、还是锁范围过大,再针对性地 kill 掉阻塞事务或者优化 SQL。
7. 写在最后:锁机制不是用来背的,是用来推导的
从那次压测事故到现在,我遇到 MySQL 并发相关的问题时,养成了一套自己的思考路径。先明确当前操作的隔离级别和读类型(快照读还是当前读),再判断 SQL 的条件能命中哪个索引,接着推演 InnoDB 会在哪些索引记录上加什么类型的锁,最后评估锁的范围会不会影响其他业务。这套推导路径比背任何锁分类表都实用,因为大部分线上问题都能通过它一步一步定位到根因。
另外,我自己有一个习惯:写完任何一条涉及并发更新的 SQL 后,会习惯性问自己三个问题。这条 SQL 会锁住哪些行或者哪个区间?同一个事务里还有没有其他的加锁操作,它们的顺序是否一致?这个事务从开始到提交的窗口内,有没有非数据库操作在里面?每一条都是踩过坑之后才总结出来的。
如果这篇文章只能留下一句话,那就是:在 MySQL 里,你设计的每一条 SQL 的加锁范围,本质上就是你对事务并发边界的一次声明——搞清楚这个范围,你的数据库并发能力才能真正掌握在自己手里。
