ARTICLE DETAIL

资讯详情

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

07-MVCC多版本并发控制:读写互不阻塞的底层原理

07-MVCC多版本并发控制:读写互不阻塞的底层原理 MVCC多版本并发控制读写互不阻塞的底层原理作者黒漂技术佬适用读者知道事务和隔离级别但不清楚MVCC到底怎么工作的同学关联场景无人售货柜高并发扣库存、智慧农业传感器数据查询一、为什么需要MVCC锁的并发性能瓶颈上一篇我们聊了事务隔离级别知道了MySQL默认用RR可重复读隔离级别。但有个问题一直被回避——并发读写时到底怎么实现读不阻塞写、写不阻塞读最朴素的思路是加锁。读的时候加共享锁写的时候加排他锁。但问题来了无人售货柜高峰期 - 100个用户同时在查商品库存读 - 10个用户在扣库存下单写 如果用锁 读加S锁写加X锁S和X互斥 → 写事务执行时所有读事务全部阻塞等待 → 高峰期系统卡死用户看到转圈圈这种读时不能写、写时不能读的并发模型在高并发场景下性能灾难性地差。数据库本来就是为并发设计的总不能让查询排队等写完。于是MySQL的InnoDB引擎搞了一套MVCCMulti-Version Concurrency Control多版本并发控制——给每行数据维护多个历史版本让读操作去读旧版本写操作去写新版本互不打扰。MVCC核心思想一句话总结读操作走快照版本写操作生成新版本读和写操作各走各的版本链互不阻塞。这是MySQL在RC和RR隔离级别下实现非阻塞读的秘密武器。二、隐藏字段每行数据都有三个暗号InnoDB给每行记录偷偷加了几个用户看不到的隐藏字段这是MVCC的基础。理解这三个字段MVCC的原理就懂了一半。隐藏字段含义作用DB_TRX_ID事务ID记录这一行是哪个事务修改的DB_ROLL_PTR回滚指针指向undo log中的上一个版本DB_ROW_ID行ID没有主键时自动生成聚簇索引用DB_TRX_ID事务ID每开启一个事务InnoDB就分配一个递增的事务ID。某行被修改时当前事务ID写到这一行的DB_TRX_ID字段。DB_ROLL_PTR回滚指针指向undo log回滚日志里这行数据的上一个版本。每次修改都生成一个旧版本回滚指针把这些版本串成一条链。DB_ROW_ID行ID如果你建表时没指定主键也没有唯一非空索引InnoDB自动用DB_ROW_ID做聚簇索引。这个字段和MVCC关系不大了解一下就行。product表中product_id1001的记录 [实际数据页] stock47, DB_TRX_ID300, DB_ROLL_PTR→undo_log_addr_5 ↓ [undo log] stock48, DB_TRX_ID200, DB_ROLL_PTR→undo_log_addr_4 ↓ [undo log] stock50, DB_TRX_ID100, DB_ROLL_PTR→NULL最早的版本这条链叫版本链。每次UPDATE都把旧值塞进undo log再用回滚指针把新旧版本串起来。这就是多版本的来源。三、Undo Log版本链的存储仓库上一篇讲事务原子性时提过undo log回滚日志它用来在事务回滚时恢复数据。但undo log还有个关键作用——为MVCC提供历史版本。3.1 undo log怎么记录旧版本当事务A执行UPDATE product SET stock47 WHERE product_id1001时InnoDB做这几步1. 找到product_id1001这行当前版本stock48, trx_id200 2. 把这行旧值拷贝到undo logundo log记录stock48, trx_id200 3. 修改数据页中的行为stock47, trx_id300, roll_ptr指向刚才的undo log记录 4. 写redo log保证持久性如果紧接着另一个事务B又改了一次又会生成新版本undo log链继续延长。3.2 undo log的回收undo log不能无限堆积否则磁盘撑不住。InnoDB有个后台线程purge thread专门清理undo log。只有当没有任何事务还需要读某个旧版本时那个版本才能被清理。长事务危害 - 一个事务开着10分钟不提交 - 这10分钟内所有被修改行的undo log都不能清理 - undo表空间膨胀磁盘告警 - 历史版本堆积查询要走很长的版本链性能下降生产事故常见原因之一长事务大表更新导致undo表空间暴涨。看到undo表空间异常增长第一件事查有没有跑了几小时没提交的事务。四、Read View可见性判断的核心算法版本链建好了但读事务怎么知道该读哪个版本这就靠Read View读视图。4.1 Read View是什么Read View是事务在做快照读时生成的一个可见性判断快照。它记录在我开始读的那一刻哪些事务已经提交、哪些还没提交。读操作拿着Read View去版本链上一条条比对找到第一个可见的版本返回。Read View里有四个核心字段字段含义m_ids生成Read View时当前活跃的未提交事务ID列表min_trx_idm_ids里的最小值max_trx_id下一个要分配的事务ID即m_ids最大值1creator_trx_id创建这个Read View的事务自己的ID4.2 可见性判断算法拿着当前行版本的DB_TRX_ID设为trx_id按以下规则判断这个版本对当前事务是否可见规则1trx_id creator_trx_id → 自己改的可见 规则2trx_id min_trx_id → 这个版本在我开始前就提交了可见 规则3trx_id max_trx_id → 这个版本是Read View生成后才有的事务改的不可见 规则4min_trx_id trx_id max_trx_id → 看 trx_id 在不在 m_ids 中 在 → 还没提交不可见 不在 → 已提交可见如果当前版本不可见就顺着DB_ROLL_PTR找上一个版本继续套规则直到找到可见的版本或到链尾。4.3 举个例子理清思路初始product_id1001, stock50, trx_id100已提交 时间线 T1: 事务A(id200)开始 T2: 事务B(id300)开始 T3: 事务B执行 UPDATE stock48生成新版本trx_id300未提交 T4: 事务A执行 SELECT快照读 T5: 事务B提交 T6: 事务A再执行 SELECT快照读事务A在T4时生成Read View此时m_ids[200, 300]A和B都还没提交。A读到版本链最新版本trx_id300发现300在m_ids里→不可见顺roll_ptr找到上一个版本trx_id100100 min_trx_id200→可见返回stock50。这就是为什么RC和RR能避免脏读——未提交事务的修改对其他事务不可见。五、RC和RR的差异Read View生成时机RC读已提交和RR可重复读都用MVCC但生成Read View的时机不同这是两者唯一的本质区别。隔离级别Read View生成时机效果RC读已提交每次SELECT都生成新的Read View每次读都能看到最新已提交数据 → 不可重复读RR可重复读事务第一次SELECT时生成整个事务复用事务内多次读结果一致 → 可重复读5.1 RC为什么会出现不可重复读事务A T1: SELECT stock → 50 生成ReadView1m_ids[A,B300] T2: 事务B改成48并提交 T3: SELECT stock → 48 又生成ReadView2m_ids[A]B已提交不在列表 → 两次读结果不一样这就是不可重复读RC每次SELECT都重新拍快照所以能看到别人新提交的修改。5.2 RR为什么能可重复读事务A T1: SELECT stock → 50 生成ReadViewm_ids[A,B300]整个事务复用 T2: 事务B改成48并提交 T3: SELECT stock → 50 复用旧ReadViewB的trx_id300仍在m_ids不可见 → 两次读结果一致这就是可重复读RR只在第一次读时拍一次快照整个事务都看这一张快照所以别人提交了也看不到。这就解释了上一篇留下的悬念RR隔离级别下同一个事务里多次SELECT结果一样不是因为有锁而是因为Read View复用。六、MVCC版本链工作图解用一个完整例子把整条链串起来。假设product表product_id1001初始stock50。时间 事务A(id200) 事务B(id300) 事务C(id400) T1 BEGIN T2 BEGIN T3 UPDATE stock48 T4 未提交 T5 UPDATE stock45 T6 COMMIT T7 SELECTRR第一次读 T8 COMMIT T9 BEGIN T10 SELECT版本链状态数据页(最新): stock45, trx_id300, roll_ptr→undo_2 undo_2: stock48, trx_id200, roll_ptr→undo_1 undo_1: stock50, trx_id100, roll_ptr→NULL事务A在T7的读RR复用Read ViewA的Read View在T7生成m_ids[200]A自己还没提交B300已提交不在列表最新版本trx_id300300≥max_trx_id(201)? 不300在[min,max]区间查m_ids不在→可见?注意这里有个坑实际比对要看B提交时机。B在T6提交T7生成ReadView时B300已经不在活跃列表。所以A看到stock45。但A自己改了stock48没提交按规则1 trx_idcreator_trx_id可见的是A自己的修改。真实情况比图解更细——同一条记录A的未提交修改对A自己可见规则1所以A在T7读到的是自己改的stock48。事务C在T10的读RC新建Read ViewRead View在T10生成此时A200已提交m_ids[400]只有C自己最新版本trx_id300300min_trx_id400→可见返回stock45七、快照读 vs 当前读MVCC只对快照读生效。还有一类叫当前读直接读最新数据不走MVCC。读类型说明语句快照读走MVCC读历史版本普通SELECT当前读读最新数据加锁SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE/DELETE/INSERT-- 快照读走MVCC不加锁读历史快照SELECTstockFROMproductWHEREproduct_id1001;-- 当前读读最新数据加共享锁SELECTstockFROMproductWHEREproduct_id1001LOCKINSHAREMODE;-- 当前读读最新数据加排他锁SELECTstockFROMproductWHEREproduct_id1001FORUPDATE;-- UPDATE/DELETE也是当前读必须读到最新值才能改UPDATEproductSETstockstock-1WHEREproduct_id1001;重要售货柜扣库存场景里UPDATE product SET stockstock-1是当前读不走MVCC会加行锁。MVCC只在普通查询时省去加锁开销。所以MVCC解决读读阻塞写写的问题不解决写写冲突——那还是得靠锁。八、MVCC的代价和适用边界MVCC不是免费的它有代价undo log空间占用版本链越长磁盘开销越大。长事务是undo表空间膨胀的元凶。回表版本遍历开销长版本链下读要走多步才能找到可见版本性能下降。只解决读-写并发不解决写-写并发两个事务同时改同一行还是要靠行锁串行化。适用边界高并发读、中低并发写的场景 → MVCC效果最好智慧农业传感器数据查询、售货柜商品列表展示高并发写同一行秒杀扣库存→ MVCC帮不上忙要靠锁业务限流工程经验看到InnoDB: undo log sequence number在监控里异常增长先查长事务。SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) 100;能揪出超过100秒的事务。九、总结概念一句话MVCC本质给每行维护多个历史版本读快照写新版本互不阻塞三个隐藏字段DB_TRX_ID事务ID、DB_ROLL_PTR回滚指针、DB_ROW_ID行IDUndo Log存旧版本串成版本链也用于事务回滚Read View可见性判断快照记录活跃事务列表RC vs RRRC每次SELECT新建ReadViewRR只在第一次SELECT时建并复用快照读普通SELECT走MVCC当前读FOR UPDATE/LOCK SHARE/写操作读最新并加锁MVCC是InnoDB高并发读性能的支柱。理解版本链和Read View算法再看RC/RR的行为就不再是死记硬背——所有现象都从这套机制自然推导出来。下一篇我们聊MySQL锁机制看看写-写并发是怎么被管控的。
返回列表