SQL 数据库锁 超清晰总结
一、按锁粒度分(最核心分类)
1. 行锁(Row Lock)
- 锁一行数据
- MySQL InnoDB 默认
- 并发高、冲突小、容易出现死锁
- 例:
UPDATE 表 WHERE id=1只锁 id=1 这一行
2. 表锁(Table Lock)
- 锁整张表
- 并发极低,一锁全表不能写
- MyISAM 默认、InnoDB 不加索引时会退化成表锁
- 例:
UPDATE 表 WHERE name='张三'(name 无索引 → 全表锁)
3. 页锁(Page Lock)
- 锁一页数据(很少用,了解即可)
- 锁定数据页(一组相邻记录)。是行级和表级锁的折中,主要用于 SQL Server 等数据库。
4. 全局锁
- 锁定整个数据库实例,让库变为只读状态。典型命令是 MySQL 的 FLUSH TABLES WITH READ LOCK,常用于全库备份。
二、按锁功能分(面试高频)
1. 共享锁 / 读锁(Shared Lock,S锁)
- 允许多个事务同时读
- 但不能写
- 行为:一个事务加了 S 锁后,其他事务还能再加 S 锁,但不能加排他锁,直到 S 锁释放。
- 手动加锁:
sql
SELECT * FROM 表 LOCK IN SHARE MODE;
2. 排他锁 / 写锁(Exclusive Lock,X锁)
- 只有一个事务能写
- 其他人既不能读也不能写
- 行为:一个事务加了 X 锁后,其他事务无法再加任何锁(S 或 X)
UPDATE / DELETE / INSERT自动加排他锁- 手动加锁:
sql
SELECT * FROM 表 FOR UPDATE;
3. 意向锁
表级锁,完全由系统自动管理,为了协调行级锁和表级锁。事务想给某行加锁前,会先在表级加个意向锁,声明"我想做某类操作"。
-
意向共享锁(IS):事务想在某些行上加共享锁。
-
意向排他锁(IX):事务想在某些行上加排他锁。
这样,当有事务想锁整张表时,只需检查表的意向锁,就知道有没有行被锁住,而不用一行行检查。
意向锁是和行锁搭配工作的,我们以事务 T1 执行为例:
sql
-- 事务 T1
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
它的内部加锁顺序是:
- 在 users 表上,申请并加上意向排他锁(IX)。
- 加表锁成功后,再在 id = 1 的行记录上,加上排他锁(X)。
如果此时事务 T2 想锁定整张表:
sql
-- 事务 T2
LOCK TABLES users WRITE;
- T2 的请求需要 users 表上的排他锁。
- 系统检查发现,T1 已在表上持有 IX 锁。
- IX 和 X 锁冲突,因此 T2 的请求会进入等待,直到 T1 提交或回滚。
4. 更新锁
用于解决"先读后写"操作(如UPDATE的查找阶段)中的死锁问题。SQL Server 中常用,它允许共享读,但只允许一个事务获得更新锁,并最终升级为排他锁。
典型的 UPDATE 流程是两步:先找到行,再修改。如果最初用共享锁来"找行",就埋下了死锁隐患。
死锁推演:
- 事务A SELECT ... FOR SHARE 找到数据,持有该行的共享锁 (S)。
- 事务B 也 SELECT ... FOR SHARE 同一行,共享锁兼容,也成功持有S锁。
- 事务A 执行 UPDATE,想把自己的S锁升级为排他锁 (X),但必须等事务B释放S锁。
- 事务B 也执行 UPDATE,同样想升级为X锁,但必须等事务A释放S锁。
双方互相等待,死锁就发生了。
更新锁(U锁)正是为了打破这个循环而设计的。
更新锁的核心规则
它的工作规则很简单:
- 与共享锁兼容:U锁和S锁可以共存,满足最初的"读"需求。
- 与自身互斥:一个资源上只能有一个U锁。这从源头上阻止了多个事务同时持有U锁并等待升级。
- 可直接升级为排他锁:U锁持有者可以将其直接升级为X锁,而无需先释放。
1. 安全的"先读后写"
这是最经典、也是最正确的用法。通过在 SELECT 阶段就加上 (UPDLOCK),确保后续更新安全无死锁。
sql
BEGIN TRANSACTION;
-- 查询时立刻对该行加上更新锁(U)
SELECT * FROM Products WITH (UPDLOCK)
WHERE ProductID = 1;
-- 判断逻辑,比如检查库存...
-- IF ...
-- 后续更新,此时U锁将无缝升级为排他锁(X)
UPDATE Products
SET Stock = Stock - 1
WHERE ProductID = 1;
COMMIT TRANSACTION;
此时,如果另一个事务也执行同样的 WITH (UPDLOCK) 查询,它的U锁请求会因为U锁互斥而立刻等待,这就避免了之前共享锁升级造成的死锁环。
2. 避免丢失更新
直接用 WITH (UPDLOCK) 做读取-修改-写回,也是实现悲观锁、防止并发丢失更新的一种手段。
sql
-- 事务A
BEGIN TRANSACTION;
-- 读出当前值并锁定
SELECT @current_value = Balance FROM Accounts WITH (UPDLOCK) WHERE AccountID = 100;
-- 计算新值
SET @new_value = @current_value + 500;
-- 基于锁的保护进行更新
UPDATE Accounts SET Balance = @new_value WHERE AccountID = 100;
COMMIT TRANSACTION;
3. 在可序列化隔离级别下,用更新锁防止幻读
在需要范围查询并可能后续插入的场景下,可将 UPDLOCK 和 SERIALIZABLE 结合使用。
sql
BEGIN TRANSACTION;
-- 检查订单是否存在,对查询范围施加更新锁和范围锁
IF NOT EXISTS (
SELECT 1 FROM Orders WITH (UPDLOCK, SERIALIZABLE)
WHERE OrderDate = '2023-01-01' AND CustomerID = 5
)
BEGIN
-- 如果不存在则插入
INSERT INTO Orders (OrderDate, CustomerID) VALUES ('2023-01-01', 5);
END
COMMIT TRANSACTION;
与 SELECT ... FOR UPDATE 的对比
- MySQL 的做法
SELECT ... FOR UPDATE 会直接加排他锁 (X)。它在读取阶段就把数据强锁住,虽然也避免了升级死锁,但并发度会更低,因为X锁与任何锁都互斥。 - 更新锁(U锁)的优势
它是一种更精细化的设计。在读取阶段,它允许共享锁(S)共存,不影响纯读操作,同时通过自身的互斥性避免了死锁。可以说是并发度和安全性之间的一个更好平衡。
总之,在 SQL Server 中,通过 WITH (UPDLOCK) 这个提示,你就能显式控制更新锁,它是实现安全"先读后写"操作的关键工具。
5. 自增锁
特指 MySQL 里 AUTO_INCREMENT 列的锁,保证自增 ID 唯一。它有多种锁模式(传统、连续、交错),可由 innodb_autoinc_lock_mode 参数控制。
自增锁(AUTO-INC Lock)是一种特殊的表级锁 ,专门用于保护 AUTO_INCREMENT 列的并发赋值。它和意向锁一样,完全由数据库自动管理 ,无法通过 SQL 显式调用。我们能控制的是它的行为模式。
核心目标:保证主键唯一,而非连续
首先要明确,自增锁的核心任务是保证自增 ID 的唯一性 ,并不保证连续性。出现回滚或冲突时,已分配的 ID 会被浪费,这是设计取舍。
三种工作模式
自增锁的行为由 MySQL 参数 innodb_autoinc_lock_mode 控制,有 0、1、2 三种。我们结合 INSERT 的几种类型来看,它们的核心区别在于锁的粒度和释放时机。
- 简单插入 :能提前确定行数,如
INSERT INTO t VALUES (1,'a'), (2,'b')。 - 批量插入 :不能提前确定行数,如
INSERT INTO t SELECT ... FROM s。 - 混合插入 :如
INSERT INTO t (name) VALUES ('a'), (NULL, 'b'),部分值自增。
| 模式 | 行为特点 | 主要影响 |
|---|---|---|
| 0-传统模式 | 所有 INSERT 都加表级自增锁,语句执行完才释放。 |
并发最低,主从复制最安全 (基于语句复制时 ID 一定连续)。 |
| 1-连续模式 (默认) | • 简单插入 :用轻量级互斥量,拿到所需 ID 就释放,不用等语句结束。 • 批量插入:同传统模式,加表级锁直到语句结束。 | 性能与安全的平衡 。缺点 :二进制日志用 STATEMENT 格式时,批量插入的复制不安全,必须用 ROW 格式。 |
| 2-交错模式 | 所有插入都立即释放锁,ID 分配是所有事务交错的。 | 并发最高。缺点 :任何基于 STATEMENT 的复制都不安全,且 ID 可能不连续。 |
核心使用方法:配置与排查
日常开发中,你的"使用方法"主要是这三点:
1. 根据场景设置模式
在配置文件 my.cnf 中设置:
ini
[mysqld]
# 使用默认的连续模式,适合大多数场景
innodb_autoinc_lock_mode = 1
# 若主从复制用的是 STATEMENT 格式,可能需要更安全的传统模式
# innodb_autoinc_lock_mode = 0
# 若全用 ROW 格式复制且追求极高插入并发,可考虑交错模式
# innodb_autoinc_lock_mode = 2
2. 排查锁等待
自增锁在表级冲突,现象是大量 INSERT 卡在 "AUTO-INC lock waiting"。
sql
-- 查看正在等待自增锁的线程
SELECT * FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE = 'TABLE' AND LOCK_TYPE = 'AUTO-INC';
结合 SHOW ENGINE INNODB STATUS\G 就能看到哪个事务长时间持有自增锁不释放。
3. 优化批量插入
在默认模式 1 下,要避免让简单的单行插入,被大的、不确定行数的批量插入阻塞。
sql
-- 这种插入行数不确定,会持有表级自增锁直到结束,阻塞所有插入
INSERT INTO t (data) SELECT data FROM huge_table;
-- 如果业务允许,可手动分批提交,或考虑用程序先在外部取完数据再插入。
三种模式场景总结
- 0-传统 :老系统或主从复制仍用
STATEMENT格式时,用于保证 ID 绝对连续。 - 1-连续(默认):线上系统首选。大事务可能引发锁等待,需关注。
- 2-交错 :批量插入极大负载,且主从复制为
ROW格式,且不关心 ID 连续性时使用。
切换到模式 2 前,要确保 replication 配置中 binlog_format 设置为 ROW,否则主从数据可能不一致。
三、按实现方式分
1. 乐观锁(Optimistic Lock)
与后面讲的记录锁、间隙锁等由数据库内核自动管理的悲观锁不同,乐观锁是一种应用层级的并发控制策略。
它的核心思想是:假定冲突很少发生,操作时不加锁,只在最终提交更新时检查数据是否被修改过。 如果被改了,就回滚重试。
乐观锁的核心实现:版本号或时间戳
乐观锁不依赖 FOR UPDATE,而是在你的业务表里加一个字段,最常用的是 version。
1. 表结构设计
在你的业务表中,必须有一个用于版本校验的字段。
sql
-- 常用的版本号字段
ALTER TABLE accounts ADD COLUMN version INT NOT NULL DEFAULT 0;
-- 或者用时间戳
ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
2. 更新的标准 SQL 写法
逻辑是:读出数据时带出版本号,更新时在 WHERE 里校验这个版本号,同时把版本号加 1。
sql
-- 1. 查询余额,同时获取当前版本号
SELECT balance, version FROM accounts WHERE account_id = 100;
-- 假设结果:balance = 500, version = 3
-- 2. 应用程序计算新余额
-- new_balance = 500 - 100 = 400
-- 3. 执行带版本号校验的更新
UPDATE accounts
SET balance = 400, version = version + 1
WHERE account_id = 100 AND version = 3;
核心判断:更新后的受影响行数。
- Affected rows = 1:版本号匹配,更新成功。
- Affected rows = 0 :说明在读取后,有别的事务抢先更新了数据(
version已经不是 3 了)。此时你的应用程序应该处理"更新失败"的逻辑,通常是重试。
完整的使用模式(含重试)
将查询、计算、更新、重试整合起来,就是乐观锁的标准实现。
python
# 伪代码示例
max_retry = 3
retry_count = 0
while retry_count < max_retry:
# 1. 查询当前数据及版本
cursor.execute("SELECT balance, version FROM accounts WHERE account_id = 100")
row = cursor.fetchone()
if not row:
print("账户不存在")
break
current_balance = row['balance']
current_version = row['version']
# 2. 业务逻辑计算
new_balance = current_balance - 100
# 3. 带版本号条件的更新
cursor.execute(
"UPDATE accounts SET balance = %s, version = version + 1 "
"WHERE account_id = %s AND version = %s",
(new_balance, 100, current_version)
)
conn.commit()
# 4. 检查结果
if cursor.rowcount > 0:
print("更新成功!")
break
else:
retry_count += 1
print(f"版本冲突,正在进行第 {retry_count} 次重试...")
乐观锁的适用与不适用场景
最适合的场景:
- 读多写少:冲突概率低,重试开销可忽略。
- 短事务:持有版本号的时间窗口短,产生冲突的几率就小。
- 无法长时间持锁的情况:如 Web 应用的"先读取-后修改"模式,用户可能在编辑页面停留很久,悲观的长事务会严重阻塞他人。
不适合的场景:
- 写并发极高:冲突变成常态,会导致大量重试,性能急剧下降。这种场景应使用悲观锁。
- 需要强一致性的复杂操作:乐观锁模型较简单,若涉及跨多表、多行的一致性状态变更,实现起来会很复杂,且重试代价高。数据库的悲观锁和事务是更好的选择。
乐观锁与悲观锁的对比
| 特性 | 乐观锁 (Optimistic) | 悲观锁 (Pessimistic) |
|---|---|---|
| 假设 | 冲突很少发生 | 冲突很可能发生 |
| 实现 | 应用层通过 version 字段校验 |
数据库内核的行锁/表锁 (FOR UPDATE) |
| 数据锁定时机 | 只在提交更新时校验,全程无锁 | 读取时就锁定数据 |
| 资源开销 | 消耗 CPU 做重试,无锁等待 | 消耗数据库锁和连接资源,事务可能长时间等待 |
| 最适场景 | 读多写少,Web 应用 | 写并发高,后台批处理 |
| 死锁风险 | 无(没有锁等待) | 有 |
需要我接着讲讲如何在后端应用中,用 AOP 或拦截器封装这个重试逻辑吗?
2. 悲观锁(Pessimistic Lock)
与乐观锁不同,悲观锁的核心思想是:假定冲突一定会发生,所以在读取数据的瞬间就将其锁定,直到事务结束才释放。
它由数据库内核实现,是之前聊过的行锁、表锁、临键锁等的具体应用。
使用方式:显式锁定读
悲观锁主要通过 SELECT ... FOR UPDATE 和 SELECT ... FOR SHARE 实现。它们必须在事务中使用,否则读完后锁立即释放,毫无意义。
| 语句 | 加的锁 | 其他事务还能做什么 | 典型用途 |
|---|---|---|---|
SELECT ... FOR UPDATE |
排他锁 (X锁) | 只能读快照版本 ,不能加任何锁(S/X)。修改和 FOR UPDATE 都会被阻塞。 |
锁定一行,准备后续修改。 |
SELECT ... FOR SHARE (旧版:LOCK IN SHARE MODE) |
共享锁 (S锁) | 可以读,也可以加 S 锁,但不能修改(会被阻塞)。 | 保护数据在事务期间不被改动,但允许别人读。 |
标准使用流程
1. 经典"锁定-修改"
这是最常用的场景:先锁住数据,确保其间无人更改,再做更新。
sql
START TRANSACTION;
-- 1. 对即将操作的数据加排他锁
SELECT stock FROM products WHERE id = 100 FOR UPDATE;
-- 假设 stock = 50
-- 2. 在应用层安全地做业务判断
-- if stock < 10 → ROLLBACK
-- 3. 执行更新
UPDATE products SET stock = stock - 10 WHERE id = 100;
COMMIT;
在事务提交前,任何其他想通过 SELECT ... FOR UPDATE 或修改这行的事务都会被阻塞。
2. 安全的"读取-插入"
当需要检查后再决定是否插入时,悲观锁是防止竞态最直接的方法。
sql
START TRANSACTION;
-- 1. 尝试锁定还未存在的记录。若记录不存在,InnoDB会加间隙锁/临键锁
SELECT * FROM unique_emails WHERE email = 'test@example.com' FOR UPDATE;
-- 2. 如果没查到,则安全插入
INSERT INTO unique_emails (email, created_at) VALUES ('test@example.com', NOW());
COMMIT;
这个操作能保证,在检查和插入之间,不会有其他事务插入相同的值。
悲观锁的完整特点
-
长事务风险 :从
SELECT ... FOR UPDATE到COMMIT,锁定时间越长,数据库并发性能下降越明显。 -
规避死锁 :所有事务都按相同的顺序访问资源,是最有效的避免死锁方式。比如统一先锁主表,再锁子表。
-
避免锁范围扩大 :
WHERE条件要能精确命中索引,否则可能退化为全表扫描并锁住大量行甚至整张表。 -
超时处理 :被阻塞的事务不会永远等待,可用
innodb_lock_wait_timeout控制超时,避免雪崩。sql-- 超时后应用会收到 Lock wait timeout exceeded 异常,需要处理 SET innodb_lock_wait_timeout = 5;
决策参考:乐观锁 vs. 悲观锁
你可以根据这个对比来选择:
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 冲突概率 | 高 | 低 |
| 并发模式 | 读多写多,强数据一致性 | 读多写少 |
| 事务模式 | 短事务,无用户交互等待 | 适用于长对话,如Web应用 |
| 实现成本 | 完全由数据库负责 | 需应用层额外实现版本号校验和重试 |
| 性能瓶颈 | 集中在数据库锁和连接资源 | 冲突多时,CPU浪费在重试上 |
| 死锁风险 | 存在 | 不存在 |
常见的应用场景
- 金融/库存系统:扣款、减库存,并发冲突高,必须用悲观锁保证绝对数据正确。
- 后台批处理:多个批处理任务需更新同一批数据,悲观锁能清晰串行化任务。
- 高冲突配置表 :只读配置表会被多个事务读,并用
FOR SHARE来保证读取期间配置不被修改。
悲观锁是把利剑,用好了能清晰可靠地解决并发问题,但前提是事务设计必须短小精悍,索引条件精准。
四、按算法分(InnoDB 特有)
1. 记录锁(Record Lock)
锁定索引中的一条精确记录
sql
WHERE id=1
记录锁(Record Lock)是 InnoDB 中最基础的锁之一,它直接锁定索引中的一条具体记录,用来防止其他事务修改或删除这条数据。
和前面的意向锁、自增锁不同,记录锁是我们在日常写 SQL 时最能直接控制和感受到的锁。
核心概念:锁的是索引记录
一个关键点是:记录锁总是锁定索引记录,而不是直接锁数据行。
InnoDB 的表是基于聚簇索引组织的 ,数据就存在叶子节点。所以,即使你 WHERE 条件里没有用到索引列,它也会退化为扫全表,并在扫描到的每一行聚簇索引记录上都加上锁。
记录锁的精确定义
- 作用对象:锁定一个索引中的单条记录。
- 互斥关系:记录锁(无论是共享的 S 锁,还是排他的 X 锁)与任何其他试图锁定同一索引记录的排他锁都是冲突的。
- 触发隔离级别 :在读已提交(Read Committed, RC) 隔离级别下,它是主要的加锁方式,用于防止脏写和丢失更新。
如何使用记录锁?
你通过特定的 SQL 语句,在特定的隔离级别下,就能精确触发记录锁。
1. 显式加锁读取
这是最精确的控制方式,通过在 SELECT 语句后加上 FOR UPDATE 或 FOR SHARE,可以锁定查询命中的索引记录。
-
SELECT ... FOR UPDATE对扫描到的索引记录加上排他记录锁(X Lock) 。这意味着在你的事务提交前,其他事务既不能给这些行加 X 锁(不能修改或删除),也不能加 S 锁(不能执行
SELECT ... FOR SHARE),但普通的非锁定读不受影响。sql-- 事务 A START TRANSACTION; -- 假设 id 是主键。对 id=10 的主键索引记录加上 X 锁。 SELECT * FROM users WHERE id = 10 FOR UPDATE; -- 在提交前,其他事务无法 UPDATE/DELETE 这行,也无法 SELECT ... FOR UPDATE/SHARE 这行 -
SELECT ... FOR SHARE(MySQL 8.0+,之前叫LOCK IN SHARE MODE)对扫描到的索引记录加上共享记录锁(S Lock)。它允许其他事务也加 S 锁,但会阻止加 X 锁。
sql-- 事务 A START TRANSACTION; -- 对 id=10 的主键索引记录加上 S 锁 SELECT * FROM users WHERE id = 10 FOR SHARE; -- 其他事务可以继续读这行(加 S 锁),但不能修改它(加 X 锁会等待)
2. 隐式加锁:写操作
UPDATE 和 DELETE 语句会自动对被修改/删除的索引记录加上排他记录锁(X Lock)。
-
修改时
sql-- 事务 A UPDATE users SET name = 'new_name' WHERE id = 10; -- 会自动对 id=10 的主键索引记录加 X 锁。 -
删除时
sql-- 事务 A DELETE FROM users WHERE id = 10; -- 同样会对 id=10 的主键索引记录加 X 锁。
实践要点:索引的影响
锁是对索引记录加的。如果 WHERE 条件没有用到索引,就会导致锁表。
假设 users 表的 city 列没有索引,你在 RC 隔离级别下执行:
sql
-- 事务 A
START TRANSACTION;
DELETE FROM users WHERE city = 'Beijing';
- 存储引擎行为:优化器会选择全表扫描。它会扫描整个主键索引。
- 加锁过程 :InnoDB 会对扫描到的所有主键索引记录都加上 X 锁。
- 实际结果 :即使某行的
city不是 'Beijing',在被扫描到时也会被锁。效果上等同于锁定了整张表,极大影响并发。 - 优化 :只需给
city列加上索引,DELETE就能精确定位,只锁住相关的索引记录。
由上可知,在使用记录锁删改查带索引的列的一大好处就是避免全局锁表,造成其他事务无法进行
隔离级别的关键影响
记录锁的行为严重依赖隔离级别:
- 在读已提交(RC)下 :这是记录锁工作的主要级别。
UPDATE/DELETE语句在找到匹配的索引记录时加排他记录锁,完成一行就解锁一行。SELECT ... FOR UPDATE只对结果集加锁,非常明确。 - 在可重复读(RR)下 :记录锁仍然存在,但通常会和间隙锁 合并成临键锁(Next-Key Lock) ,用来防止幻读。此时,你
SELECT ... FOR UPDATE的加锁范围会比 RC 大得多。
实践总结
| 你的目标 | 在 RC 隔离级别下的操作 | 锁的效果 |
|---|---|---|
| 强锁定一行,防止被修改 | SELECT ... FOR UPDATE |
该行的索引记录被加 X 锁,其他事务的写操作、FOR UPDATE/FOR SHARE 都会被阻塞。 |
| 弱锁定一行,允许别人读,但不许修改 | SELECT ... FOR SHARE |
该行的索引记录被加 S 锁,其他事务的写操作会被阻塞。 |
| 修改/删除数据(自动) | UPDATE ... / DELETE ... |
自动对被操作行的索引记录加 X 锁。 |
| 避免锁表 | 使用精确命中索引的 WHERE 条件 |
只有符合条件的索引记录被锁,并发度最高。 |
2. 间隙锁(Gap Lock)
和记录锁锁定具体记录不同,间隙锁锁定的是索引记录之间的间隙(一个开区间),用来防止其他事务在这个间隙里插入新记录,是解决"幻读"问题的核心武器。
间隙锁同样由 InnoDB 自动施加 ,你不能显式地"加一个间隙锁",但可以通过隔离级别 和特定的查询条件来触发它。
核心规则:只在可重复读(RR)下生效
这是最重要的一点。间隙锁只在隔离级别设置为 REPEATABLE READ 时才工作。如果你用的是 READ COMMITTED,即便执行同样的 SELECT ... FOR UPDATE,InnoDB 也只会加记录锁,不会加间隙锁。
间隙锁的精确行为
- 锁定范围 :锁定索引记录之间 的开区间。比如有 id 为 5 和 10 两条记录,间隙锁会锁定
(5, 10)这个区间。 - 排他性 :间隙锁之间不冲突 。多个事务可以同时持有同一个间隙的间隙锁。它唯一的目的是阻止插入操作。
- 组合形态 :通常以临键锁 的形式出现,即"记录锁 + 间隙锁",锁定一个左开右闭区间,如
(5, 10]。
如何"使用"间隙锁?
你不能直接写 LOCK GAP,但可以通过以下方式精确触发。
1. 通过锁定不存在的记录触发
当你查询一条不存在的记录并加上锁,就会产生间隙锁,锁住该记录本应落入的间隙。
sql
-- 会话 A(隔离级别为 RR)
BEGIN;
-- 表中只有 id=5 和 id=10 两条记录
-- 尝试锁定 id=7(不存在),会触发间隙锁,锁住 (5, 10) 这个区间
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 会话 B
BEGIN;
-- 会被阻塞,因为 id=6 落在被锁住的 (5, 10) 区间内
INSERT INTO users (id, name) VALUES (6, 'blocked');
2. 通过范围查询的边界触发
当范围查询 WHERE id > X 的终点"正无穷"没有记录时,也会触发间隙锁。
sql
-- 会话 A
BEGIN;
-- 表中最大 id=10,那么 id>10 的区间是 (10, +∞)
-- 执行此查询会锁住 (10, +∞) 这个间隙
SELECT * FROM users WHERE id > 10 FOR UPDATE;
-- 会话 B
-- 会被阻塞,因为 id=15 落在 (10, +∞) 内
INSERT INTO users (id, name) VALUES (15, 'blocked');
3. 通过组合触发"临键锁"
更常见的情况是,你锁定了存在的行,InnoDB 会默认加上临键锁,其间的间隙部分就是由间隙锁提供的。
sql
-- 会话 A
BEGIN;
-- 表中存在 id=10,查询 id <= 10 的两条记录(假设 5 和 10)
-- 可能会对 (5, 10] 和 (10, +∞) 加锁,其中 (5,10) 和 (10,+∞) 都是间隙锁
SELECT * FROM users WHERE id <= 10 FOR UPDATE;
线上排查与观察
在执行 SELECT ... FOR UPDATE 前后,可以通过 performance_schema 观察锁情况:
sql
-- 查看当前事务持有的锁
SELECT
lock_type, lock_mode, lock_status, lock_data
FROM
performance_schema.data_locks
WHERE
engine_transaction_id = (SELECT trx_id FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = CONNECTION_ID());
你会在 lock_mode 列中看到 X,GAP(纯粹间隙锁)或 X(临键锁,包含了间隙和记录),lock_data 则显示锁定的边界值。
一个常见的影响与应对
间隙锁的主要副作用是导致并发插入性能下降,容易引发死锁。比如两个事务互相持有对方要插入位置的间隙锁,然后都试图插入数据,就会产生死锁。
应对思路:
- 评估业务 :如果业务不需要防止幻读,可以考虑将隔离级别降为
READ COMMITTED。 - 优化索引 :尽量使用唯一索引的等值查询。如果
WHERE条件是唯一索引且命中,InnoDB 会将其降级为记录锁,而不会加间隙锁。 - 调整逻辑顺序:在批量操作时,确保所有事务以相同的顺序访问记录,可以有效减少死锁。
间隙锁是 InnoDB 在 RR 级别下保证数据一致性的基石,理解它的触发条件对排查死锁和性能抖动至关重要。需要我继续讲它最常组合的"临键锁"吗?
锁范围之间的空隙,防止幻读.比如有记录 1 和 10,间隙锁会锁住 (1,10),防止插入 id=5 的行。只在可重复读隔离级别下生效。
sql
WHERE id BETWEEN 1 AND 10
3. 临键锁(Next-Key Lock)
临键锁是间隙锁和记录锁的组合,也是 InnoDB 在可重复读(RR)隔离级别下,用来防止幻读的核心默认锁。
和间隙锁一样,你无法直接"加一个临键锁",但当你执行 SELECT ... FOR UPDATE 这类锁定读时,它就会被自动触发。
为什么需要临键锁?
记录锁只能保护存在的行,间隙锁只能保护未来的行。如果只用一个,都无法彻底防幻读:
- 仅记录锁 :锁住了
id=10,但别人可以在id=9的位置插入新行。 - 仅间隙锁 :锁住了
(5,10)的区间,但别人可以删除或修改id=5本身。
临键锁通过**"当前记录 + 它之前的间隙"**的组合,完美覆盖了这两种情况。
临键锁的精确定义
- 锁定范围 :一个左开右闭 的区间,即
(起始值, 当前记录值]。 - 组成 :针对索引记录的记录锁 + 锁定该记录前一个区间的间隙锁。
- 效果:既锁定了当前记录不被修改/删除,又防止了在该记录前插入新记录。
默认加锁规则:所有区间都是临键锁
在 RR 级别下,你执行一个范围查询的 SELECT ... FOR UPDATE,InnoDB 的默认行为就是给扫描到的所有区间加上临键锁。
假设表中有 id 索引,记录值为 5, 10, 15,可能的临键锁区间如下:
- 负无穷到 5:
(-∞, 5] - 5 到 10:
(5, 10] - 10 到 15:
(10, 15] - 15 到正无穷:
(15, +∞]
如何"使用"(触发)临键锁?
你通过查询的范围和条件,精确控制它加哪些区间的临键锁。
1. 范围查询触发
这是最标准的触发方式。
sql
-- 假设表中有 id=5, 10, 15 三行
-- 会话 A
BEGIN;
-- 范围扫描,会锁住命中的所有临键锁区间
SELECT * FROM users WHERE id BETWEEN 8 AND 12 FOR UPDATE;
此时,id=10 被命中,锁会加到 (5, 10] 和 (10, 15] 这两个区间。效果是:
- 不能修改或删除
id=10。 - 不能在
(5, 10)或(10, 15)之间插入新记录(如 7, 12)。 - 试图插入
id=6的操作会被阻塞。
2. 等值查询触发(不存在时降级为间隙锁)
如果等值查询命中了记录,加的也是临键锁;如果没命中,则退化为间隙锁。
sql
-- 会话 A:锁定存在的记录 id=10
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 会加临键锁 (5, 10],锁住 id=10 的记录,以及它前面的间隙 (5,10)
-- 会话 A:锁定不存在的记录 id=7
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 记录不存在,退化为间隙锁 (5, 10),不锁任何记录。
3. 唯一索引等值查询的降级
这是最重要的优化 。当查询条件是唯一索引 ,且精确命中 一条记录时,InnoDB 会认为不再需要间隙锁来防幻读,临键锁会自动降级为单纯的记录锁。
sql
-- 假设 id 是主键
-- 会话 A
BEGIN;
-- 唯一索引 + 等值命中,只加记录锁,锁住 id=10
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 会话 B(不会被阻塞)
INSERT INTO users (id, name) VALUES (9, 'ok');
-- 因为 (5,10) 的间隙锁被省略了,插入 id=9 是允许的。
实践中的直接影响与排查
临键锁的"左开右闭"设计,是很多锁冲突的根源。
一个经典死锁场景:
两个事务分别锁定对方的间隙并等待插入。
- 事务 A :
SELECT * FROM users WHERE id = 10 FOR UPDATE;持有(5, 10]临键锁。 - 事务 B :
SELECT * FROM users WHERE id = 15 FOR UPDATE;持有(10, 15]临键锁。 - 事务 A :
INSERT INTO users (id) VALUES (12);想要(10, 15)的插入意向锁,被事务 B 的临键锁阻塞。 - 事务 B :
INSERT INTO users (id) VALUES (7);想要(5, 10)的插入意向锁,被事务 A 的临键锁阻塞。
排查时,在 SHOW ENGINE INNODB STATUS 的输出中,你会看到 lock_mode X 后面缺少 GAP 字样,通常表示临键锁。 它直接锁定了记录本身。
临键锁是 RR 隔离级别默认行为,也是我们日常写锁定读时真正打交道的锁。理解它的触发和降级条件,是写好高并发 SQL 和排查死锁的基础。
五、最常出现的面试/工作问题总结
1. 什么情况下行锁会变表锁?
- 没有索引
- 使用了函数
- 模糊查询
LIKE '%xxx%' - 事务太大
2. UPDATE 不加 WHERE 会加什么锁?
表锁!
全表锁定,严重阻塞业务。
3. 共享锁和排他锁的关系?
- 读锁 + 读锁 = 共存
- 读锁 + 写锁 = 互斥
- 写锁 + 写锁 = 互斥
4. 乐观锁和悲观锁怎么选?
- 读多写少 → 乐观锁
- 写多、并发高 → 悲观锁
总结
- 行锁:锁一行,InnoDB 默认
- 表锁:锁全表,无索引会触发
- 共享锁:多人可读
- 排他锁:一人可写
- 乐观锁:版本号控制
- 悲观锁:直接上锁