事务隔离级别与 MVCC 实战为什么你改了我还看不见关键词事务隔离、MVCC、一致性读、当前读、长事务、undo 版本链、ReadView、ReadView实验环境真实云服务器 Ubuntu 24.04 MySQL 8.0.46所有输出均为实机回显IP 已脱敏为121.36.***.***。一、引子一个让无数人翻车的问题“我在事务 A 里改了数据并提交为什么事务 B 死活查不到”“明明是 RR可重复读为什么我UPDATE之后看到的值不是我刚才SELECT的那个”“长事务到底危险在哪怎么一眼揪出它”这是丁奇《MySQL 实战 45 讲》第 3、8 讲里最经典的“事务到底隔离不隔离”的坑也是面试高频雷区。答案藏在两个词里一致性读快照读和当前读。本文我在真实云服务器上开了两个并发会话把 RR 与 RC 下的可见性差异、当前读的“魔幻 1”、长事务的危害一个一个跑给你看全部是实机回显。二、环境说明真实机器配置同一台 8C / 14G 云服务器公网 IP 脱敏为121.36.***.***MySQL 8.0.46。root 通过本地 socket 的auth_socket插件免密登录。先确认默认隔离级别mysqlSELECTtransaction_isolation,transaction_isolation;----------------------------------------|transaction_isolation|REPEATABLE-READ|----------------------------------------默认就是REPEATABLE-READ可重复读RR。为了并发演示我开了两个独立的交互式 mysql 会话会话 A、会话 B每个会话保持自己的连接和事务——只有这样事务才真正“活着”。下文所有[A]/[B]标注的就是不同会话里敲的命令是实机双窗口的真实回显。贯穿全文用一张极简表test.T(id int primary key, c int)基线令c idmysqlUSEtest;mysqlUPDATETSETcid;SELECT*FROMT;-------|id|c|-------|1|1||2|2||3|3||4|4||5|5|-------三、原理讲解MVCC 是怎么让你“看不见”的InnoDB 的 MVCC多版本并发控制靠三件套实现隐藏事务 ID 字段每行数据除了业务列还有trx_id最近修改它的事务 ID和roll_pointer指向 undo 日志里上一版本的指针。undo log 版本链每次更新旧版本被写入 undo log通过roll_pointer串成一条链旧事务可以顺着链读到自己该看到的版本。ReadView读视图事务在“一致性读”时生成一个 ReadView记录当前活跃事务列表判断某行版本的trx_id对自己是否可见——可见条件简化说就是版本由“已提交且在我快照前”的事务产生才可见。由此引出两个关键概念务必分清一致性读快照读普通SELECT走 MVCC读的是事务开始或START TRANSACTION WITH CONSISTENT SNAPSHOT那一刻的快照不会看到别人已提交的修改RR 下。当前读UPDATE / DELETE / SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE读的是最新已提交版本并加锁。这就是“你改了我能看见”的真相。隔离级别差异一句话RR可重复读一致性读始终读事务第一次读时建立的快照所以别人提交了你也“看不见”。RC读已提交每次一致性读都重新生成 ReadView所以能看见别人已提交的修改。下面用真刀真枪的实验验证这两句。四、分步实操真实命令 真实回显4.1 RR 下你改了我真的看不见会话 A 开启事务并第一次读id1[A]begin;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|1|------1rowinset(0.00sec)与此同时会话 B 把id1的c改成 2 并提交B 默认 autocommitUPDATE 立即生效[B]UPDATETSETcc1WHEREid1;Query OK,1rowaffected(0.00sec)Rowsmatched:1Changed:1Warnings:0此时 A 在同一事务里再查一次[A]SELECTcFROMTWHEREid1;------|c|------|1|------1rowinset(0.00sec)仍然是 1这就是 MVCC 快照读——A 的 ReadView 在begin后第一次读时生成B 的提交对 A 不可见。直到 A 提交后新事务才看到最新值[A]commit;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|2|------1rowinset(0.00sec)4.2 RC 下你提交了我立刻看见把 A 的会话隔离级别切成 READ COMMITTED 再重复[A]SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;Query OK,0rowsaffected(0.01sec)[A]begin;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;-- 此刻 c2------|c|------|2|------[B]UPDATETSETcc1WHEREid1;-- B 把它改成 3 并提交Query OK,1rowaffected(0.00sec)A 再次查询[A]SELECTcFROMTWHEREid1;------|c|------|3|------1rowinset(0.00sec)从 2 直接变 3因为 RC 每次读都重建 ReadView已提交的修改立刻可见。对比 4.1 的 RR隔离级别对可见性的影响一目了然。演示完记得把 A 切回 RR[A]commit;[A]SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;4.3 经典例RR 下“你改了我 UPDATE 后却看到了”——当前读这是丁奇“事务到底隔离不隔离”里最烧脑的一幕。先把id1复位成 1mysql-uroot test-eUPDATE T SET c1 WHERE id1;A 开启事务并快照读[A]begin;[A]SELECTcFROMTWHEREid1;------|c|------|1|------[B]UPDATETSETcc1WHEREid1;-- B 改成 2已提交Query OK,1rowaffected(0.00sec)按 4.1 的经验A 此刻SELECT应该还看到 1。但 A 接着执行一条UPDATE[A]UPDATETSETcc1WHEREid1;-- 当前读读最新已提交(2) 1 3Query OK,1rowaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|3|------1rowinset(0.00sec)[A]commit;结果 c 变成了 3而不是 2为什么UPDATE是当前读它读的是最新已提交版本c2来自 B 的提交在此基础上1得到3并写回。所以“A 的 UPDATE 看到了 B 的修改”是成立的——只是普通SELECT看不见而写操作当前读看得见。这正是“事务到底隔离不隔离”的标准答案快照读隔离当前读不隔离。4.4 START TRANSACTION WITH CONSISTENT SNAPSHOT显式“拿一致性快照”的写法效果等价于 RR 下begin后的第一次读但语义更明确[A]STARTTRANSACTIONWITHCONSISTENTSNAPSHOT;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|1|------[B]UPDATETSETcc1WHEREid1;-- 改成 2Query OK,1rowaffected(0.00sec)[A]SELECTcFROMTWHEREid1;-- 仍读快照看到 1------|c|------|1|------[A]commit;它与 4.1 的区别只是把“快照建立点”从“第一次读”提前到“事务刚开始”对 RR 行为一致常用于需要事务起点就固定快照的备份/对账场景。4.5 长事务一眼揪出它开一个事务但故意不提交让它一直“活着”[A2]begin;[A2]SELECTcFROMTWHEREid1;------|c|------|2|------在另一个连接查information_schema.innodb_trx抓真实长事务现场下面就是实机回显未删改mysqlSELECTtrx_id,trx_state,trx_started,trx_mysql_thread_id,trx_query,trx_isolation_levelFROMinformation_schema.innodb_trx\G***************************1.row***************************trx_id:420578098148136trx_state: RUNNING trx_started:2026-07-2911:55:01trx_mysql_thread_id:73trx_query:NULLtrx_isolation_level:REPEATABLEREAD关键点trx_state: RUNNING且trx_query: NULL—— 事务空闲但没提交典型“长事务挂起”。trx_started: 2026-07-29 11:55:01—— 启动时间和“现在”一比就知道它活了多久。trx_mysql_thread_id: 73—— 对应连接 ID可用KILL 73或更安全的KILL QUERY 73处理。长事务的危害它持有的 ReadView 永远不释放导致 undo log 里的旧版本无法 purgeundo 链越积越长不仅拖慢该事务本身还让整库回滚段膨胀、甚至撑爆空间。线上监控innodb_trx是 DBA 的必备动作。五、sysbench 侧写隔离与持久化的“双 1”代价同一台机器顺带把“双 1”性能梯度贴在这里方便理解为什么很多业务会为性能放宽持久化但隔离级别通常不会为性能牺牲一致性组合innodb_flush_log_at_trx_commitsync_binlogTPSavg 延迟双 111981.878.15 msf1,s0101388.655.76 msf2,s1211748.244.57 msf2,s0203561.462.25 ms隔离级别与 redo/binlog 是两码事隔离级别决定“看见什么”持久化参数决定“宕机丢不丢”。两者正交别混为一谈。六、踩坑记录真实报错 / 注意点RR 下 SELECT 看不到别人提交但 UPDATE 看得到这是“快照读 vs 当前读”的正常现象不是 Bug。写操作永远是当前读。改了隔离级别只对“下一个事务”生效SET SESSION TRANSACTION ISOLATION LEVEL ...之后必须begin新事务才生效在已开启的事务里改会报错或无效。长事务trx_query为 NULL空闲未提交事务查出来就是 NULL别误以为“没事务在跑”要结合trx_started判断它挂了多久。SHOW BINARY LOG STATUS 在 8.0 报 1064第 1 篇已详述8.0 用SHOW MASTER STATUS。与本篇无关但属同源版本坑。RC 下会出现“不可重复读”同一事务两次读结果不同本文 4.2 从 2 变 3这是 RC 的定义行为若业务要求可重复读必须 RR。七、面试高频问答事务的四大特性 ACID 是什么InnoDB 靠什么实现原子性undo log、一致性应用层 约束、隔离性锁 MVCC、持久性redo log double write buffer。RR 下为什么“你改了我看不见”一致性读走 MVCC读的是事务第一次读时建立的 ReadView 快照别人已提交的新版本不满足可见条件所以看不到。RR 下为什么 UPDATE 后又能“看见”了UPDATE/DELETE/SELECT ... FOR UPDATE是当前读读最新已提交版本并加锁因此能看到别人提交的数据4.3 的“c 变 3”就是当前读 1 的结果。RR 和 RC 在实现上的核心区别快照建立时机不同RR 在整个事务内用同一个 ReadView第一次读时建立RC 每次一致性读都新建 ReadView所以能看到最新已提交。MVCC 的 undo log 版本链和 ReadView 是什么每行有trx_id和roll_pointer旧版存入 undo log 串成链ReadView 记录活跃事务用来判断某版本对当前事务是否可见。长事务有什么危害怎么排查危害占住 ReadView 导致 undo log 无法 purge回滚段膨胀、性能下降。排查SELECT * FROM information_schema.innodb_trx;看trx_started和trx_state结合trx_mysql_thread_idKILL 掉。如何解决长事务代码里及时 commit避免事务里做 RPC/HTTP 等耗时外部调用监控innodb_trx告警必要时用wait_timeout/innodb_rollback_on_timeout兜底。“可重复读”能避免幻读吗RR 下普通快照读通过 MVCC 避免幻读但当前读SELECT ... FOR UPDATE靠间隙锁gap lock/ Next-Key Lock 防住其他事务插入从而杜绝幻读。这是 InnoDB 对标准 SQL“RR 允许幻读”的增强。七又二分之一、补充深挖把 MVCC 再往下凿四层补充 AReadView 的可见性判断算法ReadView 内部有四个关键字段m_ids创建瞬间所有活跃事务 ID 列表、min_trx_id其中最小的事务 ID、max_trx_id系统下一个将要分配的事务 ID、creator_trx_id创建该 ReadView 的事务自身 ID。判断某一行版本其trx_id X对当前事务是否可见规则如下若X min_trx_id说明修改该版本的事务在 ReadView 创建前就已经提交 →可见。若X max_trx_id说明修改该版本的事务是在 ReadView 创建之后才开启的 →不可见需要顺着 undo 链找更早的版本。若min_trx_id X max_trx_id看X是否还在m_ids活跃列表里——在说明事务尚未提交不可见不在说明已提交可见。这就是 RR 下“同一事务内反复读都是同一份数据”的底层逻辑RR 的 ReadView 只在事务第一次一致性读时建立一次之后整个事务复用而RC 每次一致性读都重新建立 ReadView于是能看见别的事务最新提交的修改。可见性算法完全相同差异只在于 ReadView 的建立时机。补充 Bundo log 与版本链的物理结构InnoDB 每行记录除了业务列还藏着DB_TRX_ID最近修改它的事务 ID即上面说的 trx_id和DB_ROLL_PTR回滚指针。每次UPDATE引擎先把修改前的整行拷贝进 undo log若是INSERT则记一条反向的DELETE新行的roll_pointer指向这条 undo 记录形成一个单向链表。一个老事务顺着链表一路回溯就能拼出“属于自己快照”的那一版数据。版本链越长回放越慢、越占内存——这正是长事务的衍生危害它长期霸占 ReadView导致旧版本无法被 purge 线程清理undo 表空间不断膨胀。补充 CRR 下的幻读与间隙锁Next-Key LockSQL 标准里“可重复读”是允许幻读的同一事务两次范围查询可能返回不同行数。但 InnoDB 在 RR 下用Next-Key Lock记录锁 间隙锁把幻读也堵住了当你执行SELECT ... WHERE id 10 FOR UPDATE这类当前读时引擎不仅锁住命中的记录还锁住这些记录之间的“间隙”gap别的事务无法往间隙里插入新行于是当前读也不会凭空多出幻行。普通快照读靠 MVCC 天然不幻读锁定读靠间隙锁兜底——这是 InnoDB 的 RR 比 SQL 标准更严格的地方也是它最常用的卖点之一。补充 D当前读会加锁所以是高并发锁瓶颈的来源理解了“UPDATE 是当前读”也就理解了热点行更新的性能本质当前读会加排他锁X 锁并可能触发记录锁甚至间隙锁于是多个事务“你改我改”时极易撞锁严重时还会死锁。排查锁等待应看performance_schema.data_locks与data_lock_waitsMySQL 8.0 已用它们取代旧版的INFORMATION_SCHEMA.INNODB_LOCKS。线上热点账户、库存扣减这类场景往往要把“单行高频更新”改成“批量/异步”来绕开锁竞争。补充 E隔离级别选型与一致性读的工程建议绝大多数 OLTP 业务用默认的RR即可既避免不可重复读又靠间隙锁防幻读。若业务能容忍不可重复读、且追求更简单的可见性语义或某些报表查询想立刻看到最新提交可显式用RC注意 RC 下间隙锁会被弱化幻读防护不如 RR。不论选哪个级别长事务都是大忌代码里及时 commit事务里不要做 RPC / HTTP 等耗时外部调用监控information_schema.innodb_trx并设告警必要时用wait_timeout兜底回收连接。八、总结“事务到底隔离不隔离”没有矛盾答案快照读普通 SELECT隔离当前读UPDATE/锁定读不隔离。RR 靠 ReadView 让一致性读可重复RC 靠每次重建 ReadView 让你立刻看见已提交而长事务则是把 ReadView 长期霸占既拖慢自己也撑大 undo。本文用真实双会话把 RR/RC 的可见性差异、当前读的“1 变 3”、以及information_schema.innodb_trx的真实长事务现场全部跑实并用补充章节把 ReadView 可见性算法、undo 版本链、间隙锁与幻读、当前读的锁代价一层层凿开配合同机的“双 1”性能梯度帮你把隔离性、MVCC、持久化这三条主线一次理清。补充 FRR 下“快照读 vs 当前读”的完整并发时序把 4.1 与 4.3 拼成一条时间线隔离性的全貌就清楚了时刻 T1 A: begin; -- A 建立 ReadView_1 时刻 T2 A: SELECT c1 -- 读快照看到 1 时刻 T3 B: UPDATE c2; (commit) -- B 提交产生新版本 trx_id_B 时刻 T4 A: SELECT c1 -- 快照读ReadView_1 仍不可见 B → 1 时刻 T5 A: UPDATE cc1 WHERE id1 -- 当前读读最新已提交 2写 3 时刻 T6 A: SELECT c3 -- 看到自己改的 3自己的修改对自己永远可见 时刻 T7 A: commit; -- ReadView_1 结束 时刻 T8 A: SELECT c3 -- 新事务看到最终 3关键顿悟点T4 的快照读与 T5 的当前读看到的是两个世界。这正是“事务到底隔离不隔离”的终极答案——不是隔离或不隔离的二选一而是“读的方式决定隔离程度”。这也是为什么很多 ORM 框架的“先查后改”在并发下会出现意料之外的值你以为拿着刚才查到的 1 去 1实际当前读拿到的是别人提交的 2结果变成 3。理解这一点能帮你避开一大类并发更新 bug。补充 G线上如何把长事务消灭在萌芽除了 4.5 的innodb_trx排查工程上更要在“发生前”预防连接池设超时wait_timeout/interactive_timeout不要设成永久空闲连接到期自动回收避免事务跟着连接一起“长生”。事务里不掺外部 IORPC、HTTP、消息发送等耗时操作绝对不要放在begin和commit之间否则锁和 ReadView 被长时间占住。监控告警定时采样innodb_trx的trx_started对存活超过 N 秒的事务直接告警必要时KILL QUERY thread_id只杀语句不杀连接。大事务拆小批量更新按主键分批如每 500 行一提交既缩短单事务时长也降低 undo 膨胀与锁冲突。备份/导出走一致性快照用START TRANSACTION WITH CONSISTENT SNAPSHOT本文 4.4或--single-transaction的 mysqldump避免长事务卡住整库 purge。补充 H为什么 MySQL 默认是 RR 而不是 RC一个常被追问的点很多数据库如 Oracle、PostgreSQL默认 RCMySQL 却默认 RR为什么历史原因是早年 MySQL 的 binlog 用 STATEMENT 格式做主从复制如果默认 RC主库上“先读后写”的并发在从库重放时可能因执行顺序不同而出现主从数据不一致而 RR 的快照读让同一事务内看到的数据稳定配合 ROW 格式 binlog复制更安全。即便现在默认 ROW 格式RR 仍是默认值好处是业务“开箱即得可重复读”不用每个开发都去操心不可重复读。代价仅是 RR 下的间隙锁比 RC 更重、并发插入更容易被间隙锁挡住——但这通常远小于“主从不一致”的代价。因此除非你有明确理由如某些报表要立刻看最新提交、或极端写入并发下想弱化间隙锁否则保留 RR 是最稳的选择。这也顺带解释了本文所有实验的默认基线都是 RR4.2 才手动切到 RC 做对照。补充 Iautocommit 下的“一致性读”你真的理解吗最后补一个极易混淆的细节MySQL 默认autocommit1即每条普通语句都是一个独立事务。此时你执行一条SELECTInnoDB 会为这条语句单独建立一个 ReadView语句结束即释放。所以“autocommit 下的 RR”其实等同于“每条语句都读自己那一刻的快照”和显式begin后多次读共用一个快照的“长快照”是两回事。这也是为什么很多同学以为“开了 RR 就绝对可重复读”但用框架的短连接池时却看到前后两条查询值不一样——因为两次查询分属两个 autocommit 事务、两个 ReadView中间若有别的事务提交自然看到新值。要真正的“可重复读”必须显式begin或START TRANSACTION让 ReadView 绑定到事务生命周期这正是本文 4.1 用显式事务才复现出“看不见”现象的根本原因。本文实验均在真实云服务器完成输出为实机回显。
事务隔离级别与 MVCC 实战:为什么你改了我还看不见
事务隔离级别与 MVCC 实战为什么你改了我还看不见关键词事务隔离、MVCC、一致性读、当前读、长事务、undo 版本链、ReadView、ReadView实验环境真实云服务器 Ubuntu 24.04 MySQL 8.0.46所有输出均为实机回显IP 已脱敏为121.36.***.***。一、引子一个让无数人翻车的问题“我在事务 A 里改了数据并提交为什么事务 B 死活查不到”“明明是 RR可重复读为什么我UPDATE之后看到的值不是我刚才SELECT的那个”“长事务到底危险在哪怎么一眼揪出它”这是丁奇《MySQL 实战 45 讲》第 3、8 讲里最经典的“事务到底隔离不隔离”的坑也是面试高频雷区。答案藏在两个词里一致性读快照读和当前读。本文我在真实云服务器上开了两个并发会话把 RR 与 RC 下的可见性差异、当前读的“魔幻 1”、长事务的危害一个一个跑给你看全部是实机回显。二、环境说明真实机器配置同一台 8C / 14G 云服务器公网 IP 脱敏为121.36.***.***MySQL 8.0.46。root 通过本地 socket 的auth_socket插件免密登录。先确认默认隔离级别mysqlSELECTtransaction_isolation,transaction_isolation;----------------------------------------|transaction_isolation|REPEATABLE-READ|----------------------------------------默认就是REPEATABLE-READ可重复读RR。为了并发演示我开了两个独立的交互式 mysql 会话会话 A、会话 B每个会话保持自己的连接和事务——只有这样事务才真正“活着”。下文所有[A]/[B]标注的就是不同会话里敲的命令是实机双窗口的真实回显。贯穿全文用一张极简表test.T(id int primary key, c int)基线令c idmysqlUSEtest;mysqlUPDATETSETcid;SELECT*FROMT;-------|id|c|-------|1|1||2|2||3|3||4|4||5|5|-------三、原理讲解MVCC 是怎么让你“看不见”的InnoDB 的 MVCC多版本并发控制靠三件套实现隐藏事务 ID 字段每行数据除了业务列还有trx_id最近修改它的事务 ID和roll_pointer指向 undo 日志里上一版本的指针。undo log 版本链每次更新旧版本被写入 undo log通过roll_pointer串成一条链旧事务可以顺着链读到自己该看到的版本。ReadView读视图事务在“一致性读”时生成一个 ReadView记录当前活跃事务列表判断某行版本的trx_id对自己是否可见——可见条件简化说就是版本由“已提交且在我快照前”的事务产生才可见。由此引出两个关键概念务必分清一致性读快照读普通SELECT走 MVCC读的是事务开始或START TRANSACTION WITH CONSISTENT SNAPSHOT那一刻的快照不会看到别人已提交的修改RR 下。当前读UPDATE / DELETE / SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE读的是最新已提交版本并加锁。这就是“你改了我能看见”的真相。隔离级别差异一句话RR可重复读一致性读始终读事务第一次读时建立的快照所以别人提交了你也“看不见”。RC读已提交每次一致性读都重新生成 ReadView所以能看见别人已提交的修改。下面用真刀真枪的实验验证这两句。四、分步实操真实命令 真实回显4.1 RR 下你改了我真的看不见会话 A 开启事务并第一次读id1[A]begin;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|1|------1rowinset(0.00sec)与此同时会话 B 把id1的c改成 2 并提交B 默认 autocommitUPDATE 立即生效[B]UPDATETSETcc1WHEREid1;Query OK,1rowaffected(0.00sec)Rowsmatched:1Changed:1Warnings:0此时 A 在同一事务里再查一次[A]SELECTcFROMTWHEREid1;------|c|------|1|------1rowinset(0.00sec)仍然是 1这就是 MVCC 快照读——A 的 ReadView 在begin后第一次读时生成B 的提交对 A 不可见。直到 A 提交后新事务才看到最新值[A]commit;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|2|------1rowinset(0.00sec)4.2 RC 下你提交了我立刻看见把 A 的会话隔离级别切成 READ COMMITTED 再重复[A]SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;Query OK,0rowsaffected(0.01sec)[A]begin;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;-- 此刻 c2------|c|------|2|------[B]UPDATETSETcc1WHEREid1;-- B 把它改成 3 并提交Query OK,1rowaffected(0.00sec)A 再次查询[A]SELECTcFROMTWHEREid1;------|c|------|3|------1rowinset(0.00sec)从 2 直接变 3因为 RC 每次读都重建 ReadView已提交的修改立刻可见。对比 4.1 的 RR隔离级别对可见性的影响一目了然。演示完记得把 A 切回 RR[A]commit;[A]SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;4.3 经典例RR 下“你改了我 UPDATE 后却看到了”——当前读这是丁奇“事务到底隔离不隔离”里最烧脑的一幕。先把id1复位成 1mysql-uroot test-eUPDATE T SET c1 WHERE id1;A 开启事务并快照读[A]begin;[A]SELECTcFROMTWHEREid1;------|c|------|1|------[B]UPDATETSETcc1WHEREid1;-- B 改成 2已提交Query OK,1rowaffected(0.00sec)按 4.1 的经验A 此刻SELECT应该还看到 1。但 A 接着执行一条UPDATE[A]UPDATETSETcc1WHEREid1;-- 当前读读最新已提交(2) 1 3Query OK,1rowaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|3|------1rowinset(0.00sec)[A]commit;结果 c 变成了 3而不是 2为什么UPDATE是当前读它读的是最新已提交版本c2来自 B 的提交在此基础上1得到3并写回。所以“A 的 UPDATE 看到了 B 的修改”是成立的——只是普通SELECT看不见而写操作当前读看得见。这正是“事务到底隔离不隔离”的标准答案快照读隔离当前读不隔离。4.4 START TRANSACTION WITH CONSISTENT SNAPSHOT显式“拿一致性快照”的写法效果等价于 RR 下begin后的第一次读但语义更明确[A]STARTTRANSACTIONWITHCONSISTENTSNAPSHOT;Query OK,0rowsaffected(0.00sec)[A]SELECTcFROMTWHEREid1;------|c|------|1|------[B]UPDATETSETcc1WHEREid1;-- 改成 2Query OK,1rowaffected(0.00sec)[A]SELECTcFROMTWHEREid1;-- 仍读快照看到 1------|c|------|1|------[A]commit;它与 4.1 的区别只是把“快照建立点”从“第一次读”提前到“事务刚开始”对 RR 行为一致常用于需要事务起点就固定快照的备份/对账场景。4.5 长事务一眼揪出它开一个事务但故意不提交让它一直“活着”[A2]begin;[A2]SELECTcFROMTWHEREid1;------|c|------|2|------在另一个连接查information_schema.innodb_trx抓真实长事务现场下面就是实机回显未删改mysqlSELECTtrx_id,trx_state,trx_started,trx_mysql_thread_id,trx_query,trx_isolation_levelFROMinformation_schema.innodb_trx\G***************************1.row***************************trx_id:420578098148136trx_state: RUNNING trx_started:2026-07-2911:55:01trx_mysql_thread_id:73trx_query:NULLtrx_isolation_level:REPEATABLEREAD关键点trx_state: RUNNING且trx_query: NULL—— 事务空闲但没提交典型“长事务挂起”。trx_started: 2026-07-29 11:55:01—— 启动时间和“现在”一比就知道它活了多久。trx_mysql_thread_id: 73—— 对应连接 ID可用KILL 73或更安全的KILL QUERY 73处理。长事务的危害它持有的 ReadView 永远不释放导致 undo log 里的旧版本无法 purgeundo 链越积越长不仅拖慢该事务本身还让整库回滚段膨胀、甚至撑爆空间。线上监控innodb_trx是 DBA 的必备动作。五、sysbench 侧写隔离与持久化的“双 1”代价同一台机器顺带把“双 1”性能梯度贴在这里方便理解为什么很多业务会为性能放宽持久化但隔离级别通常不会为性能牺牲一致性组合innodb_flush_log_at_trx_commitsync_binlogTPSavg 延迟双 111981.878.15 msf1,s0101388.655.76 msf2,s1211748.244.57 msf2,s0203561.462.25 ms隔离级别与 redo/binlog 是两码事隔离级别决定“看见什么”持久化参数决定“宕机丢不丢”。两者正交别混为一谈。六、踩坑记录真实报错 / 注意点RR 下 SELECT 看不到别人提交但 UPDATE 看得到这是“快照读 vs 当前读”的正常现象不是 Bug。写操作永远是当前读。改了隔离级别只对“下一个事务”生效SET SESSION TRANSACTION ISOLATION LEVEL ...之后必须begin新事务才生效在已开启的事务里改会报错或无效。长事务trx_query为 NULL空闲未提交事务查出来就是 NULL别误以为“没事务在跑”要结合trx_started判断它挂了多久。SHOW BINARY LOG STATUS 在 8.0 报 1064第 1 篇已详述8.0 用SHOW MASTER STATUS。与本篇无关但属同源版本坑。RC 下会出现“不可重复读”同一事务两次读结果不同本文 4.2 从 2 变 3这是 RC 的定义行为若业务要求可重复读必须 RR。七、面试高频问答事务的四大特性 ACID 是什么InnoDB 靠什么实现原子性undo log、一致性应用层 约束、隔离性锁 MVCC、持久性redo log double write buffer。RR 下为什么“你改了我看不见”一致性读走 MVCC读的是事务第一次读时建立的 ReadView 快照别人已提交的新版本不满足可见条件所以看不到。RR 下为什么 UPDATE 后又能“看见”了UPDATE/DELETE/SELECT ... FOR UPDATE是当前读读最新已提交版本并加锁因此能看到别人提交的数据4.3 的“c 变 3”就是当前读 1 的结果。RR 和 RC 在实现上的核心区别快照建立时机不同RR 在整个事务内用同一个 ReadView第一次读时建立RC 每次一致性读都新建 ReadView所以能看到最新已提交。MVCC 的 undo log 版本链和 ReadView 是什么每行有trx_id和roll_pointer旧版存入 undo log 串成链ReadView 记录活跃事务用来判断某版本对当前事务是否可见。长事务有什么危害怎么排查危害占住 ReadView 导致 undo log 无法 purge回滚段膨胀、性能下降。排查SELECT * FROM information_schema.innodb_trx;看trx_started和trx_state结合trx_mysql_thread_idKILL 掉。如何解决长事务代码里及时 commit避免事务里做 RPC/HTTP 等耗时外部调用监控innodb_trx告警必要时用wait_timeout/innodb_rollback_on_timeout兜底。“可重复读”能避免幻读吗RR 下普通快照读通过 MVCC 避免幻读但当前读SELECT ... FOR UPDATE靠间隙锁gap lock/ Next-Key Lock 防住其他事务插入从而杜绝幻读。这是 InnoDB 对标准 SQL“RR 允许幻读”的增强。七又二分之一、补充深挖把 MVCC 再往下凿四层补充 AReadView 的可见性判断算法ReadView 内部有四个关键字段m_ids创建瞬间所有活跃事务 ID 列表、min_trx_id其中最小的事务 ID、max_trx_id系统下一个将要分配的事务 ID、creator_trx_id创建该 ReadView 的事务自身 ID。判断某一行版本其trx_id X对当前事务是否可见规则如下若X min_trx_id说明修改该版本的事务在 ReadView 创建前就已经提交 →可见。若X max_trx_id说明修改该版本的事务是在 ReadView 创建之后才开启的 →不可见需要顺着 undo 链找更早的版本。若min_trx_id X max_trx_id看X是否还在m_ids活跃列表里——在说明事务尚未提交不可见不在说明已提交可见。这就是 RR 下“同一事务内反复读都是同一份数据”的底层逻辑RR 的 ReadView 只在事务第一次一致性读时建立一次之后整个事务复用而RC 每次一致性读都重新建立 ReadView于是能看见别的事务最新提交的修改。可见性算法完全相同差异只在于 ReadView 的建立时机。补充 Bundo log 与版本链的物理结构InnoDB 每行记录除了业务列还藏着DB_TRX_ID最近修改它的事务 ID即上面说的 trx_id和DB_ROLL_PTR回滚指针。每次UPDATE引擎先把修改前的整行拷贝进 undo log若是INSERT则记一条反向的DELETE新行的roll_pointer指向这条 undo 记录形成一个单向链表。一个老事务顺着链表一路回溯就能拼出“属于自己快照”的那一版数据。版本链越长回放越慢、越占内存——这正是长事务的衍生危害它长期霸占 ReadView导致旧版本无法被 purge 线程清理undo 表空间不断膨胀。补充 CRR 下的幻读与间隙锁Next-Key LockSQL 标准里“可重复读”是允许幻读的同一事务两次范围查询可能返回不同行数。但 InnoDB 在 RR 下用Next-Key Lock记录锁 间隙锁把幻读也堵住了当你执行SELECT ... WHERE id 10 FOR UPDATE这类当前读时引擎不仅锁住命中的记录还锁住这些记录之间的“间隙”gap别的事务无法往间隙里插入新行于是当前读也不会凭空多出幻行。普通快照读靠 MVCC 天然不幻读锁定读靠间隙锁兜底——这是 InnoDB 的 RR 比 SQL 标准更严格的地方也是它最常用的卖点之一。补充 D当前读会加锁所以是高并发锁瓶颈的来源理解了“UPDATE 是当前读”也就理解了热点行更新的性能本质当前读会加排他锁X 锁并可能触发记录锁甚至间隙锁于是多个事务“你改我改”时极易撞锁严重时还会死锁。排查锁等待应看performance_schema.data_locks与data_lock_waitsMySQL 8.0 已用它们取代旧版的INFORMATION_SCHEMA.INNODB_LOCKS。线上热点账户、库存扣减这类场景往往要把“单行高频更新”改成“批量/异步”来绕开锁竞争。补充 E隔离级别选型与一致性读的工程建议绝大多数 OLTP 业务用默认的RR即可既避免不可重复读又靠间隙锁防幻读。若业务能容忍不可重复读、且追求更简单的可见性语义或某些报表查询想立刻看到最新提交可显式用RC注意 RC 下间隙锁会被弱化幻读防护不如 RR。不论选哪个级别长事务都是大忌代码里及时 commit事务里不要做 RPC / HTTP 等耗时外部调用监控information_schema.innodb_trx并设告警必要时用wait_timeout兜底回收连接。八、总结“事务到底隔离不隔离”没有矛盾答案快照读普通 SELECT隔离当前读UPDATE/锁定读不隔离。RR 靠 ReadView 让一致性读可重复RC 靠每次重建 ReadView 让你立刻看见已提交而长事务则是把 ReadView 长期霸占既拖慢自己也撑大 undo。本文用真实双会话把 RR/RC 的可见性差异、当前读的“1 变 3”、以及information_schema.innodb_trx的真实长事务现场全部跑实并用补充章节把 ReadView 可见性算法、undo 版本链、间隙锁与幻读、当前读的锁代价一层层凿开配合同机的“双 1”性能梯度帮你把隔离性、MVCC、持久化这三条主线一次理清。补充 FRR 下“快照读 vs 当前读”的完整并发时序把 4.1 与 4.3 拼成一条时间线隔离性的全貌就清楚了时刻 T1 A: begin; -- A 建立 ReadView_1 时刻 T2 A: SELECT c1 -- 读快照看到 1 时刻 T3 B: UPDATE c2; (commit) -- B 提交产生新版本 trx_id_B 时刻 T4 A: SELECT c1 -- 快照读ReadView_1 仍不可见 B → 1 时刻 T5 A: UPDATE cc1 WHERE id1 -- 当前读读最新已提交 2写 3 时刻 T6 A: SELECT c3 -- 看到自己改的 3自己的修改对自己永远可见 时刻 T7 A: commit; -- ReadView_1 结束 时刻 T8 A: SELECT c3 -- 新事务看到最终 3关键顿悟点T4 的快照读与 T5 的当前读看到的是两个世界。这正是“事务到底隔离不隔离”的终极答案——不是隔离或不隔离的二选一而是“读的方式决定隔离程度”。这也是为什么很多 ORM 框架的“先查后改”在并发下会出现意料之外的值你以为拿着刚才查到的 1 去 1实际当前读拿到的是别人提交的 2结果变成 3。理解这一点能帮你避开一大类并发更新 bug。补充 G线上如何把长事务消灭在萌芽除了 4.5 的innodb_trx排查工程上更要在“发生前”预防连接池设超时wait_timeout/interactive_timeout不要设成永久空闲连接到期自动回收避免事务跟着连接一起“长生”。事务里不掺外部 IORPC、HTTP、消息发送等耗时操作绝对不要放在begin和commit之间否则锁和 ReadView 被长时间占住。监控告警定时采样innodb_trx的trx_started对存活超过 N 秒的事务直接告警必要时KILL QUERY thread_id只杀语句不杀连接。大事务拆小批量更新按主键分批如每 500 行一提交既缩短单事务时长也降低 undo 膨胀与锁冲突。备份/导出走一致性快照用START TRANSACTION WITH CONSISTENT SNAPSHOT本文 4.4或--single-transaction的 mysqldump避免长事务卡住整库 purge。补充 H为什么 MySQL 默认是 RR 而不是 RC一个常被追问的点很多数据库如 Oracle、PostgreSQL默认 RCMySQL 却默认 RR为什么历史原因是早年 MySQL 的 binlog 用 STATEMENT 格式做主从复制如果默认 RC主库上“先读后写”的并发在从库重放时可能因执行顺序不同而出现主从数据不一致而 RR 的快照读让同一事务内看到的数据稳定配合 ROW 格式 binlog复制更安全。即便现在默认 ROW 格式RR 仍是默认值好处是业务“开箱即得可重复读”不用每个开发都去操心不可重复读。代价仅是 RR 下的间隙锁比 RC 更重、并发插入更容易被间隙锁挡住——但这通常远小于“主从不一致”的代价。因此除非你有明确理由如某些报表要立刻看最新提交、或极端写入并发下想弱化间隙锁否则保留 RR 是最稳的选择。这也顺带解释了本文所有实验的默认基线都是 RR4.2 才手动切到 RC 做对照。补充 Iautocommit 下的“一致性读”你真的理解吗最后补一个极易混淆的细节MySQL 默认autocommit1即每条普通语句都是一个独立事务。此时你执行一条SELECTInnoDB 会为这条语句单独建立一个 ReadView语句结束即释放。所以“autocommit 下的 RR”其实等同于“每条语句都读自己那一刻的快照”和显式begin后多次读共用一个快照的“长快照”是两回事。这也是为什么很多同学以为“开了 RR 就绝对可重复读”但用框架的短连接池时却看到前后两条查询值不一样——因为两次查询分属两个 autocommit 事务、两个 ReadView中间若有别的事务提交自然看到新值。要真正的“可重复读”必须显式begin或START TRANSACTION让 ReadView 绑定到事务生命周期这正是本文 4.1 用显式事务才复现出“看不见”现象的根本原因。本文实验均在真实云服务器完成输出为实机回显。