Administrator
发布于 2019-02-14 / 6728 阅读
108

MVCC 多版本并发控制是怎么实现的

跑批脚本算出来的数对不上

2 月 11 号早上,财务来找我对账:昨天的日结脚本跑出来的金额是 1000 元,但后台页面显示是 1200 元。我翻脚本日志,发现它做了这么一件事:

START TRANSACTION;
SELECT SUM(amount) FROM t_order WHERE pay_date = '2019-02-10';   -- 得到 1000
-- 中间有别的逻辑
UPDATE t_order SET settle_flag = 1 WHERE pay_date = '2019-02-10'; -- Rows matched: 1200
COMMIT;

同一个事务、同一批行,先 SELECT 出来 1000 元,紧接着 UPDATE 却匹配到 1200 行。要是不懂 MVCC,看这段代码会觉得数据库坏了。

两个 session 就能复现

我把场景简化,在测试库上开两个连接,隔离级别默认 RR(MySQL 5.7 的 tx_isolation 默认 REPEATABLE-READ):

-- 会话 A(事务 ID 假设为 100)
START TRANSACTION;
SELECT amount FROM t_order WHERE id = 5;        -- 结果:100
-- 先不提交

-- 会话 B(事务 ID 101)
START TRANSACTION;
UPDATE t_order SET amount = 300 WHERE id = 5;
COMMIT;                                          -- 已提交,amount 现在是 300

-- 回到会话 A
SELECT amount FROM t_order WHERE id = 5;        -- 结果还是:100
SELECT amount FROM t_order WHERE id = 5 FOR UPDATE;  -- 结果:300
COMMIT;

会话 A 的普通 SELECT 读到的是 B 提交之前的值,加了 FOR UPDATE 之后读到的却是最新的 300。这两条语句的差别,就是快照读和当前读的分界。

之前我把 RR 理解成"事务期间给读到的数据加了锁,别人改不了"。这个理解是错的:B 明明改成功了,A 只是"看不见"而已。MVCC 的实现不是加锁,而是给每一行数据维护多个历史版本,读的时候挑一个合适的版本给你

undo log 版本链

InnoDB 的每行记录(聚簇索引叶子节点)里除了我们定义的字段,还藏着三个隐藏列:

列名大小作用
DB_TRX_ID6 字节最后一次修改这行的事务 ID
DB_ROLL_PTR7 字节回滚指针,指向 undo log 里的上一个版本
DB_ROW_ID6 字节没有主键时 InnoDB 自己生成的行标识

每次 UPDATE 时,InnoDB 会先把当前行的内容拷贝到 undo log 里,然后用新值覆盖原记录,并把 DB_ROLL_PTR 指向刚写进去的那份 undo。多次修改之后,通过 DB_ROLL_PTR 串起来就是一条版本链:

最新版本(在聚簇索引里)
  id=5, amount=300, DB_TRX_ID=101, DB_ROLL_PTR ──┐
                                                  │
undo log:  id=5, amount=100, DB_TRX_ID=100, DB_ROLL_PTR ──┐
                                                            │
undo log:  id=5, amount=50,  DB_TRX_ID=88,  DB_ROLL_PTR = NULL(链尾)

DELETE 也是"更新":不会真的把记录抹掉,而是打一个删除标记(把 DB_TRX_ID 更新成删除它的事务 ID,并置上 deleted bit),记录照样留在版本链上,最后由后台的 purge 线程回收。

可以用这个命令看当前有多少 undo 相关的历史长度:

mysql> SELECT COUNT(*) FROM information_schema.innodb_trx;
mysql> SHOW ENGINE INNODB STATUS\G   -- 看 History list length

ReadView:决定你能看见哪个版本

光有版本链还不够,读的时候需要一套规则判断"哪个版本对我可见"。这套规则就是 ReadView(一致读视图),它包含四个字段:

  • m_ids:生成 ReadView 时,系统里所有活跃(未提交)事务的 ID 列表
  • min_trx_idm_ids 里的最小值
  • max_trx_id:系统即将分配给下一个事务的 ID
  • creator_trx_id:创建这个 ReadView 的事务自己

顺着版本链往下找时,对每一个版本的 DB_TRX_ID 依次判断:

if (DB_TRX_ID == creator_trx_id)
    return 可见;                       // 自己改的,当然看得见
if (DB_TRX_ID < min_trx_id)
    return 可见;                       // 生成视图时就已经提交了
if (DB_TRX_ID >= max_trx_id)
    return 不可见;                     // 是"未来"的事务改的,那时我还没开始
if (DB_TRX_ID in m_ids)
    return 不可见;                     // 生成视图时它还没提交
return 可见;                           // 生成视图时它已经提交了

不可见就顺着 DB_ROLL_PTR 往上一个版本走,继续判断,一直到链尾还不可见,那这一行对当前事务就不存在。

把这套规则套回前面的例子:会话 A 的 ReadView 在第一次 SELECT 时生成,此时 B 还没启动,m_ids 里没有 101,max_trx_id 是 101。B 提交后把行的 DB_TRX_ID 改成了 101,A 再读时判断 101 >= max_trx_id(101),属于"未来事务",不可见,于是回滚到上一个版本拿到 amount=100。这就解释了那个 100 是怎么来的。

RR 和 RC 的差异,根源就一行

这两个隔离级别用的是同一套版本链和同一套判断规则,唯一的区别是ReadView 什么时候生成

  • READ COMMITTED:每一次快照读都生成一个新的 ReadView
  • REPEATABLE READ:只在事务里第一次快照读时生成,之后一直复用

把上面那个例子改成 RC(SET tx_isolation='READ-COMMITTED',5.7 里变量名还是 tx_isolation,8.0 才改成 transaction_isolation):A 第二次 SELECT 会重新生成 ReadView,此时 B 已经提交,101 不在 m_ids 里,判断结果变成可见,读到的就是 300。

所以 RR 下的"可重复读"不是靠锁实现的,而是靠"整个事务共用一份视图"实现的。这也能解释一个常被忽略的事实:RR 下粒度更粗的一致性,代价是读到的可能是很旧的数据。

顺带记一个细节

MySQL 5.7 里,只读事务默认不会分配真正的事务 ID(内部用一个很大的虚拟值),只有发生写操作时才分配。这个优化能减少 m_ids 里的噪声。另外,一致性读视图的创建还有个 START TRANSACTION WITH CONSISTENT SNAPSHOT 的写法,它会立刻建视图,不用等第一条 SELECT。

回到那个跑批脚本:快照读和当前读

现在能解释文章开头那个问题了。

  • SELECT SUM(amount)快照读,走 ReadView,读到的是事务开始时的数据,所以是 1000。
  • UPDATE ... SET settle_flag = 1当前读。所有写操作(INSERT、UPDATE、DELETE)都必须基于最新的数据做,否则会丢失别人的更新。所以它看到的是 1200 行,而且它会给这些行加行锁。

除了写操作,这两种 SELECT 也是当前读:

SELECT * FROM t_order WHERE pay_date = '2019-02-10' LOCK IN SHARE MODE;  -- 加共享锁
SELECT * FROM t_order WHERE pay_date = '2019-02-10' FOR UPDATE;          -- 加排他锁

脚本的修法就是让统计口径统一。要么一开始就用当前读,要么整个跑批放在业务低峰、确保期间没有写入:

START TRANSACTION;
SELECT SUM(amount) FROM t_order
  WHERE pay_date = '2019-02-10' FOR UPDATE;   -- 当前读 + 加排他锁,期间别人改不了
UPDATE t_order SET settle_flag = 1 WHERE pay_date = '2019-02-10';
COMMIT;

我们最后选了后者:把日结挪到凌晨 3 点,并且在脚本开头加一句检查,确认没有未提交的长事务。加锁版本虽然准确,但会锁住一整天的订单行,风险比收益大。

题外话:长事务会把 undo 撑爆

理解了版本链之后,另一个常见问题就能想明白了。只要还有事务可能要读某个老版本,undo log 就不能被 purge 线程清掉。如果一个事务开了几个小时不提交,这期间所有被修改过的行的历史版本都得留着。

我们上个月就出过一次:一个跑批脚本在循环里没有及时提交,SHOW ENGINE INNODB STATUS 里的 History list length 涨到 240 万,undo 表空间文件从 2G 涨到 32G,磁盘告警。用这个命令能揪出来:

mysql> SELECT trx_id, trx_started, trx_mysql_thread_id,
              TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration,
              trx_query
       FROM information_schema.innodb_trx
       WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
       ORDER BY duration DESC;

查出来是一个跑了 4 小时 12 分的统计事务。之后我们给所有跑批加了两个约束:单事务处理行数上限 5000,以及超过 5 分钟未完成就告警。

小结

  • MVCC = undo log 版本链 + ReadView。每行有隐藏的 DB_TRX_IDDB_ROLL_PTR,历史数据串成链;读的时候用 ReadView 判断哪个版本可见。
  • RR 和 RC 用的是同一套判断规则,差别只在 ReadView 的生成时机:RC 每次快照读都重建,RR 整个事务复用一份。
  • 写操作必然是当前读,所以"先 SELECT 再 UPDATE"这两步看到的数据可能不一致。需要口径一致时显式用 FOR UPDATE,或者干脆避免并发写。
  • MVCC 让读不加锁,代价是历史版本要一直保留,长事务是 undo 膨胀的头号原因。

这篇没有涉及锁的部分(行锁、间隙锁、next-key lock),那些属于当前读的范畴,我另写了一篇死锁日志分析,两篇最好一起看。

参考