欢迎光临
我们一直在努力

事务都提交了,Undo为什么还留着?让一个旧读视图挡住清理看看

Undo 这个名字很容易让人先想到“撤销”。

如果一笔更新已经提交,不需要撤销了,它对应的 Undo 能不能马上清掉?

先看另一个正在读数据的人。A 读到余额 1000 后,暂时不结束事务;B 连续更新、提交,已经把余额改成 1300。在可重复读下,A 再次普通查询,仍然可能需要读到先前的 1000。

那这份旧值从哪里来?如果 B 一提交就把构造旧值所需的信息删掉,A 接下来该读什么?

旧提纲里“提交后 Undo 是否可以删除”的问题,就值得沿着这个场景做一次。

先摆好三个连接

本次实际使用 MySQL 8.0.46,在独立测试实例里新建实验库,不操作业务数据。库名已存在就换一个,不要为照抄而删除已有库。

本次专用实例还使用了一个观察条件:启动时设置 –innodb-purge-rseg-truncate-frequency=1,而不是默认的 128,让小样本的后台回收更容易在短时间内观察。它调整回收检查的频率,不会让仍被旧读视图需要的信息变得可删。这不是生产调优建议,不要为了复现实验修改业务实例;参数含义见官方变量说明。

A、B、C 是三个真正不同的持续连接,不是三个编辑页签的名字。新建连接均使用 autocommit=1;后面只在 A 显式开启读事务。C 只负责观察指标,不在其中挂一个长事务。

在 B 初始化一张普通 InnoDB 表:

CREATE DATABASE blog_undo_history_lab;
USE blog_undo_history_lab;
CREATE TABLE account (
id INT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 1000);
SELECT balance FROM account WHERE id=1;

结果是 1000。

C 先记下一个基线:

SELECT `COUNT`
FROM information_schema.INNODB_METRICS
WHERE NAME='trx_rseg_history_len';

这是 InnoDB 的历史链表长度指标。记录基线时先稍等初始化工作清理;在上面这份专用配置里,最终基线为 0。生产实例还会有其他工作,不能要求所有实例初始都为零,更不能把这个值当成通用告警阈值。InnoDB 指标表说明

先记住它不是“Undo 占用了多少字节”,也不是“有多少行旧数据”。后面只拿它观察这一小段实验的变化。

A先读一次,把需要的旧值留下来

在 A 执行:

USE blog_undo_history_lab;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE id=1;

得到 1000。先别提交,也别关掉这个连接。

这里特意保留了那次普通查询,不是只写一个 START TRANSACTION 就开始等。A 在这里建立了一致性读视图,后续同一事务中的普通一致性读,要按这个视图判断可见版本。

接着让 B 更新。B 没有显式开启事务,自动提交状态为 1,因此下面三次更新分别提交:

UPDATE account SET balance=1100 WHERE id=1;
UPDATE account SET balance=1200 WHERE id=1;
UPDATE account SET balance=1300 WHERE id=1;
SELECT balance FROM account WHERE id=1;

B 得到 1300,三次更新都已经提交了。

现在回 A:

SELECT balance FROM account WHERE id=1;

仍然是 1000。

A先读:1000,保留读视图

B:1100 -> 提交
1200 -> 提交
1300 -> 提交

A再读:仍需要1000

所以,“B 已经提交”并不等于“所有读者都已经不需要之前的版本”。B 的事务结束了,A 的那次读视图还没有结束。

数据最新是1300,A怎样还能看见1000?

可以先把可还原的逻辑版本画成这样:

当前版本:1300
|
v
1200
|
v
1100
|
v
1000 <- A仍要读到这里

这不是说每改一次,数据库就把整行复制一份,按图里的方块存起来。InnoDB 利用记录里的事务信息、回滚指针和 Undo 等信息,在需要时构造旧版本;图只是用来说明这次查询还需要追溯到哪里。

如果 A 仍可能读到 1000,就不能只因为产生更新的 B 已提交,把这段还需要的历史信息直接清掉。

这也说明 Undo 不只服务于“我的事务失败了,帮我撤销”。已提交更新产生的部分历史,仍可能被其他事务的一致性读使用。具体到插入 Undo 与更新 Undo,它们的用途和清理条件也有区别,不能把这次 UPDATE 的现象推广成“所有 Undo 都必须一直留着”。InnoDB 多版本机制说明

旧读事务挂着,清理指标会怎样?

A 保持不结束,去 C 再查同一个指标:

SELECT `COUNT`
FROM information_schema.INNODB_METRICS
WHERE NAME='trx_rseg_history_len';

本次这里读到的是 3,比最初记录的 0 多了三个。继续等一小段时间,再查,A 的旧读视图仍在时,这个值保持为 3。

这里恰好有三次分别提交的更新,但不能由此发明一个公式:“更新三行就必定增加三”。这个指标统计的是历史链表中的 Undo 日志数量,不是更新行数。一笔事务改多少行,和有多少个待清理的日志,不是同一个单位。

它的作用是补一条观察:更新已经提交,旧读者还能读到旧值,历史清理也没有越过这个读者仍需要的范围。不能只凭一个指标,就认定已经读懂了 Undo 文件里的每条记录。

接下来在 A 结束事务,再查一次余额:

COMMIT;
SELECT balance FROM account WHERE id=1;

这次得到 1300。旧事务结束后,新的普通查询不再沿用刚才那个读视图。

C 继续观察:

SELECT `COUNT`
FROM information_schema.INNODB_METRICS
WHERE NAME='trx_rseg_history_len';

在这份独立实例及上述观察配置中,稍等后台清理推进后,这个指标下降,回到了 0。

A的旧视图还在
-> 这段历史仍有用途
-> 不能清到不满足旧读者

A提交,旧视图结束
-> 本例没有其他旧读者
-> 后台清理得以继续

不是 COMMIT 这句 SQL 同步把所有历史都擦掉了。它先结束这位读者对旧版本的需求,后台 purge 再推进清理。因此提交后立刻查一次,不保证马上看到指标下降,更不是承诺所有实例都马上归零。InnoDB purge 说明

实际做这组实验时,我最初用默认回收频率,新实例初始化后指标就有 3;后来结束 A,等了 20 秒也没有看到预想的回落。检查引擎状态时,旧读视图已经没有了。因此,“指标暂时没降”不能被直接翻译成“肯定还有旧读者”。上面的专用配置,是为把这个小实验的前后变化观察清楚,而不是把默认配置的表现悄悄删掉,写成所有实例都该立即回零。

为什么“多开清理线程”不一定管用?

现在可以回头想这个问题了。如果旧读者还需要那段历史,多一些负责清理的人,也不能直接删掉它仍然要用的信息。

但也不能走到另一个极端,说“历史链表增长一定是长事务”。写入产生历史的速度、后台处理能力等都可能影响变化。真实排查要一起看活跃事务、读视图和持续写入,不是看到一个数字大就直接杀连接或改参数。

正式对照时,上述配置固定不变,只比较 A 的旧读事务是否结束。没有测高负载、增加清理线程或制造磁盘瓶颈,所以它解释的是一个阻碍清理的条件,不是完整性能诊断方案。

还有一层别混了:历史可清理、空间可以复用,与 Undo 表空间文件立即缩小,也不是同一件事。前面的查询没有测文件大小,不能拿“指标下降”宣称已经把同样大小的磁盘空间还给系统。

这组实验怎样核对

程序启动独立 MySQL 数据目录,只通过本地套接字连接。A、B、C 三个客户端全程分开,确认连接编号不同,再从正文抽出每段 SQL,按标注的顺序执行。

A 的第一次查询、B 的新值、A 的旧值分别核对;不是只看脚本有没有报错。C 在建立旧读视图之前记录基线,更新后记录并在旧视图保留期间复查。只有上述隔离配置的这组回归检查,才把基线与结束后的 0 写成预期;默认配置没按预期回落的记录另行保留。轮询设置超时,不使用“固定睡一秒就当成功”。

所以最终得到的不是“提交后必须马上删 Undo”,而是这次实际看到的前后关系:B 提交了,A 仍需要历史;A 结束后,这份实例的清理继续推进。判断能不能清理,不能只看产生更新的那笔事务是不是提交了。

赞(0)
未经允许不得转载:171主机测评 » 事务都提交了,Undo为什么还留着?让一个旧读视图挡住清理看看
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址