跳转到内容

MySQL 事务与锁 —— 原理、实践与排坑

在单用户场景下,一条 SQL 执行完再执行下一条,问题不大。但在真实业务中:

  • 高并发写入:订单创建、余额变更、库存扣减等操作可能被成千上万的请求同时触发。
  • 读写混合:分析类查询与线上写流量混在一起,容易出现数据不一致。
  • 热点行/热点表:如用户余额、库存记录,稍不注意就出现死锁、超时甚至数据错误。

要在这种场景下 既保证正确性,又保证性能,就必须理解 MySQL(尤其是 InnoDB 引擎)的:

  • 事务(Transaction):一组操作要么全部成功,要么全部失败。
  • 锁(Lock):协调并发访问,避免“踩踏”数据。

下面从原理到实践,系统梳理 MySQL 事务和锁。


二、一条 SQL 更新是如何执行的?

Section titled “二、一条 SQL 更新是如何执行的?”

以一条最简单的更新为例,看它在 MySQL 里会经过哪些组件:

CREATE TABLE T (ID int primary key, c int);
UPDATE T SET c = c + 1 WHERE ID = 2;

2.1 整体流程:从客户端到存储引擎

Section titled “2.1 整体流程:从客户端到存储引擎”

一条 SQL 从发起到落盘,会依次经过 连接器、分析器、优化器、执行器、存储引擎,其中写操作还会涉及 redo log、binlog、undo log 以及(若存在非唯一二级索引)change buffer。下图是这条更新语句的简化执行流程。

sequenceDiagram
    participant Client as 客户端
    participant Connector as 连接器
    participant Parser as 分析器
    participant Optimizer as 优化器
    participant Executor as 执行器
    participant Engine as 存储引擎(InnoDB)

    Client->>Connector: 发送 SQL (TCP/连接)
    Connector->>Connector: 认证、维持连接
    Connector->>Parser: 传递 SQL 文本
    Parser->>Parser: 词法分析、语法分析
    Parser->>Optimizer: 语法树
    Optimizer->>Optimizer: 生成执行计划(索引选择等)
    Optimizer->>Executor: 执行计划
    Executor->>Engine: 取 ID=2 所在页 / 加锁
    Engine->>Executor: 返回行数据
    Executor->>Engine: 更新 c=c+1,写 redo/undo
    Engine->>Engine: (两阶段提交) redo prepare → binlog → redo commit
    Engine->>Executor: 完成
    Executor->>Client: 返回影响行数
组件作用
连接器建立/维持客户端与 MySQL 的连接,做身份认证、管理连接状态。
分析器词法分析(识别关键字、表名、列名等)+ 语法分析,得到语法树,语法错误在这一步报出。
优化器在多种执行方式中选一个(例如用主键还是二级索引、是否用索引合并),生成执行计划。
执行器按执行计划调用存储引擎的接口:先查后改、校验权限、触发器等。
存储引擎InnoDB 负责:读/写数据页、加行锁、写 redo log / undo log,以及(若开启)配合写 binlog、两阶段提交。

UPDATE T SET c = c + 1 WHERE ID = 2 而言:执行器通过主键 ID=2 找到对应行,InnoDB 加行锁、读当前值、计算 c+1、写 redo/undo、在提交阶段通过两阶段提交保证 redo 与 binlog 一致。后面会分别说明 binlog两阶段提交change buffer 与 redo log


  • BinlogMySQL Server 层 的日志,与存储引擎无关;所有引擎只要支持事务并参与复制,都会配合写 binlog。
  • 以“事件”形式记录对数据的逻辑变更(如“在表 T 上执行了 UPDATE … WHERE ID=2”),而不是物理页的变更。
  • 默认在数据目录下,文件名通常为 mysql-bin.000001 等,可配置 log_binbinlog_format 等。
  1. 归档与恢复:配合全量备份 + 某时间点前的 binlog,做 PITR(时间点恢复)。
  2. 主从复制:主库写 binlog,从库拉取 binlog 并重放,实现主从一致。
  3. 数据同步 / 异构:很多 CDC、数仓同步工具也是基于 binlog 解析。
维度Redo LogBinlog
层级InnoDB 引擎层Server 层
内容物理日志(页的物理变更)逻辑日志(SQL 或行变更事件)
用途崩溃恢复、保证持久性归档、主从、PITR
写入方式循环写、固定大小追加写、可按文件切割

正因为 redo 与 binlog 都要在提交时落盘,且都要参与“是否提交成功”的语义,所以需要 两阶段提交 来保证二者一致。


  • 事务提交时,InnoDB 要写 redo log,Server 要写 binlog
  • 若先写 redo 再写 binlog,或先写 binlog 再写 redo,在“写了一个未写另一个”时宕机,会出现:
    • 从库或备份恢复出来的数据与主库不一致;
    • 或主库用 redo 恢复后与 binlog 逻辑不一致。
  • 两阶段提交 通过“先 redo prepare,再写 binlog,最后 redo commit”的顺序,保证:要么 redo 和 binlog 都认为提交了,要么都认为未提交,从而崩溃恢复后主从/备份与主库一致。
  1. Prepare 阶段:InnoDB 把当前事务的 redo log 写入并刷盘,状态记为 prepare
  2. 写 Binlog:Server 层把该事务的 binlog 写入并刷盘。
  3. Commit 阶段:InnoDB 把 redo log 中该事务的状态从 prepare 改为 commit(并刷盘)。

崩溃恢复时:

  • 若 redo 是 prepare 且能找到对应 binlog 且 binlog 完整,则认为该事务应提交,用 redo 重做并置为 commit。
  • 若 redo 是 prepare 但 binlog 没有或不全,则认为该事务应回滚,用 undo 回滚。

这样 redo 与 binlog 在“是否提交”上始终一致


  • Change Buffer 是 InnoDB 在缓冲池(Buffer Pool)里的一块内存区域,专门用来缓存对二级索引页的写操作(插入、更新、删除),当目标数据页尚未被读入内存时,不立即从磁盘读入该页,而是先把“变更”记在 change buffer 中,等后续该页被读入内存或后台刷脏时再合并,从而减少随机磁盘 I/O。
  • 适用于 非唯一二级索引 的写多、读少场景(唯一索引必须立即读页做唯一性检查,无法只写 change buffer)。
  • Redo log 记录的是“对数据页/change buffer 等”的物理变更,保证崩溃后可重做,从而保证持久性。
  • Change buffer 里的变更,在写入内存时也会生成对应的 redo;这样即使 change buffer 还没合并到数据页,只要 redo 落盘,崩溃恢复时仍能重做 change buffer 的修改,再在后续合并到数据页。
  • 因此:先有“逻辑变更”(如对二级索引的更新),再在 change buffer 中记录,并同时写 redo;redo 保证这些变更不丢;合并时再把 change buffer 应用到真实数据页
  • Redo log:引擎层、物理日志、崩溃恢复与持久性。
  • Binlog:Server 层、逻辑日志、归档与主从。
  • 两阶段提交:协调 redo 与 binlog,保证提交语义一致。
  • Change buffer:缓存对二级索引页的写,减少随机 I/O,其修改同样受 redo 保护。

一组操作被视为一个整体,要么全部成功,要么全部失败。典型场景:

  • 转账:从 A 账户扣 100 元、往 B 账户加 100 元,这两步必须同时成功或同时失败。

实现手段:InnoDB 使用 Undo Log(回滚日志) 记录修改前的数据,事务回滚时根据 Undo Log 将数据恢复。

事务前后,数据必须满足所有约束(主键、唯一约束、外键、业务不变式等)。

  • 例如“账户余额不为负数”、“库存不能小于 0”,无论中间发生什么异常,最终状态都要满足这些规则。

一致性通常需要 应用逻辑 + 约束 + 事务 一起保证。

并发事务之间应互不干扰,尽量做到“仿佛串行执行”。实际中为了性能,会在不同隔离级别做权衡,下面单独展开。

事务提交后,对数据的修改必须持久化到磁盘,即使宕机重启也能恢复。

InnoDB 通过:

  • Redo Log(重做日志)
  • WAL(Write-Ahead Logging,先写日志再写数据页)

来保证持久性。


SQL 标准定义了四种隔离级别(从弱到强):

  1. READ UNCOMMITTED
  2. READ COMMITTED
  3. REPEATABLE READ
  4. SERIALIZABLE

MySQL InnoDB 的默认隔离级别是 REPEATABLE READ

  • 脏读(Dirty Read):读到别的事务尚未提交的数据。
  • 不可重复读(Non-repeatable Read):同一事务中两次读取同一行,结果不同(因为其他事务提交了更新)。
  • 幻读(Phantom Read):同一事务中,两次按条件查询,第二次多(或少)了几行记录(因为其他事务插入/删除了满足条件的行)。
隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED可能可能
REPEATABLE READInnoDB 下基本避免(MVCC + 间隙锁)
SERIALIZABLE

InnoDB 在 REPEATABLE READ + MVCC + 间隙锁 组合下,已经能在大部分场景下避免幻读。


在理解具体锁之前,先强调两点前提:

  • 所有示例都基于 InnoDB(MyISAM 没有事务)。
  • 建议业务表必须有合适的索引,否则容易退化为表锁甚至导致严重锁冲突。
  • 表锁(Table Lock)
    • 一次锁整张表。
    • 开销小、实现简单,但并发度差
    • 典型语句:
LOCK TABLES accounts WRITE;
-- ... update / insert / delete ...
UNLOCK TABLES;
  • 行锁(Row Lock)
    • 锁定单行或多行记录。
    • 开销相对大,但并发度高,是 InnoDB 的核心能力。

8.2 从读写语义划分:共享锁 vs 排他锁

Section titled “8.2 从读写语义划分:共享锁 vs 排他锁”
  • 共享锁(S Lock)
    • 多个事务可以同时持有,同一行可被多个事务读取。
    • 典型:SELECT ... LOCK IN SHARE MODE(MySQL 8.0+ 为 FOR SHARE)。
-- 事务 A
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- 只能读,不能写;其他事务也可以 FOR SHARE 读,但不能改
  • 排他锁(X Lock)
    • 同一时刻只允许一个事务持有,阻塞其他事务的读写(读需要 S 锁)。
    • 典型:UPDATE, DELETE, INSERT,或显式使用 FOR UPDATE
-- 事务 A
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 获取行的排他锁,其他事务对该行的 FOR UPDATE / UPDATE / DELETE 会被阻塞

意向锁是 表级锁,用来标记“某个事务准备在某些行上加行锁”。有两种:

  • 意向共享锁(IS):表示“准备在某些行上加 S 锁”。
  • 意向排他锁(IX):表示“准备在某些行上加 X 锁”。

作用:加速“表级锁”与“行级锁”的兼容性判断

  • 如果事务要对整张表加表级 X 锁,需要确认:这张表上没有其他事务持有任何行级锁。
  • 失败的锁兼容检测会非常频繁,如果每次都去扫行锁代价巨大。
  • 因此先在表上加 IS / IX,之后通过 表级意向锁矩阵 快速判断能否再加表锁。

你通常不需要手动管理意向锁,它由 InnoDB 自动加/释放。

8.4 间隙锁(Gap Lock)与 Next-Key Lock

Section titled “8.4 间隙锁(Gap Lock)与 Next-Key Lock”

为了避免幻读,InnoDB 不仅会锁定 已经存在的记录行,还会锁定 记录之间的“间隙”

  • 间隙锁(Gap Lock):锁住两个记录之间的区间(不含记录本身)。
  • Next-Key Lock:行锁 + 上方间隙锁的组合。

例如,表 accounts 中有 id1020 的两行:

-- 事务 A
BEGIN;
SELECT * FROM accounts WHERE id BETWEEN 10 AND 20 FOR UPDATE;

在 InnoDB 中,这通常会对 (10, 20] 区间加 Next-Key 锁,效果是:

  • 其他事务不能在 (10, 20) 区间插入新行(防止幻读)。

九、事务 + 锁的典型场景与示例

Section titled “九、事务 + 锁的典型场景与示例”

表结构示例:

CREATE TABLE account (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
balance DECIMAL(18, 2) NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
INDEX idx_user (user_id)
) ENGINE=InnoDB;

转账逻辑:从 A 用户扣 100 元,转给 B 用户。

-- 事务开始
START TRANSACTION;
-- 锁定两条记录,防止并发修改
SELECT id, balance
FROM account
WHERE id IN (1, 2)
FOR UPDATE;
-- 检查余额是否足够
-- (在应用层完成判断:若余额不足则 ROLLBACK)
UPDATE account
SET balance = balance - 100
WHERE id = 1;
UPDATE account
SET balance = balance + 100
WHERE id = 2;
COMMIT;

要点:

  • 使用 FOR UPDATE 锁定参与转账的账户行,避免并发同时修改导致超扣。
  • 所有检查、更新都在同一个事务中完成。

典型错误写法(可能超卖):

-- ❌ 不安全示例
SELECT stock FROM product WHERE id = 1001;
-- 应用判断 stock >= 1
UPDATE product SET stock = stock - 1 WHERE id = 1001;

多个并发请求都可能在 SELECT 时看到相同的库存,然后同时通过判断,最终把库存减成负数。

推荐两种写法:

START TRANSACTION;
SELECT stock
FROM product
WHERE id = 1001
FOR UPDATE;
-- 在应用层判断:若 stock < 1,则 ROLLBACK 并提示“库存不足”
UPDATE product
SET stock = stock - 1
WHERE id = 1001;
COMMIT;
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock >= 1;
-- 然后检查受影响行数:
-- 0 行 -> 库存不足
-- 1 行 -> 扣减成功

优点:

  • 避免显式事务管理,利用单条语句的原子性和锁语义。

最常见的模式:循环等待

事务 A:锁定行 1 -> 等待行 2
事务 B:锁定行 2 -> 等待行 1

双方都在等待对方释放锁,最终 MySQL 检测到死锁后,会 回滚其中一个事务

-- 会话 1
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 会话 2
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 2;
-- 会话 1
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 会话 2
UPDATE account SET balance = balance + 100 WHERE id = 1;

这时很容易出现死锁。

  • 固定加锁顺序:访问多行/多资源时,统一按某个顺序(例如按 id 升序)加锁。
  • 缩短事务时间:不要在事务中做长时间操作(外部 RPC 调用、用户交互等)。
  • 合理拆分大事务:把无强依赖关系的操作拆成多个小事务。
  • 确保索引命中:避免 WHERE 条件不走索引,导致锁范围扩大为大量行甚至整表。

当遇到 Deadlock found when trying to get lock 报错,可在 MySQL 中查看最近的死锁信息:

SHOW ENGINE INNODB STATUS\G

重点关注:

  • LATEST DETECTED DEADLOCK 部分,查看是哪些 SQL / 哪些锁之间冲突。

十一、事务与锁的实战建议清单

Section titled “十一、事务与锁的实战建议清单”
  • 尽量使用 InnoDB + 合理的主键和二级索引
  • 默认使用 REPEATABLE READ 即可,大部分 OLTP 场景足够;若有特别需求再调整隔离级别。
  • 所有涉及 余额、库存、计数器 等关键字段的写操作,务必在事务中完成,并使用:
    • SELECT ... FOR UPDATE + 应用判断,或
    • UPDATE ... WHERE ... AND 条件原子更新模式
  • 访问多行/多资源时,统一加锁顺序,降低死锁概率。
  • 对热点写场景,考虑:
    • 将热点数据拆分(分表/分行)
    • 把部分计数类需求转到缓存/异步统计。
  • 经常使用 SHOW ENGINE INNODB STATUS\G 以及慢查询日志,结合监控系统分析锁等待和死锁。

  • 一条 SQL 更新会经过连接器、分析器、优化器、执行器、存储引擎;写操作依赖 redo log、binlog、undo log,以及(对非唯一二级索引)change buffer
  • Binlog 是 Server 层逻辑归档日志,用于主从、PITR;redo log 是引擎层物理日志,用于崩溃恢复;二者通过 两阶段提交 保证提交语义一致。
  • Change buffer 缓存对二级索引页的写,减少随机 I/O,其修改同样由 redo 保护。
  • 事务通过 ACID 保证数据可靠性,是数据库正确性的基石。
  • 隔离级别 + MVCC 决定了在并发场景下能看到什么数据。
  • 锁(行锁、意向锁、间隙锁等) 是实现隔离和并发控制的核心手段。
  • 通过合理设计索引、固定加锁顺序、控制事务粒度,既可以保障数据安全,又能获得足够的吞吐性能。

掌握这些概念和实践,可以更自信地在高并发业务中设计和调优基于 MySQL 的核心数据模型。