WSの小屋

本文以 MySQL 8.x 的 InnoDB 为主要示例。目标不是背概念,而是搞清楚:并发读写时数据库到底做了什么、锁为什么出现、问题如何定位、代码应该怎么写。

1. 先建立一张完整地图

数据库并发控制主要解决两件事:

  1. 多条 SQL 要么全部成功,要么全部失败——这是事务
  2. 多个事务同时访问相同数据时,结果仍然正确——主要依靠 MVCC 与锁

可以先记住这组结论:

  • 普通 SELECT 通常通过 MVCC 读取历史版本,不会阻塞写操作。
  • INSERTUPDATEDELETE 和锁定读需要加锁。
  • 锁的对象不等于 SQL 条件;InnoDB 实际锁的是索引记录或索引区间
  • 没有合适索引时,锁的扫描范围可能远大于最终命中的数据。
  • 事务越长,占锁越久,发生阻塞和死锁的概率越高。
  • 死锁无法仅靠“调整参数”彻底消灭,业务代码必须能够重试。

2. 事务到底保证了什么

事务是一组需要作为整体执行的数据库操作。

START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE id = 1;

UPDATE account
SET balance = balance + 100
WHERE id = 2;

COMMIT;

转账不能只完成扣款或只完成入账,所以两条 SQL 必须处于同一个事务中。

2.1 ACID

原子性 Atomicity

事务中的操作要么全部成功,要么全部回滚。

InnoDB 主要借助 undo log 保存修改前的数据,用于事务回滚。

一致性 Consistency

事务执行前后,数据应满足业务规则和数据库约束。

例如转账前后总金额不变、唯一键不能重复、余额不能违反业务约束。一致性不是某一种日志单独提供的,它是原子性、隔离性、持久性、约束设计和业务代码共同作用的结果。

隔离性 Isolation

并发事务之间不能随意看到彼此尚未完成的中间状态。

隔离越强,并发异常越少,但等待和冲突通常越多。

持久性 Durability

事务一旦提交,结果在数据库崩溃后仍应恢复。

InnoDB 主要借助 redo log 实现崩溃恢复。简单区分:

  • undo log:记录如何撤销,服务于回滚和 MVCC。
  • redo log:记录如何重做,服务于持久化和崩溃恢复。
  • binlog:MySQL Server 层的逻辑日志,主要服务于复制、恢复和数据订阅。

3. 并发事务会产生哪些问题

假设事务 A、B 同时操作相同数据,典型异常有四类。

3.1 脏读

事务 B 读到了事务 A 尚未提交的数据;如果 A 回滚,B 读到的就是不存在的状态。

3.2 不可重复读

同一事务中,两次读取同一行得到不同结果,因为其他事务在此期间提交了修改。

3.3 幻读

同一事务用相同条件查询两次,第二次出现了原本不存在的新行,像产生了“幻影”。幻读关注的是结果集中的行数变化。

3.4 丢失更新

两个事务都先读取旧值,再基于旧值计算并写回,后一次写入覆盖前一次结果。

库存初始为 10
事务 A 读取 10,计算得到 9
事务 B 读取 10,计算得到 9
事务 A 写入 9
事务 B 写入 9
最终库存为 9,但实际发生了两次扣减

不要使用“先查、在应用中计算、再覆盖”的方式扣库存。优先让数据库原子更新:

UPDATE product
SET stock = stock - 1
WHERE id = 1001
  AND stock >= 1;

然后检查受影响行数:

  • 1:扣减成功。
  • 0:库存不足或记录不存在。

4. 四种隔离级别怎么选

SQL 标准定义了四种隔离级别:

隔离级别 脏读 不可重复读 幻读 典型特点
READ UNCOMMITTED 可能 可能 可能 隔离最弱,业务系统很少使用
READ COMMITTED 避免 可能 可能 每次一致性读生成新快照,常用于高并发系统
REPEATABLE READ 避免 避免 InnoDB 通过 MVCC 和间隙锁处理 MySQL InnoDB 默认级别
SERIALIZABLE 避免 避免 避免 并发能力最低,普通读也可能参与锁竞争

查看和设置当前会话隔离级别:

SELECT @@transaction_isolation;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

4.1 READ COMMITTED 与 REPEATABLE READ 的核心区别

对普通快照读而言:

  • READ COMMITTED:每条语句通常读取语句开始时的最新已提交快照。
  • REPEATABLE READ:事务内通常复用第一次一致性读建立的快照。

因此在 READ COMMITTED 下,同一事务两次普通查询可能看到其他事务刚提交的变化;在 REPEATABLE READ 下,通常仍看到原来的版本。

4.2 隔离级别不是越高越好

选择隔离级别时考虑的是正确性要求与并发成本:

  • 普通互联网业务常在 READ COMMITTED 或 REPEATABLE READ 中选择。
  • 如果业务已经通过原子 SQL、唯一约束、乐观锁等方式保证一致性,READ COMMITTED 往往能减少锁冲突。
  • SERIALIZABLE 不应被当成“不会出错”的开关,它可能明显降低吞吐量,而且不能替代业务约束。

5. MVCC:为什么读写通常互不阻塞

MVCC,即多版本并发控制。它允许数据库保留一行数据的多个版本,让普通查询读取符合自身快照的版本,而不是必须等待最新写事务结束。

InnoDB 的 MVCC 可以从三个要素理解:

  • 行记录中的隐藏事务信息。
  • undo log 形成的历史版本链。
  • Read View,用来判断哪个版本对当前事务可见。

5.1 快照读

普通查询一般是快照读:

SELECT * FROM account WHERE id = 1;

它通常不加行锁,而是根据 Read View 找到可见版本。

5.2 当前读

以下操作需要读取当前最新版本,并可能加锁:

SELECT * FROM account WHERE id = 1 FOR UPDATE;

SELECT * FROM account WHERE id = 1 FOR SHARE;

UPDATE account SET balance = balance - 100 WHERE id = 1;

DELETE FROM account WHERE id = 1;

关键点:同一个事务中,普通 SELECTSELECT ... FOR UPDATE 可能看到不同结果,因为前者是快照读,后者是当前读。

5.3 长事务为什么危险

长事务迟迟不结束时,它可能持续持有旧快照,导致相关 undo 历史版本不能及时清理,同时长期占用锁。常见后果包括:

  • undo 空间增长。
  • 查询历史版本链变长。
  • 锁等待增多。
  • 主从复制或变更发布受到影响。
  • 回滚成本变高。

所以事务中不要执行远程调用、等待用户输入、上传文件或处理大段无关逻辑。


6. 锁的分类:不要混着背

数据库锁可以从不同维度分类,同一把锁可能同时具有多个标签。

6.1 按锁粒度分类

  • 全局锁:影响整个实例,常见于某些备份场景。
  • 表级锁:锁住整张表或表级意向状态。
  • 行级锁:锁住索引记录或索引范围,并发度最高,管理成本也更高。

6.2 按读写兼容性分类

共享锁 S Lock

允许其他事务继续加共享锁,但阻止不兼容的排他修改。

SELECT * FROM account WHERE id = 1 FOR SHARE;

排他锁 X Lock

用于修改或排他读取。其他事务不能对相同目标获得不兼容的锁。

SELECT * FROM account WHERE id = 1 FOR UPDATE;

常见兼容关系:

已持有 / 请求 S X
S 兼容 冲突
X 冲突 冲突

6.3 意向锁

InnoDB 的意向共享锁 IS 和意向排他锁 IX 是表级标记,表达事务准备或已经在表内某些行上加对应锁。

它们主要用于快速判断表级锁与行级锁是否冲突。意向锁通常由 InnoDB 自动管理,业务开发不需要手动控制。

6.4 按锁定范围分类

Record Lock:记录锁

锁定某条索引记录。例如用唯一索引等值查询并命中记录时,通常可以精确锁住该索引记录。

Gap Lock:间隙锁

锁定索引记录之间的区间,不锁具体记录,主要防止其他事务在该区间插入新记录。

Next-Key Lock:临键锁

记录锁与间隙锁的组合,通常可理解为前开后闭区间。它既锁现有索引记录,也锁相邻间隙。

在 InnoDB 的 REPEATABLE READ 隔离级别下,范围当前读常使用 Next-Key Lock 控制幻读。


7. InnoDB 锁的其实是索引

这是理解锁问题最重要的一句话:InnoDB 的行锁建立在索引之上。

准备示例表:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    status TINYINT NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    KEY idx_user_id (user_id),
    KEY idx_status (status)
) ENGINE = InnoDB;

7.1 主键等值查询

SELECT * FROM orders WHERE id = 100 FOR UPDATE;

如果 id = 100 存在,通常精确锁住主键索引中的对应记录。

7.2 唯一索引查询不存在的值

SELECT * FROM user WHERE email = 'a@example.com' FOR UPDATE;

如果唯一索引中不存在该值,为阻止并发插入破坏当前读的结果,数据库可能锁住其应落入的索引间隙,而不是“什么也不锁”。

7.3 非唯一索引查询

SELECT * FROM orders WHERE user_id = 10 FOR UPDATE;

user_id 不是唯一索引,可能匹配多条记录。在 REPEATABLE READ 下,锁定范围通常包含匹配记录及相关间隙。

7.4 没有合适索引

UPDATE orders
SET status = 2
WHERE amount = 99.00;

如果 amount 没有索引,InnoDB 需要扫描大量记录并逐步判断。即使最终只更新几行,过程中涉及的锁范围也可能很大。

这就是很多“明明只改一条,为什么整张表都像卡住了”的根源。严格地说,它不一定升级成了表锁,而可能是大量索引记录被锁,效果接近锁表。

先检查执行计划:

EXPLAIN UPDATE orders
SET status = 2
WHERE amount = 99.00;

8. 锁什么时候释放

InnoDB 中,大部分事务锁在事务提交或回滚时释放,而不是某条 SQL 执行完成后立即释放。

START TRANSACTION;

UPDATE account SET balance = balance - 100 WHERE id = 1;

-- 此处即使 UPDATE 已执行完,相关锁通常仍然存在

COMMIT;

因此,下面这些代码结构风险很高:

开启事务
更新数据库并持有锁
调用支付、库存或消息服务
等待远程响应
继续更新数据库
提交事务

远程调用一旦超时,数据库锁会被一起拖住。应尽量把外部调用移出本地事务,并通过本地消息表、事务消息或 Saga 等机制处理跨服务一致性。


9. 悲观锁与乐观锁怎么选

9.1 悲观锁

悲观锁假设冲突很可能发生,所以先锁定再处理。

START TRANSACTION;

SELECT stock
FROM product
WHERE id = 1001
FOR UPDATE;

UPDATE product
SET stock = stock - 1
WHERE id = 1001;

COMMIT;

适合:

  • 冲突概率高。
  • 临界区很短。
  • 操作必须基于最新值完成。

注意:FOR UPDATE 必须处在有效事务中才有实际业务意义;如果开启自动提交后语句立即提交,锁也会随即释放。

9.2 乐观锁

乐观锁不提前阻塞其他事务,而是在更新时检查数据是否仍是自己读到的版本。

SELECT id, balance, version
FROM account
WHERE id = 1;

应用得到 version = 7 后执行:

UPDATE account
SET balance = 900,
    version = version + 1
WHERE id = 1
  AND version = 7;

检查受影响行数:

  • 1:更新成功。
  • 0:数据已被其他事务修改,需要重新读取并重试或提示冲突。

适合:

  • 读多写少。
  • 冲突概率低。
  • 业务可以接受有限次数重试。

9.3 能用原子 SQL,就别急着“先查再锁”

库存扣减通常可直接写成:

UPDATE product
SET stock = stock - :quantity
WHERE id = :id
  AND stock >= :quantity;

相比“查询库存 → 应用判断 → 更新库存”,一条带条件的原子更新通常更短、更安全,也减少了事务中的往返次数。


10. 死锁是怎么形成的

死锁不是“锁等得太久”,而是多个事务形成了循环等待。

事务 A 已锁住订单 1,等待订单 2
事务 B 已锁住订单 2,等待订单 1

复现示例。

会话 A:

START TRANSACTION;
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;

会话 B:

START TRANSACTION;
UPDATE account SET balance = balance - 10 WHERE id = 2;
UPDATE account SET balance = balance + 10 WHERE id = 1;

如果两边交错执行,就可能产生死锁。InnoDB 检测到死锁后,会选择其中一个事务回滚,让另一个继续。

10.1 死锁与锁等待超时的区别

  • 死锁:形成循环依赖,数据库检测后通常主动回滚一个事务。
  • 锁等待超时:事务在等待其他事务释放锁,但未必存在环;等待超过阈值后报错。

两者处理方向不同,排查时不要混为一谈。

10.2 降低死锁概率

固定加锁顺序

转账时始终按较小账户 ID 到较大账户 ID 的顺序加锁:

SELECT id, balance
FROM account
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

仅写 ORDER BY 还不够,所有相关业务入口都要遵循同样顺序。

缩短事务

事务内只保留必要的数据库操作,不做网络请求和耗时计算。

建立正确索引

让 SQL 尽快定位目标记录,减少扫描和加锁范围。

拆分超大事务

一次更新几十万行会长期占锁、产生大量日志。可按主键分批处理,但必须重新设计批次失败后的幂等与恢复逻辑。

应用层重试

即使设计合理,死锁仍可能发生。对可安全重试的事务,捕获死锁错误后进行有限次数重试,并加入随机退避。

最多重试 3 次
第 1 次失败后等待约 50ms
第 2 次失败后等待约 100ms
第 3 次失败后返回错误并记录上下文

重试前提是操作具有幂等性,或具备防止重复提交的业务键。


11. 线上锁问题怎么查

11.1 查看当前事务

SELECT *
FROM information_schema.innodb_trx
ORDER BY trx_started;

重点关注:

  • 事务开始时间。
  • 当前状态。
  • 正在执行的 SQL。
  • 已锁记录数量。
  • 是否长时间运行。

11.2 查看谁在等谁

MySQL 8.x 可以使用 Performance Schema:

SELECT
    waiting_pid,
    waiting_query,
    blocking_pid,
    blocking_query
FROM sys.innodb_lock_waits;

还可以查看更底层的数据锁:

SELECT *
FROM performance_schema.data_locks;

SELECT *
FROM performance_schema.data_lock_waits;

排查思路不是只盯着“正在等待”的 SQL,而是找到真正的阻塞者:

等待 SQL
  → 它在等哪把锁
  → 哪个事务持有这把锁
  → 阻塞事务从何时开始
  → 阻塞事务为什么没有提交
  → 相关 SQL 使用了哪个索引、锁了多大范围

11.3 查看最近一次死锁

SHOW ENGINE INNODB STATUS;

重点搜索 LATEST DETECTED DEADLOCK,关注:

  • 涉及哪些事务。
  • 每个事务正在执行什么 SQL。
  • 已经持有什么锁。
  • 正在等待什么锁。
  • 哪个事务被回滚。

线上需要持续收集死锁时,可评估开启:

SET GLOBAL innodb_print_all_deadlocks = ON;

它会把所有死锁记录到错误日志。开启前应评估日志量并接入监控,不要只在问题发生后临时翻找。

11.4 检查执行计划

EXPLAIN SELECT *
FROM orders
WHERE user_id = 10
FOR UPDATE;

重点观察:

  • key:实际使用的索引。
  • type:访问类型。
  • rows:预计扫描行数。
  • Extra:是否存在额外过滤、排序等。

注意:执行计划只能帮助判断访问路径,最终锁行为还与隔离级别、索引唯一性、查询条件、数据分布和实际执行过程有关。

11.5 不要上来就 KILL

终止连接会触发事务回滚。大事务回滚也可能耗时很久,并继续占用资源。

处理前至少确认:

  • 目标连接确实是阻塞源,而不是受害者。
  • 终止后业务能否重试。
  • 事务回滚规模多大。
  • 是否存在正在执行的 DDL、批处理或关键任务。

12. 常见错误写法

12.1 在事务中调用第三方接口

开启事务 → 更新订单 → 调支付接口 → 更新流水 → 提交

问题:第三方接口超时会直接拉长持锁时间。

改进:缩短本地事务,通过事务消息、本地消息表或状态机衔接外部流程。

12.2 先查余额,再无条件覆盖

SELECT balance FROM account WHERE id = 1;

UPDATE account SET balance = 900 WHERE id = 1;

问题:并发下容易丢失更新。

改进:使用原子增减、版本号或悲观锁。

12.3 以为加了事务注解就一定生效

在 Spring 等框架中,事务常通过代理实现。以下场景尤其需要检查:

  • 同类内部方法调用绕过代理。
  • 异常被捕获后没有继续抛出,导致事务正常提交。
  • 异常类型不在默认回滚规则中。
  • 方法运行在新线程中,事务上下文没有自动传递。
  • 数据源或事务管理器选错。
  • 数据库表不是支持事务的存储引擎。

不要只看代码上有没有 @Transactional,要确认实际代理调用链和最终提交行为。

12.4 用很大的 IN 一次性锁大量记录

UPDATE orders
SET status = 2
WHERE id IN (...数万个 ID...);

问题:事务日志量大、持锁时间长、回滚昂贵。

改进:按确定顺序分批更新,同时做好批次幂等和进度记录。

12.5 把锁等待超时当作根因

调大 innodb_lock_wait_timeout 只是让请求等更久,不能解决索引缺失、长事务或错误加锁顺序。超时参数是保护措施,不是优化手段。


13. DDL 也会阻塞:理解 MDL

MySQL 还有元数据锁 Metadata Lock,简称 MDL。访问表的事务会持有相应的元数据锁,防止表结构在使用过程中被并发修改。

典型事故:

  1. 事务 A 查询了某张表,但一直没有提交。
  2. 会话 B 执行 ALTER TABLE,等待 A 释放 MDL。
  3. 后续访问该表的请求又排在 B 后面。
  4. 一个未提交的小事务最终造成大量请求堆积。

所以执行线上 DDL 前要检查长事务、设置合理的锁等待策略,并使用经过验证的在线变更方案。ALGORITHM=INSTANTONLINE 也不意味着完全不需要 MDL。


14. 一套可落地的事务设计原则

原则 1:事务边界围绕业务原子性划分

不是 SQL 越多越应该放一个事务,而是必须一起成功或失败的数据库操作才进入同一事务。

原则 2:尽量晚开事务,尽量早提交

参数校验、权限判断、数据组装等能放到事务外的逻辑先完成。

原则 3:更新必须有明确、稳定的定位条件

优先使用主键或合适索引,更新前用 EXPLAIN 检查扫描范围。

原则 4:通过约束兜底,而不是只相信代码判断

例如防重复创建应设计唯一索引:

ALTER TABLE payment_order
ADD UNIQUE KEY uk_business_no (business_no);

应用层的“先查询是否存在”无法在并发下替代唯一约束。

原则 5:所有多行锁定遵循统一顺序

按主键升序或业务规定的稳定顺序加锁,避免不同入口互相反向等待。

原则 6:对死锁和瞬时冲突做有限重试

重试必须满足:

  • 次数有限。
  • 带退避。
  • 记录关键日志。
  • 操作幂等。
  • 不在原事务内部直接复用已经失败的事务上下文。

原则 7:建立监控,而不是等用户反馈

至少监控:

  • 活跃长事务数量与持续时间。
  • 锁等待数量与持续时间。
  • 死锁频率。
  • 慢 SQL 与扫描行数。
  • undo 历史积压。
  • 数据库连接池等待情况。

15. 面试与实战最容易答错的几个问题

“行锁是不是锁一整行数据?”

不准确。InnoDB 行锁基于索引项实现,锁定范围由访问的索引、条件和隔离级别决定。

“普通 SELECT 会加锁吗?”

InnoDB 中普通一致性读通常通过 MVCC 完成,不加记录锁。但在 SERIALIZABLE、显式锁定读或某些特殊场景下不能简单套用这句话。

“REPEATABLE READ 完全没有幻读吗?”

要区分快照读和当前读。InnoDB 通过 MVCC 处理快照读的一致性,通过 Next-Key Lock 等机制控制当前读的幻读问题。不能只背一张 SQL 标准表格。

“死锁是不是数据库性能太差?”

不是。死锁首先是并发事务锁依赖形成了环。性能差可能放大事务重叠时间,但根因通常要从访问顺序、索引和事务边界中找。

“加索引一定能减少锁吗?”

通常能缩小扫描与锁定范围,但不是绝对。低选择性索引、范围查询、非唯一索引以及执行计划选错时,仍可能锁住较大范围。

“事务提交了为什么代码还报错?”

需要区分数据库提交、网络响应和应用状态。数据库可能已经提交,但客户端在收到响应前断线。涉及重试的写接口必须使用幂等键,不能仅凭客户端报错判断数据库一定没有执行。


16. 最后的实战清单

设计事务前问自己:

  • 哪些操作必须一起成功或失败?
  • 能否改成一条原子 SQL?
  • 是否有唯一约束、非空约束等数据库兜底?
  • 是否存在外部 RPC、文件操作或耗时计算?
  • SQL 实际使用哪个索引?会扫描多少行?
  • 多行操作的加锁顺序是否统一?
  • 死锁后是否可以安全重试?

出现锁问题时按这个顺序排查:

  1. 找到等待事务和阻塞事务。
  2. 确认阻塞事务何时开始、为何未提交。
  3. 查看双方 SQL 与实际索引。
  4. 判断锁的是记录、间隙还是更大的扫描范围。
  5. 检查是否存在网络调用、批量处理或遗漏提交。
  6. 查看最近死锁日志,而不是只看当前现场。
  7. 从事务边界、访问顺序、索引和幂等重试四个方向修复。

一句话收尾:事务负责把一组操作变成一个整体,MVCC 提升读写并发,锁保护当前数据和范围;真正决定系统稳定性的,是短事务、准索引、统一顺序、数据库约束和可重试设计。

Comments | 0条评论