1. “先查后改”是我最想让你养成的习惯,尤其刚接手线上表
1.1 任何 UPDATE/DELETE 先做 SELECT 核对,这比敲对语法重要
我在处理 Oracle11g 生产故障时发现,80% 以上的数据事故不是语法写错,而是“影响范围没看清楚就执行了”。开发者嘴上说着“我就改一条测试数据”,实际一条 UPDATE 下去,把全公司的订单状态、客户等级、库存余量全部覆盖。
所以第一个要养成的习惯,不是背 UPDATE 语法,而是先无条件地做一次 SELECT 核对。
sql复制-- 你准备写的 UPDATE
UPDATE orders
SET status = 'CLOSED'
WHERE create_date < DATE '2023-01-01'
AND status = 'ACTIVE';
-- 执行之前,先用同样 WHERE 查一遍
SELECT COUNT(*)
FROM orders
WHERE create_date < DATE '2023-01-01'
AND status = 'ACTIVE';
如果这个 COUNT 是 0,那这句 UPDATE 根本没有意义;如果 COUNT 是 50 万,那你就要掂量一下,这个操作落在业务低谷时段是否可行。更严谨的做法是先把要影响的订单号捞出来看一眼:
sql复制SELECT order_id, order_no, customer_id, status, create_date
FROM orders
WHERE create_date < DATE '2023-01-01'
AND status = 'ACTIVE'
AND ROWNUM <= 20
ORDER BY create_date;
很多工具提供事务回滚能力,但实际使用中,很多人执行完 UPDATE 以后,界面上并没有立刻 COMMIT,而是忙着看数据变化,然后不小心把会话关掉了。此时 Oracle 默认会回滚未提交事务,倒不一定是灾难。真正的麻烦是:你手一快按了提交,再想返回就难了。
我自己的节奏通常是:查询确认 → 备份目标数据 → 开启显式事务或保存点 → 执行 → 再次统计受影响行 → 提交或回滚。这条链路走顺以后,你在任何环境里改数据都不容易翻车。
1.2 事发前备份和 Flashback:CTAS + Savepoint
Oracle11g 里最方便的备份,不是什么导出工具,而是一句 CTAS:
sql复制CREATE TABLE orders_bak_20250128 AS
SELECT * FROM orders;
这张备份表能让你在踩坑后把整张表的数据还原回去。缺点是它会复制全表数据,如果 orders 表几个亿行,时间和空间都受不住。更常采用的是“只备份将被影响的行”:
sql复制CREATE TABLE orders_upd_20250128 AS
SELECT * FROM orders
WHERE create_date < DATE '2023-01-01'
AND status = 'ACTIVE';
这样备份文件小、查询快、恢复也直接。如果要恢复,就用备份表回写:
sql复制UPDATE orders o
SET (o.status, o.total_amount) =
(SELECT b.status, b.total_amount
FROM orders_upd_20250128 b
WHERE b.order_id = o.order_id)
WHERE o.order_id IN (SELECT order_id FROM orders_upd_20250128);
如果是手工在做短事务,也可以用 Savepoint 控制更灵活。比如我只想回滚 UPDATE,不破坏整个会话之前的状态:
sql复制SAVEPOINT sp_before_update;
UPDATE orders
SET status = 'CLOSED'
WHERE create_date < DATE '2023-01-01'
AND status = 'ACTIVE';
-- 发现不对劲
ROLLBACK TO sp_before_update;
Oracle11g 还有一个特色保障是 Flashback Query。提交以后还能查看某个时间点之前的数据,前提是 UNDO 数据还在保留期内。
sql复制-- 看一下 30 分钟前这张表里是什么样
SELECT order_id, status
FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '30' MINUTE)
WHERE order_id IN (...);
即使没有提前做 CTAS,只要 UNDO 保留时间够,你也能把误删或误改的数据捞回来。
sql复制INSERT INTO orders
SELECT * FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '30' MINUTE)
WHERE order_id IN (...);
这里有个容易忽略的问题:如果业务长时间不提交大事务,UDO 表空间被撑爆,或者你修改完过了很久才想起要闪回,Flashback 就无能为力了。所以 CTAS 永远是优先保障,Flashback 只是最后一根稻草。事务能早提交就早提交,别把 UPDATE 拖成一场持久战。
1.3 WHERE 里最常见的坑
这一节单独拿出来讲,因为很多 UPDATE/DELETE 事故都不是语法问题,而是 WHERE 条件和你的直觉不一致。
第一类是 NULL。Oracle 里的 NULL 不等于空字符串,也不等于 0。你想把没有填写手机号的客户都更新为“待补录”,可能会写成:
sql复制UPDATE customers
SET status = 'PENDING_PHONE'
WHERE phone = NULL;
这条语句永远影响 0 行。NULL 参与等值比较时结果不是 FALSE,而是“未知”,WHERE 只保留 TRUE。正确写法是:
sql复制WHERE phone IS NULL
删除过期数据时也容易踩:比如删掉备注为空的订单,直接写 WHERE remark = NULL,看起来没效果,实际一条都没删,业务上的脏数据继续留在表里,后面每次统计都会出偏差。
第二类是隐式转换。Oracle11g 里如果你拿 VARCHAR2 列和数字直接比较,优化器通常会做隐式转换。比如 order_no 列是字符串,你写:
sql复制WHERE order_no = 123456
优化器可能会把列转成数字去比较,导致 order_no 的普通 B-tree 索引失效,触发全表扫描。一次全扫在千万级大表上可能是几十秒的事,如果还叠加了其他条件,SQL 就慢慢变成定时炸弹。宁可多写一对引号:
sql复制WHERE order_no = '123456'
第三类是 AND 和 OR 的优先级。如果我想把“已关闭”和“已冻结”两类订单都删掉,并且限定 create_date 范围,新手很容易写成:
sql复制DELETE FROM orders
WHERE status = 'CLOSED'
OR status = 'FROZEN'
AND create_date < DATE '2023-01-01';
但 AND 优先级高于 OR,实际删除的是 status 为 CLOSED 的所有订单,再加 status 为 FROZEN 且日期老的订单。这基本就是事故现场。要给每个 OR 分支都加括号:
sql复制DELETE FROM orders
WHERE (status = 'CLOSED' OR status = 'FROZEN')
AND create_date < DATE '2023-01-01';
无论你多大牌,建议每次执行前把 WHERE 子句单独 SELECT 一遍,或者至少在心里把优先级过一遍。这个习惯救过我很多次。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Oracle 里没有 UPDATE JOIN,跨表更新得按 Oracle 的规矩来
2.1 UPDATE t1 JOIN t2 的思维要转过来
如果你是从 MySQL、SQL Server 转过来写 Oracle,多半会想当然地写出类似下面的语句:
sql复制UPDATE orders o
JOIN customers c ON c.customer_id = o.customer_id
SET o.status = c.default_status
WHERE o.create_date >= SYSDATE - 7;
Oracle11g 会直接报错。Oracle 的 UPDATE 不支持这种多表 JOIN 语法,它更接近“UPDATE 单表 + 子查询或 MERGE”的模式。这不是 Oracle 落后,而是它要求你更明确地说清楚两件事:你要改哪张表?你用来匹配的条件是什么?
我记得见过最夸张的一次迁移事故,是从 MySQL 导数据到 Oracle 后,程序里一百多句类似的 UPDATE JOIN 全部变成报错。即使最后有人写子查询糊弄过去,性能也比原来差了一大截。所以在 Oracle 里做跨表更新前,先打破“套 JOIN 就行”的惯性。
2.2 用子查询精确控制“更新什么列”
最常见的跨表更新写法是“相关子查询”。比如希望把 order_items 的金额汇总回写到 orders 表:
sql复制UPDATE orders o
SET o.total_amount =
(SELECT NVL(SUM(oi.amount), 0)
FROM order_items oi
WHERE oi.order_id = o.order_id)
WHERE o.create_date >= DATE '2025-01-01'
AND o.status = 'ACTIVE';
这个语法的执行逻辑是:外层语句扫描符合条件的 orders,对每一行执行一次子查询,用子查询结果更新 total_amount。这里有两个先决条件:
- 子查询对每一行必须返回单行单值;
- 如果没有任何 order_items 匹配,子查询返回 NULL,那么 NVL 会把 0 写进去。
如果你把 NVL 去掉,子查询找不到记录时,total_amount 会被更新成 NULL。这才是最危险的地方。很多开发者以为子查询没数据就不更新,但 Oracle 的 UPDATE 不是这样语义。要让“查不到就不更新”,必须把匹配条件放在 EXISTS 里:
sql复制UPDATE orders o
SET o.total_amount =
(SELECT SUM(oi.amount)
FROM order_items oi
WHERE oi.order_id = o.order_id)
WHERE o.status = 'ACTIVE'
AND EXISTS (SELECT 1
FROM order_items oi
WHERE oi.order_id = o.order_id);
还可以用“多列子查询”一次更新多列:
sql复制UPDATE orders o
SET (o.total_amount, o.item_count) =
(SELECT NVL(SUM(oi.amount), 0),
COUNT(oi.line_id)
FROM order_items oi
WHERE oi.order_id = o.order_id)
WHERE o.order_id IN (SELECT order_id
FROM order_items
GROUP BY order_id
HAVING COUNT(*) > 0);
这类 SQL 对子查询的性能要求很高。如果 order_id 不是主键或唯一索引,子查询每执行一次都全表扫,性能就会崩盘。所以跨表更新前先确认连接列有索引,这比优化外层 WHERE 更见效。
2.3 MERGE:适合能按主键/唯一键对齐的更新
MERGE 在 Oracle 里常被叫成“合并更新”,它最适合的场景是:源表和目标表之间有明确的主键或唯一键对应关系。比如外部系统每天传来客户最新状态,你想把变化更新进主表:
sql复制MERGE INTO customers c
USING customer_stage s
ON (c.customer_id = s.customer_id)
WHEN MATCHED THEN UPDATE
SET c.level = s.level,
c.update_time = SYSDATE
WHEN NOT MATCHED THEN INSERT
(customer_id, level, update_time)
VALUES
(s.customer_id, s.level, SYSDATE);
在 ON 条件命中的行中,MERGE 比“UPDATE + 子查询”多了一层优势:它一次扫描源表,能同时处理 UPDATE 和 INSERT,开发上简单很多。在处理大批量数据时,如果源表已经按 customer_id 排序,且目标表有唯一索引,MERGE 的运行效率通常优于逐行相关子查询。
但要注意一个经典报错:ORA-30926“无法在源表中获得一组稳定的行”。意思是源表 customer_stage 里同一个 customer_id 重复出现了多行,MERGE 不知道该拿哪一行去更新。解决思路是在 USING 里就先做好去重:
sql复制USING (
SELECT customer_id,
MAX(level) KEEP (DENSE_RANK LAST ORDER BY update_time) AS level
FROM customer_stage
GROUP BY customer_id
) s
ON (c.customer_id = s.customer_id)
...
另外还要提醒一句,MERGE 的 ON 条件只负责确定“匹配不匹配”,ON 里引用的列不能作为被更新的目标列。如果你想更新 customer_id 本身,Oracle 会报错 ORA-38104,因为更新连接键等于把源和目标的关系改掉,语句本身就有逻辑漏洞。
2.4 顺便说一句:可更新连接视图也能用,但有前提
Oracle 确实存在“可更新连接视图”这种语法,比如:
sql复制UPDATE (SELECT o.id old_id,
o.customer_no old_customer_no,
c.customer_no new_customer_no
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id) v
SET v.old_customer_no = v.new_customer_no;
它会通过视图把两张表关联起来,然后直接改 orders 表。但这里的要求非常苛刻:视图必须能唯一标识每条目标表的行,连接视图必须保留目标表的主键列。换句话说,底层表要能通过该视图做可更新操作,连接字段不能破坏键保存属性。加上 Oracle11g 的优化器对这种写法容易生成低效执行计划,我不建议在核心业务里把它当常规手段。与其和“多表更新魔改版”搏斗,不如清晰使用子查询或 MERGE。
3. DELETE 大表是一场“事务工程”:回滚段、锁和空间回收都得算
3.1 一条 DELETE 删几百万行,数据库内部在做什么
很多刚接触 Oracle 的人以为 DELETE 就是把数据标记删除,很快。实际上 DELETE 是 DML,它会对每一行做“删除前先把旧值写入 UNDO”的操作。删除 500 万行,就会有 500 万条 UNDO 记录产生。如果业务并发还很活跃,UNDO 表空间膨胀几乎是必然的。
同时,DELETE 属于事务,在事务提交之前,它持有的行锁一直不释放。如果这条 DELETE 跑了二十分钟,那么二十分钟内这张表上对同一批数据的 UPDATE、DELETE 都会被阻塞。就算别人只 SELECT,Oracle 的读一致性也会让 SELECT 去 UNDO 里找到旧版本数据,UNDO 读取一旦超过保留期,就可能报 ORA-01555“快照过旧”。
我处理过一个典型的“删数据删挂数据库”案例。业务方要在订单表里清理两年以前的停用订单,直接执行一句 DELETE,跑了四十多分钟没提交。期间另一张关联表做批量更新,全部都卡在那里。最后我不得不等事务完成,或者找业务确认后杀掉会话。但杀会话也不是瞬间结束,Oracle 需要回滚这几十万行已经执行的修改,这个过程本身又可能持续十几分钟。生产环境里,这就是业务故障级别的事故。
所以 DELETE 大批量数据前,先算一笔账:数据量多大?有没有索引支撑 WHERE 条件?会占用多少 UNDO?事务大概持续多久?如果超过一两分钟,那就要考虑分批。
3.2 分批提交是目前最实用的处理方式
分批 DELETE 的核心目标是缩短单次事务长度,避免一条 DELETE 占用过多 UNDO 和行锁。Oracle11g 下最通用的写法是 PL/SQL 循环:
sql复制DECLARE
v_cnt NUMBER := 0;
BEGIN
LOOP
DELETE FROM orders
WHERE status = 'CLOSED'
AND create_date < DATE '2022-01-01'
AND ROWNUM <= 10000;
v_cnt := SQL%ROWCOUNT;
COMMIT;
EXIT WHEN v_cnt < 10000;
END LOOP;
END;
这里每批删 1 万行,提交一次。SQL%ROWCOUNT 会告诉本次循环删了几行,当不足 1 万时说明已经没有符合条件的剩余数据,循环退出。
为什么用 1 万行而不是一百万?因为每批的事务时间短,行锁只在很短时间内释放,UNDO 占用能控制在合理范围。1 万这个数字可以按表大小、删除条件的索引选择性灵活调整。如果删除条件命中主键范围,索引扫描很高效,每批可以适当放大;如果删除条件是全表扫描出来的,尽量减小批量值,避免每次循环重新扫表的开销不可控。
还有一点很重要:如果删除的数据不要求按某种顺序,上面的写法完全行得通。但如果业务要求只能删除“最老的一批数据”,避免某次批次执行把新数据先删掉,就要先排序再限制:
sql复制DECLARE
v_cnt NUMBER := 0;
BEGIN
LOOP
DELETE FROM orders
WHERE order_id IN
(SELECT order_id
FROM (SELECT order_id
FROM orders
WHERE status = 'CLOSED'
AND create_date < DATE '2022-01-01'
ORDER BY create_date)
WHERE ROWNUM <= 10000);
v_cnt := SQL%ROWCOUNT;
COMMIT;
EXIT WHEN v_cnt < 10000;
END LOOP;
END;
不过这种“套一层 ROWNUM”的写法在超大表上性能不一定理想,尤其是内层做了全表排序时。实际生产里我更喜欢先建一张“待删临时表”,把符合条件的主键捞出来,再按主键范围分批删除。
另外要注意,分批 DELETE 不等于没有风险。你在批次之间 COMMIT,如果有人恰好在这段时间查询,他会看到一部分数据已删、一部分没删的中间状态。对报表或下游同步来说,这种中间态可能产生脏读。所以更稳妥的方案是:把事务切成若干等工作单元,并通过一张“批次状态表”记录进度。万一脚本中途失败,下次启动可以从上次提交的节点继续。
3.3 删完整表用 TRUNCATE 还是 DELETE?千万别凭手感
DELETE 和 TRUNCATE 最大的区别不是“能不能带 WHERE”,而是它们在 Oracle11g 里的内部机制完全不同。
DELETE 会逐行删除并写入 UNDO,支持 ROLLBACK 回滚,也可以配合 Flashback Query 找到旧版本。TRUNCATE 是 DDL,它直接把表的段空间和高水位线重置,不做逐行级别的 UNDO 记录。所以 TRUNCATE 通常极快,但它不可回滚。即使你把它放在事务里,执行后也会隐式提交,前功尽弃。
如果你确认某张临时表的内容已经不需要,应该直接用:
sql复制TRUNCATE TABLE temp_report_data;
如果只想清一张表的全部数据,能接受操作不可回滚,那么 TRUNCATE 比 DELETE 快几个量级,同时还能把高水位线拉回初始状态,后续全表扫描不会再去扫那些早已空掉的块。
但 TRUNCATE 也有坑。它会使得外部键约束失效?准确说,如果一张表被其他表的外键引用,TRUNCATE 父表会报 ORA-02266“表中的唯一/主键被启用的外部键引用”。这时候你不能直接 TRUNCATE,必须考虑先处理子表或禁用约束。而 DELETE 父表则更谨慎,逐行检查子表记录。两者在约束场景下表现完全不同。
还有一点:TRUNCATE 之后,如果表上有 Fast Refresh 的物化视图或者基于 ROWID 的索引结构,会带来失效风险。Oracle 11g 里对普通堆表执行 TRUNCATE 不会使 B-tree 索引失效,但会对明细日志和物化视图刷新产生影响。动手前先查依赖。
3.4 删除之后空间还回来吗
这是 DELETE 被误会最深的地方。很多人发现 DELETE 了一张大表 90% 的数据,但表占用空间几乎没变。原因在于 DELETE 只是把数据块里的行标记为已删除,并把空间放到 freelist 上供后续 INSERT 复用,并不把数据块交还给表空间。高水位线仍然停留在原来的位置,全表扫描依然要把这些块读一遍。
如果你清理完后还要继续往表里写同样的量级数据,其实空间复用是好事,不用做额外处理。但如果你删除后表要长期变小,而你又希望这条表的查询性能恢复,就要考虑把高水位线降下来。
Oracle11g 下最方便的做法是先启用行迁移,再收缩空间:
sql复制ALTER TABLE orders ENABLE ROW MOVEMENT;
ALTER TABLE orders SHRINK SPACE CASCADE;
SHRINK 会重新组织表数据,把高水位线往下压,同时更新受影响行的 ROWID。线上大表执行前要评估锁竞争。它会在 shrink 期间移动大量数据,等价于一次大 DML,所以最好安排在业务低峰。
另一个常见手段是把表 MOVE 一下:
sql复制ALTER TABLE orders MOVE;
MOVE 会重建表段,释放空闲空间。但 MOVE 会让所有普通索引失效,之后必须重建索引,否则应用访问时会报“索引不可用”。11g 里很多人只 MOVE 不重建索引,然后被研发追着问为什么 SQL 突然变慢,其实就是这个原因。
4. UPDATE/DELETE 并发时卡住:从锁等待到快速定位阻塞源
4.1 两个会话抢同一行数据时发生了什么
Oracle 的默认读操作不需要锁,SELECT 不会阻塞 UPDATE,但写和写之间一定会互相阻塞。假设会话 A 执行了一条 UPDATE 但没有提交,会话 B 再对同一行执行 UPDATE,会话 B 会一直等待,直到会话 A 提交或回滚。
在一个正常的 OLTP 系统里,行锁等待不是 bug,但如果等待时间太长,就会形成“某张表卡住”的现象。更麻烦的是锁会被层层传递。会话 A 锁了订单 1001 没提交,会话 B 想更新订单 1001 但卡住,同时会话 B 之前已经锁了订单 1002,会话 C 想更新订单 1002……最终一串会话全部挂起,应用响应越来越慢。
这时候很多人会惯性认为“SQL 性能慢”,然后去优化那个 UPDATE 的 WHERE 条件,半天没效果。真正的原因在锁等待,而不是 SQL 本身。
4.2 v$session / v$locked_object 定位阻塞源
Oracle11g 提供了一张非常直观的视图 v$session,里面保存了当前会话的阻塞信息。先找到被阻塞的会话:
sql复制SELECT sid,
serial#,
sql_id,
event,
wait_class,
blocking_session
FROM v$session
WHERE blocking_session IS NOT NULL
AND username IS NOT NULL;
如果返回结果的 event 是“enq: TX - row lock contention”,你就可以确定它正在等待另一个事务释放行锁。blocking_session 字段给出了是谁堵住了它。
再通过 v$locked_object 结合 dba_objects,看看锁在了哪张表、什么模式:
sql复制SELECT s.sid,
s.serial#,
s.username,
do.object_name,
lo.locked_mode,
o.status
FROM v$locked_object lo
JOIN dba_objects do ON do.object_id = lo.object_id
JOIN v$session s ON s.sid = lo.session_id
WHERE do.object_name = 'ORDERS';
locked_mode 在不同数值下含义不同,常见的 3 表示行级排他锁,2 表示行级共享锁。多数 UPDATE/DELETE 会以 3 的模式存在。
定位之后,还要找到阻塞会话当前正在执行的 SQL:
sql复制SELECT sql_text
FROM v$sqltext
WHERE sql_id = '被阻塞会话的SQL_ID'
OR sql_id = '阻塞会话的SQL_ID'
ORDER BY piece;
如果确认阻塞会话已经变成僵尸会话,业务上也不会再继续跑,可以询问业务后杀掉。杀会话句法如下,SID 和 SERIAL# 可以从 v$session 查出:
sql复制ALTER SYSTEM KILL SESSION '123,45678';
如果会话仍在活动且不能杀,也可以换成 IMMEDIATE,但杀完之后事务回滚由 Oracle 后台进程接管。那种“杀完马上释放锁”的预期并不总成立。
4.3 长事务回滚比执行还慢的情况
一个 UPDATE 更新了几百万行还没提交,你决定杀掉会话止损,结果系统依然卡着。很多人不理解:语句不都取消了吗?怎么还卡?
因为 Oracle 要回滚已经执行的操作。已经锁过的每一行都得恢复原值,UNDO 数据要重新应用到数据块。更新的行数越多,回滚时间越长。你杀掉一个运行了 30 分钟的 UPDATE,回滚时间可能也接近 30 分钟。
所以我的习惯是在生产环境里尽量避免用一条超大 DML 去碰几百万行。真得改这么多数据,也要拆成批次,每批 COMMIT,中间观察系统等待事件。这不是为了偷懒,而是为了让“万一出错”的代价可控。运行中如果发现锁冲突多了,可以用 v$transaction 看当前事务的 UNDO 块使用情况:
sql复制SELECT s.sid,
t.used_ublk,
t.used_urec,
t.xid
FROM v$transaction t
JOIN v$session s ON s.taddr = t.addr
ORDER BY used_urec DESC;
如果 used_ublk 一直在增加,说明事务仍在写回滚;如果杀掉后这个数字在慢慢下降,说明回滚正在进行,这时候不要火上浇油去重启数据库。Oracle 在实例恢复和市场驱动恢复之前还有大量后台进程在工作,强行重启反而让数据库进入更长的恢复阶段。
5. 约束和触发器不会因为你是 UPDATE/DELETE 就网开一面
5.1 ORA-02292 父键被删除是最常见的尴尬
删除父表数据时,最经典报错就是 ORA-02292:违反完整性约束,子表还有记录引用。比如要清理一个已注销客户的主档数据:
sql复制DELETE FROM customers WHERE customer_id = 10086;
如果 orders 表里存在 customer_id=10086 的订单,Oracle 会拒绝删除并返回 ORA-02292。
很多新人的第一反应是“那我先禁用外键”。禁用外键后确实能删,但后续启用时 Oracle 会对整张子表做校验,如果残留了孤儿数据,ENABLE CONSTRAINT 会报 ORA-02298,表示无法验证约束。那时候你反而要在子表里补数据或删孤儿数据,事情越搞越大。
更安全的顺序永远是先处理子表,再删父表:
sql复制DELETE FROM orders WHERE customer_id = 10086;
DELETE FROM customers WHERE customer_id = 10086;
COMMIT;
如果业务版本采用“逻辑删除”,那就不要物理 DELETE。给 customers 表加一个 status 字段,更新成 DISABLED,从根源上避免外键连锁问题。
还有一点容易出现在性能排查里:如果子表的外键列没有索引,删除父表一行时会锁住子表并扫描子表。子表越大,这个删除越慢,而且并发场景下锁范围可能扩大。查看外键列是否缺少索引,可以快速判断。
sql复制SELECT table_name, constraint_name,
cname1 || NVL2(cname2, ',' || cname2, '') AS columns
FROM (SELECT ...); -- 典型写法较繁琐,生产里直接查 DBA_CONS_COLUMNS
日常维护中,我的原则是:外键列几乎一定需要索引。这不只是为查询,更是为了父表删除时能快速判断子表记录。
5.2 触发器引发的慢 DML 和逻辑重复
有些 UPDATE/DELETE 本身非常轻量,但执行起来却奇慢无比。可能是表上有触发器,每条被修改的行都会执行一段额外逻辑。假如你更新一万行,触发器也会被触发一万次,一次毫秒级操作就被放大成数秒。
我最常见到的问题,就是有人在 UPDATE 订单状态时,想顺便记录日志,就在触发器里写一条 INSERT:
sql复制CREATE OR REPLACE TRIGGER trg_orders_audit
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
INSERT INTO orders_audit(order_id, old_status, new_status, audit_time)
VALUES (:OLD.order_id, :OLD.status, :NEW.status, SYSDATE);
END;
单笔更新没问题,可一旦做批量 UPDATE,orders_audit 表会被插入海量记录,反过来拖慢整个更新。更隐蔽的是如果 orders_audit 上还有触发器或唯一约束,报错会回滚主表 UPDATE。
所以批量操作前,先检查这张表上到底挂了多少触发器:
sql复制SELECT trigger_name, trigger_type, status
FROM dba_triggers
WHERE table_name = 'ORDERS';
如果有自定义触发器且本次业务不希望触发,需要评估是否可以临时禁用,并在禁用期间通知应用方。禁用触发器属于 DDL,会隐式提交,所以要在所有相关 DML 都提交后再重新启用。
触发器还有一个坑叫“可变表(mutating table)”。你在 orders 表的行级触发器里去查或改 orders 表,Oracle 会报 ORA-04091,因为它不允许行级触发器查询正在被修改的表。这个限制不是 Oracle 故意刁难,而是语句级读一致性下,触发器里的 SELECT 不知道当前行集哪个版本是最终状态。正确做法是把行级逻辑改成语句级触发器,或者用集合变量收集 :NEW 值。
5.3 UPDATE 之后索引为什么变慢
UPDATE 一个表的主键或索引字段时,Oracle 要同步维护相关索引。更新一行,可能会涉及索引叶子节点的分裂和重组。索引越多,UPDATE 负载越大。DELETE 也一样,每一行都要在索引里标记删除位置。
有些索引碎片化严重后,即使删除了 80% 的数据,索引扫描性能也不会自动恢复。这时需要重建或收缩索引:
sql复制ALTER INDEX idx_orders_create_date REBUILD;
如果只是删除大量数据后想批量释放空间,可以重建所有相关索引。但在 Oracle11g 中,REBUILD 会锁索引并占用大量排序空间,必须避开业务高峰。
另一个容易忽视的问题是 UPDATE 后表的统计信息过期了。开发者改完几十万行数据,觉得操作完成了,可优化器还拿旧的统计信息做执行计划。第二天业务查询突然走了“看起来不合理”的执行计划,其实不是 SQL 变差,而是统计信息和真实数据严重失配。
正确做法是批量 UPDATE/DELETE 结束后,对目标表重新收集统计信息:
sql复制BEGIN
DBMS_STATS.GATHER_TABLE_STATS(ownname => 'APP',
tabname => 'ORDERS',
cascade => TRUE);
END;
对大表来说,这会消耗一些资源。如果担心影响业务,可以暂缓到夜间维护窗口执行。
