ARTICLE DETAIL

资讯详情

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

MySQL InnoDB MVCC原理:从版本链到流量洪峰下的读写并发

MySQL InnoDB MVCC原理:从版本链到流量洪峰下的读写并发 流量洪峰这四个字做过线上系统的人都懂它的分量。秒杀开场那一秒订单表、库存表、账户流水表同时被几千个并发线程怼上来底层存储引擎只要一致性校验慢一拍轻则超卖重则主库锁等待飙升直接拖垮整个集群。我见过不少团队在这个节骨眼上把问题归咎于“SQL写得慢”反复调索引、加缓存但核心矛盾其实藏在更底层——InnoDB怎么在同一行数据被疯狂更新时还能让读请求不排队、不阻塞、不读到脏数据这背后就是MVCCMulti-Version Concurrency Control多版本并发控制在起作用。这篇文章把InnoDB的MVCC从头到尾拆开讲清楚它在流量洪峰下到底怎么工作、靠什么保证隔离性又不牺牲读性能适合正在啃MySQL原理的后端开发、DBA还有所有想弄明白“为什么高并发下我的SELECT不挡别人UPDATE”的同行。1. 流量洪峰下的并发困局为什么锁定读会拖垮系统1.1 没有MVCC的世界读写互斥有多痛苦先回到最原始的并发模型。如果数据库只有锁、没有版本控制那么任何读操作和写操作之间都必须互斥事务A要更新一行数据得先拿到这行的写锁事务B想读同一行必须等A提交释放锁。这在低并发下没问题但流量洪峰一来场景就变成一个热点商品SKU每秒被更新几百次而同时有上千个查询在等它。结果就是读请求全部堆积在锁等待队列里平均延迟从2毫秒涨到2秒连接数被打满数据库整体雪崩。我早年维护过一个秒杀系统上线当天就栽在这个坑里。当时为了防超卖所有库存查询都用了SELECT ... FOR UPDATE心想“读之前先锁住肯定安全”结果压测到500并发时数据库直接卡死一堆线程互相等锁应用层超时重试又加剧了堆积。后来才意识到用锁去串行化所有读操作相当于让1000个人排队看同一张告示明明大部分人只想知道“还有没有货”根本不需要站在告示板前面一动不动。数据库里得有另一种机制让读操作不看“当前正在改的最新值”而是看“某个时间点已经固化的旧值”读写各走各的道。1.2 MVCC的核心思路为每行数据保存多个版本MVCC解决问题的思路用一句话概括一行数据在物理上可以同时存在多个版本读操作从版本链中挑一个对当前事务可见的旧版本写操作在最新版本上做修改并生成新版本读和写之间不需要互相等待。打个比方。你们公司的请假审批单只有一张实体纸领导在上面改意见其他人想看就得等他写完。MVCC的做法是把这张纸变成一份带版本号的电子文档领导每次修改都生成一个新版本同事查阅时直接读历史版本不需要攥着笔等领导松手。Git的机制也类似每次提交保留历史快照你随时可以checkout到任意commit不影响别人继续提交新代码。InnoDB实现这套“多版本”依赖三个核心设施隐藏列、undo log、ReadView。隐藏列给每一行打上“版本标签”记录这行是哪个事务改的、改之前的样子存在哪undo log把各个历史版本串成一条链像Git的提交记录一样可以从最新一路回溯到最初ReadView则是一个事务的“可见性快照”决定它看得到版本链上的哪几个版本。三者配合MVCC才算真正落地。下一章逐个拆。2. InnoDB MVCC的三块基石隐藏列、undo log、ReadView2.1 隐藏列数据行上的“版本标签”InnoDB的聚簇索引主键索引里每一行记录除了用户定义的字段还维护着三个隐藏字段很多做业务开发的同学可能从来没见过它们但它们才是MVCC的“坐标原点”。隐藏列大小作用DB_TRX_ID6字节记录最近一次插入或修改该行的事务IDDB_ROLL_PTR7字节回滚指针指向undo log中该行修改前的旧版本DB_ROW_ID6字节隐藏自增ID仅当表没有主键时使用用于生成聚簇索引这三个字段对用户透明但引擎判断可见性全靠它们。DB_TRX_ID告诉你“这行是谁改成现在这样的”DB_ROLL_PTR告诉你“这个版本改之前上个版本存放在哪儿”。你可以把每一行想象成快递包裹上的运单DB_TRX_ID是“最近一次揽收的快递员编号”DB_ROLL_PTR是“上一个中转站的地址”沿着地址就能倒查这一单途经的所有节点。这里要提一个细节DB_ROW_ID只在表没有主键也没有非空唯一索引时才出现。只要你有主键聚簇索引就直接以主键组织DB_ROW_ID只是概念上存在不会实际占用空间。所以线上建表务必带上主键否则MySQL偷偷生成隐藏主键不仅不可控二级索引回表时还会多一次隐式扫描。2.2 undo log版本链从最新版本回溯旧版本的指针链undo log顾名思义是“撤销日志”它记录的是事务修改之前的数据镜像。插入一条记录时生成insert undo log只用于事务回滚事务提交后就没用了更新、删除记录时生成update undo log这部分不仅用于回滚还承担着MVCC版本链的功能。版本链的组织方式是这样的聚簇索引记录本身的DB_TRX_ID、DB_ROLL_PTR代表最新版本DB_ROLL_PTR指向undo log中修改前的旧版本记录旧版本记录里同样有trx_id和roll_pointer字段继续指向上一个更旧的版本如此反复直到最早的insert undo log为止。一个事务对同一行做三次UPDATE就会产生三条update undo log形成一条长度为4包含原始插入版本的版本链。这也解释了InnoDB的一个经典现象UPDATE不是原地覆盖而是“改新留旧”。新版本写到当前记录位置或因为页分裂挪到新位置旧版本完整保存在undo log中。等到没有任何事务需要看到旧版本了后台purge线程才会物理删除它们。所以做全表大UPDATE时要格外小心几百万行数据一改undo log体积可能膨胀到比表本身还大磁盘空间被瞬间吃光这种事我在生产环境见过不止一次。2.3 ReadView事务的“可见性快照”有了版本链还需要一个判断标准当前事务读这行时到底该读哪个版本ReadView就是这份判断标准。它在事务执行快照读普通SELECT时生成内部记录了四个关键信息m_ids生成ReadView时系统中所有活跃未提交事务的ID列表。min_trx_id活跃事务列表中最小的事务ID。max_trx_id生成ReadView时系统分配给下一个事务的ID等于当前最大事务ID加1。creator_trx_id生成这个ReadView的事务自己的ID。可见性判断规则如下我用一个表格尽量说得直白被访问版本的DB_TRX_ID条件结论原因等于creator_trx_id可见自己修改的数据当然看得到小于min_trx_id可见该版本在ReadView生成前就已提交大于等于max_trx_id不可见该版本由ReadView生成后才开始的事务修改在min_trx_id和max_trx_id之间查活跃列表在则不可见不在则可在列表里说明还没提交不在则说明已提交这个规则不复杂但值得再强调一句ReadView判定的是“事务提交状态”不是“时间先后”。一个事务哪怕启动得很早只要它还没提交就一直待在活跃列表里它对其他事务来说就是“不存在”的反过来一个刚启动的事务如果快速提交了它的版本对后续ReadView而言反而是“可见的”。理解这一点才算真正理解快照读。3. 完整事务流程拆解一条UPDATE引发的版本变迁3.1 一个库存扣减事务的版本链演变理论说多了容易飘拿一个具体的库存表案例走一遍完整流程MVCC的运作就直观了。假设有一张商品库存表CREATE TABLE sku_stock ( id INT PRIMARY KEY AUTO_INCREMENT, sku_id INT NOT NULL, stock_num INT NOT NULL, updated_at DATETIME, KEY idx_sku (sku_id) ) ENGINEInnoDB;初始状态id1这行记录sku_id1001stock_num100。这条记录由事务ID10插入并已提交此时聚簇索引记录上DB_TRX_ID10DB_ROLL_PTR指向insert undo log。现在事务A事务ID20执行BEGIN; UPDATE sku_stock SET stock_num 90 WHERE sku_id 1001;执行UPDATE的瞬间InnoDB做了两件事第一对满足条件的行加排他锁当前读必须加锁后文会展开第二把修改前的旧版本stock_num100、DB_TRX_ID10写入undo log新版本的DB_TRX_ID改为20DB_ROLL_PTR指向刚写入的undo log。此时版本链变成两段最新版本stock_num90事务20旧版本stock_num100事务10的insert undo。事务A还没提交这时候事务B事务ID30来了执行一条普通SELECTSELECT stock_num FROM sku_stock WHERE sku_id 1001;B的ReadView生成活跃列表里有事务20所以m_ids[20]min_trx_id20max_trx_id31下一个要分配的事务IDcreator_trx_id30。B顺着版本链从最新开始查第一个版本DB_TRX_ID20等于min_trx_id且存在于活跃列表不可见沿roll_pointer回溯到旧版本DB_TRX_ID1010小于min_trx_id事务10早已提交可见。于是B读到stock_num100。这就是快照读的完整过程不阻塞、不加锁、还能看到一致的历史值。A继续提交后版本链上DB_TRX_ID20的版本变成已提交状态但B的ReadView已经生成在这个事务里再查多少次结果都是100。可重复读的“一致性”由此而来。3.2 同一行数据上的读写并发场景推演把上面的例子放到流量洪峰场景下推演。秒杀开始后库存行被大量UPDATE不断推进版本版本链越拉越长同时海量SELECT在读取。关键在于这些SELECT根本不去等UPDATE释放锁而是直接沿着版本链找一个自己可见的旧版本读操作的延迟几乎不随写压力增加。但这个模型不是没有代价有两个点特别需要踩过坑的人提醒你。第一版本链越长读操作的回溯成本越高。每次快照读都要从最新版本开始一个个判断DB_TRX_ID遇到不可见就顺着roll_pointer回溯。如果一个热点行被更新了几百次一次SELECT可能要扫描几百个undo log版本才能找到可见的那个CPU和随机IO开销都不小。所以业务上不要在一个事务里反复更新同一行能合并的UPDATE尽量合并别让一条订单状态从“待支付”到“已支付”被拆成十几次小更新刷版本链。第二MVCC解决的是读写互斥解决不了写写互斥。事务A更新了库存行还没提交事务C也要更新同一行C的UPDATE必须先等A的排他锁释放。这个时候并发写请求还是得排队MVCC挽回不了写冲突只能靠锁和事务设计去规避。这也是为什么秒杀系统通常把库存扣减做成单行短事务一条SQL完成“检查并扣减”把锁持有时间压到毫秒级写请求才排得动。4. ReadView的生成策略与隔离级别的关系4.1 快照读与当前读两种读的本质区别MySQL里的“读”并不都走MVCC很多并发问题其实出在把两种读混为一谈。普通SELECT不带FOR UPDATE、LOCK IN SHARE MODE属于快照读一致性非锁定读走MVCC从ReadView可见的版本里取值不加锁。这是MVCC最舒服的场景读不阻塞写写也不阻塞读。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT这些操作属于当前读锁定读它们必须读到“最新已提交版本”因为要基于最新状态做修改或加锁保护。当前读会走版本链找到最新版本同时对目标记录加锁——UPDATE和DELETE加排他锁LOCK IN SHARE MODE加共享锁。我见过一个典型的线上事故某个统计报表程序为了查“最新库存快照”在SELECT后面加了FOR UPDATE结果每次执行都跟秒杀业务的库存UPDATE抢锁把报表查询的慢查询报警从一天几条干到一小时几百条。代码改成普通SELECT后一切恢复平静。不是所有读都需要最新值统计、报表、展示类查询请一律使用快照读只有真正要改数据时才用当前读。4.2 RC与RR一次SELECT背后的版本可见性差异ReadView的生成时机和复用策略直接决定了隔离级别之间的行为差异。MySQL默认的隔离级别是REPEATABLE READ可重复读简称RR另一种常见级别是READ COMMITTED读已提交简称RC。在RR级别下ReadView在事务第一次执行快照读时生成之后整个事务期间复用同一个ReadView。这意味着同一个事务里无论执行多少次SELECT看到的版本都是一致的不会因为期间其他事务提交了新版本而变化。这正是“可重复读”的语义来源事务启动时看到什么整个事务期间就看到什么。在RC级别下每条快照读语句执行时都会重新生成ReadView。所以同一个事务里第一次SELECT看不到的数据如果第二次SELECT之前其他事务提交了第二次就能看到。两条SELECT之间可能出现结果不一致但换来的是“每次读到的都是最近已提交状态”对某些读多写少、追求数据新鲜度的业务更友好。两种级别没有绝对优劣。RC的优势是间隙锁范围更小后面会提到死锁概率更低劣势是同一个事务内多次读可能结果不一致需要业务自己能接受。RR的优势是一致性视图清晰但间隙锁会带来更多锁冲突。5.7版本开始binlog默认使用ROW格式RC配合ROW格式的binlog也安全所以越来越多的新项目选择RC来降低死锁率。不过如果业务依赖“同一事务多次读结果一致”的语义还是老老实实留在RR。这里还有个常用的判断技巧如果一个事务里只需要读一次数据写后读例外RC和RR没区别如果要在事务里多次查询并依赖结果一致选RR如果事务里以短查询为主、不太介意前后微小的提交差异选RC。5. 流量洪峰下MVCC与锁如何协同战斗5.1 写写冲突的兜底行锁与间隙锁MVCC负责把读操作从锁等待中解放出来但写操作之间的冲突还是得靠锁控制。InnoDB的锁体系按锁定的范围可以分为记录锁、间隙锁和临键锁Next-Key Lock。记录锁只锁住索引记录本身对应RC级别的默认行为。它解决的是“同一行不能同时被两个事务修改”的问题。间隙锁锁住的是索引记录之间的“空隙”核心目的是防止幻读。RR级别下InnoDB对索引扫描范围不仅锁命中的记录还锁住记录之间的间隙让其他事务无法在这个间隙插入新记录。临键锁则是记录锁加间隙锁的组合左开右闭区间。实际落地时行锁加在索引上——注意是索引不是行本身。如果UPDATE的WHERE条件没走索引InnoDB只能全表扫描逐行加锁等于把所有记录都锁了性能灾难。所以高并发UPDATE语句务必保证WHERE条件命中索引尤其热点表这个优化比任何参数调优都立竿见影。5.2 务实的经验RR下如何减少死锁流量洪峰下RR级别有个绕不开的痛点当前读加的是临键锁锁的范围比RC大多个事务交叉更新不同记录时更容易死锁。最经典的场景是两条UPDATE语句以相反顺序更新两张表或两个SKU事务1更新sku_id1001后想更新sku_id1002事务2先更新sku_id1002再更新sku_id1001。两个事务各自持有一把锁又同时在等对方手里的锁死锁瞬间形成。InnoDB检测到死锁后会回滚其中一个事务但回滚本身也是开销高并发下会放大延迟和失败率。我项目里的标准做法是三条铁律第一固定更新顺序比如所有SKU的扣减都按sku_id升序处理从机制上消除循环等待第二缩短事务事务里不要做远程调用、消息发送、耗时计算拿到锁赶紧提交锁持有时间越短和别人交叉的概率越低第三能走RC就走RC如果业务字典化确认不依赖可重复读RC的锁粒度更小死锁率明显下降。特别提醒一点不要在事务里SELECT ... FOR UPDATE之后又调用外部API。一旦外部API响应慢事务就一直攥着锁不放后面的写请求全部堵在这把锁上你以为在做并发控制其实在做全局串行化。真需要“先检查后更新”尽量把检查和更新压缩到一条SQL里比如UPDATE ... SET stock_num stock_num - 1 WHERE sku_id ? AND stock_num 0。6. 踩坑实录MVCC相关故障排查与性能调优6.1 undo log膨胀与purge线程滞后MVCC最隐蔽的坑是历史版本没有被及时清理。InnoDB的后台purge线程负责回收不再被任何ReadView需要的undo log它回收的判断标准是undo log版本对应的DB_TRX_ID小于当前所有活跃事务中的最小事务ID。也就是说只要存在一个长事务哪怕它一直空闲它的ReadView会锁住一批早期版本purge线程就不能删undo log只能不断堆积。线上表现就是磁盘空间莫名快速增长SHOW ENGINE INNODB STATUS里History list length持续走高。history list是已经提交但尚未purge的undo log链表正常情况下这个数值应该小且平稳流量洪峰期短暂上涨可以接受但持续不降就要警惕。排查思路按顺序来先查information_schema.innodb_trx看有没有trx_started时间很早的事务——长事务是undo堆积的头号元凶一个事务跑两小时期间所有已提交事务的旧版本都要为它保留确认没有长事务后再检查purge线程是否正常undo日志所在的表空间是否接近容量上限。调优方面MySQL 5.7支持配置innodb_purge_threads从默认1调高到4可以加快回收但前提是确认瓶颈在purge速度而不是长事务否则调再高也没用。6.2 长事务导致的历史版本堆积长事务的杀伤力不止于undo膨胀它还会拖慢所有依赖快照读的查询。你想啊一个事务在早晨10点就生成了ReadView到下午3点还没提交那么这5小时内所有被更新过的行在它眼里都“不该看到最新版本”任何查询要读到它的可见版本都得沿着版本链回溯到早晨10点之前的快照。版本链越长回溯成本越高慢查询就是这么被拖出来的。我排查过一个线上案例业务方在一个事务里做了30次循环UPDATE中间还有SLEEP模拟耗时压测一上这个事务变成全库最大的锁持有者其他事务的UPDATE全部排队SELECT也因为版本链过长变慢。最后是把循环UPDATE拆成多条独立短事务每条提交后再执行下一条问题立刻消失。所以针对流量洪峰事务设计的原则其实就两条能短则短能少则少。单事务操作行数控制在千行以内事务内不掺入无关操作尤其避免在事务内等用户输入、等外部响应。这个习惯养成后你会发现历史和undo膨胀相关的故障能少一大半。6.3 排查工具与参数调优速查最后整理一份排查速查表都是我在生产环境里实际用过的命令和参数遇到MVCC相关问题可以直接按图索骥。问题现象排查命令/对象处理方向慢查询集中在同一张热点表检查版本链长度EXPLAIN看是否走索引合并UPDATE次数优化WHERE索引History list length持续上涨SHOW ENGINE INNODB STATUS找长事务、加大purge线程数事务迟迟不结束SELECT * FROM information_schema.innodb_trx定位trx_started最早的事务通知业务整改死锁频繁SHOW ENGINE INNODB STATUS查看LATEST DETECTED DEADLOCK固定更新顺序缩短事务评估切换RC磁盘暴涨快照恢复慢检查undo表空间大小排查长事务必要时重启清理并规范事务参数方面常见的有innodb_purge_threads控制purge线程数innodb_purge_batch_size控制单次批量清理的undo page数innodb_max_purge_lag用于在purge跟不上时延迟写入给purge线程喘息空间。这些参数都不是越大越好我一般是先看History list length和磁盘IO再决定要不要调不确定时保持默认优先从业务侧缩短事务、降低更新频率往往比调参更有效。最后再说说我这几年排障下来最大的感受MVCC不是银弹它把“读写互斥”这个老问题转化成了“版本链管理”和“历史版本清理”两个新问题而在流量洪峰下最容易出事的恰恰是后者。大部分团队一开始都盯着锁等待、死锁率等undo把磁盘撑爆了才反应过来版本清理没跟上。所以做高并发系统设计时除了调SQL、加缓存也值得专门看一眼数据库的版本堆积曲线。一条经验法则是每分钟单热点行的UPDATE次数超过几百次就值得单独盯它的undo增长和purge状态了。把这层地基打扎实洪峰来了心里才有底。
返回列表