MySQL在高并发写入或更新同一批数据时,经常出现锁等待时间升高、CPU消耗在互斥量上的现象。许多优化尝试只关注SQL写法,却忽略了锁争用背后的索引与事务边界问题。本文从InnoDB锁机制出发,拆解锁争用的成因,并给出减少锁冲突、提升并发吞吐的具体方案。

一、锁争用的底层原因:行锁与索引紧密相关
InnoDB实现行级锁并不是直接锁住物理行,而是通过索引记录加锁。当一条更新语句能够使用索引精确定位到目标记录时,引擎只需在该索引记录上添加排他锁;但如果过滤条件没有走索引,优化器可能选择扫描主键索引或全表扫描。此时InnoDB为了确保事务安全,会对扫描到的所有记录加锁,最终表现为表级锁行为,大量无关行也被锁住,锁争用随之扩大。
例如orders表的主键是id,user_id列没有索引。执行下面的语句:
-- 假设user_id并未建立索引 UPDATE orders SET status = 'shipped' WHERE user_id = 1001;
即便只更新一个用户的订单,InnoDB也可能扫描大量主键记录并加锁。并发更新不同user_id时,这些事务之间仍然可能互相等待,造成TPS下降。给user_id建立普通索引后,定位到少量索引记录,锁范围大幅缩小。
在可重复读隔离级别下,间隙锁也会放大锁范围。比如事务A执行范围条件查询并加锁,会锁住索引记录之间的空隙,阻止其他事务插入符合范围的数据。事务B即使插入不同主键、但落到同一区间,也可能被阻塞。理解这一点后,优化目标就变得清晰:让所有写操作都尽可能通过窄索引定位,并尽量避免持有不必要的间隙锁。
二、从SQL与索引设计入手,压缩锁范围
减少锁争用最直接的方式,是确保更新和删除语句能够命中高选择性的索引。常见问题是索引列参与函数运算、隐式类型转换或范围条件过宽,导致优化器放弃索引。比如使用函数包裹日期列,或对字符串列传入数字,都会触发扫描。显式写成可比较的范围条件,比函数运算更利于索引使用。
-- 不推荐:created_at列被函数包裹,无法走索引 UPDATE orders SET status = 'paid' WHERE YEAR(created_at) = 2025; -- 推荐:使用可索引的范围条件 UPDATE orders SET status = 'paid' WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';
除了避免函数,还要关注联合索引的列顺序。对于频繁按status和created_at查询并更新的场景,可以考虑建立idx_status_created(status, created_at)联合索引。这样既能覆盖WHERE条件,又能让排序或范围扫描更高效。单列索引分散在多列上,往往只能部分过滤,扫描行数多,锁范围自然变大。
利用覆盖索引也能减少回表加锁。如果SELECT查询只需要读取索引包含的列,不需要回表随机读取主键记录,可以降低共享锁和排他锁的竞争。例如经常按订单号查询订单状态,可以为order_no和status建立联合索引,让查询在二级索引中完成。
三、缩短事务持有锁时间,避免锁等待堆积
锁争用很多时候不是单条SQL慢,而是事务边界过大。一个事务从开启到提交可能需要几十毫秒甚至更久,期间持有的排他锁不会释放。如果事务内还嵌入了远程接口调用、文件解析、消息推送等慢操作,锁会被无谓地拉长时间。数据库事务应当只包含必要的数据库读写,外部耗时操作必须放在事务提交之后。
另一个常见问题是在循环中频繁提交。比如逐条更新一万行数据,每条开启一个事务,会增加日志刷盘和锁释放频率,但一次提交过多又可能长时间占用锁。前者并非锁争用典型,后者则容易阻塞其他连接。合理做法是按批次处理,如每500条提交一次,并在批次间适当留出时间或条件。
还可以调整事务内的操作顺序,将热点表的写操作尽量放到事务尾部。先完成普通查询、业务计算,最后对少数热点记录加锁并立即提交,这样排他锁持有窗口非常短。比如先根据业务规则筛选出可处理的订单,再执行类似下面的语句:
START TRANSACTION; SELECT id FROM orders WHERE status = 'pending' AND priority = 'high' ORDER BY id LIMIT 10 FOR UPDATE; -- 后续更新操作紧跟 UPDATE orders SET status = 'processing' WHERE id IN (...); COMMIT;
事务内部不要加入不可控等待,否则锁等待数和线程状态会迅速恶化。短事务意味着锁能快速释放,后续排队请求可以更早获取锁。
四、热点行高并发处理:SKIP LOCKED、NOWAIT与乐观锁
对于秒杀库存、账户余额等热点行,单纯依赖排他锁会造成请求串行化。MySQL 8.0提供了FOR UPDATE SKIP LOCKED和FOR UPDATE NOWAIT语法,适合任务队列场景。SKIP LOCKED会跳过已经被其他事务锁定的行,而不是原地等待,从而让多个工作线程分别处理不同记录。NOWAIT则在遇到锁冲突时立刻返回错误,由应用层决定重试或降级。
START TRANSACTION; SELECT id FROM orders WHERE status = 'pending' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED; -- 对选中的订单执行处理逻辑 UPDATE orders SET status = 'processing' WHERE id IN (...); COMMIT;
对于账户余额或库存扣减,可以采用乐观锁避免长事务。给表增加version字段,每次更新校验版本号,并通过影响行数判断是否成功。如果影响行数为0,说明版本已变化,可以重试或提示用户。这样在低冲突场景下不会真的加排他锁阻塞其他请求。
-- 乐观扣减库存 UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE sku_id = 100 AND stock > 0 AND version = 12;
乐观锁的代价是冲突率高时会产生大量无效重试。如果更新非常集中,仍然需要结合缓存预扣、消息队列削峰或分桶库存等方案。数据库层可以通过将单个热点行拆成多个槽位,例如把库存总数分散到10个桶中,扣减时随机选择桶,降低单行锁竞争。
五、通过监控与死锁日志定位锁争用
优化不能只靠猜测,需要观察锁等待的具体事务。MySQL提供了多种查看锁信息的方式。当出现锁等待时,可以查询information_schema.innodb_trx、performance_schema.data_lock_waits或sys.innodb_lock_waits视图,了解哪些事务在等待、持锁的事务是谁、等待了多久。
-- 查看当前事务 SELECT * FROM information_schema.innodb_trx\G -- 查看锁等待关系 SELECT * FROM sys.innodb_lock_waits\G -- 查看InnoDB整体状态,包含最近死锁信息 SHOW ENGINE INNODB STATUS\G
死锁是锁争用的极端表现。InnoDB会自动检测死锁并回滚其中一个事务,但频繁死锁说明事务加锁顺序不一致。例如事务A先锁订单再锁库存,事务B先锁库存再锁订单,交叉等待就会触发死锁。统一加锁顺序、缩小锁粒度、使用NOWAIT快速失败,能有效降低死锁概率。
最后需要强调,锁争用优化不是让所有事务完全无锁,而是让锁影响范围尽量小、持有时间尽量短。结合合理索引、短事务、热点行拆分和监控定位,MySQL可以在高并发写入场景下保持稳定吞吐。如果应用层存在大量无效重试或锁等待超时,应优先排查数据库之外的连接池与超时参数,避免将问题简单归咎于MySQL本身。