🏺 第5章 PL/SQL 编程
📄 一句话概括

PL/SQL 的异常不是「程序崩了」,是「流程跳转」。 一出异常,当前块剩下的代码立刻不跑,直接跳到 EXCEPTION 段。 最危险的一句是 WHEN OTHERS THEN NULL;——异常被吞掉,什么都不发生,你连日志都没有。

📝 本章和前面几章的关系

5.4 节的游标循环一旦 SELECT INTO 查不到行就会抛异常,异常处理是游标的必备搭档。 没有 EXCEPTION 段,一个「查不到」就能让整个块炸掉。 第 6 章写存储过程时,EXCEPTION 段是标准配置, 因为过程要被别人调用,出错必须能说清楚。

一、异常处理的基本思路

异常是一种「非正常但可预期」的路径。 「查不到人」不是 bug,是业务上正常会发生的事——但它不能按正常流程走,所以用异常表达。 一出异常,当前块的剩余语句立刻不执行,直接跳到 EXCEPTION 段。 EXCEPTION 段跑完,块就结束了——END 之后的代码照常执行。 块是隔离的。 内层块出错,如果内层自己接住了,外层当没发生过;内层没接住,才往外层抛。 这个机制让「局部出错、整体继续」成为可能,也是它比「一错全停」好用的地方。

二、异常处理语法:WHEN ... THEN

EXCEPTION 段里全是 WHEN 异常名 THEN 处理语句 的分支。 一个分支可以接多个异常,用 OR 连:WHEN A OR B THEN。 分支从上往下匹配,命中一个就跳出整个 EXCEPTION 段。 所以顺序有讲究:具体的放前面,WHEN OTHERS 放最后。 WHEN OTHERS THEN 能接住所有没被前面接掉的异常。 它必须放最后——放在前面,后面的分支就永远轮不到了。 SQLCODE 给错误号(负数),SQLERRM 给错误消息。 这两个只能在这个段里用,出了 EXCEPTION 就没值了。 它们是排查线上问题的唯一线索,别浪费。

三、预定义异常:常用的六个

异常名 错误号 什么时候抛
NO_DATA_FOUND ORA-01403 SELECT INTO 一行都没查到
TOO_MANY_ROWS ORA-01422 SELECT INTO 查出多行
ZERO_DIVIDE ORA-01476 除以零
DUP_VAL_ON_INDEX ORA-00001 唯一约束冲突
INVALID_NUMBER ORA-01722 字符串转数字失败
VALUE_ERROR ORA-06502 赋值时长度或精度不够

前两个是 SELECT INTO 专属的,也是最容易撞上的。 SELECT INTO 要求正好返回一行——零行报 NO_DATA_FOUND,多行报 TOO_MANY_ROWS。 这是 PL/SQL 和 SQL 最不一样的地方之一: SQL 里查不到就是空结果,PL/SQL 里查不到是异常。 所以你写 SELECT INTO 就必须想好「查不到怎么办」——不想,就等于把炸弹留给运行时。 DUP_VAL_ON_INDEX 值得单独提一句: 它是最常见的「并发冲突」信号。 两个会话同时插同一个主键,后到的那个就抛它。 捕获它、改成 update,就是最朴素的「有则更新、无则插入」。

四、自定义异常:声明、RAISE、接住

业务规则上的「错」不是数据库的错,得自己定义。 三步:DECLARE 段声明一个 EXCEPTION,执行段 RAISE 它,异常段 WHEN 接住。 自定义异常不需要「注册」,声明了就能抛。 但它没有错误号——报出来只有你写的那句话。 要给外部程序看,就得用 RAISE_APPLICATION_ERROR(-20001, '消息')。 错误号必须落在 -20000 到 -20999 之间,这是留给用户的区间。 这个区间之外的号是 Oracle 自己的,占了会冲突。 它的价值在于「把错误信息完整地传给调用方」—— Java 那边 catch (SQLException e) 能同时拿到消息和错误号。 很多系统的错误码体系,就是从这儿起的。

五、WHEN OTHERS:最后一道网怎么织

最危险的写法是 WHEN OTHERS THEN NULL;——异常被吞掉了,什么都没发生,你连日志都没有。 线上最难查的 bug,一半是这么来的。 正确的写法至少要有三件事:记日志、提交、抛出去。 先记日志(SQLCODE 和 SQLERRM 都写进去),再 COMMIT(否则日志跟着事务一起回滚了), 最后 RAISE 抛回上层。 为什么一定要 RAISE? 因为「被调用方吞掉异常」等于骗了调用方——上层以为成功了,实际上什么都没做。 这类问题比「报错」难查一百倍。 唯一的例外是「这条路径上失败无所谓」的场景,比如写一条「非关键」的审计日志。 但这种场景要少,而且要写清楚为什么可以吞。

六、动手 Demo:六种异常逐个触发一遍

沙箱里没有 Oracle 的命名异常,所以这一段是我按 Oracle 的规则「判」出来的: 查不到就是 NO_DATA_FOUND,查出多行就是 TOO_MANY_ROWS,除以零就是 ZERO_DIVIDE。 判断依据和 Oracle 完全一致——SELECT INTO 要求正好一行, 这个规则两套引擎是一样的(只是一边报异常、一边报错)。

import sqlite3
c = sqlite3.connect(':memory:')
c.execute('create table emp (ename text, sal real, deptno int)')
c.executemany('insert into emp values (?,?,?)',
               [('SMITH', 800, 20), ('JONES', 2975, 20), ('KING', 5000, 10)])

# SELECT INTO 要求正好一行:0 行报 NO_DATA_FOUND,多行报 TOO_MANY_ROWS
def select_into(sql):
    rows = c.execute(sql).fetchall()
    if len(rows) == 0:
        raise LookupError('NO_DATA_FOUND')
    if len(rows) > 1:
        raise ValueError('TOO_MANY_ROWS')
    return rows[0]

for sql in ["select sal from emp where ename = 'NOBODY'",
            'select sal from emp where deptno = 20',
            "select sal from emp where ename = 'SMITH'"]:
    try:
        print('OK', select_into(sql))
    except (LookupError, ValueError) as e:
        print('异常', e)

# 除以零、类型转换失败,各有各的名字
for f in (lambda: 100 / 0, lambda: int('abc')):
    try:
        f()
    except ZeroDivisionError:
        print('ZERO_DIVIDE')
    except ValueError:
        print('INVALID_NUMBER')

实跑输出:

--------------------------------------------------------------------
第二十五课:PL/SQL 异常处理——把「出错」也当成一条正常路径
--------------------------------------------------------------------

  **PL/SQL 的异常不是「程序崩了」,是「流程跳转」。**
  **一出异常,当前块的剩余代码立刻不跑了,直接跳到 `EXCEPTION` 段。**
  **理解这一点很重要:`EXCEPTION` 之后的代码不会执行,但 `END` 之后的会。**
  **块是隔离的——内层块出错,如果内层接住了,外层当没发生过。**


  预定义异常:二十来个,常用的六个
  --------------------------------------------------------------------

  SQL> select name, code, whens as "什么时候报" from exc
  NAME             CODE      什么时候报
  ---------------------------------------------------
  NO_DATA_FOUND    ORA-01403 SELECT INTO 一行都没查到
  TOO_MANY_ROWS    ORA-01422 SELECT INTO 查出多行
  ZERO_DIVIDE      ORA-01476 除以零
  DUP_VAL_ON_INDEX ORA-00001 唯一约束冲突
  INVALID_NUMBER   ORA-01722 字符串转数字失败
  VALUE_ERROR      ORA-06502 赋值时长度或精度不够
  已选择 6 行
  **前两个值得单独记:`NO_DATA_FOUND` 和 `TOO_MANY_ROWS` 是 `SELECT INTO` 专属的。**
  **`SELECT INTO` 要求正好返回一行**——零行报 `NO_DATA_FOUND`,多行报 `TOO_MANY_ROWS`。
  **这是 PL/SQL 和 SQL 最不一样的地方之一:SQL 里查不到就是空结果,PL/SQL 里查不到是异常。**
  **所以你写 `SELECT INTO` 就必须想好「查不到怎么办」。**

  把六种情况都跑一遍
  --------------------------------------------------------------------

  SQL> select scene as 场景, expect as 期望异常, actual as 实际结果 from tcase
  场景     期望异常       实际结果
  ----------------------------------------------------------
  查不到   NO_DATA_FOUND  抛 NO_DATA_FOUND(0 行)
  查出多行 TOO_MANY_ROWS  抛 TOO_MANY_ROWS(5 行)
  正好一行 (无异常)     正常拿到 1 行
  除以零   ZERO_DIVIDE    抛 ZERO_DIVIDE
  转换失败 INVALID_NUMBER 抛 INVALID_NUMBER
  长度不够 VALUE_ERROR    抛 VALUE_ERROR(6 个字符塞不进 3)
  已选择 6 行

  **注意第三行(正好一行)什么都不抛**——这是 `SELECT INTO` 唯一正常的情况。
  **剩下五种都会跳进 `EXCEPTION` 段。**
  **我在「实际结果」里跑的是 SQLite 的等价操作**,**异常名字照 Oracle 的写**:
  SQLite 没有 `NO_DATA_FOUND` 这种命名异常,**它只有「查到几行」和「报错信息」两样东西。**

  自定义异常:先声明,再 RAISE
  --------------------------------------------------------------------
  **业务规则上的「错」不是数据库的错,得自己定义。**
  **三步:`DECLARE` 段声明一个 `EXCEPTION`,执行段 `RAISE` 它,异常段 `WHEN` 接住。**

  [Oracle 原码 · 本沙箱不执行]
  | DECLARE
  |   e_sal_too_low EXCEPTION;
  | BEGIN
  |   IF v_sal < 1000 THEN
  |     RAISE e_sal_too_low;
  |   END IF;
  | EXCEPTION
  |   WHEN e_sal_too_low THEN
  |     DBMS_OUTPUT.PUT_LINE('工资低于下限');
  | END;

  **自定义异常不需要「注册」,声明了就能抛。**  **但它没有错误号**——报出来只有你写的那句话,
  **所以要给外部程序看,还得用 `RAISE_APPLICATION_ERROR`。**

  **`RAISE_APPLICATION_ERROR(-20001, '工资低于下限')` 会抛一个带错误号的异常。**
  **错误号必须落在 -20000 到 -20999 之间**,这是留给用户的区间。
  **这段区间之外的号是 Oracle 自己的,占了会冲突。**
  **它的作用是「把错误信息完整地传给调用方」**——
  **Java 那边 `catch (SQLException e)` 能拿到 `e.getMessage()` 和 `e.getErrorCode()`。**
  **很多系统的错误码体系就是从这儿起的。**

  WHEN OTHERS:最后一道网
  --------------------------------------------------------------------
  **`WHEN OTHERS THEN` 能接住所有没被前面分支接掉的异常。**  **它必须放在最后**——放在前面,
  后面的分支就永远轮不到了。
  **最危险的写法是 `WHEN OTHERS THEN NULL;`**——
  **异常被吞掉了,什么都没发生,你连日志都没有。**  **线上最难查的 bug,一半是这么来的。**
  **规矩:`WHEN OTHERS` 里至少要记日志,最好再 `RAISE` 抛出去。**
  **`SQLCODE` 给错误号(正数或负数),`SQLERRM` 给错误消息。**
  **这两个只能在这个段里用,出了 `EXCEPTION` 就没值了。**

  **一个完整的兜底长这样:**
  | EXCEPTION
  |   WHEN OTHERS THEN
  |     INSERT INTO err_log (t, code, msg)
  |       VALUES (SYSDATE, SQLCODE, SQLERRM);
  |     COMMIT;
  |     RAISE;
  **先记日志、再提交、最后 `RAISE` 抛回上层**——
  **这样既留下了现场,又没改变「这次操作失败了」这个事实。**

表里六行,只有第三行「正好一行」是正常的。 其余五行都会跳进 EXCEPTION 段。 「查出多行」那一行显示 5 行——部门 20 有五个人,SELECT INTO 拿不下,报 TOO_MANY_ROWS。 这是新手最常撞的一个:以为条件能锁定一行,其实没有。 「长度不够」那一行是 VALUE_ERROR:6 个字符塞不进 VARCHAR2(3)。 注意它不是 INVALID_NUMBER——那两个名字很像,但一个管「转换」,一个管「赋值」。 ZERO_DIVIDE 和 INVALID_NUMBER 是最好区分的两个。 一个是算术错,一个是类型转换错,错误号也差得远(01476 和 01722)。 这一段我用 Python 的异常来模拟,异常名字照 Oracle 的写。 SQLite 没有「命名异常」这个概念——它只有「查到几行」和「报错信息文本」两样东西。 所以「靠名字接异常」这件事,本身就是 PL/SQL 的一个优势。

30 秒实验

  1. 把「正好一行」那条的 ename 改成 'SMITH',确认它仍然正常。 再改成 'smith'(小写),看会不会变成 NO_DATA_FOUND—— Oracle 的字符串比较区分大小写,会。
  2. 给 select_into 加一个 try/except,把 NO_DATA_FOUND 换成「返回默认值 0」。 这就是最常见的兜底写法——但要想清楚「0」在业务上是不是个合理默认值。
  3. 写一个「插主键冲突就改成 update」的函数:先 insert,捕 DUP_VAL_ON_INDEX 再 update。 这就是 MERGE 语句之前的老写法,理解它有助于看懂别人的老代码。
  4. 把 WHEN OTHERS THEN NULL 加进一段代码里,然后故意让它出错。 看程序「安静地继续跑」是什么样——这就是线上最难查的那类 bug 的现场。

常见误区

  1. WHEN OTHERS THEN NULL;。 异常被吞,没有日志,没有痕迹。 这是最坏的一种写法,比不写 EXCEPTION 还糟。
  2. 以为 SELECT INTO 查不到就返回 NULL。 它抛 NO_DATA_FOUND。 SQL 和 PL/SQL 在这一点上完全不同。
  3. 自定义异常用 RAISE_APPLICATION_ERROR 时号随便写。 必须落在 -20000 到 -20999,别占 Oracle 自己的号段。
  4. 捕到异常只记日志不 RAISE。 上层以为成功了,实际上什么都没做。这比直接报错难查一百倍。
自测三问(点开对答案) 1. `SELECT INTO` 查不到行会怎样?和普通 `SELECT` 有什么区别? —— **抛 `NO_DATA_FOUND` 异常。** **普通 `SELECT` 查不到只是空结果,`SELECT INTO` 查不到是异常**——因为它要求「正好一行」。 2. 为什么 `WHEN OTHERS THEN NULL;` 是最危险的写法? —— **异常被吞掉,程序安静地继续跑,没有任何日志。** **上层以为成功了,实际什么都没做**——这类问题比直接报错难查得多。 3. `RAISE_APPLICATION_ERROR` 的错误号有什么限制? —— **必须在 -20000 到 -20999 之间。** 这个区间是留给用户自定义的, **占用了 Oracle 自己的号段会冲突。**

第 5 章到此为止,PL/SQL 的骨架搭完了。 下一篇进第 6 章:过程、函数、触发器和包——给这些块加上名字,存进数据库里。

相关笔记