MySQL锁
# MySQL 锁
# 锁的类型
- InnoDB实现了两种标准的行级锁:
- 共享锁(S Lock)
- 语法:
SELECT * FROM table FOR SHARE(8.0 起的写法;LOCK IN SHARE MODE是 5.7 的旧语法,8.0 仍兼容但不建议在新代码里用)。
- 语法:
- 排他锁(X Lock)
- 语法:
SELECT * FROM table FOR UPDATE。
- 语法:
- 共享锁(S Lock)
- 如果一个事务T1已经获取了行r的共享锁,另一个事务T2可以立即获得行r的共享锁。因为读取不会改变行的数据,所以多个事务可以同时获取共享锁,这称为锁兼容。
- 但是,如果有其他事务T3想获得行r的排他锁,则必须等待事务T1和T2释放行r上的共享锁,这称为锁不兼容。
| X | S | |
|---|---|---|
| X | 不兼容 | 不兼容 |
| S | 不兼容 | 兼容 |
版本说明
本文写于 2022-09,基于 MySQL 8.0 InnoDB。
行锁/表锁/间隙锁等锁机制未变,经 2026-07 复核仍适用。
- 普通的 SELECT 语句默认不加锁(快照读,走 MVCC);INSERT / UPDATE / DELETE 会对扫描到的行加排他锁——注意是「扫描到的」而不是「最终改动的」,条件没走索引时两者差别巨大。
# 锁的粒度
| 锁级别 | 说明 |
|---|---|
| 表级锁 | 开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。 |
| 行级锁 | 开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。 |
| 页面锁 | 开销和加锁时间介于表锁和行锁之间。InnoDB 和 MyISAM 都不提供页面锁,这一行是数据库通论里的分类,MySQL 里只有早已移除的 BDB 引擎用过,实际选型时不用考虑。 |
- 以下情况表锁反而更划算(注意这是 MyISAM 语境,InnoDB 上一般不需要手动
LOCK TABLES):- 表的大部分行用于读取。
- 对严格的关键字进行读取和更新:
UPDATE tbl_name SET column=value WHERE unique_key_col=key_value;DELETE FROM tbl_name WHERE unique_key_col=key_value;
- SELECT 结合并行的 INSERT 语句,并且只有很少的 UPDATE 或 DELETE 语句。
- 在整个表上有许多扫描或 GROUP BY 操作,没有任何写操作。
# 死锁
InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON),检测到环路后会主动回滚其中一个事务来打破死锁,被选中的是代价最小的那个——判据是该事务在 undo 里累积的修改量(插入/更新/删除的行数),不是「持有锁最少」。被回滚方收到 ERROR 1213 (40001): Deadlock found,应用侧必须有重试逻辑,把它当成正常会发生的事件处理,而不是异常告警。
死锁细节与典型场景见 MySQL死锁问题。
# 乐观锁与悲观锁
# 乐观锁
- 通过数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。
- 为数据增加一个版本标识,通常通过为数据库表增加一个数字类型的
version字段来实现。 - 当读取数据时,将 version 字段的值一同读出,每次更新数据时,对 version 值加 1。
- 当提交更新时,判断数据库表对应记录的当前版本信息与第一次取出的 version 值是否相等,如果相等,则予以更新,否则认为是过期数据。
- 为数据增加一个版本标识,通常通过为数据库表增加一个数字类型的
-- 读取数据和版本号
SELECT id, value, version FROM TABLE WHERE id = <id>;
-- 按特定版本号更新
UPDATE TABLE
SET value = 2, version = version + 1
WHERE id = <id> AND version = <version>;
2
3
4
5
6
7
# 悲观锁
- 即上面提到的共享锁和排他锁。
# 锁升级:InnoDB 没有这个机制
网上大量中文资料会写「InnoDB 的锁升级阈值是 5000 行」「锁内存超过 40% 触发升级」——这是 SQL Server 的锁升级(Lock Escalation)机制,InnoDB 没有。照着这个结论去解释线上现象,会把根因找错。
InnoDB 的实际做法是:行锁在页级用位图管理。一个事务在同一个页上锁 1 行还是锁 100 行,锁结构的内存开销基本一致,所以 InnoDB 不需要「锁太多了就升级成表锁」这种降级保护。
那为什么线上确实会看到「明明只更新一行,却像锁了整张表」?真实原因是下面这三种,都不是锁升级:
- 没走索引,退化成全表加锁。
UPDATE t SET c=1 WHERE no_index_col=5会扫全表,扫到的每一行都要加锁——现象上等同于锁表,但机制是「扫了多少行就锁多少行」。这也是「更新条件一定要走索引」的硬性理由。 - 间隙锁 / next-key lock 把范围整段锁住。RR 隔离级别下范围条件会锁住索引记录之间的间隙,阻塞该范围内的插入,影响面远大于「几行」。
- 元数据锁(MDL)。DDL 会持有 MDL 写锁,与之冲突的查询全部排队,
SHOW PROCESSLIST里表现为大量Waiting for table metadata lock——这是最常被误认成「锁升级」的一类。
验证「到底锁了什么」不要靠猜,直接查(MySQL 8.0):
-- 当前持有和等待中的锁
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
-- 谁在等谁
SELECT * FROM sys.innodb_lock_waits\G
2
3
4
5
6
LOCK_MODE 里出现 X,GAP、X,REC_NOT_GAP、X(即 next-key)能直接区分上面第 2 种情况;LOCK_TYPE=TABLE 且 LOCK_MODE=IX 只是意向锁,不代表表被锁住了——意向锁之间互相兼容,这也是一个高频误读。
# MySQL 并发更新数据的加锁处理
- MySQL 支持给数据行加锁(InnoDB),并且在 UPDATE/DELETE 等操作时会自动加上排他锁。
- 但并非只要有 UPDATE 关键字就会全程加锁,例如:
UPDATE table1 SET num = num + 1 WHERE id = 1;
- 这条语句实际上相当于两条 SQL 语句(伪代码):
a = SELECT * FROM table1 WHERE id = 1;
UPDATE table1 SET num = a.num + 1 WHERE id = 1;
2
- 其中执行 SELECT 语句时没有加锁,只有在执行 UPDATE 时才加锁,这会导致并发操作时的数据更新不一致。
- 解决方法有两种:
- 通过事务显式对 SELECT 加锁。
- 使用乐观锁机制。
# SELECT 显式加锁
- 对 SELECT 进行加锁的方式有两种:
SELECT ... FOR SHARE; -- 共享锁(8.0 语法,旧写法 LOCK IN SHARE MODE 仍兼容)
SELECT ... FOR UPDATE; -- 排他锁
2
「其它事务不可读写」这个说法是错的,这是关于 FOR UPDATE 最常见的误解:它只挡住当前读(其他事务的 FOR UPDATE / FOR SHARE / UPDATE / DELETE),挡不住普通 SELECT——普通 SELECT 是快照读,走 MVCC 读 undo 里的历史版本,不需要加锁也不会被阻塞。所以:
- 想让并发更新串行化 → 用
FOR UPDATE,有效 - 想让别人「读不到旧值」→
FOR UPDATE做不到,那是隔离级别的事(见 MySQL的事务隔离级别)
8.0 还补了两个实用修饰符,避免锁等待把连接堆死:
SELECT ... FOR UPDATE NOWAIT; -- 拿不到锁立刻报错 ERROR 3572,不等
SELECT ... FOR UPDATE SKIP LOCKED; -- 跳过已被锁住的行,做队列表分发很好用
2
- 对于上述场景,必须使用排它锁。
- 上述两种语句只有在事务中才能生效,否则不会生效。在 MySQL 命令行中使用事务的方式如下:
SET AUTOCOMMIT = 0;
BEGIN WORK;
a = SELECT num FROM table1 WHERE id = 2 FOR UPDATE;
UPDATE table1 SET num = a.num + 1 WHERE id = 2;
COMMIT WORK;
2
3
4
5
- 这样在更新数据时使用事务操作,可以在并发情况下通过锁将并发改为顺序执行。
# 使用乐观锁
- 乐观锁是锁实现的一种机制,它假定所有需要修改的数据都不会冲突。
- 在更新之前不加锁,而是查询数据行的版本号(版本号是自定义字段,需在业务表上增加,每次更新时自增或更新)。
- 在具体更新数据时,更新条件中添加版本号信息。当版本号未变化时,说明数据行未被更新过,满足更新条件,因此更新成功。
- 当版本号变化时,条件不满足,需要重新查询数据行,再次使用新的版本号进行更新。
原则上,两种方式都可支持,具体使用哪一种取决于实际业务场景,对哪种支持更好,并且对性能影响最小。
# 坑与边界
data_locks只看得到当前还持有的锁。事务提交/回滚后锁立即消失,事后排查锁冲突要靠SHOW ENGINE INNODB STATUS(最近一次死锁)或提前打开innodb_print_all_deadlocks=ON把每次死锁都记进错误日志。- 意向锁(IS/IX)不是表锁。
performance_schema.data_locks里几乎每个写事务都会有一条LOCK_TYPE=TABLE, LOCK_MODE=IX,它只是「我打算在这张表里加行锁」的声明,彼此兼容,看到它不代表表被锁了。 - 乐观锁不是数据库提供的锁。本文「乐观锁」一节讲的 version 字段是应用层协议,MySQL 对此一无所知——它没有加任何锁,靠的是
UPDATE ... WHERE version=?的影响行数为 0 来发现冲突。所以它只能保护走这套约定的写入路径:任何绕过 version 条件的 SQL(手工改数、别的服务直写、导数任务)都会悄悄破坏它。 autocommit=1下单条SELECT ... FOR UPDATE等于白加锁:语句结束事务就提交,锁立刻释放。加锁读必须包在显式事务里。- RR 与 RC 下的加锁范围不同。RC 没有间隙锁,锁冲突面小得多,但代价是不可重复读;如果线上大量因间隙锁产生的死锁,把隔离级别降到 RC 是常见解法,但要先确认业务能接受。