☰
PL/SQL例外处理:构建Oracle数据库的程序免疫系统
2026/10/15 14:50:01 网站建设 项目流程

1. 这不是“报错处理”,而是PL/SQL程序的免疫系统

你写完一个存储过程,测试数据跑通了,心里刚松一口气,用户那边就打来电话:“一查张三的订单就卡死”“导出2023年全量数据直接报错退出”。你翻日志,看到一行ORA-01422: exact fetch returns more than requested number of rows,再看代码里那句SELECT ... INTO ...——没错,它只准备接1条,结果数据库塞给你3条。这不是bug,是程序没长“免疫细胞”。

PL/SQL里的例外(Exception)机制,从来就不是教你怎么把错误信息打印出来,而是让你在数据异常、资源不足、逻辑冲突、权限缺失这些真实生产现场中,依然能守住业务底线:该回滚的事务不残留脏数据,该返回友好提示的绝不抛裸错,该降级执行的自动切换备用路径。我带过的几个项目里,90%以上的线上故障追溯下来,问题不在SQL写得对不对,而在于例外分支压根没写,或者写了但只写了WHEN OTHERS THEN NULL;——这等于给程序装了个假安全气囊,撞上墙时它根本不会弹。

关键词“Oracle数据库”“PL/SQL程序设计”“例外”三个词连在一起,指向的是一套成熟、严谨、不容妥协的错误应对范式。它不依赖外部监控告警来事后补救,而是在每一行PL/SQL代码执行前,就预设好所有可能的“失序点”。比如你调用一个银行转账过程,UPDATE account SET balance = balance - 1000 WHERE id = 1001执行成功,但紧接着UPDATE account SET balance = balance + 1000 WHERE id = 1002因为账户被锁住失败了——这时候,不是让第一笔扣款悬在那里,而是整个事务必须原子性回滚。这个“必须”,就是由例外处理块强制保障的。

它适合谁?不是只适合DBA或资深开发。如果你正在写一个每天要处理5万条物流单据的入库过程,如果你负责维护一个对接三方支付接口的结算模块,甚至如果你只是个刚学完基础语法、正准备写第一个真实业务存储过程的新人——例外处理都是你绕不开的第一道实战门槛。跳过它,你的代码就像没装刹车的自行车,平路能骑,下坡必翻。而掌握它,意味着你能把“程序可能出错”这个模糊担忧,转化成可枚举、可捕获、可响应、可记录的确定性动作。接下来的内容,我会带你从最常踩的坑开始,一层层拆开PL/SQL例外机制的肌肉与神经,告诉你每种写法背后的真实代价和收益。

2. 例外机制的设计逻辑:为什么不能只靠WHEN OTHERS?

2.1 Oracle的例外分类:预定义、非预定义、用户自定义,三者不可混用

PL/SQL的例外不是笼统的“错误集合”,而是分层明确的三类实体,每类解决不同维度的问题,强行混用会直接导致逻辑失控。

预定义例外(Predefined Exceptions)是Oracle内核为你打包好的高频故障场景,共21个,全部有固定名称和唯一错误号。比如NO_DATA_FOUND(ORA-01403)、TOO_MANY_ROWS(ORA-01422)、ZERO_DIVIDE(ORA-01476)。它们的特点是:只要触发对应条件,Oracle自动抛出,无需你声明;你捕获时也必须用它的标准名,不能自己起名。我见过最典型的错误,是有人写WHEN NO_ROW_FOUND THEN ...——少了个S,编译直接报错,因为Oracle根本不认识这个标识符。

非预定义例外(Non-predefined Exceptions)解决的是另外约2000个标准Oracle错误号(如ORA-00001唯一约束冲突、ORA-01400插入空值到非空列),它们没有内置名称,但你必须给它们“注册”一个名字,才能在代码里捕获。注册方式是通过PRAGMA EXCEPTION_INIT编译指令,将一个自定义的例外变量绑定到具体错误号。这步绝不能省,否则你只能靠WHEN OTHERS捕获后,再用SQLCODE去判断——这等于放弃编译期检查,把问题拖到运行时。

用户自定义例外(User-defined Exceptions)则完全由你掌控,用于表达业务规则层面的异常,比如“客户信用额度不足”“订单状态不允许取消”。它不对应任何Oracle错误号,需要你先DECLARE一个例外变量,再在逻辑中用RAISE主动抛出。它的价值在于把“业务语义”注入到错误流中,让调用方能根据WHEN insufficient_credit THEN ...这样的分支,做出精准的业务响应,而不是面对一个模糊的ORA-20001干瞪眼。

这三类例外的并存,本质是Oracle对“错误来源”的分层治理:内核级故障(预定义)、数据库约束级故障(非预定义)、业务规则级故障(自定义)。混淆它们,就像把交通违章、车辆故障、司机醉驾全归为“开车出事”,后续处理必然粗放失效。

2.2 WHEN OTHERS的陷阱:它不是兜底,而是最后防线

WHEN OTHERS THEN看似万能,实则是PL/SQL里最危险的语法糖。它的危险性不在于功能,而在于它诱使开发者放弃思考“具体会出什么错”。

我参与过一个电商库存扣减过程的重构。原代码是这样的:

CREATE OR REPLACE PROCEDURE reduce_stock(p_sku VARCHAR2, p_qty NUMBER) IS BEGIN UPDATE inventory SET qty = qty - p_qty WHERE sku = p_sku; IF SQL%ROWCOUNT = 0 THEN RAISE_APPLICATION_ERROR(-20001, 'SKU not found: ' || p_sku); END IF; COMMIT; EXCEPTION WHEN OTHERS THEN ROLLBACK; DBMS_OUTPUT.PUT_LINE('Error: ' || SQLERRM); END;

表面看,有回滚、有输出,很“完整”。但上线后发现两个致命问题:第一,当并发高时,UPDATE可能因行锁等待超时(ORA-00054),WHEN OTHERS捕获后只打印日志,用户看到的是“操作失败”,却不知道是系统忙还是数据错;第二,DBMS_OUTPUT.PUT_LINE在生产环境通常被禁用,日志实际没留下任何痕迹,排查时只能靠猜。

正确的做法,是把WHEN OTHERS当作真正的“最后防线”,只做最基础的兜底动作,并确保前面已覆盖所有可预见的主干路径:

EXCEPTION -- 业务主干错误:SKU不存在、库存不足 WHEN no_sku_found THEN log_error('SKU_NOT_FOUND', p_sku); RAISE_APPLICATION_ERROR(-20001, '商品不存在'); WHEN insufficient_stock THEN log_error('INSUFF_STOCK', p_sku || ':' || p_qty); RAISE_APPLICATION_ERROR(-20002, '库存不足'); -- 数据库约束错误:唯一索引冲突、外键违规 WHEN dup_val_on_index THEN log_error('DUP_INDEX', p_sku); RAISE_APPLICATION_ERROR(-20003, '数据重复,请勿重试'); -- 系统级错误:锁超时、空间不足,需人工介入 WHEN others THEN log_error('UNEXPECTED_ERROR', 'SQLCODE=' || SQLCODE || ', SQLERRM=' || SQLERRM); -- 发送告警邮件或写入专用错误表 send_alert_to_dba(SQLCODE, SQLERRM); RAISE; -- 重新抛出,让上层知道这里崩了 END;

关键变化有三点:第一,WHEN OTHERS里不再NULL或简单DBMS_OUTPUT,而是强制记录结构化错误日志并触发告警;第二,RAISE;(无参数)表示原样重抛,保留原始错误堆栈,避免掩盖真相;第三,它被放在所有具体分支之后,真正成为“兜不住时才启动的应急协议”。

2.3 例外传播链:子程序出错,父程序如何知情?

PL/SQL的例外不是孤立事件,而是一条贯穿调用栈的传播链。理解这条链,才能避免“子过程挂了,主过程还傻乎乎往下走”的灾难。

假设你有一个主过程process_order,它内部调用validate_payment和update_inventory两个子过程。如果update_inventory因为ORA-01400(插入空值)抛出例外,而它自己没处理,这个例外会立即向上“冒泡”,传给process_order。此时,process_order的执行点会停在update_inventory调用的那一行,其后的所有代码(比如发通知、更新订单状态)都不会执行。

但问题来了:如果process_order自己也没写例外处理块,这个例外会继续向上传播,最终到达匿名块或客户端。这时,整个事务默认回滚——这是Oracle的保障。可如果process_order写了WHEN OTHERS THEN NULL;,那就糟了:例外被无声吞掉,process_order后续代码照常执行,订单状态被标记为“已处理”,但库存根本没扣减。用户付了钱,货却没发出去,这就是典型的“静默失败”。

因此,例外传播链的设计原则是:每个过程都应明确自己的责任边界。底层工具过程(如update_inventory)应专注数据操作,抛出清晰的例外;中间业务过程(如process_order)应捕获并翻译这些例外,决定是重试、降级还是终止;顶层入口过程(如API触发的匿名块)则负责最终的事务控制和用户反馈。

一个经过实战检验的模式是“三层例外策略”:

  • 数据层(包内私有过程):只抛出预定义或自定义例外,不处理,让错误暴露;
  • 服务层(公有过程):捕获数据层例外,转换为业务语义例外(如RAISE insufficient_stock;),并记录操作上下文;
  • 应用层(调用方):捕获服务层例外,执行事务回滚/提交,并返回用户友好的HTTP状态码或消息。

这种分层,让每个环节只关心自己该管的事,既避免了例外处理的重复劳动,又保证了错误信息在传递中不失真、不衰减。

3. 核心实操:从声明到捕获,手把手构建健壮例外流

3.1 预定义例外:21个高频场景,必须烂熟于心

Oracle预定义的21个例外,覆盖了95%以上的常见执行失败。它们不是可选项,而是你写PL/SQL时的“肌肉记忆”。下面按使用频率排序,给出每个的触发条件、典型场景、避坑要点和实操示例。

例外名称触发条件典型场景避坑要点实操示例
NO_DATA_FOUNDSELECT ... INTO未返回任何行查询用户信息、获取配置参数✅ 必须捕获,否则程序中断
❌ 不可用于UPDATE/DELETE的SQL%NOTFOUND判断
BEGIN SELECT email INTO v_email FROM users WHERE id = p_user_id; EXCEPTION WHEN NO_DATA_FOUND THEN v_email := 'default@company.com'; END;
TOO_MANY_ROWSSELECT ... INTO返回多于1行用ID查单条记录,但ID非唯一✅ 检查WHERE条件是否足够精确
❌ 不能用SQL%ROWCOUNT > 1替代,因为INTO本身已报错
`BEGIN SELECT name INTO v_name FROM products WHERE category = p_cat; -- 若category非唯一,必报此错 EXCEPTION WHEN TOO_MANY_ROWS THEN log_warning('Multiple products in category '
ZERO_DIVIDE数值除以零计算折扣率、平均单价✅ 除法前加IF divisor != 0 THEN防御
❌ 不要等报错再处理,性能损耗大
IF p_total_qty > 0 THEN v_avg_price := p_total_amt / p_total_qty; ELSE v_avg_price := 0; END IF;
DUP_VAL_ON_INDEX插入/更新违反唯一索引约束用户注册填重邮箱、订单号重复✅ 捕获后引导用户修改
❌ 不要WHEN OTHERS吞掉,丢失具体原因
INSERT INTO users(email, name) VALUES(p_email, p_name); EXCEPTION WHEN DUP_VAL_ON_INDEX THEN RAISE_APPLICATION_ERROR(-20001, '邮箱已被注册');
INVALID_NUMBER字符串转数字失败(TO_NUMBER)解析前端传来的价格字符串✅ 用REGEXP_LIKE(p_str, '^[0-9]+\.?[0-9]*$')预校验
❌ 避免在循环内频繁调用TO_NUMBER
`BEGIN v_num := TO_NUMBER(p_input); EXCEPTION WHEN INVALID_NUMBER THEN log_error('Invalid number format: '

其余16个(如CURSOR_ALREADY_OPEN,PROGRAM_ERROR,STORAGE_ERROR)虽出现频率低,但一旦触发,往往指向严重问题。例如STORAGE_ERROR(ORA-06500)表示PL/SQL引擎内存耗尽,这通常意味着你的游标没关闭、集合过大或递归过深,必须立刻检查资源管理,而不是简单捕获。

实操心得:别死记硬背21个名字。我的方法是,在开发环境建一个测试包,把每个预定义例外都写一个最小复现案例,亲自触发一次。比如专门写一段代码去制造LOGIN_DENIED(ORA-01017),虽然生产环境不会用错密码,但这个过程会让你深刻理解“连接级错误”和“执行级错误”的区别。只有亲手“捅过娄子”,才能在真正遇到时秒级定位。

3.2 非预定义例外:用PRAGMA绑定错误号,让隐性错误显性化

Oracle有2000多个错误号,但只有21个有预定义名字。剩下那些,比如ORA-00001(唯一约束)、ORA-01400(非空约束)、ORA-02291(外键约束),你必须手动“认领”它们,否则只能靠WHEN OTHERS这个笨办法。

PRAGMA EXCEPTION_INIT就是这个“认领”工具。它的语法是PRAGMA EXCEPTION_INIT(例外变量名, 错误号)。注意,错误号是负数(Oracle约定),且必须在声明段完成。

来看一个真实案例:某物流系统要求“同一运单号在同一时间只能有一条调度记录”。数据库加了唯一索引(waybill_no, schedule_time)。当并发插入相同运单号时,会报ORA-00001。如果不用PRAGMA,代码会这样写:

-- ❌ 错误示范:用WHEN OTHERS硬扛 INSERT INTO dispatch_records(waybill_no, schedule_time, driver_id) VALUES(p_waybill, p_time, p_driver); EXCEPTION WHEN OTHERS THEN IF SQLCODE = -1 THEN -- 手动判断错误号 RAISE_APPLICATION_ERROR(-20010, '运单号' || p_waybill || '在该时间已存在调度'); ELSE RAISE; -- 其他错误原样抛出 END IF;

问题在于:SQLCODE = -1这个判断,编译器无法检查,写错了(比如写成-01或1)要到运行时才发现;而且所有非预定义错误都挤在OTHERS里,逻辑混乱。

正确做法是提前声明并绑定:

CREATE OR REPLACE PROCEDURE create_dispatch(p_waybill VARCHAR2, p_time DATE, p_driver NUMBER) IS -- 声明一个例外变量 e_dup_waybill EXCEPTION; -- 用PRAGMA将其绑定到ORA-00001 PRAGMA EXCEPTION_INIT(e_dup_waybill, -1); BEGIN INSERT INTO dispatch_records(waybill_no, schedule_time, driver_id) VALUES(p_waybill, p_time, p_driver); EXCEPTION -- 现在可以像预定义例外一样,用名字捕获 WHEN e_dup_waybill THEN log_error('DUP_WAYBILL', p_waybill || '|' || TO_CHAR(p_time, 'YYYYMMDDHH24MISS')); RAISE_APPLICATION_ERROR(-20010, '运单号' || p_waybill || '在该时间已存在调度'); WHEN OTHERS THEN log_error('UNEXPECTED', 'SQLCODE='||SQLCODE||', SQLERRM='||SQLERRM); RAISE; END;

参数计算与选择逻辑:为什么选-1?因为ORA-00001的错误号就是-1(Oracle文档明确列出)。PRAGMA的作用就是在编译期建立“变量名 ↔ 错误号”的映射,这样运行时Oracle就知道,当抛出ORA-00001时,应该激活e_dup_waybill这个分支。这个映射是静态的、确定的,比运行时IF SQLCODE = -1判断更安全、更高效。

实操技巧:把常用非预定义错误号做成一个“速查表”贴在IDE边栏。我常用的有:-1(唯一约束),-1400(非空约束),-2291(外键不存在),-2292(子表有记录,父表不能删)。每次建新表加约束,顺手就把对应的PRAGMA声明写好,养成习惯。

3.3 用户自定义例外:用RAISE_APPLICATION_ERROR注入业务灵魂

预定义和非预定义例外,解决的是“数据库能不能干”,而用户自定义例外,解决的是“业务该不该干”。它是把业务规则编码进错误流的核心手段。

RAISE_APPLICATION_ERROR是创建它的唯一官方途径,语法为RAISE_APPLICATION_ERROR(错误号, 错误消息[, 是否回滚])。错误号必须在-20000到-20999之间,这是Oracle留给用户的专属区间。

来看一个库存管理的典型场景:扣减库存时,不仅要检查数据库里有没有这条记录,还要检查业务规则——比如“预售商品库存不能低于100件”。这个规则,数据库约束无法表达,必须由PL/SQL逻辑判断。

CREATE OR REPLACE PROCEDURE safe_reduce_stock(p_sku VARCHAR2, p_qty NUMBER) IS v_current_qty NUMBER; -- 声明业务例外 e_low_stock EXCEPTION; e_invalid_qty EXCEPTION; BEGIN -- 1. 查询当前库存 SELECT qty INTO v_current_qty FROM inventory WHERE sku = p_sku; -- 2. 业务规则检查:扣减量不能为负 IF p_qty <= 0 THEN RAISE e_invalid_qty; -- 主动抛出自定义例外 END IF; -- 3. 业务规则检查:扣减后库存不能低于安全线 IF v_current_qty - p_qty < 100 THEN RAISE e_low_stock; -- 主动抛出自定义例外 END IF; -- 4. 执行扣减 UPDATE inventory SET qty = qty - p_qty WHERE sku = p_sku; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20001, '商品SKU不存在: ' || p_sku); WHEN e_invalid_qty THEN RAISE_APPLICATION_ERROR(-20002, '扣减数量必须大于0'); WHEN e_low_stock THEN RAISE_APPLICATION_ERROR(-20003, '扣减后库存将低于安全线100,当前库存:' || v_current_qty); WHEN OTHERS THEN RAISE_APPLICATION_ERROR(-20004, '库存扣减失败: ' || SQLERRM); END;

为什么必须用RAISE_APPLICATION_ERROR而不是RAISE?
RAISE只能抛出已声明的例外变量(如e_low_stock),但它抛出的错误号是随机的(Oracle内部分配),调用方无法根据错误号做区分处理。而RAISE_APPLICATION_ERROR强制你指定一个确定的、范围内的错误号(如-20003),这样上层Java程序就能用if (sqlEx.getErrorCode() == -20003)精准识别“库存不足”,并触发相应的业务流程(如自动创建补货单)。

实操心得:自定义例外号要遵循“业务域+错误类型”编码规则。比如库存域用-201xx(-20101库存不足,-20102SKU不存在),订单域用-202xx(-20201订单状态非法,-20202支付超时)。这样,当DBA在日志里看到-20101,不用看代码就知道是库存问题,极大提升排障效率。

4. 完整实操:一个电商下单过程的例外处理全流程

4.1 需求与架构:下单不是一条SQL,而是一条风险链

我们来实现一个真实的电商下单核心过程place_order。它不是简单的INSERT INTO orders,而是一条包含4个关键环节的风险链:

  1. 库存预占:检查并锁定商品库存(防止超卖)
  2. 价格校验:确认下单时价格与当前价一致(防价格篡改)
  3. 用户余额扣减:从用户钱包扣款(涉及资金安全)
  4. 订单创建:生成订单主表及明细

每个环节都可能失败,且失败的影响范围不同:库存检查失败,可以提示用户“缺货”;价格校验失败,应拒绝下单并记录欺诈嫌疑;余额扣减失败,必须回滚所有前置操作,否则钱扣了但单没下成。

因此,这个过程的例外处理,必须是分层捕获、分级响应、统一回滚。

4.2 代码实现:嵌套例外块与事务控制

CREATE OR REPLACE PROCEDURE place_order( p_user_id NUMBER, p_sku_list sku_array, -- 自定义数组类型,存(SKU, qty)对 p_order_id OUT NUMBER ) IS -- 1. 声明所有可能用到的例外 e_sku_not_found EXCEPTION; e_insufficient_stock EXCEPTION; e_price_mismatch EXCEPTION; e_insufficient_fund EXCEPTION; e_system_error EXCEPTION; -- 2. PRAGMA绑定非预定义错误 PRAGMA EXCEPTION_INIT(e_sku_not_found, -1403); -- NO_DATA_FOUND的别名,更语义化 PRAGMA EXCEPTION_INIT(e_insufficient_fund, -1); -- ORA-00001,这里借用来表示余额唯一约束冲突(实际中可能用其他方式) -- 3. 局部变量 v_total_amount NUMBER := 0; v_current_price NUMBER; v_user_balance NUMBER; -- 4. 内部过程:校验单个SKU PROCEDURE validate_sku(p_sku VARCHAR2, p_req_qty NUMBER) IS v_stock_qty NUMBER; BEGIN -- 查询库存 SELECT qty INTO v_stock_qty FROM inventory WHERE sku = p_sku FOR UPDATE NOWAIT; IF v_stock_qty < p_req_qty THEN RAISE e_insufficient_stock; END IF; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE e_sku_not_found; WHEN TIMEOUT_ON_RESOURCE THEN -- 行锁等待超时 RAISE_APPLICATION_ERROR(-20050, '库存系统繁忙,请稍后重试'); END validate_sku; BEGIN -- 开启事务(隐式,PL/SQL块即事务边界) -- 步骤1:遍历SKU列表,逐个校验库存 FOR i IN 1..p_sku_list.COUNT LOOP validate_sku(p_sku_list(i).sku, p_sku_list(i).qty); -- 同时查询当前价格,累加总价 SELECT price INTO v_current_price FROM products WHERE sku = p_sku_list(i).sku; v_total_amount := v_total_amount + (v_current_price * p_sku_list(i).qty); END LOOP; -- 步骤2:校验用户余额 SELECT balance INTO v_user_balance FROM user_wallet WHERE user_id = p_user_id FOR UPDATE; IF v_user_balance < v_total_amount THEN RAISE e_insufficient_fund; END IF; -- 步骤3:扣减余额(注意:这里用UPDATE,不是INSERT) UPDATE user_wallet SET balance = balance - v_total_amount WHERE user_id = p_user_id; -- 步骤4:创建订单主表 INSERT INTO orders(order_id, user_id, total_amount, status, created_at) VALUES(seq_orders.NEXTVAL, p_user_id, v_total_amount, 'PAID', SYSDATE) RETURNING order_id INTO p_order_id; -- 步骤5:创建订单明细(循环插入) FOR i IN 1..p_sku_list.COUNT LOOP INSERT INTO order_items(order_id, sku, qty, price) VALUES(p_order_id, p_sku_list(i).sku, p_sku_list(i).qty, v_current_price); END LOOP; -- 所有步骤成功,提交事务 COMMIT; EXCEPTION -- 分级捕获:业务规则错误,返回用户友好提示 WHEN e_sku_not_found THEN ROLLBACK; RAISE_APPLICATION_ERROR(-20101, '商品不存在,请检查SKU'); WHEN e_insufficient_stock THEN ROLLBACK; RAISE_APPLICATION_ERROR(-20102, '库存不足,无法下单'); WHEN e_price_mismatch THEN ROLLBACK; RAISE_APPLICATION_ERROR(-20103, '商品价格已变动,请刷新页面重试'); WHEN e_insufficient_fund THEN ROLLBACK; RAISE_APPLICATION_ERROR(-20104, '账户余额不足'); -- 系统级错误:记录详细日志,通知运维 WHEN OTHERS THEN ROLLBACK; -- 记录结构化错误日志(写入error_log表) INSERT INTO error_log(error_code, error_msg, module, params, created_at) VALUES(SQLCODE, SQLERRM, 'PLACE_ORDER', 'user_id='||p_user_id||', sku_list='||p_sku_list.COUNT, SYSDATE); COMMIT; -- 确保日志写入 RAISE_APPLICATION_ERROR(-20999, '系统繁忙,请稍后重试。错误代码:' || SQLCODE); END place_order;

关键设计解析:

  • FOR UPDATE NOWAIT:在库存查询时立即加锁,避免长时间等待。如果锁被占用,直接抛TIMEOUT_ON_RESOURCE,而不是让用户等几秒再失败。
  • 嵌套过程validate_sku:将库存校验封装成独立单元,其内部的例外(如NO_DATA_FOUND)被转换为更高层的e_sku_not_found,实现了错误语义的升级。
  • RETURNING INTO:在INSERT时直接获取生成的order_id,避免额外查询,减少竞态风险。
  • ROLLBACK的位置:每个业务例外分支都显式ROLLBACK,确保事务干净结束;OTHERS分支也ROLLBACK,但额外做了日志落盘(COMMIT日志表),这是生产环境的黄金准则——错误日志必须持久化,不能随主事务回滚而丢失。

4.3 测试用例:用真实错误场景验证例外流

光写代码不够,必须用真实错误场景“锤炼”它。以下是我在测试环境中必跑的5个用例:

  1. SKU不存在:传入一个数据库里根本没有的p_sku。预期:捕获e_sku_not_found,抛出-20101,日志表无记录(因为ROLLBACK前没到日志写入点)。
  2. 库存不足:传入一个库存为50的SKU,但请求扣减100。预期:捕获e_insufficient_stock,抛出-20102。
  3. 余额不足:用户余额100,但订单总价200。预期:捕获e_insufficient_fund,抛出-20104。
  4. 并发超卖:用两个会话同时对同一SKU下单,且库存刚好等于总需求(如库存10,两个会话各要5)。预期:第二个会话在SELECT ... FOR UPDATE NOWAIT时立即报ORA-00054,进入OTHERS分支,写入error_log表,抛出-20999。
  5. 价格篡改:在validate_sku查询价格后,手动UPDATE products SET price = price * 2 WHERE sku = 'xxx',再执行扣减。预期:UPDATE user_wallet成功,但INSERT orders后的INSERT order_items会因价格不一致失败(此处简化,实际应在扣款前二次校验),触发OTHERS。

实操心得:测试时,一定要打开SQL*Plus的SET SERVEROUTPUT ON,并在关键点加DBMS_OUTPUT.PUT_LINE。但记住,这只是调试手段,生产代码里必须删除所有DBMS_OUTPUT,因为它会严重拖慢性能。真正的日志,只应写入error_log这样的专用表,并配好分区和清理策略。

5. 常见问题与独家避坑指南

5.1 “例外被吞了!”——WHEN OTHERS THEN NULL的七宗罪

这是PL/SQL新手最常犯、也是最致命的错误。WHEN OTHERS THEN NULL;看似“安静”,实则埋下无数雷。

罪状一:掩盖真实错误,让问题雪球越滚越大
某次线上故障,订单状态卡在“支付中”,查日志一片空白。最后发现,一个关键的UPDATE语句后面,跟着WHEN OTHERS THEN NULL;。而那个UPDATE失败的真实原因是ORA-01555(快照过旧),因为事务太长。NULL吞掉后,程序继续执行,把订单状态设为“已支付”,但库存根本没扣。用户付了钱,货没发,客服电话被打爆。

罪状二:破坏事务一致性

BEGIN INSERT INTO t1 VALUES(1); INSERT INTO t2 VALUES(2); -- 这里报ORA-00001 EXCEPTION WHEN OTHERS THEN NULL; -- 吞掉错误! END;

执行后,t1里多了一条记录,t2没插入,事务没回滚。数据处于不一致状态。

罪状三:让调试变成玄学
当你在WHEN OTHERS里只写NULL,等于主动放弃了所有线索。下次出问题,你只能靠SELECT * FROM v$session_longops猜哪个会话在干啥,效率极低。

解决方案:永远用WHEN OTHERS THEN做三件事

  1. 记录:INSERT INTO error_log ...写入结构化日志;
  2. 告警:调用UTL_MAIL.SEND或写入消息队列,触发运维告警;
  3. 重抛:RAISE;(无参数),把错误原样抛给上层,让调用方决定如何收场。

提示:如果业务逻辑确实要求“忽略某个特定错误”,请务必用PRAGMA EXCEPTION_INIT绑定它,然后写WHEN specific_exception THEN NULL;。例如,WHEN e_no_such_file THEN NULL;(文件不存在就跳过),这比WHEN OTHERS安全一万倍。

5.2 “回滚没生效?”——SAVEPOINT与部分回滚的精确控制

有时,你不想整个事务回滚,只想回滚到某个中间点。比如下单过程,库存预占失败了,但之前的价格校验和用户查询已经成功,这些查询操作其实可以保留,只需回滚预占部分。

这时,SAVEPOINT就是你的手术刀。

BEGIN -- 步骤1:查询用户信息(无副作用,可保留) SELECT name INTO v_user_name FROM users WHERE id = p_user_id; -- 步骤2:设置保存点,标记“从此处开始的操作可撤销” SAVEPOINT before_stock_check; -- 步骤3:库存预占(可能失败) SELECT qty INTO v_stock FROM inventory WHERE sku = p_sku FOR UPDATE NOWAIT; IF v_stock < p_qty THEN RAISE e_insufficient_stock; END IF; -- 步骤4:如果走到这里,说明库存OK,继续后续... ... EXCEPTION WHEN e_insufficient_stock THEN -- 只回滚到保存点,用户查询结果v_user_name依然有效 ROLLBACK TO before_stock_check; -- 然后可以尝试其他逻辑,比如推荐替代商品 recommend_alternative(p_sku); END;

关键点:SAVEPOINT名字是局部的,只在当前PL/SQL块内有效;ROLLBACK TO savepoint_name不会结束事务,后续仍可COMMIT或ROLLBACK整个事务;一个块内可设多个保存点,形成嵌套回滚能力。

注意:SAVEPOINT不能跨过程调用。如果你在子过程中设了保存点,父过程无法ROLLBACK TO它。所以,保存点的粒度要和过程职责对齐。

5.3 “日志写不进去!”——错误日志表的事务陷阱

很多团队会建一个error_log表来记录错误,但常犯一个致命错误:在主事务里INSERT INTO error_log,然后ROLLBACK。结果是,错误日志和主数据一起被回滚了,什么都没留下。

正确姿势:自治事务(Autonomous Transaction)
自治事务是一个

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

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

立即咨询