跑批脚本算出来的数对不上
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_ID | 6 字节 | 最后一次修改这行的事务 ID |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 里的上一个版本 |
| DB_ROW_ID | 6 字节 | 没有主键时 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_id:m_ids里的最小值max_trx_id:系统即将分配给下一个事务的 IDcreator_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_ID和DB_ROLL_PTR,历史数据串成链;读的时候用 ReadView 判断哪个版本可见。 - RR 和 RC 用的是同一套判断规则,差别只在 ReadView 的生成时机:RC 每次快照读都重建,RR 整个事务复用一份。
- 写操作必然是当前读,所以"先 SELECT 再 UPDATE"这两步看到的数据可能不一致。需要口径一致时显式用
FOR UPDATE,或者干脆避免并发写。 - MVCC 让读不加锁,代价是历史版本要一直保留,长事务是 undo 膨胀的头号原因。
这篇没有涉及锁的部分(行锁、间隙锁、next-key lock),那些属于当前读的范畴,我另写了一篇死锁日志分析,两篇最好一起看。