
读者你好,我是《Redis 高手心法》畅销书作者,可以叫我码
“余弦:码哥,我今天面试被问到 “事务并发执行会带来什么问题,并发安全如何解决呢?MySQL 有哪些锁?”
今天我要跟你聊聊 MySQL 的锁。数据库锁设计的初衷是处理并发问题。
并发事务访问相同记录的情况大致可以划分以下几种:
读-读情况:即并发事务相继读取相同的记录。读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。写-写情况:即并发事务相继对相同的记录做出改动。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。
而锁就是用来实现这些访问规则的重要数据结构。
根据加锁的范围,MySQL 的锁可以分为全局锁、表锁和行锁。
“对于
MyISAM、MEMORY、MERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。
“余弦:什么是全局锁?干嘛用的?
全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。
当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞。
全局锁的适应场景之一,做全库的逻辑备份。把整个库的表数据都查出来存储为文本。
“余弦:让整库都只读,在备份期间都不能执行更新,业务基本上就得停摆。这怎么办?
是的。
比如手机卡,购买套餐信息。这里分为两张表 u_acount (用于余额表),u_pricing (资费套餐表)。
可以看到备份的结果是,u_account 表中的数据没有变, u_pricing 表中的数据 已近购买了资费套餐 100.
哪这时候用这个备份文件来恢复数据的话,用户 A 赚了 100 ,用户是不是很舒服啊。但是你的想想公司利益啊。
也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个数据是逻辑不一致的。
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。
表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。
另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。
当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
不过请尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。
InnoDB的厉害之处还是实现了更细粒度的行锁,关于表级别的锁大家了解一下就罢了。
“余弦:在使用
MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性。之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值。 这是什么锁?
比方说我们有一个表:
CREATE TABLE t (
id INT NOT NULL AUTO_INCREMENT,
c VARCHAR(100),
PRIMARY KEY (id)
) Engine=InnoDB CHARSET=utf8;
系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:
AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。INSERT ... SELECT、REPLACE ... SELECT或者LOAD DATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。t的例子中,在语句执行前就可以确定要插入 2 条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。行锁,也称为记录锁,顾名思义就是在记录上加的锁。这是最复杂的锁,前面的只是开胃菜。
一个行锁玩出了各种花样,也就是把行锁分成了各种类型。
MySQL 的行级锁是 InnoDB 存储引擎实现高并发的核心技术之一。它允许在保证数据一致性的同时,大幅提升数据库的并发处理能力。
下面这张表格汇总了行级锁的主要类型和核心特点,可以帮助你快速建立整体印象。
锁类型 | 英文名 | 锁定对象 | 主要作用 | 常见触发方式 |
|---|---|---|---|---|
记录锁 | Record Lock | 索引中的单条记录 | 防止其他事务修改或删除被锁定的记录 | SELECT ... FOR UPDATE或更新语句通过索引精准匹配到一条存在记录时 |
间隙锁 | Gap Lock | 索引记录之间的间隙 | 防止其他事务在间隙中插入新数据,从而避免“幻读” | 范围查询(如 WHERE id BETWEEN 5 AND 10)或查询不存在的唯一记录时 |
临键锁 | Next-Key Lock | 记录锁 + 间隙锁,锁定一个左开右闭的区间 | 既防止幻读,又保证当前读的数据一致性,是 InnoDB 在可重复读隔离级别下的默认锁算法 | 范围查询或在非唯一索引上的等值查询时 |
从锁的互斥性来看,行级锁分为共享锁和排他锁,它们的兼容关系是理解锁冲突的基础。
SELECT ... LOCK IN SHARE MODE;或 SELECT ... FOR SHARE;SELECT ... FOR UPDATE;显式加锁。UPDATE, DELETE, INSERT语句会自动对其操作的记录加排他锁。它们的兼容关系可以总结为下表:
当前已持有的锁 | 请求 共享锁 (S) | 请求 排他锁 (X) |
|---|---|---|
共享锁 (S) | ✔️ 兼容 | ❌ 冲突 |
排他锁 (X) | ❌ 冲突 | ❌ 冲突 |
行锁基于索引实现
这是理解行锁最核心的一点。InnoDB 的行锁是加在索引项上的,而不是直接加在物理数据行上的。这意味着:
WHERE条件必须能够有效命中索引。两阶段锁协议(Two-Phase Locking, 2PL)
InnoDB 遵循此协议,锁的操作分为两个阶段:
SELECT ... FOR UPDATE)放在事务的后面执行,以缩短排他锁的持有时间。意向锁(Intention Locks)
意向锁是表级锁,用于快速判断表内是否有被锁定的行,从而避免为了检查行锁而需要遍历每一行的低效操作。
理解行锁的关键在于区分其三种基本类型。下图通过一个数据索引的例子,清晰展示了三种锁的锁定范围差异,这是理解所有高级锁概念的基础。

图解说明:
id=10)。它确保在更新或删除某条确切存在的记录时,其他事务无法修改或删除它。(10, 15))。它的存在仅仅是为了防止插入(防止幻读),但不会阻止其他事务修改这个区间内已存在的记录(例如,如果id=12存在,你仍然可以修改它)。(5, 10]意味着它锁定了id=10这条记录,也锁定了5到10之间的间隙。这能有效防止在10之前插入新数据(解决幻读),同时保护10这条记录本身。以下通过示例和场景进一步解释这三种锁。
记录锁(Record Lock)
它锁住的是索引项。例如,执行 SELECT * FROM users WHERE id = 10 FOR UPDATE;会在 id=10这个索引项上加一个排他型的记录锁,防止其他事务修改或删除这行数据。
间隙锁(Gap Lock)
它锁住的是索引项之间的“空隙”,以防止其他事务在这个空隙中插入新数据,从而解决“幻读”问题。
间隙锁只在可重复读(REPEATABLE READ)及以上隔离级别生效
users的 id字段有值 5, 10, 15。SELECT * FROM users WHERE id BETWEEN 10 AND 15 FOR UPDATE;id=10和 15的记录,还会锁住它们之间的间隙 (10, 15)。INSERT INTO users (id) VALUES (12);会被阻塞,因为 12落在了被锁定的间隙内。临键锁(Next-Key Lock)
它是 InnoDB 在可重复读(REPEATABLE READ)隔离级别下默认使用的锁算法。
它相当于一个 记录锁 + 间隙锁,锁定一个左开右闭的区间 (previous_index, current_index]。
id值为 5, 10, 15 的表。SELECT * FROM users WHERE id > 10 FOR UPDATE;(10, 15]和 (15, +∞)。(10, 15)区间内插入新的 id=12,也防止了修改或删除 id=15的现有记录,同时还防止了插入任何大于 15 的新 ID。“死锁是如何产生的呢?
行级锁虽然提升了并发度,但也带来了死锁的风险。当两个或多个事务互相等待对方释放锁时,就会形成死锁。
理解 MySQL 中事务的加锁流程以及死锁如何形成,是构建高并发应用的基石。
下面我们通过一个清晰的流程图来展示一个安全的事务加锁/解锁全过程,然后深入剖析几种典型的死锁场景。
首先要明确一个核心概念:两阶段锁协议。它规定锁的操作分为两个阶段:
COMMIT)或回滚(ROLLBACK)时,一次性释放所有在该事务中获取的锁。下面的序列图清晰地展示了一个安全、无冲突的事务加锁与解锁流程。

流程解读:
SELECT ... FOR UPDATE语句,意图锁定某行数据(例如行 1)进行更新。此时,它会向锁管理器申请该行的排他锁(X Lock)COMMIT)后,进入解锁阶段,一次性释放它持有的所有锁。这个流程是理想状态下的。但当多个事务并发执行且锁的获取顺序出现环状依赖时,死锁就发生了。
这个流程是理想状态下的。但当多个事务并发执行且锁的获取顺序出现环状依赖时,死锁就发生了。
这是非常经典的死锁情况,常发生在先读后写的业务逻辑中。
时间点 | 事务 A | 事务 B |
|---|---|---|
T1 | SELECT * FROM accounts WHERE id=1 LOCK IN SHARE MODE; (获得 id=1 的S 锁) | |
T2 | SELECT * FROM accounts WHERE id=1 LOCK IN SHARE MODE; (也获得 id=1 的S 锁) | |
T3 | UPDATE accounts SET balance = balance - 100 WHERE id=1; (尝试将S 锁升级为 X 锁,但需等待事务 B 的 S 锁释放,阻塞) | |
T4 | UPDATE accounts SET balance = balance - 50 WHERE id=1; (也尝试将S 锁升级为 X 锁,但需等待事务 A 的 S 锁释放) |
死锁形成:
当多个事务以不同的顺序访问和锁定资源时,极易发生死锁。
时间点 | 事务 A | 事务 B |
|---|---|---|
T1 | UPDATE accounts SET ... WHERE id=1; (成功获得 id=1 的X 锁) | |
T2 | UPDATE accounts SET ... WHERE id=2; (成功获得 id=2 的X 锁) | |
T3 | UPDATE accounts SET ... WHERE id=2; (尝试获取 id=2 的 X 锁,被事务 B 阻塞) | |
T4 | UPDATE accounts SET ... WHERE id=1; (尝试获取 id=1 的 X 锁,被事务 A 阻塞) |
死锁形成:
在可重复读(REPEATABLE READ)隔离级别下,MySQL 会使用间隙锁(Gap Lock)来防止幻读,这也可能引发更复杂的死锁。
假设 accounts表 id 有 1, 5, 10 三个值,存在间隙 (1,5), (5,10)。
时间点 | 事务 A | 事务 B |
|---|---|---|
T1 | SELECT * FROM accounts WHERE id=3 FOR UPDATE; (在间隙(1,5)上加了Gap 锁) | |
T2 | SELECT * FROM accounts WHERE id=4 FOR UPDATE; (同样在间隙(1,5)上加了Gap 锁,Gap 锁之间不互斥,成功) | |
T3 | INSERT INTO accounts (id, ...) VALUES (3, ...); (尝试插入,需要获取插入意向锁,与事务 B 的 Gap 锁冲突,阻塞) | |
T4 | INSERT INTO accounts (id, ...) VALUES (4, ...); (同样需要获取插入意向锁,与事务 A 的 Gap 锁冲突) |
死锁形成:
nnoDB 存储引擎内置了死锁检测机制。当检测到死锁时,它会选择一个回滚代价较小的事务(通常是影响行数较少的事务)进行回滚,并让另一个事务继续执行。
被回滚的事务会收到 ERROR 1213 (40001): Deadlock found错误。
事务的加锁解锁遵循“两阶段锁协议”,提交或回滚时释放所有锁。死锁的本质是事务间形成了对锁资源的循环等待。
通过理解其原理并采用固定的资源访问顺序、使用乐观锁、减小事务粒度等预防措施,可以显著降低死锁发生概率。
希望这些图解和说明能帮助你更深入地理解 MySQL 的锁机制。