十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL 内核实战(3):事务隔离级别与 MVCC 实现

MySQL 内核实战(3):事务隔离级别与 MVCC 实现 问题背景上一篇把索引树讲清楚了数据怎么放由页决定怎么找由 BTree 决定。但那是单线程世界一旦多个会话同时碰同一行问题立刻换了一副面孔——你 UPDATE 之后自己看得见新值、别人却还读着旧值这不是 bug 而是特性有人在事务里 SELECT 了两遍同一行拿到两个不同总和业务报表当场对不上最玄学的一种“我的 SELECT 明明说这行不存在UPDATE 却命中了 1 行”。这些现象共用一套底层机制多版本并发控制MVCC。MySQL 默认隔离级别是可重复读RROracle、PostgreSQL 默认读已提交RC国内大量团队又主动把 MySQL 改成 RC——这个选择背后是锁开销、binlog 格式、undo 膨胀一整串连锁反应。本篇回答四个问题四种隔离级别在 InnoDB 里各靠什么手段达成而不是背 SQL 标准定义一行数据的版本链和 ReadView 如何精妙配合让读写互不阻塞快照读与当前读这两套眼睛为什么在同一事务里会看到不同世界以及 RC 与 RR 怎么选、长事务为什么要命。核心原理第一四级隔离在 InnoDB 里的真实实现手段。脏读读未提交 RC 级别下的 U 状态数据由只读已提交版本杜绝不可重复读两次 SELECT 结果不同在 RR 下由 MVCC 快照杜绝幻读范围查询多出不存在的幽灵行则是双保险纯 SELECT 看到的幻象由 MVCC 挡掉——新插入的行 trx_id 在快照之后版本链走到它时判不可见而 UPDATE/DELETE 撞到的幻象 MVCC 挡不住必须靠 next-key lock记录锁间隙锁在写入侧禁止间隙插入。所以RR 能防幻读的完整表述是读路径靠快照、写路径靠间隙锁两套机制拼起来才闭环。MySQL 的 RR 比 SQL 标准要求的更强标准只承诺用锁定读防幻读。第二MVCC 版本链 ReadView没有更玄。每行记录头里藏着两个隐藏列DB_TRX_ID最后修改这行的事务 id和DB_ROLL_PTR指向 undo log 里上一版本的指针同一行的历次修改由此串成一条从新到旧的版本链。事务第一次一致性读时生成一张 ReadView四个字段活跃事务 id 列表m_ids、其中最小值min_trx_id、下一个将分配的事务 idmax_trx_id即 up_limit_id、自己的creator_trx_id。可见性判定四步走版本 trx_id 等于自己→可见小于 min_trx_id→快照建立前早已提交可见大于等于 max_trx_id→快照之后才出现不可见否则查 m_ids在名单里当时未提交不可见不在则可见。读某行时从链头往下走第一个可见版本就是返回值。RC 与 RR 的机制差异只有一条RC 给每条 SELECT重建 ReadViewRR 整个事务只用第一张——仅此一改两类隔离的全部行为差异都能推导出来。第三快照读与当前读是两套眼睛。普通 SELECT 是快照读走版本链不加任何锁这是读写互不阻塞的来源而 UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE是当前读必须读最新版本并对它加锁——否则改写就会丢失别人已提交的增量。这就产生了 RR 的经典裂缝快照读看到 100你基于它算出 150 回写UPDATE 落笔时行上已是别人提交的 130——你的 50 被吞。RR 保证的是你读到的不变不是你写的依据为真。乐观锁WHERE version?后检查影响行数与悲观锁先 FOR UPDATE 再算都是把读依据切到当前读这一侧的补救。第四undo、purge 与长事务的经济账。版本链物理上躺在 undo log 里事务提交不等于旧版本立刻消失只要还有一个 ReadView 可能引用它purge 线程就不能清理。一个忘了提交、或跑了几小时的大查询会把整个数据库的清理水位钉死undo 表空间暴涨、版本链越拖越长——之后所有行的快照读都要多走 N 跳链表表现为莫名全局变慢。RR 比 RC 更容易积累这种代价因为它的快照从第一条一致性读起就冻结。第一次代码实验及输出下面是纯 Python 的确定性 MVCC 模拟内存模型非真 mysqld行是一个从新到旧的版本链事务启动时领取单调递增 id 并生成 ReadView最小活跃 id、活跃名单、id 上限visible()复刻 InnoDB 的四步判定read()沿链找第一个可见版本RC 每次读重建快照、RR 只建一次。场景T100(RR) 与 T101(RC) 先开局随后三个事务先后把余额改成 120、150、200。# MVCC 模拟: 行 undo 版本链(新-旧), 事务 一张 ReadView(快照)# visible() 即 InnoDB readview-sees(trx_id) 的判定顺序NEXT[100]# 事务 id 分配器(单调递增)ACTIVEset()# 已 START 未提交的事务 idclassVersion:def__init__(self,trx_id,value,prevNone):self.trx_id,self.value,self.prevtrx_id,value,prevclassRow:def__init__(self,trx_id,value):self.headVersion(trx_id,value)defupdate(self,trx,value):self.headVersion(trx.id,value,self.head)# 旧值压进版本链defchain(self):out,v[],self.headwhilev:out.append((trx%d,val%d)%(v.trx_id,v.value))vv.prevreturn - .join(out)classTrx:def__init__(self,iso):self.id,self.isoNEXT[0],iso NEXT[0]1ACTIVE.add(self.id)self.viewself.snapshot()# RR: 开启时只建一次defsnapshot(self):lomin(ACTIVE)return(lo,set(ACTIVE),NEXT[0])# 最小活跃 id / 活跃名单 / 上限defvisible(self,trx_id):lo,mids,limitself.viewiftrx_idself.id:returnTrue# 自己改的必可见(当前读依据)iftrx_idlo:returnTrue# 快照前早已提交iftrx_idlimit:returnFalse# 快照后才出现的事务returntrx_idnotinmids# 快照时仍活跃 - 不可见defread(self,row):ifself.isoRC:self.viewself.snapshot()# RC: 每条 SELECT 重建快照vrow.headwhilevandnotself.visible(v.trx_id):vv.prev# 顺版本链找第一个可见版本returnv.valueifvelseNonedefcommit(self):ACTIVE.discard(self.id)rowRow(1,100)# trx_id1 为已提交历史t_rr,t_rcTrx(RR),Trx(RC)t_aTrx(RR);row.update(t_a,120);t_a.commit()t_bTrx(RR);row.update(t_b,150);t_b.commit()print(两次已提交更新后版本链:,row.chain())t_cTrx(RR);row.update(t_c,200)# 未提交的第三个版本print(T%d(RR) 读 - %s | T%d(RC) 读 - %s (T%d 改动未提交)%(t_rr.id,t_rr.read(row),t_rc.id,t_rc.read(row),t_c.id))t_c.commit()print(T%d(RR) 读 - %s | T%d(RC) 读 - %s (T%d 提交后)%(t_rr.id,t_rr.read(row),t_rc.id,t_rc.read(row),t_c.id))print(再读一次: T%d(RR) - %s | T%d(RC) - %s%(t_rr.id,t_rr.read(row),t_rc.id,t_rc.read(row)))print(此刻版本链:,row.chain())运行输出两次已提交更新后版本链: (trx103,val150) - (trx102,val120) - (trx1,val100) T100(RR) 读 - 100 | T101(RC) 读 - 150 (T104 改动未提交) T100(RR) 读 - 100 | T101(RC) 读 - 200 (T104 提交后) 再读一次: T100(RR) - 100 | T101(RC) - 200 此刻版本链: (trx104,val200) - (trx103,val150) - (trx102,val120) - (trx1,val100)三行对照把两个隔离级别的差异全部演完。RR 恒读 100它的 ReadView 里 max_trx_id102102/103/104 三个版本全都出生于快照之后沿链下走只能退到 trx1 的起点版本——可重复读不是锁住了行而是你永远在走自己的时间线。RC 的第一读落在 150 而非 200因为重建快照时 T104 仍在活跃名单里未提交修改不可见这就是读已提交四个字T104 提交后下一读立刻跟上 200。另一个细节藏在版本链里四次修改、四个版本全在——真实 InnoDB 此刻若无早快照引用旧版本purge 已可回收而 T100 这个从开局没再动过的事务会把 120、150 两个版本一直钉在 undo 里。把 T100 换成跑三小时的报表查询就是生产 undo 暴涨的标准剧本。工程化改进第一步把隔离级别当成部署配置显式锁定。transaction_isolation在 my.cnf 定全局值应用连接参数再声明一次保证测试、预发、生产一致——RC 与 RR 的行为差异足以让同一份代码两边结果不同这类问题必须靠配置对齐消灭而不是靠人记。选 RC 的团队高并发 OLTP、互联网写多读少拿走的是间隙锁大幅减少、锁等待变短、undo 链更短必须同时接受主从复制必须用binlog_formatROWstatement 格式在 RC 下有序性不保证从库回放会漂数据以及下文所有并发写保护要自己建。第二步写路径三选一杜绝裸读-改-写。增量类计数、余额一律原子 UPDATESET bal bal ?右侧在当前读行锁保护下求值天然正确条件类用乐观锁行带version列UPDATE ... WHERE id? AND version?应用必须检查 affected rows为 0 即冲突重试必须先读后算的复杂判断扣库存前验证一组状态用SELECT ... FOR UPDATE把读依据钉在当前读上事务内完成校验与写入。三条路殊途同归让判断和写共用同一双眼睛。第三步把长事务当故障对待。监控information_schema.INNODB_TRX中trx_started超过阈值OLTP 建议 60 秒的事务并告警trx_mysql_thread_id直接给出 kill 目标报表与分析查询走只读从库或加MAX_EXECUTION_TIME连接池回收连接时必须回滚悬挂事务框架层的借出未提交是隐形元凶。RR 下尤其要盯因为一个冻结的快照就是全库 purge 的刹车片。第四步给快照与当前不一致预留解释位。代码评审时看到先 SELECT 判断、后 INSERT/UPDATE的模式就问一句要不要 FOR UPDATE监控上遇到 1062 主键冲突或 affected rows 与预期不符先怀疑快照读与当前读的裂缝别当成随机故障。把这条认知写进团队排障手册能省掉无数复现不了的悬案。第二次代码实验及输出下面回放开头那个玄学场景两个事务给同一账户分别 50、30期望终态 180。三种写法——应用层先读后回写RR 默认行为、SELECT ... FOR UPDATE串行化带锁等待时间线、单条原子 UPDATE——对比各自终态把隔离级别防什么、不防什么变成可运行结论。# 场景回放: 事务 A、B 给同一账户分别加 50 / 30, 期望终态 180# 三种写法的结局完全不同 —— RR 隔离级别本身救不了读-改-写deflost_update_demo():bal100# 当前已提交唯一值a_snap,b_snapbal,bal# A/B 先各自快照读, 都读到 100a_new,b_newa_snap50,b_snap30bala_new# A: UPDATE account SET bal150balb_new# B: UPDATE SET bal130, 当前读直接压最新值print([先读后回写] 终态 %d, 应为 180 - A 的 50 被吞掉%bal)deffor_update_demo():bal,log100,[]log.append(A: SELECT ... FOR UPDATE 拿到行锁, 当前读 bal%d%bal)log.append(B: SELECT ... FOR UPDATE 撞锁, 进入 row lock wait)a_newbal50log.append(A: UPDATE bal%d, COMMIT 放锁%a_new)bala_new# A 提交才轮到 B 拿锁log.append(B: 获锁后当前读看到 %d, 而非自己的旧快照%bal)bal30log.append(B: UPDATE bal%d, COMMIT%bal)forlineinlog:print( line)print( [FOR UPDATE] 终态 %d, 正确%bal)defatomic_update_demo():bal100bal50# UPDATE account SET balbal50bal30# SET 右侧是当前读, 在行锁保护下求值print([原子 UPDATE] 终态 %d, 正确且无需显式加锁%bal)lost_update_demo()for_update_demo()atomic_update_demo()运行输出[先读后回写] 终态 130, 应为 180 - A 的 50 被吞掉 A: SELECT ... FOR UPDATE 拿到行锁, 当前读 bal100 B: SELECT ... FOR UPDATE 撞锁, 进入 row lock wait A: UPDATE bal150, COMMIT 放锁 B: 获锁后当前读看到 150, 而非自己的旧快照 B: UPDATE bal180, COMMIT [FOR UPDATE] 终态 180, 正确 [原子 UPDATE] 终态 180, 正确且无需显式加锁第一行就是丢失更新lost update的全部机制A、B 的快照读各自合法地看到 100两次 UPDATE 又各自合法地覆盖最新值——没有任何一步违反 RR 的承诺MVCC 从不阻止基于过期依据的写入SQL 标准的四级隔离在 ANSI/ISO:SQL92 定义里本来也不要求防它是 InnoDB 用间隙锁把 RR 加强到防读出来的幻象而已。FOR UPDATE 时间线的修复点要看准它不是让 B读到新值这么简单而是把 B 的读搬进了锁队列使判断与写入之间不可能插入第三者——代价是并发度降为串行。原子 UPDATE 最优把读改写压进一条语句行锁只在语句级持有一瞬。生产里按这个优先级排序能原子就不加锁必须加锁就 FOR UPDATE只有跨语句复杂校验才考虑乐观锁加重试。常见陷阱其一以为 RR 防丢失更新。上面实验第一行就是证据可重复读防的是读侧异常写侧冲突要靠当前读。其二以为BEGIN之后快照立刻冻结。RR 的 ReadView 生成于事务内第一条一致性 SELECT 执行时不是 BEGIN——BEGIN; DO OTHER STUFF(3s); SELECT与前一种写法的可见集合可能不同靠事务起始点推理结果会错。其三长事务拖垮 purge一个忘记提交的事务让 undo 只增不减、版本链越读越长全库点查悄悄变慢排障第一步查 INNODB_TRX而不是加索引。其四RC statement 格式 binlog非确定性更新LIMIT无 ORDER BY、NOW()主从回放结果不同数据静默漂移改 RC 必须同时锁 ROW 格式。其五把SELECT 没有这行但 INSERT 报 1062当 bug快照读按自己时间线看不见、当前读按最新版本撞主键——正确姿势是 INSERT 前先想清楚以哪边为准INSERT ... ON DUPLICATE KEY UPDATE或先 FOR UPDATE 探测间隙。落地清单my.cnf 显式设定transaction_isolation应用连接参数二次声明三环境一致RC 部署必配binlog_formatROW写路径优先级原子 UPDATE SELECT ... FOR UPDATE 乐观锁(version 列 affected rows 检查)禁止裸读改写监控information_schema.INNODB_TRX事务超 60 秒告警并可定位 kill连接池回收时强制回滚悬挂事务报表/大查询走从库或设MAX_EXECUTION_TIME不给主库留冻结快照的机会代码评审专项拦截先 SELECT 判断后 UPDATE两段式排障手册收录 1062 与快照裂缝的解释隔离让读写互不阻塞代价是写与写之间仍要排队——一旦排队形成环就是锁等待图上的死锁两个事务互相持有对方想要的锁谁来都不放行。为什么UPDATE范围条件会锁住不存在的行为什么加了索引反而死锁变少SHOW ENGINE INNODB STATUS里那段 LATEST DETECTED DEADLOCK 怎么读下一篇《MySQL 内核实战4行锁、间隙锁与死锁定位》把 InnoDB 的锁模型与现场取证讲透。参考来源MySQL 8.0 Reference ManualSET TRANSACTION 与事务隔离级别https://dev.mysql.com/doc/refman/8.0/en/set-transaction.htmlMySQL 8.0 Reference ManualInnoDB Multi-Versioning版本链与 ReadViewhttps://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.htmlMySQL 8.0 Reference ManualConsistent Nonlocking Reads快照读与当前读https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.htmlMySQL 8.0 Reference ManualLocks Set by Different SQL Statements in InnoDBhttps://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.htmlWikipediaIsolation (database systems)https://en.wikipedia.org/wiki/Isolation_(database_systems) 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《MySQL 内核实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。
返回列表