前言
干了这么多年 DBA,我发现一个挺有意思的现象:绝大多数人对 Oracle 事务的理解,都停留在 insert / update / delete 加 commit / rollback 这个层面。顶多再知道个 savepoint,能说出"DDL 会自动提交"就已经算不错的了。
但如果你真的去 dump 过块、看过 undo 链、追过递归 SQL,你会发现:用户层面那条简简单单的 CREATE TABLE,在内核眼里根本不是"一条语句",而是一场涉及数据字典、空间管理、段头格式化的"多兵种联合作战"。而所谓的"自动提交",背后藏着一整套递归事务、控制文件事务、自治事务的机制。
这篇文章我就从一个最平常的动作聊起——你在 SQL*Plus 里敲下 CREATE TABLE t (id NUMBER);,按下回车。光标闪了那么零点几秒,返回了 Table created.。
这零点几秒里,内核到底在忙什么?
一、CREATE TABLE 不是一条语句,是一堆 DML
先从最重要的认知颠覆开始:DDL 在用户层面是单条语句,在内核层面是一堆递归 DML。
你以为你执行的是一条 CREATE TABLE,实际上 Oracle 替你悄悄地往数据字典基表里插了一大堆行。具体都干了啥?我给你列个清单:
insert/update OBJ$:对象登记表,你的表在这里有了个户口(obj#)
delete from FET$ / insert into UET$:字典管理表空间下,空闲区(Free Extent)转成已用区(Used Extent)
insert into SEG$:段信息登记
update TSQ$:表空间配额扣减
insert into TAB$:表定义
insert into COL$:列定义,一列一行
维护
I_OBJ1、I_OBJ2等字典索引在目标数据文件里格式化段头块(segment header)
注意最后一条——这不只是写字典,还涉及物理块的格式化。这也是为什么 CREATE TABLE 会产生 redo:所有这些变化都要记 redo log,段头块格式化的 redo opcode 是13.1(Layer 13 是块格式化层)。11.2 之后有了 deferred segment creation(延迟段创建),空表不建段,这个动作会推迟到第一行数据插入时才发生——但那是后话。
这些对字典基表的 insert/update/delete,Oracle 内部叫递归 SQL(recursive SQL)。名字挺形象:你的 SQL 触发了 Oracle 自己的 SQL,一层套一层。
二、隐式提交:DDL 最坑人的特性
接着说一个坑,很多老手都踩过。
DDL 有一个特性:执行成功会自动提交。但这句话只说了一半。完整的真相是——DDL 开始执行之前,会先把你当前会话里所有未提交的 DML 隐式提交掉。
也就是说:
UPDATE emp SET sal = sal * 1.1 WHERE deptno = 10; -- 没提交CREATE TABLE tmp_t (id NUMBER); -- 这一敲,UPDATE 被提交了
你本来以为那个 UPDATE 还能 ROLLBACK 反悔,结果 CREATE TABLE 一执行,反悔的机会没了。DDL 前后的两个隐式提交(执行前提交一次,成功后再提交一次)是 Oracle 的经典行为,目的之一是让字典修改和你的事务彻底解耦。
所以我的习惯是:DDL 永远单独开一个会话执行,别和业务 DML 混在一起。这不是洁癖,是血泪教训。
三、DDL 失败了,会全部回滚吗?不会
这里有个反直觉的点,值得好好说说。
既然 DDL 内部是一堆递归 DML,那它中途失败了(比如磁盘满、配额不够),是不是把所有递归操作全回滚?
不是。Oracle 只回滚"让数据库恢复一致所必需的部分",刻意不回滚空间管理操作。
最经典的例子:你执行一个大 insert,过程中表空间给你分配了新的 extent,然后语句失败了。回滚这个语句时,extent 的分配不会被回滚——新分配的 extent 就留在那里了。
为什么?因为失败的语句经常会被重试。如果每次失败都把 extent 还回去,下次重试又得重新分配,空间管理事务来来回回折腾,纯属浪费。Oracle 的设计哲学是:空间管理事务次数降到最少,一致性恢复只做到"够用"就行。
这个设计思路其实贯穿了 Oracle 内核很多地方:不是所有东西都要"干净利落",性能优先的前提下,够用就好。
四、递归事务与 SYSTEM undo 段的"特权"
下面聊一个大部分人不知道的机制:递归事务(Recursive-Level Transaction)。
递归 SQL 执行时也是要改数据的(改字典基表),改数据就要有事务保护,就可能失败、可能崩溃。所以 Oracle 为递归操作和空间管理事务生成 undo 和 redo——即使它们最终会被隐式提交,undo 也不能少,这是崩溃恢复的底线。
递归事务有几个特点,我觉得挺优雅:
第一,递归事务总是和它的顶层事务使用同一个 undo 段。不另起炉灶,undo 都记在一个地方,恢复的时候顺着一条链走就行。
第二,递归事务可以使用 SYSTEM undo 段——这是特权。
大家知道,数据字典基表(OBJ$、TAB$、COL$ 这些)都放在 SYSTEM 表空间里。Oracle 有一条铁律:SYSTEM 回滚段只给 SYSTEM 表空间上的事务用。普通用户事务永远轮不到 SYSTEM undo 段——为什么?因为 Oracle 要保证 SYSTEM undo 段里始终有槽位留给递归事务。DDL 随时可能来,字典随时可能要改,SYSTEM undo 段的槽位是给它们预留的,不能让用户事务挤占了。
这个设计在 RBU(手工回滚段)和 AUM(自动 undo 管理)模式下都成立。你可以理解为:SYSTEM undo 段是急诊通道,平时空着也不许私家车走,因为救护车随时要来。
第三,递归事务的提交是立即生效的。
经典例子是 sequence。对一个没 cache 的 sequence 取 nextval:
SELECT seq_t.nextval FROM dual;这个取值动作是一个递归事务,它立即提交——不管你外层事务最后是 commit 还是 rollback,sequence 的值已经涨上去了。所以 sequence 有缺口(gap)是正常的,第二个会话永远能拿到递增后的值。想让 sequence 连续无缺口?别想了,那是拿并发性能换来的,Oracle 不干这事。
顺带一提,Resource Manager 的 UNDO_POOL 指令下,递归事务消耗的 undo 是计入顶层事务的,顶层事务结束前额度不还回消费组。细节虽小,做资源管控排障时可能会遇到。
五、ORA-604:递归事务的报错哲学
递归事务失败时,Oracle 报的错误是:
ORA-00604: error occurred at recursive SQL level 1这句话翻译过来就是:"你那条 SQL 本身没问题,但它触发的内部 SQL 挂了。"
ORA-604 最让人头疼的地方是:它常常把真正的错误藏起来。你只能看到一个"递归层级"的数字,不知道底下到底发生了什么。多数时候 ORA-604 后面会跟着真正的错误(比如 ORA-01653 表空间无法扩展),但偶尔就是没有,只给你一行干巴巴的 604。
这时候别慌,抓 errorstack:
ALTER SESSION SET events = '604 trace name errorstack';然后再执行一遍出问题的操作,到 trace 文件里看完整的错误栈,真错误就藏在里面。
我排障这些年,ORA-604 见得多了。经验就一条:永远别只盯着 604 本身,它只是个报信的,真凶在栈里。
六、控制文件事务:双缓冲的艺术
说完了字典,再看看另一个特殊的"事务"——控制文件事务。
加数据文件、改文件名、检查点更新、表空间进热备模式、介质恢复……这些操作都要修改控制文件。但控制文件不是普通数据块,它不在 buffer cache 里走常规的 redo/undo 流程,它有自己的玩法:双镜像翻转。
控制文件内部维护着两份镜像(image)。进程要修改控制文件时,流程是:
在 CF Enqueue 的保护下,修改非活跃的那份镜像(shadow block)
改完后,翻转一个 bit,切换活跃/非活跃的角色
释放 enqueue
熟不熟悉?这就是**双缓冲(double buffering)**的思路——图形学里防画面撕裂用的就是这招。改的是影子,翻的是开关,读者永远看到的是完整的一致版本。
redo 层面也有对应的设计:redo 第六层(Layer 6)的 opcode 保留给控制文件相关的 undo 操作。注意措辞:控制文件事务本身不产生 redo,但表空间/数据文件这类操作需要 undo 记录(防止中途失败),这些 undo 记录修改的是回滚段的块,而对 undo 块的修改会以 opcode5.1记入 redo。
举几个具体的 opcode,体会一下这套设计的对称美:
操作 | undo opcode | 含义(失败时怎么补救) |
|---|---|---|
创建表空间 | 6.7 | remove tablespace(把刚建的表空间抹掉) |
创建表空间 | 6.1 | remove data file(把刚加的文件抹掉) |
offline 表空间 | 6.4 | online(失败了就改回 online) |
drop 表空间 | 6.2 | add data file(失败就把文件加回来) |
drop 表空间 | 6.8 | add tablespace(失败就把表空间加回来) |
看出来了吗?每条 undo 记录都是正操作的逆操作。创建表空间要记两条 undo(6.7 + 6.1),drop 也要记两条(6.2 + 6.8)——因为这些操作涉及"表空间"和"数据文件"两个实体,回滚得分别撤销。这种一一对应的设计,让崩溃恢复的逻辑变得极其清晰。
七、Savepoint:不是"存盘",是一个地址
接下来聊几个大家或多或少听过、但未必理解本质的"特殊事务家族"。先说 savepoint。
SAVEPOINT s1; 这条命令干了什么?很多人望文生义,以为 Oracle 把当前状态"存了一份"。不是的——savepoint 没有任何物理落盘动作,它只是内存里的一个关联记录:savepoint 序列号 + UBA(Undo Block Address)。
UBA 是什么?undo 块地址。说白了,savepoint 就是在 undo 链上插了一面小旗子:"记住这个位置。"内存里有个 ktcsp 结构维护 savepoint 表和对应的 UBA。
当你执行 ROLLBACK TO s1 时,Oracle 做的事是:从事务表里的 undo 链尾端开始,逐条应用 undo 记录,直到目标 UBA 为止。回滚到那面小旗子的位置,停。
由此带出 savepoint 的几条行为规则,其实都是这个实现的必然结果:
只回滚 savepoint 之后的语句(undo 链回滚到 UBA 为止)
指定的 savepoint 保留,它之后建立的 savepoint 全部丢失(旗子在链上,链回滚了,后面的旗子自然没了)
释放 savepoint 之后获得的表锁/行锁,之前的保留
事务保持活跃,可以继续
最后一条值得展开——这里有个著名的"savepoint 不公平性"现象,做个实验就能看到:
-- 会话 T1UPDATE t SET val = 1 WHERE id = 1; -- 改 row1SAVEPOINT s1;UPDATE t SET val = 2 WHERE id = 2; -- 改 row2-- 会话 T2UPDATE t SET val = 99 WHERE id = 2; -- 等待(row2 被 T1 锁着)-- 回到 T1ROLLBACK TO s1; -- 回滚 row2 的修改
按上面的规则,rollback to s1 会释放 savepoint 之后获得的锁——row2 的行锁应该释放了。但你会发现T2 还在等!锁没释放干净?
更诡异的还在后面:这时开个会话 T3 来 update row2,T3 能成功,而先来的 T2 反而继续等着。
这就是 savepoint 锁释放机制的历史遗留行为——rollback to savepoint 在锁释放的排队处理上并不严格公平,后来的事务可能插队。知道这个现象的存在,将来遇到"明明回滚了锁怎么还不放"的场景,你就不会怀疑人生了。
八、自治事务:栈里的独立王国
savepoint 有个天然缺陷:没有 COMMIT TO SAVEPOINT——你不能只提交事务的一部分。但现实中确实有这种需求,最典型的就是错误日志表:无论主事务成功还是失败,错误日志都必须留下来。
Oracle 给出的答案是自治事务(Autonomous Transaction)。
思路很直白:临时把当前事务挂起,开一个完全独立的子事务,子事务 commit/rollback 后,父事务接着走,两边互不影响。
CREATE OR REPLACE PROCEDURE log_error (p_msg VARCHAR2) ASPRAGMA AUTONOMOUS_TRANSACTION;BEGININSERT INTO error_log (msg, ts) VALUES (p_msg, SYSDATE);COMMIT; -- 必须显式提交END;/
几个关键规则:
块结束返回前必须显式
COMMIT或ROLLBACK,否则报 ORA-06519自治事务提交后立即对所有会话可见
自治事务看不到父事务未提交的修改(它是个独立事务,读一致基准不同)
父事务回滚,不影响已提交的自治事务
Oracle 内部把事务组织成一个栈结构:任何时刻只有栈顶事务可访问,挂起的事务压在下面。挂起事务的数量上限由 TRANSACTIONS 参数控制。前面说的递归事务,用的也是类似机制——你看,内核的设计语言是统一的。
ATM 取钱是个经典的教学例子:无论你取款成功还是失败(余额不足),用卡记录(usage 表)都必须提交——银行可不能因为交易失败就丢掉"你用过这张卡"这个事实。这种"无论成败都要留痕"的需求,就是自治事务的主场。
最后提醒一个坑:自治事务和父事务可能互相死锁——父事务持有某行的锁,自治事务去改同一行,就会等自己的"爹",而爹在等儿子返回。Oracle 不预防这种死锁,只会报个专门的错误。所以用自治事务的函数里,千万别碰父事务可能修改的表,这是开发者自己的责任。
九、串行化事务:ORA-8177 与 ITL 槽的故事
再往上一个隔离级别:Serializable(串行化)。
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;-- 或者ALTER SESSION SET ISOLATION_LEVEL = SERIALIZABLE;
效果等同于所有事务一个接一个串行执行:你只能看到自己事务的修改 + 事务开始前已提交的数据。不可重复读、幻读,统统防住。(初始化参数 SERIALIZABLE 早已废弃,别去翻旧文档了。)
代价呢?除了并发度下降,最出名的就是ORA-08177: can't serialize access for this transaction。
8177 的根源要挖到块结构里。串行化事务构建读一致视图时,依赖块里可用的 ITL(Interested Transaction List)槽。ITL 数量由 INITRANS/MAXTRANS/PCTFREE 决定:块内空间够(PCTFREE 预留的那部分)时 ITL 可以扩展,也会复用。索引块还要额外考虑串行化期间可能发生的块分裂。
实战经验就三条:
如果串行化事务生命周期内可能有 N 个独立事务更新同一个块,就给它分配 N+1 个 ITL(建表时把 INITRANS 调大)
多个事务可能改同一行,先
SELECT ... FOR UPDATE把行锁住ORA-8177 要在应用设计阶段就考虑怎么处理(重试逻辑),别等上线了被它打个措手不及
十、PDML:并行事务的两阶段提交
最后一个家族成员:并行 DML(PDML)。
UPDATE /*+ parallel(sales,4) */ sales SET amount = amount * 1.1;一条 PDML 实际是"一个协调者 + 一堆 slave 事务"。内部用**两阶段提交(2PC)**协议保证原子性——但注意,失败的 PDML不会在 dba_2pc_pending 里留记录,也不需要 distributed option,这个 2PC 是实例内部用的,跟分布式数据库没关系。
PDML 的行为规则有点特殊,容易踩坑:
PDML 之后必须显式 commit/rollback,才能发后续 DML
如果 PDML 后面紧跟 DDL,DDL 会隐式强制提交这个 PDML。比如:
UPDATE /*+ parallel(sales,4) */ sales SET amount = amount * 1.1;ALTER TABLE sales DROP PARTITION p2023; -- 这个 drop 会强制提交上面的 update
PDML 里不允许
SET TRANSACTION
还有个性能细节:并行 slave 事务如果挤在同一个 undo 段,会争用 undo 段头。为减少争用,slave 事务应该分散到尽可能多的回滚段上——AUM 模式下 Oracle 通常自己就分好了,手工 RBU 时代这是 DBA 要操心的事。
想观察并行事务的父子关系?查 v$transaction,里面有 PTX 标识列,ptx_xidusn / ptx_xidslt / ptx_xidsqn 三列指向父事务的 XID,一目了然。
十一、动手环节:亲眼看看内核在干什么
纸上得来终觉浅。上面说的这些东西,全都可以亲手验证。我给你三个实验。
实验 1:SQL_TRACE 看 CREATE TABLE 的递归 SQL
ALTER SESSION SET sql_trace = TRUE;CREATE TABLE trace_t (id NUMBER, name VARCHAR2(20));ALTER SESSION SET sql_trace = FALSE;
找到 trace 文件(select value from v$diag_info where name='Default Trace File'),跑 tkprof:
tkprof orcl_ora_12345.trc out.prf sys=no打开 out.prf,你能清清楚楚看到对 OBJ$、TAB$、COL$、SEG$ 的递归 insert/update——一条 CREATE TABLE 背后站着多少条 SQL,数字会震撼你。
实验 2:dump 块,看段头和 ITL
-- 查表在哪个文件哪个块(段头)SELECT header_file, header_block FROM dba_segmentsWHERE segment_name = 'TRACE_T' AND owner = '你的用户名';-- dump 段头块ALTER SYSTEM DUMP DATAFILE BLOCK ;
到 trace 文件里找,你能看到 ITL 条目、Xid(格式是 undo段号.槽位.序列号)、Uba 字段——这些就是前面讲的理论的"肉身"。
实验 3:追踪 undo 链
拿到 Xid 的第一段(undo 段号)后:
SELECT file#, block# FROM undo$ WHERE us# = ;
定位到 undo 段头块,再 dump 段头和 UBA 指向的 undo 块,顺着链条一路看下去——一条 DDL 的 undo 足迹就完整地呈现在你眼前了。
这三个实验做下来,"DDL 是一堆递归 DML + 空间管理事务 + 块格式化"这句话,对你来说就不再是概念,而是你亲眼见过的东西。
总结
回到开头那个问题:CREATE TABLE 按下回车后,内核在忙什么?
现在你可以回答了:它在开隐式提交,在往 OBJ$/TAB$/COL$/SEG$ 里插行,在做 FET$/UET$ 的空间流转,在用 SYSTEM undo 段的专属槽位记录递归事务的 undo,在以 opcode 13.1 把段头格式化写进 redo,在控制文件里翻转双镜像。
而这一切的设计语言是统一的:undo 记录逆操作、递归事务复用顶层 undo 段、SYSTEM undo 段预留特权槽位、空间管理事务能省则省。
Oracle 内核没有魔法,只有无数个这样精心设计、互相咬合的小机制。DBA 这行干得越久,我越觉得:所谓功力,就是当别人只看到 Table created. 的时候,你脑子里能自动放映出这一整串画面。
下次敲 CREATE TABLE 的时候,不妨停一秒——你知道内核正在为你忙什么了。
今天话题就聊到这,欢迎留言交流。觉得内容有用,别忘了点赞转发给有需要的朋友,回见!