InnoDB 事务启动探秘:从 BEGIN 到第一个 SQL,MySQL 到底在忙什么?
本文基于 MySQL 8.0.44 源码,存储引擎为 InnoDB。
目录
为什么需要搞清楚 BEGIN 的行为
BEGIN 语句的语法变体与解析器逻辑
BEGIN 的执行入口:trans_begin 函数
辞旧:隐式提交老事务与 MDL 锁释放
迎新:打上 OPTION_BEGIN 标记
事务到底什么时候真正启动?
只读事务与读写事务的差异
总结
1. 为什么需要搞清楚 BEGIN 的行为
在日常开发中,BEGIN可能是我们最常写的 SQL 之一。在很多人的认知里,BEGIN就是"开启一个事务"的意思——简单、直接、没什么好说的。
但事实真的如此吗?
如果你曾经在同一个连接里连续执行过两次BEGIN,可能会注意到一个现象:第一次BEGIN之后做了一些数据修改,还没提交,第二次BEGIN一执行,前面的修改就"消失"了。它们不是回滚了,而是被自动提交了。
这说明BEGIN干的活,远比表面上看到的要多。
要真正理解BEGIN的行为,需要深入 MySQL 8.0.44 的源码,看看从客户端敲下BEGIN到事务真正可用,中间到底经历了什么。
2. BEGIN 语句的语法变体与解析器逻辑
MySQL 官方文档中,开启事务的语法比想象中要丰富:
START TRANSACTION [transaction_characteristic [, transaction_characteristic] ...] transaction_characteristic: { WITH CONSISTENT SNAPSHOT | READ WRITE | READ ONLY } BEGIN [WORK]把这些语法要素排列组合,可以得到以下 10 种 SQL 语句:
/* 1 */ BEGIN /* 2 */ BEGIN WORK /* 3 */ START TRANSACTION /* 4 */ START TRANSACTION READ WRITE /* 5 */ START TRANSACTION READ ONLY /* 6 */ START TRANSACTION WITH CONSISTENT SNAPSHOT /* 7 */ START TRANSACTION WITH CONSISTENT SNAPSHOT, READ WRITE /* 8 */ START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY /* 9 */ START TRANSACTION WITH CONSISTENT SNAPSHOT, READ WRITE, READ ONLY /* 10 */ START TRANSACTION READ WRITE, READ ONLY其中,语句 1~8 可以正常执行,语句 9 和 10 会报语法错误。
为什么会报错?翻一下语法解析器的代码就知道了。在sql/sql_yacc.yy中,处理START TRANSACTION的逻辑是这样的:
start: START_SYM TRANSACTION_SYM opt_start_transaction_option_list { LEX *lex= Lex; lex->sql_command= SQLCOM_BEGIN; /* READ ONLY and READ WRITE are mutually exclusive. */ if (($3 & MYSQL_START_TRANS_OPT_READ_WRITE) && ($3 & MYSQL_START_TRANS_OPT_READ_ONLY)) { YYTHD->syntax_error(); MYSQL_YYABORT; } lex->start_transaction_opt= $3; } ;解析器的逻辑很直接:**READ WRITE和READ ONLY是互斥的,不能同时出现**。一旦检测到两者同时存在,就主动报错。
在可正常执行的语句中,可以根据行为差异分为两类:
语句 1~5(
BEGIN、BEGIN WORK、START TRANSACTION、START TRANSACTION READ WRITE、START TRANSACTION READ ONLY):这些语句不会立即启动事务,也不会创建一致性读视图。事务的实际启动被延迟到真正需要的时候。语句 6~8(带
WITH CONSISTENT SNAPSHOT的三种形式):这些语句会先启动事务,然后立即创建一致性读视图。
3. BEGIN 的执行入口:trans_begin 函数
在 MySQL 源码中,所有显式开启事务的入口函数都是trans_begin。无论是BEGIN、BEGIN WORK还是START TRANSACTION,最终都会汇聚到这个函数中。
trans_begin函数的声明位于sql/transaction.cc中:
bool trans_begin(THD *thd, uint flags)这个函数有一句关键的注释,直接点明了BEGIN语句的核心行为:
Beginning a transaction implicitly commits any current transaction and releases existing locks.
翻译过来就是:开始一个新事务会隐式提交当前事务并释放已有的锁。
如果用四个字概括BEGIN语句在trans_begin中的核心工作,那就是辞旧迎新——先提交老事务,再准备新事务。
4. 辞旧:隐式提交老事务与 MDL 锁释放
先来看一个常见场景:
你在 MySQL 客户端中执行了BEGIN,开始了一个事务(事务 1),然后执行了一条INSERT语句。事务 1 还没有提交(处于活跃状态),此时你在同一个连接中又执行了一条BEGIN语句——事务 1 会怎样?
答案是:事务 1 会被自动提交。
原因很简单:MySQL不支持嵌套事务。事务 1 还没结束,又要开始事务 2,事务 1 无处安放,只能被隐式提交。
BEGIN语句是如何判断"当前连接中是否存在未提交事务"的?源码中的判断逻辑是这样的:
if (thd->in_multi_stmt_transaction_mode() || ...) { ... }而in_multi_stmt_transaction_mode()的实现是:
inline bool in_multi_stmt_transaction_mode() const { return variables.option_bits & (OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN); }检查当前线程的option_bits标志位中,是否包含了OPTION_NOT_AUTOCOMMIT或OPTION_BEGIN。只要包含其中任意一个,就说明当前连接中可能存在未提交的事务。
如果检测到存在未提交事务,BEGIN语句会主动调用提交操作:
res = ha_commit_trans(thd, true);ha_commit_trans(thd, true)的第二个参数all传的是true,表示这是一个隐式提交。这个提交不会记录在 binlog 里,也不会触发commit相关的触发器,只是悄无声息地把事务结束了。
除了提交事务,这个过程还会做一件重要的事:释放 MDL 锁。MDL(Metadata Lock)是 Server 层用来保护表结构定义的锁机制。事务持有的 MDL 锁如果不释放,后面的 DDL 语句(比如ALTER TABLE)就会被卡住。BEGIN隐式提交老事务时,会一并把 MDL 锁释放掉。
5. 迎新:打上 OPTION_BEGIN 标记
辞旧完成之后,就该迎新了。
MySQL 在资源使用上秉承一个原则:能少分配就少分配,能晚分配就晚分配。启动事务也需要分配资源,因此BEGIN语句并不会真正启动一个事务,只是做一些最轻量的准备工作。
在trans_begin函数中,这个准备工作主要包括以下几行代码:
thd->variables.option_bits |= OPTION_BEGIN; thd->server_status |= SERVER_STATUS_IN_TRANS; if (thd->tx_read_only) thd->server_status |= SERVER_STATUS_IN_TRANS_READONLY;逐行来看:
**
thd->variables.option_bits |= OPTION_BEGIN**:给当前连接的线程打上OPTION_BEGIN标记。这是最关键的一步。**
thd->server_status |= SERVER_STATUS_IN_TRANS**:设置 Server 层状态,表示当前连接处于事务中。**
if (thd->tx_read_only) thd->server_status |= SERVER_STATUS_IN_TRANS_READONLY**:如果事务被标记为只读,则额外设置只读事务状态位。
那么,OPTION_BEGIN这个标记到底起了什么作用?
在默认情况下,MySQL 是自动提交(autocommit=1)模式,每执行完一条 SQL 语句就会自动提交。但一旦线程被打上OPTION_BEGIN标记,自动提交行为就被禁用了。事务不会在每条 SQL 之后自动提交,而是需要用户显式执行COMMIT或ROLLBACK才会结束事务。
打个比方:你去饭店点菜。
BEGIN语句就相当于你跟服务员说"我要开始点菜了",服务员在你的桌号上贴了个"用餐中"的标签。但此时厨房并没有开始做菜——真正的"做菜"(启动事务、分配事务 ID、生成 Read View 等)要等你真正点了菜(执行了具体的 SQL)才会开始。贴标签这个动作几乎不花时间,但它的意义在于告诉系统:接下来的操作都属于同一个事务,不要自动提交。
6. 事务到底什么时候真正启动?
既然BEGIN语句不会真正启动事务,那事务到底在什么时候启动?
答案是:在执行BEGIN之后的第一条 SQL 语句时。
当用户执行BEGIN之后,线程只是被打上了OPTION_BEGIN标记,事务对象(trx_t)的状态仍然是TRX_STATE_NOT_STARTED,表示事务还未开始。
在 InnoDB 的源码中,事务状态定义在storage/innobase/include/trx0types.h中:
**
TRX_STATE_NOT_STARTED**:事务对象存在,但尚未启动**
TRX_STATE_ACTIVE**:事务正在执行,可以获取锁和修改数据
直到执行第一条 SQL 语句(不管是SELECT、UPDATE还是DELETE),InnoDB 才会真正启动事务。这个启动过程由trx_start_if_not_started()函数触发。
trx_start_if_not_started()内部会调用trx_start_low(),完成以下关键操作:
将事务状态从
TRX_STATE_NOT_STARTED修改为TRX_STATE_ACTIVE如果是读写事务,分配真正的事务 ID(
trx->id)将事务对象加入全局事务链表
此时,事务才算是真正"活"了起来。我们在执行SHOW ENGINE INNODB STATUS时看到的ACTIVE状态,就来源于此。
这里还有一个值得注意的细节:事务总是以"读事务"的身份启动的。即使你执行的是UPDATE语句,事务在启动瞬间也是以读事务的身份出现,事务 ID 被设置为 0。
只有当事务确实需要写入数据时,才会被分配一个真正的事务 ID。对于只读事务,InnoDB 可以做一系列优化——不分配事务 ID、不注册到活跃事务链表、不分配 Undo 段。把"启动事务"和"分配事务 ID"解耦,可以让只读事务以极低的成本运行。
7. 只读事务与读写事务的差异
在START TRANSACTION的语法中,READ ONLY和READ WRITE选项会影响事务的行为。
只读事务(START TRANSACTION READ ONLY):
当以这种形式开启事务时,thd->tx_read_only会被设置为true。当 Server 层接收到任何数据更改的 SQL 时,都会直接拒绝请求,不会进入引擎层。
只读事务在 InnoDB 引擎层可以走优化过的逻辑,相比读写事务开销更小:
不用分配事务 ID
不用分配回滚段
不用维护到全局事务链表(读写事务链表)中
这些优化使得只读事务的成本极低,非常适合只执行查询操作的场景。
读写事务(START TRANSACTION READ WRITE):
这是默认的事务模式。但如果当前实例的read_only打开了,且当前连接不是超级账户,则显式开启读写事务会报错。
需要特别注意的是:**BEGIN语句默认开启的是读写事务**。即使你后续只执行SELECT查询,事务在启动时也是以读写事务的模式初始化的。不过,如果 InnoDB 在真正执行查询时能判断出这是只读操作,会进行相应的优化。
8. 总结
用一句话概括全文核心结论:**BEGIN语句并不会马上启动一个新事务**。
| 阶段 | 操作 | 事务状态 | 关键代码 |
|---|---|---|---|
执行BEGIN | ① 提交老事务(如有)② 释放 MDL 锁 ③ 打上OPTION_BEGIN标记 ④ 设置SERVER_STATUS_IN_TRANS | TRX_STATE_NOT_STARTED(未启动) | trans_begin()→ha_commit_trans()→ `option_bits |
| 执行第一条 SQL | ① 调用trx_start_if_not_started()② 分配事务 ID(读写事务)③ 加入全局事务链表 ④ 状态变为TRX_STATE_ACTIVE | TRX_STATE_ACTIVE(已启动) | trx_start_if_not_started()→trx_start_low()→state = TRX_STATE_ACTIVE |
BEGIN语句的本质工作是"辞旧迎新"——提交可能存在的旧事务并释放其 MDL 锁,然后给当前线程打上OPTION_BEGIN标记,告诉 MySQL"接下来的操作别自动提交"。而事务的真正启动,被延迟到了第一条 SQL 语句执行的那一刻。
这种延迟启动的设计,体现了 MySQL 一贯的资源管理哲学:能不做的就不做,能晚做的就晚做。对于只执行查询的短事务来说,这种设计可以避免分配事务 ID、分配 Undo 段等不必要的开销,从而提升系统的整体吞吐量。
参考源码文件(MySQL 8.0.44)
| 源码文件 | 涉及的关键函数/结构 |
|---|---|
sql/sql_yacc.yy | START TRANSACTION语法解析,READ WRITE与READ ONLY互斥检查 |
sql/transaction.cc | trans_begin()、ha_commit_trans() |
sql/sql_class.h | THD结构体、in_multi_stmt_transaction_mode()、option_bits标志位定义 |
storage/innobase/include/trx0types.h | trx_state_t枚举定义(TRX_STATE_NOT_STARTED、TRX_STATE_ACTIVE等) |
storage/innobase/trx/trx0trx.cc | trx_start_if_not_started()、trx_start_low() |