跑批凌晨突然短信告警,一张大表的存储过程执行到一半卡死;开发环境里明明好好的,生产上却抛了ORA-01403;加了异常处理之后,业务反而更慢了……这些场景你是否熟悉?我在不少基于KingbaseES的迁移和运维项目里,发现PLSQL异常处理几乎是被误解最深的部分。KingbaseES作为国内主流的商用关系型数据库,兼容Oracle的PL/SQL语法,但兼容不等于完全一致。异常处理的机制,直接决定了你的存储过程是“健壮”还是“纸糊”,也决定了线上出问题时你是能快速定位还是两眼一抹黑。
这篇文章不是给你抄教科书定义,而是把我这些年排查异常、优化存储过程的经验拆开来讲:异常体系怎么运转、捕获与抛出的正确姿势、事务和异常如何纠缠、批量作业里的坑、以及最后怎么用一张日志表和几条设计规范让异常变得可观测、可控制、高性能。适合正在写PLSQL的开发者、做Oracle迁移KingbaseES的工程师,以及经常被存储过程坑得团团转的DBA。
1. 异常处理机制:KingbaseES的PLSQL错误体系
1.1 为什么异常处理容易被低估
很多刚接触PLSQL的人觉得,在代码里加个EXCEPTION段就算“做了异常处理”。其实异常处理不只是“出错时给个友好提示”,它决定了你的业务逻辑在失败时如何收场:是整体回滚,还是跳过坏数据继续跑?是抛出原始错误,还是包装成业务错误?是只记录错误码,还是把完整堆栈和参数快照都留下来?
在KingbaseES里,PLSQL的异常处理模型与Oracle高度一致,但正因为“高度一致”,很多人直接把Oracle的写法搬过来,却没注意到底层行为差异。比如异常块内DML语句的回滚边界、SQLCODE/SQLERRM在不同场景下的取值规则、嵌套块中异常对外层游标的影响。这些细节只有在生产环境踩过坑,才会明白它们有多致命。
KingbaseES采用PL/SQL引擎兼容层来支持异常机制,触发异常时,控制权会从异常发生点立即跳转到当前块或外层块的异常处理段。这个跳转意味着,异常发生点之后的任何语句都不再执行,整个块内的局部变量状态处于“半完成”状态。理解了这一点,你才会明白为什么异常处理段不应该做太复杂的业务计算,它更适合做“记录现场、判断可否恢复、然后决定是吞掉、重抛还是转抛”。
1.2 KingbaseES异常类型全景
KingbaseES的PLSQL异常体系可以分成三类:预定义异常、非预定义异常、用户自定义异常。
预定义异常就是系统内置的错误条件,不需要声明,直接使用异常名字即可。比较常用的包括:
- NO_DATA_FOUND:SELECT INTO或游标取数找不到记录,ORA-01403
- TOO_MANY_ROWS:SELECT INTO返回多行,ORA-01422
- ZERO_DIVIDE:除零错误,ORA-01476
- DUP_VAL_ON_INDEX:违反唯一约束,ORA-00001
- VALUE_ERROR:赋值或转换错误,ORA-06502
- INVALID_CURSOR:非法游标操作,如果游标未打开就关闭,取第0行,ORA-01001
- TIMEOUT_ON_RESOURCE:资源等待超时,ORA-00051
非预定义异常是针对Oracle错误码但系统没有给出对应名字的异常。核心是使用PRAGMA EXCEPTION_INIT把错误码绑定到一个自定义异常名上。例如ORA-02292是违反外键约束,你可以在声明部分写:
DECLARE FK_VIOLATION EXCEPTION; PRAGMA EXCEPTION_INIT(FK_VIOLATION, -2292); BEGIN DELETE FROM orders WHERE id = 100; EXCEPTION WHEN FK_VIOLATION THEN DBMS_OUTPUT.PUT_LINE('存在子记录,不能删除'); END;用户自定义异常则完全由你定义,在需要的时候用RAISE语句主动触发。这是业务规则校验最常用的方式,比如余额不足、状态不合法、参数越界等。
这三类异常在KingbaseES里使用方式基本一致,但你需要记住一点:预定义异常名对应的错误码,尽量别手动绑定重复的PRAGMA EXCEPTION_INIT,否则会有歧义。KingbaseES虽然允许绑定,但不同版本对同名异常的解析可能存在差异,为了不给自己留隐患,建议只绑定那些没有预定义名的错误码。
1.3 异常传播与控制流程
PLSQL的异常传播逻辑可以归结为一句话:异常会从触发点逐层向外寻找可以处理它的EXCEPTION段,如果在当前块找不到匹配项,就传播到调用它的外层块;如果一直到最外层都没有处理,语句层面会直接报错返回给调用方。
这里的“块”指的是BEGIN...END结构,也包括存储过程、函数、匿名块。每个块都可以有独立的EXCEPTION段,这个段只处理当前块内、或者当前块调用的内部块中“未处理”的异常。内部块如果自己处理了异常,外层看不见;内部块如果没有处理,外层才接手。
一个容易混淆的地方是:异常传播不是“异常发生点所在的块未捕获,马上跳到外层块”,而是“当前块终结并回滚局部对象后,再跳到外层”。这意味着在处理异常时,当前块内之前修改的局部变量也会受到影响吗?不会。PLSQL的变量是绑定块执行期间的,异常导致块退出,变量释放。如果你在内部块里修改了外部变量,异常发生后该变量仍然保留修改后的值,除非你显式设置了回滚逻辑。这一点和事务不同,很多人误以为异常可以自动恢复变量状态,其实不能。
在设计嵌套块时,有一条原则需要刻在脑子里:内部块负责“恢复现场”,外层块负责“决定去向”。内部块捕获异常后,如果把能恢复的操作都做完了,不要让异常继续传播;如果内部块本身无法决策,那么可以用RAISE把异常给外层,让外层统一记录和抛错。这样才能避免每一层都重复写错误日志,导致日志爆炸。
2. 核心语法与写入细节:从捕获到抛出
2.1 EXCEPTION段基础写法与“吞异常”陷阱
最基础的异常段结构就是BEGIN...EXCEPTION...END。常见写法是:
BEGIN SELECT salary INTO v_salary FROM emp WHERE emp_id = v_id; EXCEPTION WHEN NO_DATA_FOUND THEN v_salary := 0; WHEN OTHERS THEN DBMS_OUTPUT.PUT_LINE(SQLCODE || ':' || SQLERRM); END;看起来没问题,但这里潜伏着一个巨大的坑:WHEN OTHERS分支把异常吞掉了,而且没有重新抛出,也没有通过返回值通知上层。如果这个块被封装成函数,调用方根本不知道发生了错误,只拿到一个默认返回值。线上经常出现“数据对不上”的疑难问题,最后查下来就是异常被某个WHEN OTHERS吞掉了。
所以我建议:任何WHEN OTHERS分支,要么写日志后重新RAISE,要么至少通过输出参数或返回值明确标记“有错误”。最好不要写“裸吞”的WHEN OTHERS。如果确实有些可预期的业务异常可以吞,比如NO_DATA_FOUND这种“记录不存在视为正常”的场景,那也要有清晰的注释,让维护的人知道这里是有意吞掉。
还要注意一个细节:在EXCEPTION段内,SQLCODE和SQLERRM获取的是“当前被处理异常的错误码和消息”。如果你想保存它们,必须在进入EXCEPTION段的最开始就赋值给局部变量,否则如果在处理过程中又执行了SQL语句,SQLCODE和SQLERRM的值就可能会被新的错误覆盖。KingbaseES的兼容层在这一点上是和Oracle一致的,稳妥做法是先保存,再用。
2.2 RAISE与RAISE_APPLICATION_ERROR:何时选择哪个
RAISE有两种用法。第一种是重新抛出当前异常,直接写“WHEN OTHERS THEN RAISE;”就行。第二种是主动抛出用户自定义异常,比如“RAISE balance_not_enough;”。
重新抛出用RAISE,它能保留原始异常的上下文,调用方还能通过SQLCODE、SQLERRM获得原始错误信息,并且错误堆栈中的行号会指向你RAISE的那个位置,而不是原始出错点。这一点有利有弊:好处是你能在异常处理段附加自己的逻辑后把“接力棒”传出去;坏处是如果你在多个嵌套层反复抛,堆栈信息会变得混乱,需要借助FORMAT_ERROR_BACKTRACE才能理清调用链。
RAISE_APPLICATION_ERROR则用于抛出自定义错误码和消息,它的错误码范围限制在-20000到-20999之间。作用是把底层技术错误翻译成业务错误,同时让调用方用统一的错误码做分支处理。比如:
RAISE_APPLICATION_ERROR(-20001, '库存不足,当前库存:' || v_stock);需要注意,RAISE_APPLICATION_ERROR会覆盖原始的SQLCODE/SQLERRM信息。如果底层其实还有一个数据库错误,一旦你调用了RAISE_APPLICATION_ERROR,调用方只能看到-20001,看不到底层错误。所以业务包装前,一定要先记录原始错误到日志表,避免丢失根因。
关于选择,我的经验是:技术异常(如资源冲突、数据库错误)尽量用RAISE保留原始信息;业务异常(如余额不足、状态非法)用RAISE_APPLICATION_ERROR定义清晰消息。两者分工明确,不要混着用。
2.3 SQLCODE、SQLERRM与FORMAT_ERROR_BACKTRACE
SQLCODE是数值型错误码,SQLERRM是错误消息。它们是异常处理中最常用的诊断函数。但SQLERRM有一个长度限制,通常返回的字符长度有限,超长会被截断,而且如果反复调用SQLERRM,也可能返回“ORA-01000: maximum open cursors exceeded”这种误导性的错误,因为它在内部可能触发新的上下文。所以我很少直接依赖SQLERRM做完整记录,通常只用来抓核心错误码。
更可靠的是DBMS_UTILITY.FORMAT_ERROR_BACKTRACE,它能返回从当前异常点到最外层调用点的完整调用堆栈,包括每个调用点的所在对象名、行号等。这个函数在KingbaseES中有对应的实现,迁移自Oracle的代码基本可以直接用。
比如在日志表里记录错误:
l_backtrace := DBMS_UTILITY.FORMAT_ERROR_BACKTRACE; l_err_msg := SQLERRM; INSERT INTO proc_err_log(proc_name, err_code, err_msg, err_stack) VALUES(procedure_name, SQLCODE, l_err_msg, l_backtrace);注意把SQLCODE和SQLERRM先存到局部变量再存入表,原因前面说过了。另外,FORMAT_ERROR_BACKTRACE只能在异常状态被激活时调用,也就是必须在EXCEPTION段里调用才有效。如果你把这个函数放在异常段之外,得到的值通常是NULL或空字符串。这也是新手常踩的坑。
2.4 异常编译与运行时行为:几个边界认知
- 预定义异常不一定都是运行期抛出的。有的错误,比如访问越界、类型不匹配,可能编译期就报错,根本进不了运行时。所以异常处理只针对运行时能触发的情况。
- 一个BEGIN块中,如果异常被捕获并处理,块的执行就算“成功完成”,不会重新触发相同异常。但是块中在该异常发生之前已经执行的DML语句,并不会因为异常被捕获而自动回滚。这在KingbaseES中与Oracle一致。也就是说,下面是安全措辞吗,需要说明DML回滚呢?仔细考虑:单个PLSQL块不是事务边界。如果块内有多个DML,第一个insert成功,第二个update出错,异常被捕获后,第一个insert仍然已经修改数据。如果没有显式ROLLBACK,事务仍会持锁。所以异常处理段必须明确事务控制策略。很多开发以为“异常了就会回滚”,这个认知一定要纠正。
- 异常传播到最外层后,存储过程不会自动ROLLBACK整个事务,仍需调用方或过程内显式回滚。这也是为什么批量任务中异常处理策略必须和事务边界结合,否则很容易导致部分提交、部分回滚的脏数据。
3. 实战:一个订单处理存储过程的异常设计
3.1 场景描述与表结构
假设我们要做一个批量订单状态更新并扣减库存的过程。输入一批订单ID,逐个处理,允许单个订单出错但其他订单继续。同时要求每个失败订单记录原因,最终返回成功与失败的数量。这是电商、ERP里非常常见的“部分成功”需求。
简化表结构:
CREATE TABLE orders ( order_id NUMBER PRIMARY KEY, status VARCHAR2(10), stock_qty NUMBER ); CREATE TABLE inventory ( item_id NUMBER PRIMARY KEY, qty NUMBER );过程输入一个订单号集合,遍历处理。这里我用一个数组作为示意,实际业务可能是游标或临时表。
3.2 第一步:声明自定义异常与错误日志表
先建错误日志表,字段尽量全量覆盖诊断需要:
CREATE TABLE proc_err_log ( log_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, proc_name VARCHAR2(100), err_code NUMBER, err_msg VARCHAR2(1000), err_backtrace VARCHAR2(4000), biz_data VARCHAR2(2000), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );biz_data字段非常重要,用于记录导致失败的业务入参。比如订单号、处理批次号,这样排查时不需要反查上下文。
然后在过程中声明自定义异常,比如订单状态非法:
DECLARE CURSOR cur_orders IS SELECT order_id, status, stock_qty FROM orders WHERE ...; v_success_cnt NUMBER := 0; v_fail_cnt NUMBER := 0; e_invalid_status EXCEPTION; PRAGMA EXCEPTION_INIT(e_invalid_status, -20001); BEGIN ... EXCEPTION WHEN OTHERS THEN NULL; END;这里把PRAGMA绑定到-20001,与RAISE_APPLICATION_ERROR的自定义错误码互通,这样业务抛出的异常也能在这里被捕获。
3.3 第二步:主逻辑中如何分段处理
核心思路是:外层循环,每个订单用一个内部BEGIN块包起来,块内负责业务处理和异常捕获,块外记录失败信息并继续下一个订单。
BEGIN FOR r IN cur_orders LOOP BEGIN -- 业务校验 IF r.status = 'INVALID' THEN RAISE e_invalid_status; END IF; -- 扣减库存 UPDATE inventory SET qty = qty - r.stock_qty WHERE item_id = (SELECT item_id FROM order_items WHERE order_id = r.order_id); UPDATE orders SET status = 'PROCESSED' WHERE order_id = r.order_id; v_success_cnt := v_success_cnt + 1; EXCEPTION WHEN NO_DATA_FOUND THEN v_fail_cnt := v_fail_cnt + 1; INSERT INTO proc_err_log(proc_name, err_code, err_msg, biz_data) VALUES('batch_process_orders', 'NO_DATA_FOUND', '对应订单明细不存在', 'order_id=' || r.order_id); WHEN OTHERS THEN v_fail_cnt := v_fail_cnt + 1; INSERT INTO proc_err_log(proc_name, err_code, err_msg, err_backtrace, biz_data) VALUES('batch_process_orders', SQLCODE, SUBSTR(SQLERRM,1,500), DBMS_UTILITY.FORMAT_ERROR_BACKTRACE, 'order_id=' || r.order_id); END; END LOOP; RETURN (v_success_cnt, v_fail_cnt); END;这里每一个内部BEGIN块等于一个独立“救生舱”,即使某条订单炸了,也只影响自己。没有异常传播到外层,所以外层循环能继续跑。这就避免了“一条脏数据拖垮整个跑批”的灾难。
这里有人会问,内部块捕获了异常,那么块内UPDATE库存和UPDATE订单是部分执行的吗?是的,因为异常发生时,前面的UPDATE可能已经执行。比如库存扣了,但状态更新失败。这样会造成数据不一致。要解决这个问题,就需要在每个订单的开始设置一个保存点,或者让内部块的DML组成一个“最小事务单元”。但PLSQL块内部没有独立事务,最常用的做法是在内部块开头设置SAVEPOINT,在异常捕获后ROLLBACK TO SAVEPOINT,把该订单的部分修改撤销。
3.4 第三步:用保存点隔离单条记录
在循环内加SAVEPOINT,异常后回滚到保存点:
FOR r IN cur_orders LOOP BEGIN SAVEPOINT sp_order; -- 业务校验、扣库存、更新状态 ... EXCEPTION WHEN OTHERS THEN ROLLBACK TO sp_order; -- 记录日志,继续 END; END LOOP;注意,ROLLBACK TO SAVEPOINT只回滚到保存点之后的操作,不会回滚保存点之前的事务。这样既隔离了单条订单的脏写,又不会把前面订单已提交的部分(或未提交但不想回滚的部分)一起回滚。很多人以为异常处理不需要事务控制,实际上绝大对数PLSQL踩坑事故,都出在“没有在内部块里做保存点回滚,导致部分更新残留”。
当然,使用SAVEPOINT也有代价:它会增加事务管理的开销,尤其当循环量特别大时,保存点数量会很多。KingbaseES下保存点的资源消耗需要关注,如果单次批处理几十万单,建议每处理100个订单用一个保存点,而不是每条一个保存点。这种微调能显著减少事务内部状态的开销,后面在优化部分再细说。
3.5 第四步:最终提交策略与错误汇总
整体过程执行完后,在最外层统一COMMIT。失败单单靠内层记录日志,但日志的INSERT与业务操作在同一个事务里吗?这是个关键问题。如果内层发生了异常并ROLLBACK TO SAVEPOINT,那么该保存点之后插入的日志也会被回滚掉!因为日志INSERT也在保存点之后。
这就是一个非常隐蔽的坑:你想记录错误日志,却可能被自己的事务控制回滚掉。解决方法是,把日志INSERT放到保存点之前,或者使用自治事务记录日志。
自治事务最简单:把日志表写入封装成PRAGMA AUTONOMOUS_TRANSACTION的过程,每次记录错误独立提交,不随主事务回滚。这是批处理日志的标配方案。
PROCEDURE log_error(p_proc IN VARCHAR2, p_code IN NUMBER, p_msg IN VARCHAR2, p_stack IN VARCHAR2, p_biz IN VARCHAR2) IS PRAGMA AUTONOMOUS_TRANSACTION; BEGIN INSERT INTO proc_err_log(...) VALUES(...); COMMIT; END;使用自治事务时,主事务的回滚不会影响日志表,日志本身独立提交。这在真正意义上实现了“业务提交与否,错误一定有据可查”。
4. 常见异常场景与排查实录
4.1 查询卡死:SQL性能以外的异常处理误区
“PLSQL查询卡死”是很常见的热门搜索词。很多人第一时间想到的是索引缺失、统计信息过期、锁等待,但有时真正的原因是异常处理逻辑造成的死循环或无限递归。
举个例子,某个存储过程在循环里处理数据,内部块捕获异常后,判断错误码是某个可重试的错误,于是跳到循环开始重试。但由于业务参数没有变化,每次进去都抛同样异常,形成一个永不停歇的循环。表面看起来就是查询卡死。此时你去看数据库会话,它可能在反复执行同一UPDATE。
排查思路:首先确认会话状态是“Active”还是“Idle”,Active且等待事件通常是SQL执行慢或锁;如果CPU高且反复出现相同SQL,大概率是PLSQL死循环。这时候要抓最后的错误日志,看看是否存在同一业务数据被反复记录。处理办法很简单:给重试加次数上限,同时每次重试前改变状态或条件,不能让它在原地踏步。
另一种“卡死”是异常处理段里无意中执行了SQL,而这张表被自己的事务锁住,造成自我死锁。比如在异常段里查询一张被当前事务修改未提交的表,如果查询试图加锁(比如SELECT FOR UPDATE),就会等锁。排查这类问题的方向是检查异常段是否只有轻量操作,避免写业务SQL。
4.2 数据未找到与太多行:两个典型异常
NO_DATA_FOUND有两个触发点:SELECT INTO没有返回行,或者显式游标FETCH后访问列值。后者要小心:游标打开后,第一条FETCH如果返回空,再访问游标变量就会触发NO_DATA_FOUND。开发容易误以为是查询无数据,实际上可能是游标状态不对。
TOO_MANY_ROWS则是SELECT INTO返回多行。这也是常见问题,经常是IN子查询漏了去重条件,一旦源数据重复,整个存储过程直接中断。我建议把“唯一性校验”前置,在主逻辑前先检查是否有重复数据,然后在处理时使用有明确约束的查询,让数据库在可能返回多行之前就报错,而不是等到SELECT INTO这一步才炸。
还有一个策略是:使用游标或BULK COLLECT来避免SELECT INTO多行问题。如果预期最多返回一行,那就用显式游标+FETCH一次并判断是否有多行,代码稍微复杂,但更稳。
4.3 非预定义异常绑定实战:唯一冲突不再慌
唯一键冲突是DUP_VAL_ON_INDEX,可以直接捕获,但在批量INSERT中,如果依赖异常捕获来做“存在则跳过”,性能会非常差。更合理的方式是先用MERGE或WHERE NOT EXISTS过滤,把异常当成最后的防线。
如果确实要在并发环境下处理唯一冲突,使用EXCEPTION_INIT绑定ORA-00001:
DECLARE e_dup EXCEPTION; PRAGMA EXCEPTION_INIT(e_dup, -1); BEGIN INSERT INTO emp(emp_id, emp_name) VALUES(v_id, v_name); EXCEPTION WHEN e_dup THEN UPDATE emp SET emp_name = v_name WHERE emp_id = v_id; END;这里注意,绑定-1而不是-00001。PRAGMA EXCEPTION_INIT是用数值绝对值,习惯上写负号。如果写错成-00001这种字符串形式,编译器直接报错。
4.4 大规模批量作业中的异常“放大”效应
异常捕获本身是有开销的,尤其是在循环中频繁抛出异常。KingbaseES模拟Oracle的异常机制,每抛一次异常,都要进行上下文切换、错误堆栈构建。如果你把异常当作流程控制手段,在10万行的循环里故意用异常去触发没数据、重复键、状态非法等条件,性能会惨不忍睹。
我见过一个案例:开发人员为了处理批量数据,在循环中SELECT INTO,如果NO_DATA_FOUND就RAISE一个自定义异常,再捕获后跳过。等于每处理一行,至少进行一次异常抛出和捕获,性能比正常逻辑慢了两个数量级。正确做法是先用COUNT(*)判断是否存在,或者使用游标FETCH,FETCH自然返回NO_DATA_FOUND但不能作为正常流程控制。
所以优化原则很明确:异常只用于“异常”情况,不要用于“正常”业务分支。可预期的业务判断用IF,数据存在性用SQL聚合,唯一冲突尽量用前置约束。
5. 优化实践:让异常处理变得可控、可观测、高性能
5.1 异常处理设计规范(个人项目沉淀)
我整理过一套适合国产生态下的PLSQL异常处理规范,这里分享核心几点:
- 每个存储过程、函数必须显式声明异常处理策略,不允许裸写WHEN OTHERS THEN NULL。
- WHEN OTHERS捕获后,第一件事就是保存SQLCODE、SQLERRM、错误堆栈到变量。
- 内部块只捕获自己能够处理的异常;无法处理的异常必须向上传播,不能隐藏。
- 所有错误日志写入使用自治事务,避免日志业务被主事务牵连。
- 在批量循环中,单条失败要ROLLBACK TO SAVEPOINT并继续,但不能吞掉根本原因,日志必须记录原始错误码。
- 任何自定义错误码使用-20001到-20999,并建立错误码清单文档,避免不同模块冲突。
- 禁止在异常处理段中执行复杂的DML、远程调用、动态SQL,保持异常段“轻”。
- 尽量不要让异常处理依赖全局变量或包的全局状态,以免前一次异常污染下一次调用。
这套规范不是理论堆砌,是从几个项目里提炼出来的。遵守它,能避免大部分“跑批失败但查不出原因”的惨剧。
5.2 错误日志表的优化设计
除了之前建的简单日志表,更完整的错误日志表还应该包含:
| 字段名 | 类型 | 说明 |
|---|---|---|
| log_id | NUMBER | 自增主键 |
| app_module | VARCHAR2(50) | 模块名,区分业务系统 |
| proc_name | VARCHAR2(100) | 存储过程/函数名 |
| line_no | NUMBER | 出错行号,取自FORMAT_ERROR_BACKTRACE解析 |
| err_code | NUMBER | SQLCODE |
| err_msg | VARCHAR2(2000) | SQLERRM或自定义消息 |
| err_backtrace | CLOB | 完整调用堆栈 |
| biz_id | VARCHAR2(100) | 业务主键,如订单号 |
| biz_param | VARCHAR2(2000) | 业务参数快照 |
| occur_time | TIMESTAMP | 错误发生时间 |
| thread_id | VARCHAR2(64) | 会话或线程标识,便于并发排查 |
| status | VARCHAR2(10) | 错误状态:NEW/PENDING/RESOLVED |
其中err_backtrace用CLOB而不是VARCHAR2,避免较长的堆栈被截断。biz_param建议在调用存储过程时,把关键入参拼成JSON字符串传入,这样排查时不光能看到错误,还能还原当时场景。
日志表的写入频率也很重要。如果批处理每失败一行就插入一条日志,错误量很大时,自治事务会产生大量提交,影响整体性能。建议把日志记录先放到内存数组,每攒够一定批量(比如100条)再通过自治事务批量插入。但要注意,如果会话异常终止,内存中的日志会丢失。对于高价值交易场景,还是实时落日志更稳妥;对海量对账场景,可以异步批量。
5.3 批量循环与保存点的微优化
回到订单处理的场景,如果数据量达到数十万,逐条SAVEPOINT会导致事务内保存点数量过多,内存占用和锁开销都很明显。更优的设计是“分段提交加组内保存点”:
- 每处理N条(比如500条)执行一次COMMIT,把事务分成多个小段。
- 在每一段内部,对单条失败使用SAVEPOINT回滚到段内位置。
- 但事务已经提交的段不受影响,达到“部分成功”和“失败隔离”的平衡。
伪代码:
v_batch_size := 500; v_count := 0; FOR r IN cur LOOP v_count := v_count + 1; SAVEPOINT sp_seg; BEGIN -- 处理逻辑 v_success_cnt := v_success_cnt + 1; EXCEPTION WHEN OTHERS THEN ROLLBACK TO sp_seg; log_error(...); v_fail_cnt := v_fail_cnt + 1; END; IF MOD(v_count, v_batch_size) = 0 THEN COMMIT; END IF; END LOOP; COMMIT;这个方案的好处是:单条失败只回滚该条,不会影响同段内已经成功的记录;段提交后,即使后续发生异常,已提交部分也不会回滚。坏处是如果批量处理中途数据库崩溃,最后未提交的段会丢,但业务端可以通过日志表进行补偿。大多数批处理场景都能接受这种权衡。
另外,对于BULK COLLECT + FORALL,可以用SAVE EXCEPTIONS子句。FORALL在处理数组时,如果某个元素出错,可以保存异常继续执行剩余元素,结束后通过SQL%BULK_EXCEPTIONS查看错误索引和错误码。这是批量DML异常处理的利器,比逐行游标效率高很多。例如:
BEGIN FORALL i IN 1..v_ids.COUNT SAVE EXCEPTIONS UPDATE orders SET status = 'PROCESSED' WHERE order_id = v_ids(i); EXCEPTION WHEN OTHERS THEN FOR j IN 1..SQL%BULK_EXCEPTIONS.COUNT LOOP v_idx := SQL%BULK_EXCEPTIONS(j).ERROR_INDEX; v_code := SQL%BULK_EXCEPTIONS(j).ERROR_CODE; log_error('FORALL_UPDATE', v_code, '索引:' || v_idx); END LOOP; END;注意,一旦FORALL触发SAVE EXCEPTIONS并捕获异常,前面所有已执行的更新都会保留,并不会回滚。如果需要原子性,必须在FORALL外层控制事务,必要时在异常处理后显式回滚。使用SAVE EXCEPTIONS时错误码保存在SQL%BULK_EXCEPTIONS中,不能使用SQLCODE获取,因为此时SQLCODE可能不是具体错误的最后状态。这也是一个容易误用的点。
5.4 结合KingbaseES特性的稳定性增强
KingbaseES对Oracle PLSQL有高兼容度,但仍有一些差异需要在异常处理中规避:
- 动态SQL中的异常处理。如果使用EXECUTE IMMEDIATE执行动态SQL,动态SQL内部的异常会被传递到外层块,但错误堆栈可能只显示动态SQL执行点,不带内部对象名。因此,对于动态SQL,建议在拼接SQL时额外带上标识信息,或者用日志把动态语句原文记录下来,便于定位。
- 包状态与异常。包级变量在会话内是持久的,如果在异常处理中修改了包级变量,下一次调用会读到残留数据,造成隐性状态污染。所以在异常处理段尽量不修改包级状态,或在过程开始时统一重置。
- 自治事务与主事务隔离。KingbaseES的自治事务在异常传播时,不会随主事务回滚,这一点与Oracle一致,但也意味着日志表的空间增长要定期维护,避免日志表无限膨胀拖垮数据库。
- 尽量避免在异常处理中使用隐式游标属性SQL%ROWCOUNT。在某些异常发生后,SQL%ROWCOUNT可能不可靠,需要谨慎。更稳妥的是保存业务影响行数到变量,在异常段使用变量而不是SQL%属性。
我在迁移一个Oracle存储过程到KingbaseES时,遇到过ORA-06510(PL/SQL: unhandled user-defined exception)和ORA-06512等错误,排查的突破口全在于完整记录了错误堆栈。如果日志里只有一行“ORA-06510”,根本没法定位哪个自定义异常没处理。只有配合FORMAT_ERROR_BACKTRACE,才能在几十层嵌套中快速找到是哪一个函数哪一行遗漏了处理。
在实际项目里,我见过太多因为WHEN OTHERS后不记录日志,导致线上问题无法定位的案例。异常处理看起来只是几个WHEN分支的代码,但本质上是一种系统设计:错误如何建模、如何记录、如何传播、如何恢复,都在这里体现。如果你写PLSQL,我真心建议把“异常路径”当作正路径一样仔细设计,多写一行日志、少吞一个异常,关键时刻能救你一整夜。最后再分享一个小习惯:每次新写完一个存储过程,我会故意注入一个错误数据跑一遍,检验异常处理是否真的按照预期记录和传播,而不是只在代码里“看起来安全”。这个习惯陪我避开了很多生产事故,你也可以试试。