流量洪峰这四个字,做过线上系统的人都懂它的分量。秒杀开场那一秒,订单表、库存表、账户流水表同时被几千个并发线程怼上来,底层存储引擎只要一致性校验慢一拍,轻则超卖,重则主库锁等待飙升直接拖垮整个集群。我见过不少团队在这个节骨眼上把问题归咎于“SQL写得慢”,反复调索引、加缓存,但核心矛盾其实藏在更底层——InnoDB怎么在同一行数据被疯狂更新时,还能让读请求不排队、不阻塞、不读到脏数据?这背后就是MVCC(Multi-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、ReadView
2.1 隐藏列:数据行上的“版本标签”
InnoDB的聚簇索引(主键索引)里,每一行记录除了用户定义的字段,还维护着三个隐藏字段,很多做业务开发的同学可能从来没见过它们,但它们才是MVCC的“坐标原点”。
| 隐藏列 | 大小 | 作用 |
|---|---|---|
| DB_TRX_ID | 6字节 | 记录最近一次插入或修改该行的事务ID |
| DB_ROLL_PTR | 7字节 | 回滚指针,指向undo log中该行修改前的旧版本 |
| DB_ROW_ID | 6字节 | 隐藏自增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) ) ENGINE=InnoDB;初始状态:id=1这行记录sku_id=1001,stock_num=100。这条记录由事务ID=10插入并已提交,此时聚簇索引记录上DB_TRX_ID=10,DB_ROLL_PTR指向insert undo log。
现在事务A(事务ID=20)执行:
BEGIN; UPDATE sku_stock SET stock_num = 90 WHERE sku_id = 1001;执行UPDATE的瞬间,InnoDB做了两件事:第一,对满足条件的行加排他锁(当前读必须加锁,后文会展开);第二,把修改前的旧版本(stock_num=100、DB_TRX_ID=10)写入undo log,新版本的DB_TRX_ID改为20,DB_ROLL_PTR指向刚写入的undo log。此时版本链变成两段:最新版本stock_num=90(事务20),旧版本stock_num=100(事务10的insert undo)。
事务A还没提交,这时候事务B(事务ID=30)来了,执行一条普通SELECT:
SELECT stock_num FROM sku_stock WHERE sku_id = 1001;B的ReadView生成:活跃列表里有事务20,所以m_ids=[20],min_trx_id=20,max_trx_id=31(下一个要分配的事务ID),creator_trx_id=30。B顺着版本链从最新开始查:第一个版本DB_TRX_ID=20,等于min_trx_id且存在于活跃列表,不可见;沿roll_pointer回溯到旧版本DB_TRX_ID=10,10小于min_trx_id,事务10早已提交,可见。于是B读到stock_num=100。
这就是快照读的完整过程:不阻塞、不加锁、还能看到一致的历史值。A继续提交后,版本链上DB_TRX_ID=20的版本变成已提交状态,但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_id=1001后,想更新sku_id=1002;事务2先更新sku_id=1002,再更新sku_id=1001。两个事务各自持有一把锁,又同时在等对方手里的锁,死锁瞬间形成。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状态了。把这层地基打扎实,洪峰来了心里才有底。