InnoDB 事务启动探秘从 BEGIN 到第一个 SQLMySQL 到底在忙什么本文基于 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~5BEGIN、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 锁。MDLMetadata 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 是自动提交autocommit1模式每执行完一条 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还是DELETEInnoDB 才会真正启动事务。这个启动过程由trx_start_if_not_started()函数触发。trx_start_if_not_started()内部会调用trx_start_low()完成以下关键操作将事务状态从TRX_STATE_NOT_STARTED修改为TRX_STATE_ACTIVE如果是读写事务分配真正的事务 IDtrx-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_TRANSTRX_STATE_NOT_STARTED未启动trans_begin()→ha_commit_trans()→ option_bits执行第一条 SQL① 调用trx_start_if_not_started()② 分配事务 ID读写事务③ 加入全局事务链表 ④ 状态变为TRX_STATE_ACTIVETRX_STATE_ACTIVE已启动trx_start_if_not_started()→trx_start_low()→state TRX_STATE_ACTIVEBEGIN语句的本质工作是辞旧迎新——提交可能存在的旧事务并释放其 MDL 锁然后给当前线程打上OPTION_BEGIN标记告诉 MySQL接下来的操作别自动提交。而事务的真正启动被延迟到了第一条 SQL 语句执行的那一刻。这种延迟启动的设计体现了 MySQL 一贯的资源管理哲学能不做的就不做能晚做的就晚做。对于只执行查询的短事务来说这种设计可以避免分配事务 ID、分配 Undo 段等不必要的开销从而提升系统的整体吞吐量。参考源码文件MySQL 8.0.44源码文件涉及的关键函数/结构sql/sql_yacc.yySTART TRANSACTION语法解析READ WRITE与READ ONLY互斥检查sql/transaction.cctrans_begin()、ha_commit_trans()sql/sql_class.hTHD结构体、in_multi_stmt_transaction_mode()、option_bits标志位定义storage/innobase/include/trx0types.htrx_state_t枚举定义TRX_STATE_NOT_STARTED、TRX_STATE_ACTIVE等storage/innobase/trx/trx0trx.cctrx_start_if_not_started()、trx_start_low()