一、从一次“抢购”说起 —— 行锁到底锁住了什么?

想象你经营一家网店,双十一那天,一包辣条只剩最后十包。十个用户同时下单,每个人都点“立即购买”。这时候数据库要做同一件事:把“库存”这个数字减一。如果没有并发控制,十个人同时读到库存为10,然后同时改成9,结果库存还是9,而不是0。这就是“更新丢失”。InnoDB引擎用行锁来避免这种问题:当一个人更新某一行时,这一行会被临时“锁住”,其他人必须等前面的人提交后才能操作。这种等待在业务上通常表现为“请求卡了一下”,如果卡得太久,用户就会烦躁。

等等,有人说行锁不就是把那一行锁住吗?实际上,InnoDB对“行”的理解比我们想的更微妙。它不仅会锁住你正在更新的那一行,还可能锁住一片“空位置”,用来防止其他人在并发更新时插入新数据。这就要讲到行锁家族的几个成员了。

二、InnoDB行锁家族:记录锁、间隙锁、临键锁

2.1 记录锁

记录锁是最简单的行锁,锁住的是索引记录。注意一个关键点:InnoDB的行锁是加在索引上的。如果表没有主键,它会找隐藏主键列来锁;如果连隐藏主键都没有,那它只能锁整张表,所以并非所有InnoDB表的更新都能用上行锁。因此,设计表时一定要有主键。

下面用一个MySQL示例来演示记录锁。技术栈:MySQL。

-- 技术栈: MySQL
-- 创建商品表
CREATE TABLE product (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(50) NOT NULL,
  stock INT NOT NULL
) ENGINE=InnoDB;

-- 插入一条辣条记录
INSERT INTO product (name, stock) VALUES ('辣条', 10);

-- 开始事务
START TRANSACTION;
UPDATE product SET stock = 8 WHERE id = 1;  -- 锁住id=1这一行
COMMIT;  -- 提交后释放锁

当事务执行UPDATE时,id=1这一行被加锁。另一个会话如果也想执行同样的UPDATE,会一直等待,直到第一个事务提交。InnoDB的行锁就是这样把并发写操作变成了“排队”。

2.2 间隙锁

间隙锁锁的是“记录之间的空隙”,目的是防止其他事务在某个范围内插入新行。它主要在MySQL默认的“可重复读”隔离级别下生效。间隙锁听起来简单,但很容易让人困惑:明明查询没有命中任何数据,却把一片空区域锁住了。

看下面这个示例。技术栈:MySQL。

-- 技术栈: MySQL
-- 假设product表中已经有 id=1, id=3, id=5 三条记录
START TRANSACTION;

-- 下面这个查询范围是 (3, 5),没有命中任何记录
SELECT * FROM product WHERE id > 3 AND id < 5 FOR UPDATE;
-- InnoDB会锁住(3,5)这个间隙,防止其他事务插入id=4这样的记录

COMMIT;

由于这个范围里没有数据,InnoDB会锁住(3,5)这个间隙。如果另一个事务想插入id=4的记录,就会因为间隙锁而阻塞,直到前面事务提交。这样,同一事务中如果再次查询,依然只能看到原来的三条记录,不会多出id=4,也就是防止了“幻读”。

2.3 临键锁

临键锁是记录锁和间隙锁的结合体。它锁住“左开右闭”的区间。例如记录1、3、5,临键锁会锁住(-∞,1]、(1,3]、(3,5]、(5,+∞)。InnoDB在默认隔离级别下,范围加锁时通常使用临键锁。它既能阻止其他事务修改已存在的记录,也能阻止在间隙里插入新记录,是解决幻读的核心手段。

三、并发更新场景:锁定读与非锁定读

3.1 两段锁与隔离级别

事务在执行过程中,加锁和解锁分成两个阶段:前一段只加锁,后一段只解锁。InnoDB在事务提交或回滚时统一释放所有锁。这意味着,一个事务里只要执行过更新,锁会一直持有到事务结束。如果你开了一个事务但不提交,后面所有更新这行数据的业务都会等着。这就是很多“系统卡死”问题的根源。

隔离级别会影响锁的具体行为。MySQL默认的“可重复读”会让普通查询变成快照读,不需要加锁;而更新、删除、锁定读这类语句仍然要加锁。如果切换到“读已提交”级别,InnoDB会禁用间隙锁,只保留记录锁,并发度提高了,但幻读可能发生。业务需要根据实际情况权衡,而不是所有场景都套默认值。

3.2 更新操作时的锁规则

更新一条记录时,InnoDB会通过索引定位这条记录,然后对索引记录加锁。这里最重要的规则是:更新必须走索引。如果查询条件里使用的列没有索引,或者索引区分度太低,InnoDB可能会扫描大量行,并对这些行都加锁。更严重的是,当更新扫描全表时,相当于把整个表都锁住了。

下面例子展示了无索引更新带来的风险。技术栈:MySQL。

-- 技术栈: MySQL
-- 创建一个没有索引的日志表
CREATE TABLE operation_log (
  id INT PRIMARY KEY,
  user_id INT,          -- 没有索引
  action VARCHAR(100)
) ENGINE=InnoDB;

START TRANSACTION;
UPDATE operation_log SET action = '修改' WHERE user_id = 123;
-- 由于user_id没有索引,InnoDB会扫描主键索引的全部记录,
-- 并对扫描到的每一条记录都加锁,看起来就像锁表了。
COMMIT;

这个例子提醒我们:给查询条件列建立合适的索引,是让行锁真正发挥作用的必要前提。

四、冲突解决实战:从死锁到正常

4.1 更新顺序不一致导致死锁

死锁是并发更新中最让人头疼的问题。最常见的原因是两个事务以不同顺序更新同一批记录。比如事务A先更新id=1再更新id=2,事务B先更新id=2再更新id=1。当A拿到id=1的锁,B拿到id=2的锁,接着A想等id=2,B想等id=1,双方互相等待,就形成了死循环。InnoDB检测到后,会回滚其中一个事务,让另一个继续。

下面模拟一下这个过程。技术栈:MySQL。

-- 技术栈: MySQL
-- 会话1
START TRANSACTION;
UPDATE product SET stock = stock - 1 WHERE id = 1;  -- 持有id=1的锁

-- 会话2(另开一个连接)
START TRANSACTION;
UPDATE product SET stock = stock - 1 WHERE id = 2;  -- 持有id=2的锁

-- 会话1继续更新id=2
UPDATE product SET stock = stock - 1 WHERE id = 2;  -- 等待会话2释放id=2

-- 会话2继续更新id=1
UPDATE product SET stock = stock - 1 WHERE id = 1;  -- 死锁发生

解决这个问题的通用办法很简单:让所有事务都按照同一个顺序操作记录。比如都先更新id小的,再更新id大的。在应用层,可以把一次业务需要更新的主键集合先排序,然后按顺序执行,就能极大降低死锁概率。

4.2 大事务导致锁等待超时

另一个常见冲突是一个事务执行时间过长,其他事务等待超过系统配置的锁等待超时时间(默认是50秒),然后报错。这种情况通常是因为有人开了事务,然后去做别的事情了,事务一直不提交,导致后面所有更新都卡住。

解决方向有两个:一是缩短事务时间,二是减少锁覆盖范围。比如把耗时的外部接口调用挪到事务外面,只对需要更新的几条记录开启事务;或者把一个大批量更新拆成多个小批次,每批提交一次。下面是一个分批更新的示例。技术栈:MySQL。

-- 技术栈: MySQL
-- 假设要更新1万行记录,千万不要用一条UPDATE更新全部
-- 而应该按主键范围切成小批次
START TRANSACTION;
UPDATE product SET stock = stock - 1 WHERE id BETWEEN 1 AND 1000;
COMMIT;

START TRANSACTION;
UPDATE product SET stock = stock - 1 WHERE id BETWEEN 1001 AND 2000;
COMMIT;

-- 每一批都尽快提交释放锁,给其他事务穿插的空间

注意,分批更新不是所有场景都适用。如果业务要求一次性原子完成,那不能随意拆分。但如果允许“最终一致”,分批更新能显著降低锁竞争。

4.3 通过索引优化减少锁范围

前面提到更新无索引会锁大量行,给查询条件加索引就能解决。继续用operation_log表,给user_id加一个普通索引,再执行同样语句,锁就只落在user_id=123对应的记录上。技术栈:MySQL。

-- 技术栈: MySQL
ALTER TABLE operation_log ADD INDEX idx_user_id (user_id);

START TRANSACTION;
UPDATE operation_log SET action = '修改' WHERE user_id = 123;
COMMIT;

加了索引后,执行计划会通过瞬间定位到目标行,而不是全表扫描。锁的范围瞬间缩小。这里要提醒:如果索引区分度太低,比如整个表只有两个不同的user_id,那即使有索引,扫描范围依然很大。所以高选择性的索引才是关键。

五、应用场景与优缺点分析

5.1 适合的场景

行锁机制非常适合高并发下写同一小片数据的业务。典型的包括:秒杀活动中的库存扣减、账户余额变更、订单状态流转、优惠券领取。这些业务里,同一时间通常只能有一个请求成功修改某个资源,其他请求要么等待,要么失败后重试。行锁保证了修改是串行的,同时不同资源的修改仍然可以并行,所以整体吞吐量远高于表锁。

5.2 行锁的优点

第一,并发粒度小。不同行之间的更新互相不干扰,能充分利用多CPU处理能力。第二,数据一致性好。InnoDB通过锁和事务隔离解决了脏读、不可重复读、幻读的问题,业务可以放心依赖。第三,自动死锁处理。InnoDB内部有死锁检测机制,会回滚一个事务来打破循环,不需要人为干预。

5.3 行锁的缺点和注意事项

缺点也很真实。锁本身就占用内存和CPU资源,高并发下锁管理的开销不能忽略。加锁顺序不统一时,死锁频率会上升。如果更新条件不能有效使用索引,行锁退化成“大锁”甚至“表锁”,性能反而更差。还有一个坑是:如果你在事务里做了很多操作,其中某一步失败回滚,其他事务等你的时间就会被白白浪费。

注意事项包括:尽量保持事务短小;合理设置隔离级别;查看InnoDB引擎的状态信息来定位死锁和锁等待;查询系统中的锁等待视图,快速判断谁在等待谁。下面给出查询命令。技术栈:MySQL。

-- 技术栈: MySQL
-- 查看最近一次死锁相关信息
SHOW ENGINE INNODB STATUS;

-- 查看当前有哪些锁等待
SELECT * FROM performance_schema.data_lock_waits;

六、注意事项与性能调优建议

先说一个容易忽略的点:在“可重复读”隔离级别下,范围查询加上锁定读,可能锁住一个非常大的范围。如果你只是读取数据,并不打算修改,千万不要用锁定读。只有确定需要锁定某些行防止其他事务修改时,才应该使用它。

另外,很多初学者习惯先查询,判断库存是否充足,再执行扣减。这个流程其实很危险,因为两次操作之间可能有其他事务改了数据。正确做法是把判断和更新合并到一条更新语句里。技术栈:MySQL。

-- 技术栈: MySQL
-- 原子扣减:只有库存大于0才扣减,一条语句完成判断和更新
START TRANSACTION;
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 受影响行数为1表示扣减成功,为0表示库存不足
COMMIT;

这个写法只有一条更新语句,事务极短,锁持有时间极短,冲突概率大大降低。当然,如果你的业务在扣减前需要读取库存值并做其他逻辑,那就要用其他方案了。

另一个调优建议是调整锁等待超时时间。默认50秒过长,用户等待时间太久。可以把它改成5秒,让锁等待快速超时,再由应用层返回“操作繁忙,请重试”。但也不能太短,否则一些正常但需要几秒的事务会被误杀。建议根据业务响应时间评估,一般设置2到5秒比较稳妥。

死锁的另一个隐蔽来源是“使用不同索引”。假设一张表有主键id和唯一键code。事务A通过code更新了一行,事务B通过id更新了另一行,然后互相更新对方持有的行。虽然这看起来是两条不同索引,但底层的加锁顺序不同,也可能触发死锁。解决办法是统一约定:所有事务都优先按主键操作,不要混用索引。

最后,在开发阶段要养成检查锁等待的习惯。你可以先在一个会话里开启事务并更新数据,再在另一个会话里尝试相同操作,观察是否卡住。通过这种方式,可以很直观地理解锁的阻塞行为。生产环境要慎用这种操作,最好只在维护窗口或者测试环境做。

七、总结

InnoDB的行锁机制是数据库并发控制的核心。它通过记录锁、间隙锁、临键锁,让同一行上的更新变成“排队”,同时允许不同行上的更新并行,从而在数据一致性和并发性能之间找到平衡。但行锁不是银弹,它需要配合索引、短事务、合适的隔离级别和一致的加锁顺序来使用。对于并发更新业务,我们要做的不是害怕锁,而是理解锁的粒度、作用范围和触发条件,然后设计出更稳更快的数据库访问方式。记住:给更新条件加好索引,把事务缩短,让所有事务按相同顺序访问资源,你的并发更新就能跑得又稳又快。