前言
上一篇我们聊 DDL 的时候,一起 dump 过数据块,看过 ITL 里的 Xid 和 Uba,也看过 undo 段头里那张事务表。当时有个细节我刻意没展开:你 commit 之后,再 dump 一次那个块,会发现 ITL 条目还躺在那里,行锁的标记也还在------事务明明结束了,块里怎么还留着一地"战斗痕迹"?
这就引出了一个很多 DBA 都迷糊过的问题:commit 不是事务的终点吗?为什么提交了,数据块里的事务信息还在?
更让人想不通的是:有时候一个全表扫明明数据没怎么变,却跑得忽快忽慢;有时候 commit 返回成功了,后面第一个来查这张表的人却莫名其妙多干了一堆活。摸爬滚打这么多年,我可以负责任地说,这类"诡异"现场,十有八九都跟今天要聊的这个机制有关------块清除(Block Cleanout)。
一句话概括:commit 只是宣布战争结束,而打扫战场这件事,Oracle 是分批、甚至是"甩锅"给别人做的。
这篇文章我们就把这件事讲透:块里到底留了什么、谁来打扫、什么时候打扫、打扫不动了怎么办,以及怎么亲手做实验把这些过程看得清清楚楚。

一、先搞清楚:块里到底留了什么"痕迹"
上一篇我们讲过,Oracle 改一行数据,不是"上去就改",而是要先在块里办一堆手续。这些手续,就是事务结束后需要清理的东西:
第一类:行锁(row lock)。块里每一行都有一个锁定位(lock byte),指向锁住它的事务在 ITL 里的条目。行本身不知道"我的事务提交了没有",它只记着一个 ITL 条目号。
第二类:ITL 条目。Interested Transaction List,块头里的事务登记表。里面有 Xid(指向 undo 段头事务表的槽位)、Uba(指向这块的 undo 记录)、还有我们今天的两位主角------commit flag和commit SCN。
第三类:free space credit。事务删行腾出来的空间,在事务没提交之前,别的事务是不能随便用的------万一回滚呢?这块空间是"记账"记着的,提交后要确认回收。
一个事务可能动几百上千个块。commit 那一瞬间,要把所有这些块的行锁解掉、ITL 标上"已提交+提交时间"、空间记账结清------如果全部当场做完,大事务的 commit 会慢得让你怀疑人生。
Oracle 的解法很务实:commit 只做最重要的一件事------在 undo 段头的事务表里把槽位标记为"已提交"并写下 commit SCN。这个动作做完,commit 就算成功返回了。数据块那边,能顺手收拾就收拾,收拾不了就先欠着。
"能顺手收拾的"叫Fast Block Cleanout,"先欠着的"叫Deferred Block Cleanout。下面我们分开讲。
二、Fast Block Cleanout:趁热打铁,顺手收拾
Fast cleanout 的前提是:commit 的时候,事务得记得自己碰过哪些块。不然 buffer cache 那么大,上哪儿找去?

所以事务在运行过程中,会把改过的块"记小本本"上。这个小本本在内核里叫Block List State Object(BL State Object,状态对象的一种)。它的结构很紧凑:
-
每个 BL State Object 最多记 20 个块条目;
-
每个条目里存三样东西:该块在当前事务里的 savepoint 号(回滚到保存点时要靠它定位范围)、该块用的 ITL 索引、指向 buffer cache 里 块头的指针;
-
同一个块在清单里只出现一次,改一百遍也只记一条;
-
清单总量有上限------大约是 buffer cache 缓冲区数量的 10%,而且是动态的,按 20 的倍数取整。11.2 的源码里可以在 ktl.h / ktl.c 里找到这套结构。
超过上限的块、被逐出内存的块、或者 commit 那一刻正被别的进程 pin 住的块,统统进不了 fast cleanout 的流程,只能留给 deferred cleanout。
commit 之后,事务销毁这些 BL State Object 的时候(内核函数 ktldbl),会对清单里的每个块调一次 kcbnlc(),尝试做no-logging cleanout------注意这个名字,"不产生日志的清除",这是它的灵魂,待会儿细说。
每调一次,统计项 commit cleanouts 加一;做成功了,commit cleanouts successfully completed 加一。这两个数的比率,就是 fast cleanout 的成功率。顺手说一句:如果某个 BL State Object 上的块连续失败 3 次,Oracle 就不在这本账上浪费时间了,直接跳到事务的下一个 SO 接着试------很现实的止损策略。
真正干活的是 ktbdbc()。它拿到块之后做几件检查:
-
ITL 条目是不是还指向我们这个事务(会不会已经被别人动过);
-
ITL 是不是已经被标成 committed / upperbound(会不会已经被清理过了);
-
块的 cleanout wrap 跟当前事务的 commit SCN wrap 对不对得上。
检查通过,就把 ITL 标记为 upperbound,把 commit SCN 的 base 部分拷进 ITL。
这里有个特别反直觉、也特别重要的设计:fast cleanout 并不真正清掉行锁,也不回收 free space credit。
它只是告诉后来者"这个事务在 SCN 多少多少之前就提交了"------是个上限,不是精确值(所以 ITL 里连 commit SCN 的 wrap 部分都不存)。行锁?空间记账?留给 deferred cleanout 或者下次真正要改这个块的人去收拾。上一篇说过的那句"Oracle 的设计哲学是够用就好",在这里体现得淋漓尽致。
清除做完后,块会被打上 KTBFUPB 标志(delayed-logging cleanout),块 SCN 更新为 commit SCN(如果一样就 seq 加一),块标脏,设置 block high RBA------但全程不产生 redo 记录。
为什么不产生 redo?你想想:cleanout 只是补记"事务早就提交了"这个事实,这个事实本身在 undo 段头里已经有记录了,即使 instance 崩溃、这个块恢复到旧版本,大不了下次再清一遍,丢了也不影响数据正确性。既然丢了无所谓,干嘛花 redo 的代价去保护它?这就是 no-logging 设计哲学的底层逻辑------只给"丢了会出事"的变化记 redo。
三、为什么失败:六个统计项就是排障清单
fast cleanout 是个"尽力而为"的操作,失败是常态。Oracle 把失败原因直接做成了统计项,这在排障时就是现成的清单:
| 统计项 | 含义 |
|---|---|
commit cleanout failures: block lost |
块已经不在 cache 里了 / 被做成 CR 块 / 临时块 / 正在克隆 |
commit cleanout failures: cannot pin |
想 pin 这个块但 pin 不住(被别的进程占着) |
commit cleanout failures: write disabled |
实例状态不允许写(比如只读) |
commit cleanout failures: hot backup in progress |
文件在热备中且块还没 logged |
commit cleanout failures: buffer being written |
DBWR 正在写这个 buffer |
commit cleanout failures: callback failures |
ktbdbc 报告没做成 |
这里面最常见的就是第一条block lost------大事务改了大量块,commit 的时候早期的块早就被挤出 buffer cache 了。这也是为什么大事务 commit 很快、但之后第一个扫到这些块的查询会变慢------那些没打扫的战场,全留给它了。
在 vsesstat / vsysstat 里盯着这几个值看,再配合 commit cleanouts 的比率,你基本就能判断一个系统里 deferred cleanout 的压力大不大。后面实验部分我会给出具体查法。
四、Deferred Block Cleanout:谁读到,谁打扫
fast cleanout 没收拾的块,最终由下一个读到它的人清理------这就是 deferred block cleanout。形象点说:fast 是"谁污染谁治理",deferred 是"下一个进屋的人顺手把地扫了"。
最典型的场景是 CUR(CURRENT)模式访问------你要修改这个块,必须先拿到它的最新版本,拿到之后就躲不开"这上面的旧事务到底提交没有"这个问题。
判断的依据是 ITL 描述符里的C/U 标志:
-
C 和 U 都没有 → 这个 ITL 条目对应的事务看起来还活着;
-
有其中一个 → 已提交(C 表示精确 commit SCN,U 表示 upperbound 上限)。
"看起来活着"不等于真活着------commit 只更新了 undo 段头,块这边没人通知。所以要确认,就得顺着 Xid 去 undo 段头查户口。Xid 三段信息:usn(undo 段号)、slot(事务表槽位号)、seq/wrap#(槽位复用的序列号)。查的过程会把这些信息收集到一个叫cleanout info area的地方。
然后,就会遇到三种情况,一种比一种曲折:
Case 1:undo 段已经没了。比如 undo 表空间重建过,或者那个回滚段被 drop 了。别担心,UNDO 里的行不会物理删除,只是 STATUS 置成 1,SCNBAS/SCNWRP 记下 drop 之前最近的 commit SCN。Oracle 拿着这个值,给 ITL 条目打上 C+U 标志------意思是"这个事务在这个 SCN 之前肯定提交了"。是个近似值,但够用:反正它要证明的只是"这事务死透了"。
Case 2:槽位被复用了。 Xid 里的 seq 跟事务表槽位当前的 wrap# 对不上,说明这个槽位已经换过主人,原事务肯定提交了。但精确 commit SCN 呢?事务表本身也被后来不断复用修改过------想找回原来的样子,得对事务表本身做一致性读 ,一层层应用 undo 记录回滚事务表的修改(统计项 transaction tables consistent reads - undo records applied 记的就是这个)。如果 undo 链走到头了还没找到,只能退而求其次,用一个近似值------KTUXCSCN,也就是 lowtime。
Case 3:槽位没被动过。 最省心的情况,精确的 commit SCN 就躺在事务表里,直接抄走。
五、Lowtime、Hitime 和"有效清除 SCN"
上面提到的 lowtime 值得展开讲讲,它是理解这套机制的钥匙。
undo 段头的控制结构 KTUXC 里有个字段KTUXCSCN(lowtime),它给出的保证是:所有槽位已被复用的已提交事务,它们的 commit SCN 都 ≤ lowtime。事务表里的已提交槽位是按 SCN 排成一条链的:ktuxcchd 是链头(最老的已提交事务),ktuxcctl 是链尾(最新的)。新事务来找空闲槽位时,从链头开始收------所以最老的槽位最先被复用,lowtime 才能随之前移。很精巧的 LRU 式安排。
对应的还有hitime:等于这个 buffer 的 CR_SCN。于是事务表的每一个版本,都覆盖一个确定的 SCN 区间lowtime, hitime------做一致性读找事务历史的时候,就是靠这个区间定位"哪个版本的事务表能看到我要的那个事务"。
最后说有效清除 SCN(KTBBHCSC),记在块的事务头里,含义是"这个块上次的清除工作,对哪个 SCN 是有效的"。清除的本质是一次全块事务状态盘点:扫一遍所有 ITL,验证带 C/U 标志的都确实提交了,其余当时确实活跃。盘点的结论需要一个时间戳背书,这个时间戳就是有效清除 SCN。
它的取值有个微妙的坑:如果清除过程中恰好有事务提交了怎么办?比如清除开始于 SCN 50,干到一半 T3 在 SCN 51 提交了------那有效清除 SCN 得取 51,因为盘点结论已经反映了 51 时刻的世界。反过来,如果块里还有活跃事务没提交,有效清除 SCN 不能直接取已提交事务里的最大 SCN,而要取各事务表 CR_SCN 的最小值,且不能小于清除开始时的 SCN。一句话:这个 SCN 必须同时对所有已验证的事务成立,宁可保守,不能冒进。
六、追现场:redo 与 event 10203
deferred cleanout 跟 fast cleanout 不一样,它是产生 redo 的------因为它真正改了 ITL、清了行锁,这些变化丢了会出问题。
它的 redo 记录属于layer 4(transaction block),opcode 1(block cleanout)。记录内容包括:有效清除 SCN(KTBBHCSC),以及每个被修改的 ITL 条目的 itli、flg 和 commit SCN。其中 flg 取值很有信息量:1 = SCN 是上限近似,2 = 精确 commit SCN,3 = 下限近似------前面讲的三种 case 带来的不确定性,全浓缩在这一个小小的标志位里。
想亲眼看到 cleanout 的过程,Oracle 留了个专门的追踪事件event 10203:
ALTER SESSION SET EVENTS '10203 trace name context forever, level 2';
-
level 1:只记录 cleanout 的信息;
-
level 2:附加块头和 ITL 的 dump;
-
level 3 及以上:连整个块的内容一起 dump 出来。
排查"delayed block cleanout 引发的性能抖动"这类问题时,10203 加上前面那组统计项,基本就够了。
七、动手实验:亲手制造一次 block lost
光说不练假把式。下面这套实验我自己玩过很多次,每一步都能在上面讲的理论里找到对应。
第一步:造一个"受害块"。终端 1:
CONNECT scott/tigerDROP TABLE ts;CREATE TABLE ts (a number, b number);CONNECT scott/tiger -- 重连一次,把会话统计清零INSERT INTO ts VALUES (1,1);SELECT 1 FROM dual; -- 确认已执行
第二步:记录 commit 前的 cleanout 统计。终端 2(sysdba):
SELECT sid FROM v$session WHERE username = 'SCOTT'; SELECT sn.name, ss.valueFROM v$sesstat ss, v$statname snWHERE ss.sid = &SIDAND sn.name LIKE '%cleanout%'AND ss.statistic # = sn.statistic#;
第三步:把块冲出 buffer cache,断 fast cleanout 的后路:
ALTER SYSTEM FLUSH BUFFER_CACHE;
第四步:回终端 1 执行COMMIT;,然后回终端 2 再查一次统计。
你会看到 commit cleanouts 加了一,但 commit cleanout failures: block lost 也跟着加了一------块不在了,fast cleanout 想做也做不了。这就是教科书级的 deferred cleanout 伏笔。
第五步:看现场。 先 dump 数据块(file# 和 block# 可以从 dba_extents 或上一篇介绍的方法拿到):
ALTER SYSTEM DUMP DATAFILE BLOCK ;
用 oradebug setmypid + oradebug tracefile_name 找到 trace 文件,看 ITL------flag 位还是空的,Xid 还在,行锁还在。再顺着 Xid 的第一段去查 undo 段头的位置:
SELECT file#, block # FROM undo$ WHERE us# = ;
dump 这个 undo 段头块,你会看到事务表里那个槽位已经标成 committed,commit SCN 白纸黑字写着。"事务提交了"这个事实只在 undo 段头里,数据块还蒙在鼓里------这就是 flush 之后的世界。
第六步:让下一个读者来打扫。换个会话对 TS 做一次 update 或 insert,触发 CUR 模式访问,然后重新 dump 数据块:Xid 被清了,ITL 打上了标志,cleanout SCN 出现在块头里。再回头看 redo dump,能找到 layer 4 opcode 1 的记录。undo 段头 dump 里的 txn control 部分引用的 UBA,也可以顺藤摸瓜看看事务表自己的 undo------就是 Case 2 里"回滚事务表"用的那串链。
走完这一遍,fast/deferred 的分工、统计项的含义、ITL 标志的变化,就都不是纸面概念了。
总结
把整件事串起来:
-
commit 的"正事"只有一件------改 undo 段头事务表,写 commit SCN;
-
数据块的清理分两手:在内存里、记得住、pin 得到的块,commit 时顺手做 fast cleanout,no-logging、只标 upperbound、不碰行锁;
-
其余的全部 deferred,等下一个 CUR 模式的访问者来按 Xid 查案:undo 段没了用近似值,槽位复用了对事务表做一致性读,实在不行还有 lowtime 兜底;
-
整个过程留了充足的观测窗口:一组
commit cleanout%统计项、layer 4 opcode 1 的 redo、还有 event 10203。
回到开头那个问题:commit 之后事务信息为什么还在块里?现在答案很清楚------那不是 bug,是 Oracle 精打细算后的留白。它用最小的代价保证了"提交"这个事实的持久性,把昂贵的清扫工作摊销给了时间。
但这个"留白"也引出了下一个问题:fast cleanout 留下的那个upperbound SCN,说"事务在这个点之前提交了",却不说具体哪个点------那当一致性读要重建某个历史版本时,到底凭什么判断"这行数据我该看哪个版本"?近似 SCN、ITL 标志、undo 段头里的精确记录,它们是怎么配合着把"过去的某一瞬间"精确还原出来的?
这,就是下一篇要聊的**一致性读(Consistent Read)**了。咱们到时候接着拆。
今天话题就聊到这,欢迎留言交流。觉得内容有用,别忘了点赞转发给有需要的朋友,回见!