☰
PL/SQL执行语句深度解析:从赋值到批量处理的性能优化实战
2026/10/22 16:18:36 网站建设 项目流程

1. 从一段匿名块说起:执行语句到底在数据库里干了什么

很多人学PL/SQL学到“执行语句”这一节时,会下意识把它当成“写一句能跑的话”这么简单。我刚接触这块的时候也是这么想的,直到有一次帮同事排查一个存储过程的问题,才发现自己对“执行语句”的理解有多浅。那段代码大概长这样:

BEGIN UPDATE orders SET status = 'DONE' WHERE id = 1001; IF SQL%ROWCOUNT = 0 THEN INSERT INTO orders(id, status) VALUES (1001, 'DONE'); END IF; END;

看起来平平无奇,但当时的问题是:这段逻辑在并发场景下偶尔会插入重复数据。我盯着看了半天,才意识到问题不在UPDATE或INSERT本身,而在于我没搞清楚PL/SQL里“执行语句”的执行模型——它和单纯在客户端敲一条SQL,底层走的路径是不一样的。

PL/SQL里的执行语句,本质上是把SQL语句嵌入到过程化代码块中,由PL/SQL引擎和SQL引擎协同完成。PL/SQL引擎负责流程控制、变量管理、异常处理,SQL引擎负责真正的数据操作。两者之间通过一个叫“上下文切换”的机制来回传递控制权。这个切换不是免费的,每次切换都有开销。所以理解执行语句,不只是知道语法怎么写,更要明白它在引擎层面是怎么跑的、什么时候会触发切换、怎么组织语句才能减少不必要的开销。

这篇笔记面向的是已经写过一些PL/SQL、但对执行语句的底层行为和实战细节还不够清晰的朋友。我会从执行语句的分类讲起,把赋值、SQL语句、流程控制、动态SQL这几类逐一拆开,重点讲清楚每类语句在实际项目里容易踩的坑,以及我这些年总结下来的一些写法习惯。如果你正在写存储过程、函数、触发器,或者准备做批量数据处理,这些内容应该能帮你少走一些弯路。

2. 赋值语句:看似最简单,却最容易埋下精度和空值的雷

2.1 赋值语句的两种写法与选择逻辑

PL/SQL里的赋值语句主要有两种形式:一种是直接用:=给变量赋值,另一种是通过SELECT INTO从数据库里取值赋给变量。前者是纯PL/SQL引擎内部的操作,不涉及SQL引擎,速度极快;后者需要发起一次查询,走SQL引擎,有上下文切换开销。

-- 方式一:直接赋值,纯PL/SQL引擎操作 DECLARE v_count NUMBER := 0; v_name VARCHAR2(50); BEGIN v_count := v_count + 1; v_name := 'test'; END; -- 方式二:SELECT INTO,涉及SQL引擎 DECLARE v_total NUMBER; BEGIN SELECT SUM(amount) INTO v_total FROM orders WHERE status = 'DONE'; END;

选择哪种方式,核心判断标准是:数据来源在哪里。如果值已经在PL/SQL变量里,或者可以通过简单运算得到,就用:=;如果值存在数据库表里,就必须用SELECT INTO。我见过有人在循环里反复用SELECT INTO去取一些其实可以用变量累加的值,结果性能差了好几倍。比如统计某个分类下的订单总额,完全可以在遍历游标时用:=累加,而不是每轮都去查一次数据库。

2.2 空值传播:赋值时最隐蔽的陷阱

赋值语句里最容易被忽视的问题是空值传播。在PL/SQL中,任何包含NULL的算术运算结果都是NULL,任何与NULL的比较结果都是UNKNOWN。这意味着如果你不小心把一个NULL赋给了变量,后续所有基于这个变量的计算都会变成NULL,而且不会报错。

DECLARE v_a NUMBER := 10; v_b NUMBER; -- 未初始化,默认为NULL v_c NUMBER; BEGIN v_c := v_a + v_b; -- v_c 结果是 NULL,不是 10 IF v_c > 0 THEN DBMS_OUTPUT.PUT_LINE('positive'); ELSE DBMS_OUTPUT.PUT_LINE('not positive'); -- 会走这里,因为 NULL > 0 是 UNKNOWN END IF; END;

这段代码的输出是not positive,因为NULL > 0的结果是UNKNOWN,IF条件不成立,走ELSE分支。很多人第一次遇到这种情况会非常困惑,明明v_a是10,怎么加了个v_b就变成“不大于0”了。

我的习惯是:所有参与运算的变量,在声明时就给一个默认值,尤其是数值型变量给0,字符串给空串。如果业务上确实需要区分“未赋值”和“值为0”,那就用额外的标志位来标记,而不是依赖NULL。

DECLARE v_a NUMBER := 10; v_b NUMBER := 0; -- 显式初始化 v_c NUMBER; BEGIN v_c := v_a + v_b; -- 结果是 10 END;

2.3 类型转换的隐式陷阱

赋值时另一个常见问题是隐式类型转换。PL/SQL允许你把字符串赋给数值变量,只要字符串能转成数字,但它不会在编译期报错,而是在运行期才可能抛异常。

DECLARE v_num NUMBER; BEGIN v_num := '123'; -- 可以,隐式转换成功 v_num := 'abc'; -- 运行期报错:ORA-06502 END;

更隐蔽的是日期和字符串之间的转换。v_date := '2024-01-01'能不能成功,取决于当前会话的NLS_DATE_FORMAT设置。如果换了一个环境,这个赋值可能就失败了。我的做法是:涉及日期赋值时,一律用TO_DATE显式转换,并指定格式串;涉及字符串转数字时,用TO_NUMBER并做好异常捕获。

DECLARE v_date DATE; v_num NUMBER; BEGIN v_date := TO_DATE('2024-01-01', 'YYYY-MM-DD'); v_num := TO_NUMBER('123'); EXCEPTION WHEN VALUE_ERROR THEN DBMS_OUTPUT.PUT_LINE('转换失败,请检查输入格式'); END;

提示:在赋值语句中,显式转换永远比隐式转换可靠。隐式转换的规则依赖会话参数,换环境就可能出问题。

3. SQL语句在PL/SQL中的执行:上下文切换与批量处理的取舍

3.1 上下文切换的真实成本

前面提到,PL/SQL引擎和SQL引擎之间的切换是有开销的。这个开销到底有多大?可以这样理解:PL/SQL引擎像一个调度员,SQL引擎像一个工人。调度员每次让工人干活,都要把任务交代清楚,工人干完再把结果交回来。如果只有一两个任务,这个交代过程可以忽略;但如果有几万个小任务,交代本身的时间就超过了干活的时间。

-- 逐行处理:每次循环都触发一次上下文切换 BEGIN FOR rec IN (SELECT id, amount FROM orders WHERE status = 'PENDING') LOOP UPDATE orders SET status = 'DONE' WHERE id = rec.id; END LOOP; END; -- 批量处理:一次上下文切换完成所有更新 BEGIN FORALL i IN 1..v_ids.COUNT UPDATE orders SET status = 'DONE' WHERE id = v_ids(i); END;

上面两种写法的功能完全一样,但性能差距可能达到几十倍甚至上百倍。逐行处理时,每一行都要经历“PL/SQL引擎发起UPDATE → SQL引擎执行 → 返回结果 → PL/SQL引擎继续循环”这个完整过程。而FORALL会把整个集合一次性交给SQL引擎,只切换一次。

3.2 什么时候该用FORALL,什么时候不该用

FORALL不是万能的。它适合“对集合中的每个元素执行同一条DML语句”的场景。如果每次执行的语句不一样,或者需要根据前一次执行的结果决定下一次怎么做,FORALL就不适用了。

我一般用这个标准来判断:如果循环体里只有一条DML语句,且这条语句不依赖循环体内的其他逻辑,就优先考虑FORALL。如果循环体里有IF判断、有多个DML、有异常处理分支,那就老老实实用普通FOR循环。

-- 适合FORALL:循环体只有一条UPDATE BEGIN FORALL i IN 1..v_ids.COUNT UPDATE orders SET status = 'DONE' WHERE id = v_ids(i); END; -- 不适合FORALL:循环体有IF判断和多个操作 BEGIN FOR rec IN (SELECT id, amount, type FROM orders WHERE status = 'PENDING') LOOP IF rec.type = 'VIP' THEN UPDATE orders SET status = 'DONE', priority = 1 WHERE id = rec.id; INSERT INTO vip_log(order_id, action) VALUES (rec.id, 'PROCESSED'); ELSE UPDATE orders SET status = 'DONE' WHERE id = rec.id; END IF; END LOOP; END;

3.3 隐式游标属性:SQL%ROWCOUNT、SQL%FOUND的实战用法

执行完一条DML语句后,PL/SQL会提供几个隐式游标属性,最常用的是SQL%ROWCOUNT、SQL%FOUND和SQL%NOTFOUND。这些属性反映的是最近一次DML语句的执行结果。

BEGIN UPDATE orders SET status = 'DONE' WHERE id = 1001; IF SQL%ROWCOUNT = 0 THEN DBMS_OUTPUT.PUT_LINE('没有找到ID为1001的订单'); ELSE DBMS_OUTPUT.PUT_LINE('更新了 ' || SQL%ROWCOUNT || ' 条记录'); END IF; END;

这里有个细节需要注意:SQL%ROWCOUNT在DML语句执行后立即有效,但如果中间又执行了别的SQL语句,它就会被覆盖。所以如果你需要多次使用这个值,应该先把它赋给一个变量。

DECLARE v_count NUMBER; BEGIN UPDATE orders SET status = 'DONE' WHERE id = 1001; v_count := SQL%ROWCOUNT; -- 先保存下来 INSERT INTO log_table(action, cnt) VALUES ('UPDATE_ORDER', v_count); -- 此时 SQL%ROWCOUNT 已经变成 INSERT 的影响行数了 END;

另一个容易踩的坑是:SQL%FOUND和SQL%NOTFOUND在DML语句没有影响任何行时,SQL%FOUND为FALSE,SQL%NOTFOUND为TRUE。但如果在DML之前没有执行过任何SQL语句,这两个属性的值是NULL,不是FALSE。所以不要依赖它们在DML之前的初始值。

4. 流程控制语句:IF、CASE、LOOP在真实业务里的写法

4.1 IF语句的嵌套与扁平化

IF语句是PL/SQL里最基础的流程控制结构,但嵌套层数一多,代码就变得难以维护。我见过一个存储过程里IF嵌套了七层,读起来像在走迷宫。

-- 嵌套过深,难以阅读 IF v_type = 'A' THEN IF v_amount > 1000 THEN IF v_status = 'ACTIVE' THEN -- 处理逻辑 END IF; END IF; END IF; -- 扁平化写法,提前返回或跳过 IF v_type != 'A' THEN RETURN; END IF; IF v_amount <= 1000 THEN RETURN; END IF; IF v_status != 'ACTIVE' THEN RETURN; END IF; -- 处理逻辑

扁平化的核心思路是:把不满足条件的情况提前排除掉,让主逻辑保持在最外层。这样读代码的人不需要在脑子里维护一个“当前在哪一层”的栈,顺着往下读就行。

4.2 CASE语句:比IF更适合多分支等值判断

当判断条件是“某个变量等于某个值”时,CASE语句比IF-ELSIF链更清晰,而且执行效率也略高,因为CASE是跳转表式的匹配,而IF-ELSIF是顺序比较。

-- IF-ELSIF 链 IF v_level = 1 THEN v_discount := 0.1; ELSIF v_level = 2 THEN v_discount := 0.2; ELSIF v_level = 3 THEN v_discount := 0.3; ELSE v_discount := 0; END IF; -- CASE 语句 v_discount := CASE v_level WHEN 1 THEN 0.1 WHEN 2 THEN 0.2 WHEN 3 THEN 0.3 ELSE 0 END;

CASE还有一个优势:它可以用在SQL语句内部,而IF不行。所以在写SELECT或UPDATE时,如果需要对不同值做不同处理,CASE是唯一的选择。

SELECT id, CASE status WHEN 'PENDING' THEN '待处理' WHEN 'DONE' THEN '已完成' ELSE '未知' END AS status_desc FROM orders;

4.3 LOOP的三种形式与退出条件

PL/SQL里有三种循环:基本LOOP、FOR LOOP、WHILE LOOP。选择哪种,取决于你知不知道循环次数、以及退出条件是什么。

基本LOOP适合“至少执行一次,根据内部条件决定是否继续”的场景。FOR LOOP适合“明确知道循环范围”的场景。WHILE LOOP适合“条件满足时持续循环”的场景。

-- 基本LOOP:至少执行一次 LOOP v_count := v_count + 1; EXIT WHEN v_count > 10; END LOOP; -- FOR LOOP:范围明确 FOR i IN 1..10 LOOP DBMS_OUTPUT.PUT_LINE(i); END LOOP; -- WHILE LOOP:条件驱动 WHILE v_count <= 10 LOOP v_count := v_count + 1; END LOOP;

我在实际项目里用得最多的是FOR LOOP,尤其是配合游标使用。基本LOOP一般只在需要“先执行再判断”的场景下用,比如从队列里取任务,取到了就处理,取不到就退出。

注意:在FOR LOOP里不要修改循环变量。PL/SQL的FOR循环变量是只读的,试图修改会报错。如果需要根据条件跳过某次循环,用CONTINUE;如果需要提前退出,用EXIT。

5. 动态SQL:EXECUTE IMMEDIATE的正确打开方式

5.1 什么时候必须用动态SQL

静态SQL在编译期就确定了语句结构,而动态SQL是在运行期才拼接出来的。需要动态SQL的场景主要有三类:一是表名或列名不固定,需要根据参数决定;二是DDL语句(CREATE、DROP、ALTER)不能直接写在PL/SQL里,必须用动态SQL执行;三是需要拼接复杂的WHERE条件。

-- 表名不固定,必须用动态SQL CREATE OR REPLACE PROCEDURE count_rows(p_table_name IN VARCHAR2) IS v_sql VARCHAR2(500); v_count NUMBER; BEGIN v_sql := 'SELECT COUNT(*) FROM ' || p_table_name; EXECUTE IMMEDIATE v_sql INTO v_count; DBMS_OUTPUT.PUT_LINE(p_table_name || ' 共有 ' || v_count || ' 行'); END;

5.2 绑定变量:动态SQL的安全底线

动态SQL最大的风险是SQL注入。如果直接把用户输入拼接到SQL字符串里,恶意输入就可能改变语句结构。解决办法是使用绑定变量。

-- 危险写法:直接拼接 v_sql := 'SELECT * FROM orders WHERE id = ' || p_id; EXECUTE IMMEDIATE v_sql; -- 安全写法:使用绑定变量 v_sql := 'SELECT * FROM orders WHERE id = :1'; EXECUTE IMMEDIATE v_sql INTO v_rec USING p_id;

绑定变量不仅安全,还能提高性能。因为使用绑定变量的SQL语句,数据库可以复用执行计划,不需要每次重新解析。对于频繁执行的动态SQL,这个差异非常明显。

5.3 EXECUTE IMMEDIATE的INTO和USING子句

EXECUTE IMMEDIATE支持INTO和USING两个子句。INTO用于接收查询结果,USING用于传入绑定变量。需要注意的是,USING默认是IN模式,如果需要传出参数,要显式指定OUT模式。

DECLARE v_sql VARCHAR2(500); v_name VARCHAR2(50); v_count NUMBER; BEGIN -- INTO 接收单行单列结果 v_sql := 'SELECT name FROM users WHERE id = :1'; EXECUTE IMMEDIATE v_sql INTO v_name USING 1001; -- USING 传入多个参数 v_sql := 'UPDATE orders SET status = :1 WHERE id = :2'; EXECUTE IMMEDIATE v_sql USING 'DONE', 1001; -- OUT 模式传出参数 v_sql := 'BEGIN get_order_count(:1, :2); END;'; EXECUTE IMMEDIATE v_sql USING IN 1001, OUT v_count; END;

这里有个细节:如果动态SQL返回多行,INTO子句会报错。这时候需要用OPEN FOR配合游标变量来处理。

DECLARE v_sql VARCHAR2(500); v_cur SYS_REFCURSOR; v_id NUMBER; v_name VARCHAR2(50); BEGIN v_sql := 'SELECT id, name FROM users WHERE status = :1'; OPEN v_cur FOR v_sql USING 'ACTIVE'; LOOP FETCH v_cur INTO v_id, v_name; EXIT WHEN v_cur%NOTFOUND; DBMS_OUTPUT.PUT_LINE(v_id || ': ' || v_name); END LOOP; CLOSE v_cur; END;

5.4 动态SQL的性能考量

动态SQL虽然灵活,但每次执行都需要解析、编译、生成执行计划,开销比静态SQL大。如果一段动态SQL在循环里反复执行,性能问题会非常突出。

我的做法是:能静态就静态,必须动态时尽量减少动态SQL的执行次数。比如需要根据不同的表名做统计,可以先把所有表名收集到一个集合里,然后批量处理,而不是每张表都单独发起一次动态SQL。

-- 不推荐:循环内反复执行动态SQL FOR rec IN (SELECT table_name FROM config_tables) LOOP EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM ' || rec.table_name INTO v_count; -- 处理 v_count END LOOP; -- 推荐:先收集,再批量处理(如果业务允许) -- 或者至少把动态SQL的拼接放在循环外,减少重复解析

提示:动态SQL里的字符串拼接,一定要对表名、列名做白名单校验。不要相信任何外部传入的标识符,哪怕它来自内部配置表。

6. 执行语句的异常处理:什么时候该捕获,什么时候该放手

6.1 异常传播的基本规则

PL/SQL里的异常如果不捕获,会一直向上传播,直到被某个外层块捕获,或者传到客户端。传播过程中,异常的类型和错误信息会保留,但异常发生的具体位置信息会丢失。

BEGIN BEGIN UPDATE orders SET status = 'DONE' WHERE id = 1001; -- 假设这里抛出了异常 EXCEPTION WHEN OTHERS THEN DBMS_OUTPUT.PUT_LINE('内层捕获:' || SQLERRM); RAISE; -- 重新抛出,让外层也能感知 END; EXCEPTION WHEN OTHERS THEN DBMS_OUTPUT.PUT_LINE('外层捕获:' || SQLERRM); END;

RAISE关键字在不带参数时,会重新抛出当前异常。这个用法在需要记录日志但又不想吞掉异常的场景下非常有用。

6.2 WHEN OTHERS的正确用法与误用

WHEN OTHERS能捕获所有异常,但它也是最容易被误用的。我见过很多代码用WHEN OTHERS THEN NULL;来“忽略所有错误”,结果出了问题完全查不到原因。

-- 危险写法:吞掉所有异常 BEGIN UPDATE orders SET status = 'DONE' WHERE id = 1001; EXCEPTION WHEN OTHERS THEN NULL; -- 出错了也不知道 END; -- 正确写法:记录日志后重新抛出 BEGIN UPDATE orders SET status = 'DONE' WHERE id = 1001; EXCEPTION WHEN OTHERS THEN INSERT INTO error_log(proc_name, error_msg, error_time) VALUES ('update_order', SQLERRM, SYSDATE); RAISE; -- 让调用方知道出错了 END;

如果确实需要忽略某些特定异常,应该明确指定异常类型,而不是用WHEN OTHERS。

BEGIN SELECT name INTO v_name FROM users WHERE id = 1001; EXCEPTION WHEN NO_DATA_FOUND THEN v_name := '未知用户'; -- 明确处理“没找到”的情况 END;

6.3 自定义异常与RAISE_APPLICATION_ERROR

除了系统预定义的异常,PL/SQL还允许自定义异常。自定义异常适合表达业务逻辑上的错误,比如“余额不足”“订单状态不允许此操作”等。

DECLARE e_insufficient_balance EXCEPTION; v_balance NUMBER := 100; v_amount NUMBER := 200; BEGIN IF v_amount > v_balance THEN RAISE e_insufficient_balance; END IF; EXCEPTION WHEN e_insufficient_balance THEN DBMS_OUTPUT.PUT_LINE('余额不足'); END;

如果希望错误信息能传到客户端,用RAISE_APPLICATION_ERROR更合适。它可以指定错误码(-20000到-20999之间)和错误信息。

BEGIN IF v_amount > v_balance THEN RAISE_APPLICATION_ERROR(-20001, '余额不足,当前余额:' || v_balance); END IF; END;

注意:RAISE_APPLICATION_ERROR的错误码必须在这个范围内,否则会和系统错误码冲突。错误信息长度不能超过2048字节。

7. 批量执行语句的实战:从逐行到批量的改造过程

7.1 一个真实的改造案例

之前有个数据迁移的存储过程,需要把一张表里的几十万条记录逐条更新到另一张表。最初的写法是逐行循环,跑一次要四十多分钟。后来改造成批量处理,时间降到了两分钟以内。

-- 改造前:逐行处理 BEGIN FOR rec IN (SELECT id, name, amount FROM source_table) LOOP INSERT INTO target_table(id, name, amount) VALUES (rec.id, rec.name, rec.amount); END LOOP; COMMIT; END; -- 改造后:批量处理 DECLARE TYPE t_id IS TABLE OF source_table.id%TYPE; TYPE t_name IS TABLE OF source_table.name%TYPE; TYPE t_amount IS TABLE OF source_table.amount%TYPE; v_ids t_id; v_names t_name; v_amounts t_amount; BEGIN SELECT id, name, amount BULK COLLECT INTO v_ids, v_names, v_amounts FROM source_table; FORALL i IN 1..v_ids.COUNT INSERT INTO target_table(id, name, amount) VALUES (v_ids(i), v_names(i), v_amounts(i)); COMMIT; END;

改造的核心是BULK COLLECT和FORALL的配合使用。BULK COLLECT一次性把查询结果全部加载到集合变量里,FORALL一次性把集合里的所有数据提交给SQL引擎。整个过程只有两次上下文切换:一次查询,一次插入。

7.2 BULK COLLECT的LIMIT子句

BULK COLLECT默认会把所有结果都加载到内存里。如果数据量特别大,比如几百万行,可能会耗尽PGA内存。这时候需要用LIMIT子句分批加载。

DECLARE CURSOR c_source IS SELECT id, name FROM source_table; TYPE t_rec IS TABLE OF c_source%ROWTYPE; v_recs t_rec; BEGIN OPEN c_source; LOOP FETCH c_source BULK COLLECT INTO v_recs LIMIT 1000; EXIT WHEN v_recs.COUNT = 0; FORALL i IN 1..v_recs.COUNT INSERT INTO target_table(id, name) VALUES (v_recs(i).id, v_recs(i).name); COMMIT; END LOOP; CLOSE c_source; END;

LIMIT 1000表示每次只取1000行,处理完再取下一批。这样内存占用可控,同时批量处理的性能优势也保留了。具体LIMIT设多少,需要根据每行数据的大小和可用的PGA内存来调整。我的经验是:每行数据不大的话,1000到5000比较合适;如果每行有很长的字符串或LOB字段,就降到100到500。

7.3 FORALL的异常处理:SAVE EXCEPTIONS

FORALL默认是“全有或全无”的:如果集合中任何一行执行失败,整个FORALL语句都会回滚。但有时候我们希望“能成功的先成功,失败的记录下来”。这时候可以用SAVE EXCEPTIONS子句。

DECLARE e_bulk_errors EXCEPTION; PRAGMA EXCEPTION_INIT(e_bulk_errors, -24381); BEGIN FORALL i IN 1..v_ids.COUNT SAVE EXCEPTIONS INSERT INTO target_table(id, name) VALUES (v_ids(i), v_names(i)); EXCEPTION WHEN e_bulk_errors THEN FOR j IN 1..SQL%BULK_EXCEPTIONS.COUNT LOOP DBMS_OUTPUT.PUT_LINE('第 ' || SQL%BULK_EXCEPTIONS(j).ERROR_INDEX || ' 行失败:' || SQL%BULK_EXCEPTIONS(j).ERROR_CODE); END LOOP; END;

SQL%BULK_EXCEPTIONS是一个集合,记录了每一行失败的索引和错误码。通过遍历这个集合,可以知道哪些行出了问题,然后针对性地处理。

8. 执行语句的性能诊断:从等待事件到执行计划

8.1 用DBMS_PROFILER定位慢语句

当一段PL/SQL跑得慢时,第一步是定位到底哪条语句慢。DBMS_PROFILER可以按行统计执行时间,精确到每一条SQL语句。

BEGIN DBMS_PROFILER.START_PROFILER('my_profiling'); -- 这里放需要分析的代码 DBMS_PROFILER.STOP_PROFILER; END;

跑完之后,查询PLSQL_PROFILER_DATA表,按TOTAL_TIME降序排列,就能看到哪条语句占用的时间最多。这个工具在排查“存储过程整体慢,但不知道慢在哪”的问题时特别有用。

8.2 执行计划:理解SQL引擎的选择

对于单条SQL语句,最直接的诊断手段是看执行计划。在PL/SQL里,可以用EXPLAIN PLAN或者查询V$SQL_PLAN来获取。

EXPLAIN PLAN FOR SELECT * FROM orders WHERE status = 'PENDING'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

执行计划会告诉你SQL引擎选择了全表扫描还是索引扫描、连接方式是嵌套循环还是哈希连接、预估行数和实际行数是否偏差很大。如果预估行数和实际行数差了几个数量级,通常意味着统计信息过期了,需要重新收集。

8.3 常见的性能反模式

在PL/SQL执行语句层面,有几个反复出现的性能反模式:

反模式问题改进方案
循环内逐行DML上下文切换次数等于行数用BULK COLLECT + FORALL
循环内COMMIT频繁提交导致日志同步等待批量处理完后统一COMMIT
在SQL中调用PL/SQL函数每行都要切换引擎尽量用SQL内置函数替代
动态SQL不带绑定变量每次都要硬解析使用USING子句绑定变量
SELECT INTO取多行运行期报TOO_MANY_ROWS用游标或加ROWNUM限制

其中“在SQL中调用PL/SQL函数”这一条特别隐蔽。比如SELECT my_function(col) FROM big_table,看起来是一条SQL,实际上每一行都会触发一次PL/SQL引擎调用。如果my_function里还有SQL语句,那就是嵌套的上下文切换,性能会非常差。

9. 一些让我少熬几个夜的写法习惯

写了这么多年的PL/SQL,有几个习惯是踩过坑之后慢慢养成的,分享出来供参考。

第一个习惯:所有变量声明时给默认值。数值给0,字符串给空串,日期给SYSDATE或一个明确的基准日期。这样就不会因为NULL传播导致莫名其妙的逻辑错误。

第二个习惯:DML语句执行后立即检查SQL%ROWCOUNT。如果预期应该影响一行但实际影响了零行或多行,马上抛异常或记录日志。不要等到后面数据对不上了才回头查。

第三个习惯:动态SQL的拼接部分和绑定变量部分分开写。拼接部分只放表名、列名这些标识符,并且做白名单校验;值一律用绑定变量。这样既安全又高效。

第四个习惯:批量处理时先小批量试跑。比如LIMIT 1000,先跑一批看看结果对不对,再放大到全量。直接跑全量如果逻辑有问题,回滚都要等半天。

第五个习惯:异常处理里永远不要写WHEN OTHERS THEN NULL。要么记录日志后重新抛出,要么明确指定要忽略的异常类型。吞掉异常等于把问题埋起来,迟早会炸。

第六个习惯:在循环里避免COMMIT。把COMMIT放在循环外面,或者按批次COMMIT。频繁COMMIT不仅慢,还会导致“提交了但后续步骤失败”的数据不一致问题。

这些习惯看起来都是小事,但每一个背后都有过真实的教训。PL/SQL的执行语句,语法层面并不复杂,难的是在正确的场景下选择正确的写法,并且对边界情况有足够的预判。希望这篇笔记能帮你在写执行语句时多一分笃定,少一分试错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询