- 我们为什么需要 MVCC ?
没有MVCC(多版本并发控制 Multi-Version Concurrency Control)的情况下,面对数据库事务中"读某一条数据"的行为,我们只能加锁。这个锁可以是共享锁/排他锁,保证"我读的时候没人过来写",避免脏读(读到和最终结果不一致的数据)。然而一旦加锁,其他线程就要等,一等并发效率就直线下降。
MVCC 的思路是:读的时候不加锁,而是通过"版本"来判断一条数据对我来说能不能读。
- 每一行数据的三个隐藏字段ba
每一条数据除了业务字段外,还有三个隐藏字段:
DB_TRX_ID 最近一次插入/更新/删除该行的事务id
DB_ROLL_PTR 回滚指针,指向这行数据在 undo log 中的上一个版本
DB_ROW_ID 隐藏主键,仅在表没有定义主键时才会生成
undo log 把同一行数据的历史版本串成一条链,DB_ROLL_PTR 就是链上的指针,出问题时可以顺着它一路往回找老版本。
- Read View:读之前先拍个快照
在真正读数据之前,我们会先生成一个快照,叫做 read view。它记录了这几样东西:
m_ids 生成快照那一刻,所有"活跃"(未提交)的事务id列表
min_trx_id m_ids 里最小的那个id
max_trx_id 系统里下一个将要分配的事务id(也就是目前已知最大事务id + 1)
注意 max_trx_id 不是"当前活跃事务里最大的那个",而是"还没被任何事务用过的、未来第一个可用的id"——因为事务id是全局递增分配的。
这里要先说清楚一件容易被忽略的事:read view 什么时候生成,在不同隔离级别下是不一样的。
READ COMMITTED(读已提交):每次执行 SELECT 语句都重新生成一个 read view
REPEATABLE READ(可重复读,MySQL默认):只在事务内第一次执行 SELECT 时生成一次,之后整个事务复用同一个 read view
这个区别直接决定了"我这次读,能不能看到别人事务提交的新数据"——RC 每次都能看到最新提交的结果,RR 则始终锁定在事务开始时的那个快照上,这也是可重复读名字的由来。
- 拿到 read view 之后,怎么判断某一行能不能读
现在回到最初的问题:我们要读一行数据,它的 DB_TRX_ID = 200。这条数据被事务200修改过,我们能不能读到它,要按顺序做以下判断:
判断1:如果 DB_TRX_ID 就是我自己当前事务的id,那肯定能读——是我自己改的,当然可见。
判断2:如果 DB_TRX_ID < min_trx_id,说明这个事务在我拍快照之前就已经结束(提交或回滚)了,属于"很老的事务",这个版本可见。
判断3:如果 DB_TRX_ID ≥ max_trx_id,说明这个事务是在我生成快照之后才开始的,对我来说它是"未来"的事务,这个版本不可见。
判断4:如果 min_trx_id ≤ DB_TRX_ID < max_trx_id,但这个id不在 m_ids 列表里,说明这个事务在我拍快照的时候已经提交了,这个版本可见。
判断5:如果 DB_TRX_ID 正好在 m_ids 列表里,说明这个事务在我拍快照的那一刻还没提交,对我来说不可见。这种情况下,就顺着这一版本的 DB_ROLL_PTR 回退到 undo log 里的上一个版本,拿这个更老的版本重新走一遍上面的判断,直到找到一个可见的版本为止(如果一直回退到头都没有可见版本,说明这行数据对我来说还不存在)。
- 小结
这套机制本质上是拿 undo log 存的历史版本链,换来了读不用加锁、写不阻塞读的高并发。代价也很实在:undo log 会随着未提交事务的存在不断膨胀,需要靠 purge 线程在没人再需要旧版本时把它们清理掉。如果长事务迟迟不提交,历史版本积压过多,也会拖慢查询、占用大量存储——这就是MVCC这顿"免费午餐"背后真正的账单。