
1. 为什么你的 PL/SQL 异常处理总在“漏网”写 PL/SQL 的人大多有过这种经历存储过程跑得好好的某天突然报了一个 ORA-06502 或者 ORA-01403日志里只有一行堆栈业务方追着问“到底哪条数据出问题了”你只能一行行翻代码。问题往往不在业务逻辑而在于异常捕获写得太粗——要么只写了一个WHEN OTHERS THEN NULL要么把 21 个预定义系统异常当成“知道名字就行”从来没真正触发验证过。Oracle 预定义的 21 个系统异常类型是 PL/SQL 运行时引擎在特定条件下自动抛出的命名异常。它们不需要你RAISE只要条件满足就会自己冒出来。比如NO_DATA_FOUND在SELECT INTO没查到行时触发ZERO_DIVIDE在除数为零时触发DUP_VAL_ON_INDEX在唯一索引冲突时触发。这些异常的共同点是名字固定、触发条件明确、可以被EXCEPTION块按名捕获。这篇文章面向的是已经在写 PL/SQL 存储过程、函数、触发器的开发者尤其是那些需要把异常分支逐一验证、而不是靠猜的人。我会给出一套可复制的异常处理骨架包含错误日志表的 DDL、通用EXCEPTION块结构以及 21 类异常中可实测部分的触发 SQL 与验证动作。同时整个验证过程我会放在 TaoToken 统一 Key/API 通道下完成——不是因为它能替代数据库而是因为它能让我在一个入口里同时管理模型对话、API 调用和编码辅助减少在多个平台之间切换的成本。先明确一点TaoToken 在这里的角色是“统一接入层”不是数据库代理。你的 PL/SQL 仍然跑在 Oracle 上TaoToken 负责的是你在开发过程中调用模型能力、生成测试用例、排查报错时的 API 通道。这个边界要清楚否则后面配置会乱。2. TaoToken 前置统一 Key 与 API 通道准备在开始写异常处理骨架之前先把 TaoToken 的接入信息准备好。你需要一个统一 Key以及对应的 API 地址。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基础地址是 https://taotoken.net/api 。注意 API 地址不带 UTM 参数直接用它做请求前缀。如果你只是想在验证异常时快速让模型帮你生成触发 SQL可以用模型对话入口如果你要长期做 PL/SQL 编码和 Agent 辅助建议走 Coding Plan如果你需要管理多个 Key 或查看调用量进 console如果只是拿 Key直接去 api-keys 页面。这几个入口分别对应不同场景不要只记首页。具体操作上我通常这样做先在 api-keys 页面创建一个 Key命名成plsql-exception-test方便后面排查调用记录。然后把这个 Key 写进环境变量避免硬编码在脚本里。比如在 Linux 下export TAOTOKEN_API_KEY你的Key export TAOTOKEN_BASE_URLhttps://taotoken.net/api验证 Key 是否可用可以用一个最简单的 curl 请求curl -s -X POST $TAOTOKEN_BASE_URL/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: gpt-4o-mini, messages: [{role: user, content: 回复 OK}], max_tokens: 10 }如果返回里有choices字段说明 Key 和通道都正常。这一步不做后面生成触发 SQL 时如果报 401你会以为是 Oracle 的问题其实是 Key 没配好。注意TaoToken 的 API 地址是https://taotoken.net/api不要在后面加/v1之外的路径也不要把它当成数据库连接串。它只处理 HTTP 请求不处理 Oracle 的 TNS 连接。3. 可复制配置错误日志表 DDL 与异常处理骨架异常处理要落地先得有地方记。我习惯建一张错误日志表字段不多但够用CREATE TABLE plsql_error_log ( log_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, error_time TIMESTAMP DEFAULT SYSTIMESTAMP, procedure_name VARCHAR2(200), error_code NUMBER, error_message VARCHAR2(4000), trigger_sql VARCHAR2(4000), extra_info VARCHAR2(4000) ); CREATE INDEX idx_plsql_error_log_time ON plsql_error_log(error_time);这张表的关键是error_code和error_message分开存因为SQLCODE返回数字SQLERRM返回文本。trigger_sql用来记录触发异常的那条 SQL 或操作方便复现。extra_info可以放绑定变量值或业务主键。接下来是异常处理骨架。我把它写成一个可复用的模板你直接替换过程名和业务逻辑即可CREATE OR REPLACE PROCEDURE prc_exception_demo( p_id IN NUMBER ) IS v_name VARCHAR2(100); v_num NUMBER; BEGIN -- 业务逻辑区 SELECT name INTO v_name FROM users WHERE id p_id; v_num : 100 / p_id; DBMS_OUTPUT.PUT_LINE(正常执行: || v_name); EXCEPTION WHEN NO_DATA_FOUND THEN INSERT INTO plsql_error_log(procedure_name, error_code, error_message, trigger_sql) VALUES (prc_exception_demo, SQLCODE, SQLERRM, SELECT name INTO v_name FROM users WHERE id || p_id); RAISE; WHEN TOO_MANY_ROWS THEN INSERT INTO plsql_error_log(procedure_name, error_code, error_message, trigger_sql) VALUES (prc_exception_demo, SQLCODE, SQLERRM, SELECT INTO 返回多行, id || p_id); RAISE; WHEN ZERO_DIVIDE THEN INSERT INTO plsql_error_log(procedure_name, error_code, error_message, trigger_sql) VALUES (prc_exception_demo, SQLCODE, SQLERRM, 100 / || p_id); RAISE; WHEN OTHERS THEN INSERT INTO plsql_error_log(procedure_name, error_code, error_message, extra_info) VALUES (prc_exception_demo, SQLCODE, SQLERRM, 未分类异常); RAISE; END; /这个骨架的核心是每个WHEN分支先写日志再RAISE把异常抛给上层。如果你不希望上层感知可以把RAISE去掉但那样调用方就不知道出错了。我一般保留RAISE让调用链自己决定怎么处理。对于 21 个预定义异常不是每一个都能在普通 PL/SQL 块里轻松触发。比如LOGIN_DENIED、NOT_LOGGED_ON、PROGRAM_ERROR、STORAGE_ERROR、SYS_INVALID_ID、TIMEOUT_ON_RESOURCE这几类要么需要特定环境要么属于内部错误日常开发中不建议强行构造。我下面重点给可实测的异常类型并说明触发方式。4. 逐类异常触发 SQL 与验证动作先给一张对照表把 21 个异常按“可实测”和“环境依赖”分开异常名触发条件可实测性ACCESS_INTO_NULL未初始化对象属性赋值可实测CASE_NOT_FOUNDCASE 无匹配且无 ELSE可实测COLLECTION_IS_NULL未初始化集合操作可实测CURSOR_ALREADY_OPEN重复打开已打开游标可实测DUP_VAL_ON_INDEX唯一索引重复插入可实测INVALID_CURSOR非法游标操作可实测INVALID_NUMBER字符转数字失败可实测NO_DATA_FOUNDSELECT INTO 无行可实测TOO_MANY_ROWSSELECT INTO 多行可实测ZERO_DIVIDE除数为零可实测SUBSCRIPT_BEYOND_COUNT下标超集合最大值可实测SUBSCRIPT_OUTSIDE_LIMIT下标为负数可实测VALUE_ERROR变量长度不足可实测LOGIN_DENIED错误用户名密码连接环境依赖NOT_LOGGED_ON未连接访问数据环境依赖PROGRAM_ERRORPL/SQL 内部问题环境依赖ROWTYPE_MISMATCH游标变量类型不兼容可实测SELF_IS_NULLnull 对象调用方法可实测STORAGE_ERROR内存不足环境依赖SYS_INVALID_ID无效 ROWID 字符串环境依赖TIMEOUT_ON_RESOURCE等待资源超时环境依赖下面挑几个高频的给出触发 SQL 和验证动作。NO_DATA_FOUND的触发最简单DECLARE v_name VARCHAR2(100); BEGIN SELECT name INTO v_name FROM users WHERE id -1; EXCEPTION WHEN NO_DATA_FOUND THEN DBMS_OUTPUT.PUT_LINE(捕获 NO_DATA_FOUND, SQLCODE || SQLCODE); END; /执行后你应该看到SQLCODE-100。如果没看到检查users表里是否真的没有id-1的行。TOO_MANY_ROWS需要表里有多行匹配DECLARE v_name VARCHAR2(100); BEGIN SELECT name INTO v_name FROM users WHERE ROWNUM 2; EXCEPTION WHEN TOO_MANY_ROWS THEN DBMS_OUTPUT.PUT_LINE(捕获 TOO_MANY_ROWS, SQLCODE || SQLCODE); END; /SQLCODE-1421。注意ROWNUM 2在SELECT INTO里如果返回两行就会触发。ZERO_DIVIDE直接算DECLARE v_num NUMBER; BEGIN v_num : 100 / 0; EXCEPTION WHEN ZERO_DIVIDE THEN DBMS_OUTPUT.PUT_LINE(捕获 ZERO_DIVIDE, SQLCODE || SQLCODE); END; /SQLCODE-1476。DUP_VAL_ON_INDEX需要一张有唯一约束的表-- 先建测试表 CREATE TABLE test_unique (id NUMBER PRIMARY KEY, name VARCHAR2(50)); INSERT INTO test_unique VALUES (1, A); COMMIT; -- 触发 BEGIN INSERT INTO test_unique VALUES (1, B); EXCEPTION WHEN DUP_VAL_ON_INDEX THEN DBMS_OUTPUT.PUT_LINE(捕获 DUP_VAL_ON_INDEX, SQLCODE || SQLCODE); END; /SQLCODE-1。VALUE_ERROR用长度不足触发DECLARE v_short VARCHAR2(2); BEGIN v_short : ABCDE; EXCEPTION WHEN VALUE_ERROR THEN DBMS_OUTPUT.PUT_LINE(捕获 VALUE_ERROR, SQLCODE || SQLCODE); END; /SQLCODE-6502。SUBSCRIPT_BEYOND_COUNT和SUBSCRIPT_OUTSIDE_LIMIT用 VARRAY 演示DECLARE TYPE t_varray IS VARRAY(3) OF NUMBER; v_arr t_varray : t_varray(1, 2, 3); BEGIN v_arr(5) : 10; EXCEPTION WHEN SUBSCRIPT_BEYOND_COUNT THEN DBMS_OUTPUT.PUT_LINE(捕获 SUBSCRIPT_BEYOND_COUNT); END; /负数下标则触发SUBSCRIPT_OUTSIDE_LIMITDECLARE TYPE t_varray IS VARRAY(3) OF NUMBER; v_arr t_varray : t_varray(1, 2, 3); BEGIN v_arr(-1) : 10; EXCEPTION WHEN SUBSCRIPT_OUTSIDE_LIMIT THEN DBMS_OUTPUT.PUT_LINE(捕获 SUBSCRIPT_OUTSIDE_LIMIT); END; /CURSOR_ALREADY_OPEN和INVALID_CURSOR用显式游标DECLARE CURSOR c1 IS SELECT * FROM users; BEGIN OPEN c1; OPEN c1; -- 重复打开 EXCEPTION WHEN CURSOR_ALREADY_OPEN THEN DBMS_OUTPUT.PUT_LINE(捕获 CURSOR_ALREADY_OPEN); END; /INVALID_CURSOR则在未打开时FETCH或关闭已关闭游标时触发。CASE_NOT_FOUND的写法DECLARE v_input NUMBER : 99; v_result VARCHAR2(20); BEGIN v_result : CASE v_input WHEN 1 THEN one WHEN 2 THEN two END; EXCEPTION WHEN CASE_NOT_FOUND THEN DBMS_OUTPUT.PUT_LINE(捕获 CASE_NOT_FOUND); END; /注意这里没有ELSE所以 99 会触发异常。ACCESS_INTO_NULL和SELF_IS_NULL需要对象类型CREATE OR REPLACE TYPE t_person AS OBJECT ( name VARCHAR2(50), MEMBER FUNCTION get_name RETURN VARCHAR2 ); / CREATE OR REPLACE TYPE BODY t_person AS MEMBER FUNCTION get_name RETURN VARCHAR2 IS BEGIN RETURN SELF.name; END; END; / DECLARE v_person t_person; BEGIN v_person.name : Tom; -- 未初始化对象 EXCEPTION WHEN ACCESS_INTO_NULL THEN DBMS_OUTPUT.PUT_LINE(捕获 ACCESS_INTO_NULL); END; /SELF_IS_NULL则是v_person.get_name()在v_person为 null 时调用。COLLECTION_IS_NULL用嵌套表DECLARE TYPE t_tab IS TABLE OF NUMBER; v_tab t_tab; BEGIN v_tab(1) : 100; EXCEPTION WHEN COLLECTION_IS_NULL THEN DBMS_OUTPUT.PUT_LINE(捕获 COLLECTION_IS_NULL); END; /INVALID_NUMBER用隐式转换DECLARE v_num NUMBER; BEGIN v_num : abc; EXCEPTION WHEN INVALID_NUMBER THEN DBMS_OUTPUT.PUT_LINE(捕获 INVALID_NUMBER); END; /ROWTYPE_MISMATCH需要两个不兼容的游标变量DECLARE TYPE t_cur1 IS REF CURSOR RETURN users%ROWTYPE; TYPE t_cur2 IS REF CURSOR RETURN test_unique%ROWTYPE; v_c1 t_cur1; v_c2 t_cur2; BEGIN OPEN v_c1 FOR SELECT * FROM users; v_c2 : v_c1; -- 类型不兼容 EXCEPTION WHEN ROWTYPE_MISMATCH THEN DBMS_OUTPUT.PUT_LINE(捕获 ROWTYPE_MISMATCH); END; /这些触发 SQL 你可以逐条在 SQL*Plus 或 SQL Developer 里跑每跑一条就查一次plsql_error_log确认日志写入正常。如果日志表里没有记录说明EXCEPTION块没走到或者RAISE之前就出了别的问题。5. 本篇常见错排查第一个坑WHEN OTHERS放在最前面。PL/SQL 的异常捕获是按顺序匹配的如果把WHEN OTHERS写在具体异常之前后面的WHEN NO_DATA_FOUND永远不会执行。正确顺序是具体异常在前WHEN OTHERS在最后。第二个坑SQLCODE和SQLERRM在WHEN OTHERS之外使用。这两个函数在异常处理块外调用会返回 0 和 ORA-0000: normal, successful completion。所以日志插入必须放在EXCEPTION块内部。第三个坑RAISE之后日志没提交。如果你在异常块里INSERT然后RAISE而外层没有COMMIT日志会随着事务回滚一起消失。解决办法是在INSERT后加COMMIT或者用自治事务PRAGMA AUTONOMOUS_TRANSACTION写日志。我一般用自治事务避免影响主事务CREATE OR REPLACE PROCEDURE prc_log_error( p_proc VARCHAR2, p_code NUMBER, p_msg VARCHAR2 ) IS PRAGMA AUTONOMOUS_TRANSACTION; BEGIN INSERT INTO plsql_error_log(procedure_name, error_code, error_message) VALUES (p_proc, p_code, p_msg); COMMIT; END; /然后在异常块里调用prc_log_error再RAISE。第四个坑NO_DATA_FOUND在SELECT INTO之外的地方被误捕。比如UPDATE或DELETE没有匹配行时不会触发NO_DATA_FOUND只会影响 0 行。如果你在UPDATE后面写WHEN NO_DATA_FOUND永远不会触发。第五个坑TaoToken 调用返回 401 或 403。先检查 Key 是否复制完整再检查请求头是不是Authorization: Bearer Key最后确认 API 地址是https://taotoken.net/api而不是首页地址。如果还不行去 console 看调用记录确认请求有没有到达。第六个坑DUP_VAL_ON_INDEX和VALUE_ERROR混淆。唯一索引冲突是DUP_VAL_ON_INDEX而字段长度超限是VALUE_ERROR。两者 SQLCODE 不同日志里要分开记。6. 在 TaoToken 通道下完成异常分支实测确认异常处理骨架写完之后真正的验证动作是逐类触发、逐类查日志、逐类确认 SQLCODE 和 SQLERRM 符合预期。这个过程如果手动做21 类异常要跑很多遍容易漏。我通常会让模型帮我生成一个测试脚本把所有可实测异常的触发块拼在一起然后一次性执行。在 TaoToken 统一 Key 下你可以用模型对话入口让模型生成这个测试脚本也可以用 Coding Plan 把 PL/SQL 异常处理模板纳入长期编码辅助。具体操作是打开模型对话输入“生成一个 PL/SQL 匿名块依次触发 NO_DATA_FOUND、TOO_MANY_ROWS、ZERO_DIVIDE、DUP_VAL_ON_INDEX、VALUE_ERROR每个异常捕获后输出 SQLCODE”然后把生成的代码贴到 SQL Developer 里跑。如果模型生成的代码有语法问题你可以直接在对话里让它修正不需要切换平台。对于需要长期维护的 PL/SQL 项目建议把异常处理骨架和日志表 DDL 放进版本控制然后用 Coding Plan 做代码审查辅助。每次新增存储过程时让模型检查EXCEPTION块是否覆盖了该过程可能触发的系统异常。这个习惯能帮你避免“上线后才发现某个异常没捕获”的问题。最后给一个实用技巧在plsql_error_log表上建一个视图按error_code分组统计这样你能快速看出哪个异常最常发生。比如CREATE OR REPLACE VIEW v_error_summary AS SELECT error_code, error_message, COUNT(*) AS cnt, MAX(error_time) AS last_time FROM plsql_error_log GROUP BY error_code, error_message ORDER BY cnt DESC;跑完所有触发 SQL 后查这个视图如果每个异常都有记录说明捕获链路是通的。如果某个异常没记录回到对应的触发块检查EXCEPTION块是否写对、日志过程是否提交。这个验证动作做完你对 21 个预定义异常的理解就不再停留在“知道名字”而是真正跑过、记过、确认过。