SQL 数据库锁总结

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;

它的内部加锁顺序是:

  1. 在 users 表上,申请并加上意向排他锁(IX)。
  2. 加表锁成功后,再在 id = 1 的行记录上,加上排他锁(X)。

如果此时事务 T2 想锁定整张表:

sql 复制代码
-- 事务 T2
LOCK TABLES users WRITE;
  1. T2 的请求需要 users 表上的排他锁。
  2. 系统检查发现,T1 已在表上持有 IX 锁。
  3. IX 和 X 锁冲突,因此 T2 的请求会进入等待,直到 T1 提交或回滚。

4. 更新锁

用于解决"先读后写"操作(如UPDATE的查找阶段)中的死锁问题。SQL Server 中常用,它允许共享读,但只允许一个事务获得更新锁,并最终升级为排他锁。

典型的 UPDATE 流程是两步:先找到行,再修改。如果最初用共享锁来"找行",就埋下了死锁隐患。
死锁推演:

  1. 事务A SELECT ... FOR SHARE 找到数据,持有该行的共享锁 (S)。
  2. 事务B 也 SELECT ... FOR SHARE 同一行,共享锁兼容,也成功持有S锁。
  3. 事务A 执行 UPDATE,想把自己的S锁升级为排他锁 (X),但必须等事务B释放S锁。
  4. 事务B 也执行 UPDATE,同样想升级为X锁,但必须等事务A释放S锁。

双方互相等待,死锁就发生了。

更新锁(U锁)正是为了打破这个循环而设计的。

更新锁的核心规则

它的工作规则很简单:

  1. 与共享锁兼容:U锁和S锁可以共存,满足最初的"读"需求。
  2. 与自身互斥:一个资源上只能有一个U锁。这从源头上阻止了多个事务同时持有U锁并等待升级。
  3. 可直接升级为排他锁: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 UPDATESELECT ... 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 UPDATECOMMIT,锁定时间越长,数据库并发性能下降越明显。

  • 规避死锁 :所有事务都按相同的顺序访问资源,是最有效的避免死锁方式。比如统一先锁主表,再锁子表。

  • 避免锁范围扩大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 UPDATEFOR 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. 隐式加锁:写操作

UPDATEDELETE 语句会自动对被修改/删除的索引记录加上排他记录锁(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';
  1. 存储引擎行为:优化器会选择全表扫描。它会扫描整个主键索引。
  2. 加锁过程 :InnoDB 会对扫描到的所有主键索引记录都加上 X 锁。
  3. 实际结果 :即使某行的 city 不是 'Beijing',在被扫描到时也会被锁。效果上等同于锁定了整张表,极大影响并发。
  4. 优化 :只需给 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 是允许的。

实践中的直接影响与排查

临键锁的"左开右闭"设计,是很多锁冲突的根源。

一个经典死锁场景:

两个事务分别锁定对方的间隙并等待插入。

  1. 事务 ASELECT * FROM users WHERE id = 10 FOR UPDATE; 持有 (5, 10] 临键锁。
  2. 事务 BSELECT * FROM users WHERE id = 15 FOR UPDATE; 持有 (10, 15] 临键锁。
  3. 事务 AINSERT INTO users (id) VALUES (12); 想要 (10, 15) 的插入意向锁,被事务 B 的临键锁阻塞。
  4. 事务 BINSERT 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 默认
  • 表锁:锁全表,无索引会触发
  • 共享锁:多人可读
  • 排他锁:一人可写
  • 乐观锁:版本号控制
  • 悲观锁:直接上锁
相关推荐
weixin_397574091 小时前
用自然语言查数据库出图表靠谱吗?一次智能问数实践复盘
数据库
字节跳动开源3 小时前
Viking AI 搜索 CLI 正式发布:会说话,就能做搜索推荐
数据库·人工智能·开源
TechWJ4 小时前
数据库在公司内网,出差路上想查数据怎么办?
服务器·数据库·mariadb
我是一颗柠檬4 小时前
【MySQL全面教学】MySQL事务与ACID Day9(2026年)
数据库·后端·mysql
橙子圆1234 小时前
Redis知识9之集群
数据库·redis·缓存
BlackHeart12034 小时前
【SQL】Oracle中序列(Sequence)作为默认值引发的ORA-00979
数据库·sql·oracle
bug菌5 小时前
【SpringBoot 3.x 第254节】夯爆了,数据库访问性能优化实战详解!
数据库·spring boot·后端
xxl大卡5 小时前
MySQL的执行流程
数据库·mysql
chicheese5 小时前
MySQL优化实践:选错JOIN 驱动表,性能相差几十倍
数据库·mysql
無限進步D5 小时前
MySQL 单行函数
数据库·mysql