KingbaseES中的PL_SQL编程:存储过程、函数、触发器与包的开发指南

一、为什么需要PL/SQL

如果你写过数据库应用,大概遇到过这样的场景:一段业务逻辑需要在应用层反复查表、计算、再写回表,来来回回跑了好几趟网络IO。不但慢,还容易出一致性上的问题。
PL/SQL就是为了解决这个问题而生的。它把SQL的数据操作能力和过程化语言的控制能力捏合在一起,让你可以直接在数据库里写程序------声明变量、写判断分支、循环遍历、捕获异常,一次搞定。

说白了,PL/SQL能帮我们做这几件事:

  • 少跑网络:一整块逻辑打包丢给数据库执行,省掉了应用和数据库之间反复交互的开销。
  • 写一次,到处用:存储过程和函数创建好之后,任何能连上数据库的应用都能调用。
  • 跟SQL无缝衔接:不用在应用代码和SQL之间来回做类型转换,PL/SQL原生支持所有SQL类型。

文章目录

    • 一、为什么需要PL/SQL
    • 二、从"块"开始------理解PL/SQL的基本骨架
    • 三、存储过程------把业务逻辑"装"进数据库
      • [3.1 最基本的形式](#3.1 最基本的形式)
      • [3.2 实战:批量调分](#3.2 实战:批量调分)
      • [3.3 IN OUT参数------一进一出](#3.3 IN OUT参数——一进一出)
      • [3.4 过程里套过程------嵌套子程序](#3.4 过程里套过程——嵌套子程序)
    • 四、函数------能"算"出结果的子程序
      • [4.1 写一个成绩评级函数](#4.1 写一个成绩评级函数)
      • [4.2 RETURN写漏了会怎样](#4.2 RETURN写漏了会怎样)
      • [4.3 计算BMI------一个更完整的函数示例](#4.3 计算BMI——一个更完整的函数示例)
    • 五、游标------逐行处理查询结果
      • [5.1 隐式游标------数据库帮你开的](#5.1 隐式游标——数据库帮你开的)
      • [5.2 显式游标------自己管理全流程](#5.2 显式游标——自己管理全流程)
      • [5.3 游标FOR循环------懒人写法](#5.3 游标FOR循环——懒人写法)
      • [5.4 带参数的游标](#5.4 带参数的游标)
    • 六、触发器------事件来了就自动干活
      • [6.1 触发器有哪些种类](#6.1 触发器有哪些种类)
      • [6.2 动手写一个------自动记录成绩变更](#6.2 动手写一个——自动记录成绩变更)
      • [6.3 怎么知道是哪种操作触发的](#6.3 怎么知道是哪种操作触发的)
      • [6.4 OLD和NEW------拿到变更前后的值](#6.4 OLD和NEW——拿到变更前后的值)
      • [6.5 INSTEAD OF触发器------让不可更新的视图也能写数据](#6.5 INSTEAD OF触发器——让不可更新的视图也能写数据)
      • [6.6 条件触发器------不是每一行都要触发](#6.6 条件触发器——不是每一行都要触发)
      • [6.7 启用和禁用](#6.7 启用和禁用)
    • 七、包------把相关的东西"打包"管理
      • [7.1 用包的好处](#7.1 用包的好处)
      • [7.2 先写包头------定接口](#7.2 先写包头——定接口)
      • [7.3 再写包体------做实现](#7.3 再写包体——做实现)
      • [7.4 怎么调用包里的东西](#7.4 怎么调用包里的东西)
      • [7.5 子程序重载------同一个名字,不同的参数](#7.5 子程序重载——同一个名字,不同的参数)
    • 八、出错怎么办------异常处理
      • [8.1 两种异常](#8.1 两种异常)
      • [8.2 基本写法](#8.2 基本写法)
      • [8.3 主动抛异常------RAISE和RAISE_APPLICATION_ERROR](#8.3 主动抛异常——RAISE和RAISE_APPLICATION_ERROR)
      • [8.4 捕获异常后怎么"复盘"](#8.4 捕获异常后怎么"复盘")
    • 九、动态SQL------运行时才拼接的语句
      • [9.1 EXECUTE IMMEDIATE------最常用的方式](#9.1 EXECUTE IMMEDIATE——最常用的方式)
      • [9.2 动态DDL------用EXECUTE IMMEDIATE建表删表](#9.2 动态DDL——用EXECUTE IMMEDIATE建表删表)
      • [9.3 DBMS_SQL------更精细的控制](#9.3 DBMS_SQL——更精细的控制)
    • 十、写好PL/SQL的几条实用建议
      • [10.1 代码怎么组织](#10.1 代码怎么组织)
      • [10.2 性能怎么调](#10.2 性能怎么调)
      • [10.3 安全方面要注意](#10.3 安全方面要注意)
    • 总结

二、从"块"开始------理解PL/SQL的基本骨架

PL/SQL里所有代码的基本单元叫"块"(Block)。不管是匿名块、存储过程还是函数,底层都是块结构。

一个块分三段:

复制代码
DECLARE    -- 声明部分(可以省略):放变量、常量、游标
BEGIN      -- 执行部分(必须有):放你的业务逻辑
EXCEPTION  -- 异常处理(可以省略):出了错怎么办
END;

来个直观的例子:

sql 复制代码
DECLARE
    v_name  VARCHAR2(50);
    v_score NUMBER := 0;
BEGIN
    SELECT name, score INTO v_name, v_score
    FROM student WHERE id = 1;

    RAISE NOTICE '学生姓名: %, 成绩: %', v_name, v_score;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        RAISE NOTICE '没找到这个学生';
    WHEN OTHERS THEN
        RAISE NOTICE '出错了: %', SQLERRM;
END;
/

变量怎么声明

声明变量的时候,有两个"偷懒"操作符特别好用,能让你少写不少代码:

  • %TYPE:直接"抄"某列的类型,不用你自己写。表结构改了,变量类型跟着变,完全不用管。
  • %ROWTYPE:一整行的结构全抄过来,省得你一个字段一个字段地声明。
sql 复制代码
DECLARE
    v_name student.name%TYPE;        -- 自动跟student.name同类型
    v_row  student%ROWTYPE;           -- 自动匹配student表的整行结构
BEGIN
    SELECT * INTO v_row FROM student WHERE id = 1;
    v_name := v_row.name;
    RAISE NOTICE '查到了: %', v_name;
END;
/

控制流怎么写

PL/SQL的控制语句分三类,跟大部分编程语言差不多:

条件判断------IF和CASE

sql 复制代码
DECLARE
    v_score NUMBER := 85;
BEGIN
    IF v_score >= 90 THEN
        RAISE NOTICE '优秀';
    ELSIF v_score >= 80 THEN
        RAISE NOTICE '良好';
    ELSIF v_score >= 60 THEN
        RAISE NOTICE '及格';
    ELSE
        RAISE NOTICE '不及格';
    END IF;
END;
/

循环------LOOP、FOR、WHILE

sql 复制代码
DECLARE
    v_sum INTEGER := 0;
BEGIN
    -- 简单LOOP,自己控制退出条件
    FOR i IN 1..10 LOOP
        v_sum := v_sum + i;
    END LOOP;
    RAISE NOTICE '1加到10等于: %', v_sum;

    -- WHILE循环,先判断再执行
    DECLARE
        v_n INTEGER := 5;
    BEGIN
        WHILE v_n > 0 LOOP
            RAISE NOTICE '倒计时: %', v_n;
            v_n := v_n - 1;
        END LOOP;
    END;
END;
/

三、存储过程------把业务逻辑"装"进数据库

匿名块写完就没了,下次想用还得再写一遍。存储过程不一样------它是个有名字的PL/SQL块,创建之后就住在数据库里,随时可以调用。

3.1 最基本的形式

先建一张示例表,后面会反复用到:

sql 复制代码
CREATE TABLE student (
    id    INT PRIMARY KEY,
    name  TEXT,
    score NUMBER
);

INSERT INTO student VALUES (1, '张三', 85);
INSERT INTO student VALUES (2, '李四', 92);
INSERT INTO student VALUES (3, '王五', 78);

创建一个存储过程,根据学号查学生信息:

sql 复制代码
CREATE OR REPLACE PROCEDURE get_student_info (
    p_id    IN  INTEGER,
    p_name  OUT VARCHAR2,
    p_score OUT NUMBER
)
IS
BEGIN
    SELECT name, score INTO p_name, p_score
    FROM student WHERE id = p_id;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        p_name  := NULL;
        p_score := NULL;
        RAISE NOTICE '找不到学号为%的学生', p_id;
END;
/

注意参数那里的 INOUT 关键字,它们决定了参数的"方向":

模式 干什么
IN 只往里传值(默认)
OUT 只往外返值
IN OUT 既传进去,又带出来

3.2 实战:批量调分

更实际的场景------给所有人加分:

sql 复制代码
CREATE OR REPLACE PROCEDURE adjust_scores (
    p_bonus IN NUMBER
)
IS
    v_affected INTEGER;
BEGIN
    UPDATE student SET score = score + p_bonus;
    v_affected := SQL%ROWCOUNT;  -- 看看影响了多少行
    COMMIT;
    RAISE NOTICE '已为%d名学生各加%d分', v_affected, p_bonus;
END;
/

CALL adjust_scores(5);

3.3 IN OUT参数------一进一出

有时候你想让参数既当输入又当输出,比如把一个成绩"格式化"一下:

sql 复制代码
CREATE OR REPLACE PROCEDURE round_score (
    p_score IN OUT NUMBER
)
IS
BEGIN
    p_score := ROUND(p_score, 0);
END;
/

-- 调用
DECLARE
    v NUMBER := 85.6;
BEGIN
    round_score(v);
    RAISE NOTICE '四舍五入后: %', v;   -- 输出86
END;
/

3.4 过程里套过程------嵌套子程序

PL/SQL允许你在一个子程序内部再定义另一个子程序。内部那个只在外层可见,外面谁也碰不到,封装性很好:

sql 复制代码
DECLARE
    str VARCHAR2(30) := '';

    PROCEDURE add_str (s TEXT) IS
    BEGIN
        str := str || '_' || s;
    EXCEPTION
        WHEN VALUE_ERROR THEN
            RAISE NOTICE '拼接失败,字符串太长了';
    END;
BEGIN
    add_str('Hello');
    add_str('World');
    RAISE NOTICE '结果: %', str;  -- _Hello_World
END;
/

四、函数------能"算"出结果的子程序

函数和存储过程长得像,但有两个关键区别:

  1. 函数头部必须有 RETURN,声明它返回什么类型。
  2. 函数体里,每一条执行路径 都必须遇到 RETURN 语句,否则运行时直接报错。

4.1 写一个成绩评级函数

sql 复制代码
CREATE OR REPLACE FUNCTION get_grade_level (
    p_score NUMBER
) RETURN VARCHAR2
IS
BEGIN
    IF p_score >= 90 THEN RETURN '优秀';
    ELSIF p_score >= 80 THEN RETURN '良好';
    ELSIF p_score >= 60 THEN RETURN '及格';
    ELSE RETURN '不及格';
    END IF;
END;
/

函数最大的好处是,你可以直接把它写在SQL语句里:

sql 复制代码
SELECT id, name, score, get_grade_level(score) AS grade
FROM student
ORDER BY score DESC;

结果长这样:

复制代码
 id | name | score | grade
----+------+-------+-------
  2 | 李四 |    97 | 优秀
  1 | 张三 |    90 | 优秀
  3 | 王五 |    83 | 良好

4.2 RETURN写漏了会怎样

这是个常见坑。看下面这段代码:

sql 复制代码
-- 有bug的写法:x为负数时没有RETURN
CREATE OR REPLACE FUNCTION bad_func (x INTEGER)
RETURN INTEGER IS
BEGIN
    IF x = 0 THEN RETURN x; END IF;
    IF x > 1 THEN RETURN 10; END IF;
    -- x是负数?不好意思,没有RETURN等着你
END;
/

调用 bad_func(-1) 会直接报错:"control reached end of function without RETURN"。

修正方法很简单------加上ELSE兜底:

sql 复制代码
CREATE OR REPLACE FUNCTION safe_func (x INTEGER)
RETURN INTEGER IS
BEGIN
    IF x = 0 THEN
        RETURN 1;
    ELSIF x > 1 THEN
        RETURN 10;
    ELSE
        RETURN x * x;   -- 兜底
    END IF;
END;
/

4.3 计算BMI------一个更完整的函数示例

来个贴近实际的例子,输入身高体重,算BMI并给出评估:

sql 复制代码
CREATE OR REPLACE FUNCTION calc_bmi (
    p_weight NUMBER,   -- 体重(kg)
    p_height NUMBER    -- 身高(m)
) RETURN VARCHAR2
IS
    v_bmi NUMBER;
BEGIN
    IF p_height <= 0 THEN
        RAISE_APPLICATION_ERROR(-20001, '身高必须大于0');
    END IF;

    v_bmi := p_weight / (p_height * p_height);

    IF v_bmi < 18.5 THEN RETURN '偏瘦(BMI=' || ROUND(v_bmi, 1) || ')';
    ELSIF v_bmi < 24 THEN RETURN '正常(BMI=' || ROUND(v_bmi, 1) || ')';
    ELSIF v_bmi < 28 THEN RETURN '偏胖(BMI=' || ROUND(v_bmi, 1) || ')';
    ELSE RETURN '肥胖(BMI=' || ROUND(v_bmi, 1) || ')';
    END IF;
END;
/

-- 直接在SQL里用
SELECT calc_bmi(70, 1.75) FROM dual;
-- 输出: 正常(BMI=22.9)

五、游标------逐行处理查询结果

SQL查询一次可能返回很多行,但有时候你需要一行一行地处理------这时候就得靠游标了。

打个比方,游标就像一个指针,指向查询结果集里的某一行。你让它往下走一行,它就给你一行的数据。

5.1 隐式游标------数据库帮你开的

当你执行 SELECT INTO 或者 UPDATEDELETE 这些语句时,数据库会自动打开一个隐式游标。通过 SQL% 前缀,你能拿到一些有用的信息:

sql 复制代码
BEGIN
    DELETE FROM student WHERE score < 60;

    IF SQL%FOUND THEN
        RAISE NOTICE '删掉了%d条记录', SQL%ROWCOUNT;
    ELSE
        RAISE NOTICE '没有不及格的记录';
    END IF;
END;
/

常用的隐式游标属性:

属性 作用
SQL%FOUND 最近一次操作有没有影响到行
SQL%NOTFOUND 跟上面相反
SQL%ROWCOUNT 具体影响了几行
SQL%ISOPEN 游标是否还开着(隐式游标永远是FALSE)

5.2 显式游标------自己管理全流程

返回多行数据的场景,需要显式游标。操作流程是四步:声明 → 打开 → 抓取 → 关闭

sql 复制代码
DECLARE
    CURSOR c_top_students IS
        SELECT id, name, score FROM student
        WHERE score > 80 ORDER BY score DESC;

    v_id    student.id%TYPE;
    v_name  student.name%TYPE;
    v_score student.score%TYPE;
BEGIN
    OPEN c_top_students;

    LOOP
        FETCH c_top_students INTO v_id, v_name, v_score;
        EXIT WHEN c_top_students%NOTFOUND;   -- 没数据了就退出
        RAISE NOTICE '优秀学生: %, 成绩: %', v_name, v_score;
    END LOOP;

    CLOSE c_top_students;
END;
/

5.3 游标FOR循环------懒人写法

如果你觉得手动 OPEN/FETCH/CLOSE 太啰嗦,游标FOR循环帮你把这三步全包了:

sql 复制代码
BEGIN
    FOR rec IN (SELECT * FROM student WHERE score > 80) LOOP
        RAISE NOTICE '%同学考了%d分', rec.name, rec.score;
    END LOOP;
END;
/

循环变量 rec 自动拥有 %ROWTYPE 结构,rec.namerec.score 直接就能用。循环结束,游标自动关闭,你啥都不用操心。

5.4 带参数的游标

游标还能接收参数,让查询更灵活:

sql 复制代码
DECLARE
    -- 声明带参数的游标
    CURSOR c_by_score (min_score NUMBER) IS
        SELECT id, name, score FROM student
        WHERE score >= min_score
        ORDER BY score DESC;
BEGIN
    -- 打开时传入参数
    FOR rec IN c_by_score(90) LOOP
        RAISE NOTICE '顶尖学生: %, 成绩: %', rec.name, rec.score;
    END LOOP;
END;
/

六、触发器------事件来了就自动干活

触发器跟存储过程很像,也是存在数据库里的PL/SQL程序。但有个本质区别:你不能手动调用触发器。它只在特定事件(比如插入、更新、删除)发生时,由数据库自动触发执行。

你可以把触发器想象成一个"监听器"------它挂在某张表上,盯着数据的一举一动。一旦表里的数据发生了变化,它就跳出来执行你预先写好的逻辑。

6.1 触发器有哪些种类

先搞清楚分类,不然容易混淆。

按触发事件分:

类型 触发原因
DML触发器 表或视图上发生了INSERT/UPDATE/DELETE
DDL触发器 数据库级别发生了CREATE/DROP/ALTER

DML触发器按触发时机分:

时机 含义
BEFORE 数据还没动,触发器先执行
AFTER 数据已经动了,触发器再执行
INSTEAD OF 直接替代原操作执行(专门给视图用的)

按粒度分:

  • 语句级(默认):一条SQL触发一次
  • 行级(FOR EACH ROW):影响了几行就触发几次

6.2 动手写一个------自动记录成绩变更

这是个很典型的场景:每当有人改了学生的成绩,就自动往日志表里记一笔。

sql 复制代码
-- 先建日志表
CREATE TABLE score_log (
    log_id    SERIAL PRIMARY KEY,
    stu_id    NUMBER,
    old_score NUMBER,
    new_score NUMBER,
    action    VARCHAR2(20),
    log_date  DATE DEFAULT SYSDATE
);

-- 创建触发器
CREATE OR REPLACE TRIGGER log_score_change
AFTER UPDATE OF score ON student
FOR EACH ROW
BEGIN
    INSERT INTO score_log (stu_id, old_score, new_score, action)
    VALUES (:NEW.id, :OLD.score, :NEW.score, 'UPDATE');
END;
/

现在来测试一下:

sql 复制代码
UPDATE student SET score = 95 WHERE id = 1;

SELECT * FROM score_log;

结果:

复制代码
 log_id | stu_id | old_score | new_score | action  |       log_date
--------+--------+-----------+-----------+---------+----------------------
      1 |      1 |        90 |        95 | UPDATE  | 2026-05-10 10:30:00

你看,不需要手动往日志表里插数据,触发器替你干了。

6.3 怎么知道是哪种操作触发的

一个触发器可以同时监听INSERT、UPDATE、DELETE。那在触发器内部,怎么知道到底是哪个操作进来的?靠条件谓词:

sql 复制代码
CREATE OR REPLACE TRIGGER tri_student_audit
BEFORE
    INSERT OR
    UPDATE OF score, name OR
    DELETE
ON student
BEGIN
    CASE
        WHEN INSERTING THEN       RAISE NOTICE '有人在插数据';
        WHEN UPDATING('score') THEN RAISE NOTICE '有人在改成绩';
        WHEN UPDATING('name') THEN  RAISE NOTICE '有人在改姓名';
        WHEN DELETING THEN         RAISE NOTICE '有人在删数据';
    END CASE;
END;
/

四个谓词一目了然:INSERTINGUPDATINGUPDATING('列名')DELETING

6.4 OLD和NEW------拿到变更前后的值

行级触发器里,有两个特殊的"伪记录"可以直接用:

伪记录 INSERT UPDATE DELETE
:OLD 空(NULL) 改之前的值 删之前的值
:NEW 插入的值 改之后的值 空(NULL)

BEFORE行级触发器还能修改:NEW的值,在数据真正写入表之前做"拦截修改"。

来个实用例子------不管用户输入什么名字,入库时自动转成大写:

sql 复制代码
CREATE OR REPLACE TRIGGER normalize_name
BEFORE INSERT OR UPDATE OF name ON student
FOR EACH ROW
BEGIN
    :NEW.name := UPPER(:NEW.name);
END;
/

INSERT INTO student VALUES (4, 'zhao liu', 88);
SELECT name FROM student WHERE id = 4;
-- 结果: ZHAO LIU

6.5 INSTEAD OF触发器------让不可更新的视图也能写数据

有些视图天生就没法直接INSERT/UPDATE,比如带了WHERE条件、聚合函数的视图。INSTEAD OF触发器就是来解决这个问题的------它"拦截"你对视图的写操作,转而去操作底层的那几张表。

sql 复制代码
-- 创建一个只看优秀学生的视图
CREATE OR REPLACE VIEW high_score_view AS
SELECT id, name, score FROM student WHERE score > 85;

-- 视图上创建INSTEAD OF触发器
CREATE OR REPLACE TRIGGER high_score_insert
INSTEAD OF INSERT ON high_score_view
FOR EACH ROW
BEGIN
    INSERT INTO student (id, name, score)
    VALUES (:NEW.id, :NEW.name, :NEW.score);
END;
/

-- 现在可以往视图里插数据了,实际写入的是student表
INSERT INTO high_score_view VALUES (5, 'SUN QI', 90);

6.6 条件触发器------不是每一行都要触发

加个WHEN子句,就能让触发器只在满足条件时才对那一行生效:

sql 复制代码
CREATE OR REPLACE TRIGGER show_score_change
BEFORE INSERT OR UPDATE ON student
FOR EACH ROW
WHEN (NEW.score > 80)    -- 只关注80分以上的
DECLARE
    diff NUMBER;
BEGIN
    diff := :NEW.score - :OLD.score;
    RAISE NOTICE '%: % → % (变化: %)',
        :NEW.name, :OLD.score, :NEW.score, diff;
END;
/

6.7 启用和禁用

触发器默认创建后就是启用的。有些场景你想临时关掉它(比如批量导入数据时不想触发审计逻辑),可以这样操作:

sql 复制代码
ALTER TRIGGER log_score_change DISABLE;   -- 禁用
ALTER TRIGGER log_score_change ENABLE;    -- 重新启用

七、包------把相关的东西"打包"管理

如果你的项目里存储过程和函数越来越多,散落各处,管理起来会很头疼。包(Package)就是来解决这个问题的------它把逻辑上相关的类型、变量、游标、过程、函数统统打包到一起。

包有两个组成部分:包头 (声明公共接口)和包体(写具体实现)。这跟面向对象编程里"接口"和"实现"的关系很像。

7.1 用包的好处

  • 封装:包头告诉你"能做什么",包体藏着"怎么做"。调用方只需要看包头就够了。
  • 并行开发:可以先定好包头(接口),团队其他人先对着接口开发,包体后面再补。
  • 会话级状态:包里声明的公共变量在整个会话期间一直活着,多个子程序可以共享。
  • 加载一次,反复使用:包在会话中第一次被调用时整体加载到内存,后面访问零IO。
  • 改包体不影响调用方:只要包头不改,改包体不需要重新编译那些调用它的程序。

7.2 先写包头------定接口

包头只放"声明",不放实现:

sql 复制代码
CREATE OR REPLACE PACKAGE emp_admin AUTHID DEFINER AS
    -- 公共类型
    TYPE RecTyp IS RECORD (id INT, score NUMBER);

    -- 公共游标
    CURSOR desc_score RETURN RecTyp IS
        SELECT id, score FROM student ORDER BY score DESC;

    -- 公共异常
    invalid_score EXCEPTION;

    -- 公共函数
    FUNCTION hire_student (
        name  VARCHAR2,
        score NUMBER
    ) RETURN NUMBER;

    -- 重载的过程(同名不同参数)
    PROCEDURE fire_student (emp_id NUMBER);
    PROCEDURE fire_student (emp_name VARCHAR2);

    PROCEDURE raise_score (emp_id INT, emp_score NUMBER);
    FUNCTION highest_score (n NUMBER) RETURN RecTyp;
END emp_admin;
/

7.3 再写包体------做实现

包头里声明了子程序,包体里就必须给出对应的定义。此外,包体里还可以声明"私有"的变量和函数------这些东西只在包内部用,外面看不到:

sql 复制代码
CREATE OR REPLACE PACKAGE BODY emp_admin AS
    -- 私有变量,外面看不到
    number_hired NUMBER;

    -- 实现公共函数:添加学生
    FUNCTION hire_student (
        name  VARCHAR2,
        score NUMBER
    ) RETURN NUMBER
    IS
    BEGIN
        INSERT INTO student (id, name, score)
        VALUES (number_hired, hire_student.name, hire_student.score);

        DBMS_OUTPUT.PUT_LINE('新学生编号: ' || TO_CHAR(number_hired));
        number_hired := number_hired + 1;
        RETURN number_hired - 1;
    END hire_student;

    -- 重载:按编号删除
    PROCEDURE fire_student (emp_id NUMBER) IS
    BEGIN
        DELETE FROM student WHERE id = emp_id;
    END fire_student;

    -- 重载:按姓名删除
    PROCEDURE fire_student (emp_name VARCHAR2) IS
    BEGIN
        DELETE FROM student WHERE name = emp_name;
    END fire_student;

    -- 私有函数:检查成绩是否合法(外面看不到)
    FUNCTION score_ok (score NUMBER) RETURN BOOLEAN
    IS
    BEGIN
        RETURN (score >= 0) AND (score <= 100);
    END score_ok;

    -- 实现公共过程:调整成绩(内部调了私有函数)
    PROCEDURE raise_score (
        emp_id     INT,
        emp_score  NUMBER
    )
    IS
    BEGIN
        IF score_ok(emp_score) THEN
            UPDATE student SET score = emp_score WHERE id = emp_id;
        ELSE
            RAISE invalid_score;
        END IF;
    EXCEPTION
        WHEN invalid_score THEN
            DBMS_OUTPUT.PUT_LINE('成绩必须在0到100之间');
    END raise_score;

    -- 实现公共函数:查最高分
    FUNCTION highest_score (n NUMBER) RETURN RecTyp
    IS
        emp_rec RecTyp;
    BEGIN
        OPEN desc_score;
        FETCH desc_score INTO emp_rec;
        CLOSE desc_score;
        RETURN emp_rec;
    END highest_score;

    -- 包体初始化部分:首次被访问时执行一次
    BEGIN
        number_hired := 1;
END emp_admin;
/

7.4 怎么调用包里的东西

包名.子程序名 的格式来调用:

sql 复制代码
DECLARE
    new_id      NUMBER(6);
    top_student emp_admin.RecTyp;    -- 使用包里定义的公共类型
BEGIN
    -- 调用包里的函数
    new_id := emp_admin.hire_student('赵六', 88);
    DBMS_OUTPUT.PUT_LINE('新学生编号: ' || TO_CHAR(new_id));

    -- 调用包里的过程
    emp_admin.raise_score(new_id, 95);

    -- 查最高分
    top_student := emp_admin.highest_score(1);
    DBMS_OUTPUT.PUT_LINE(
        '最高分: ' || TO_CHAR(top_student.score) ||
        ', 编号: ' || TO_CHAR(top_student.id)
    );

    -- 重载:按名字删
    emp_admin.fire_student('赵六');
END;
/

输出:

复制代码
新学生编号: 1
最高分: 95, 编号: 1

7.5 子程序重载------同一个名字,不同的参数

注意上面 fire_student 有两个版本:一个传编号,一个传姓名。编译器会根据你传入的参数类型自动匹配对的那个版本------这就是重载。

规则很简单:同名,但参数的个数、类型或顺序至少有一个不同。

八、出错怎么办------异常处理

程序运行出错是常有的事。PL/SQL的异常处理机制让你不用像写C语言那样,每一步都手动检查"刚才那步成功了没"------出错后执行流会自动跳到异常处理部分。

8.1 两种异常

  • 系统预定义异常 :数据库自己定义的,出错了自动抛。比如 NO_DATA_FOUND(查不到数据)、TOO_MANY_ROWS(查出了多行)、VALUE_ERROR(类型不匹配)。
  • 用户自定义异常:你自己声明的,用来标记业务层面的错误,比如"成绩超出范围"。

8.2 基本写法

异常处理写在 EXCEPTION 关键字后面,一个WHEN接一种异常:

sql 复制代码
DECLARE
    v_name student.name%TYPE;
    -- 声明自定义异常,绑定一个错误码
    invalid_score EXCEPTION;
    PRAGMA EXCEPTION_INIT(invalid_score, -20001);
BEGIN
    SELECT name INTO v_name FROM student WHERE id = 999;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        RAISE NOTICE '没找到这个学生';
    WHEN TOO_MANY_ROWS THEN
        RAISE NOTICE '怎么返回了多行?';
    WHEN invalid_score THEN
        RAISE NOTICE '成绩数据不合法';
    WHEN OTHERS THEN
        RAISE NOTICE '未知错误: %', SQLERRM;
END;
/

8.3 主动抛异常------RAISE和RAISE_APPLICATION_ERROR

有些错误数据库不会自动发现,需要你自己判断后主动抛出。

用RAISE触发自定义异常:

sql 复制代码
DECLARE
    score_too_high EXCEPTION;
    v_score NUMBER := 150;
BEGIN
    IF v_score > 100 THEN
        RAISE score_too_high;    -- 主动触发
    END IF;
EXCEPTION
    WHEN score_too_high THEN
        RAISE NOTICE '成绩不能超过100分';
END;
/

用RAISE_APPLICATION_ERROR给调用方返回明确的错误信息:

错误码范围是 -20000 到 -20999,随你用:

sql 复制代码
CREATE OR REPLACE PROCEDURE validate_score (
    p_score NUMBER
)
IS
BEGIN
    IF p_score < 0 OR p_score > 100 THEN
        RAISE_APPLICATION_ERROR(-20001,
            '成绩必须在0到100之间,当前值: ' || TO_CHAR(p_score));
    END IF;

    RAISE NOTICE '成绩合法: %', p_score;
END;
/

8.4 捕获异常后怎么"复盘"

异常处理部分有几个内置函数,帮你搞清楚到底哪里出了问题:

函数 返回什么
SQLCODE 错误码(数字)
SQLERRM 错误消息(文本)
ERROR_LINE 出错行号
ERROR_PROCEDURE 出错的子程序名
sql 复制代码
CREATE OR REPLACE PROCEDURE safe_query (p_id INTEGER)
IS
    v_name student.name%TYPE;
BEGIN
    SELECT name INTO v_name FROM student WHERE id = p_id;
    RAISE NOTICE '查到了: %', v_name;
EXCEPTION
    WHEN OTHERS THEN
        RAISE NOTICE '[错误] 存储过程%s第%d行出错,代码=%,原因=%',
            ERROR_PROCEDURE, ERROR_LINE, SQLCODE, SQLERRM;
END;
/

九、动态SQL------运行时才拼接的语句

前面的SQL都是写死的------编译的时候就知道完整内容。但有些场景,SQL要到运行时才能确定:比如表名是用户传进来的,或者WHERE条件是动态拼的。

这就是动态SQL的用武之地。

9.1 EXECUTE IMMEDIATE------最常用的方式

简单说就是"拿一个字符串当SQL执行":

sql 复制代码
DECLARE
    v_table VARCHAR2(30) := 'student';
    v_count INTEGER;
BEGIN
    EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM ' || v_table
    INTO v_count;

    RAISE NOTICE '%表里有%d条记录', v_table, v_count;
END;
/

不过直接拼接字符串有SQL注入风险。推荐用绑定变量(USING子句):

sql 复制代码
DECLARE
    v_name  student.name%TYPE;
    v_score student.score%TYPE;
BEGIN
    EXECUTE IMMEDIATE
        'SELECT name, score FROM student WHERE id = :id'
    INTO v_name, v_score
    USING 1;        -- :id 的值

    RAISE NOTICE '%: %分', v_name, v_score;
END;
/

9.2 动态DDL------用EXECUTE IMMEDIATE建表删表

DDL语句(CREATE、DROP、ALTER)也能用动态SQL执行:

sql 复制代码
CREATE OR REPLACE PROCEDURE create_backup_table (
    p_source_table VARCHAR2,
    p_backup_table VARCHAR2
)
IS
    v_sql VARCHAR2(500);
BEGIN
    v_sql := 'CREATE TABLE ' || p_backup_table ||
             ' AS SELECT * FROM ' || p_source_table;
    EXECUTE IMMEDIATE v_sql;
    RAISE NOTICE '已创建备份表: %', p_backup_table;
END;
/

CALL create_backup_table('student', 'student_bak_20260510');

9.3 DBMS_SQL------更精细的控制

如果你在编译时连查询返回几列、每列什么类型都不知道,EXECUTE IMMEDIATE就不够用了。这时候得用DBMS_SQL系统包,它提供了逐列绑定、逐行获取的精细化操作能力:

sql 复制代码
DECLARE
    v_cursor INTEGER;
    v_sql    VARCHAR2(200);
    v_id     INTEGER;
    v_name   VARCHAR2(50);
    v_score  NUMBER;
BEGIN
    v_cursor := DBMS_SQL.OPEN_CURSOR;

    v_sql := 'SELECT id, name, score FROM student WHERE score > :min_score';
    DBMS_SQL.PARSE(v_cursor, v_sql, 1);
    DBMS_SQL.BIND_VARIABLE(v_cursor, ':min_score', 80);

    -- 逐列定义输出
    DBMS_SQL.DEFINE_COLUMN(v_cursor, 1, v_id);
    DBMS_SQL.DEFINE_COLUMN(v_cursor, 2, v_name, 50);
    DBMS_SQL.DEFINE_COLUMN(v_cursor, 3, v_score);

    DBMS_SQL.EXECUTE(v_cursor);

    -- 逐行抓取
    WHILE DBMS_SQL.FETCH_ROWS(v_cursor) > 0 LOOP
        DBMS_SQL.COLUMN_VALUE(v_cursor, 1, v_id);
        DBMS_SQL.COLUMN_VALUE(v_cursor, 2, v_name);
        DBMS_SQL.COLUMN_VALUE(v_cursor, 3, v_score);
        RAISE NOTICE 'ID=%, 姓名=%, 成绩=%', v_id, v_name, v_score;
    END LOOP;

    DBMS_SQL.CLOSE_CURSOR(v_cursor);
END;
/

十、写好PL/SQL的几条实用建议

10.1 代码怎么组织

  • 先定接口再写实现。先把包头(公共接口)定清楚,包体后面再补。团队协作时尤其重要。
  • 包头尽量"瘦"。只放外部调用者必须看到的东西,实现细节全部藏在包体里。这样即使你重构了包体,调用方也不受影响。
  • 初始化逻辑放在包体末尾。别在包头声明变量时直接赋值,放到包体的初始化部分去做------这样你可以写更复杂的逻辑,还能加异常处理。

10.2 性能怎么调

  • 能批量就别逐行 。用 BULK COLLECTFORALL 替代逐行循环操作,性能差距可能是十倍甚至百倍。
sql 复制代码
-- 批量查询示例
DECLARE
    TYPE id_list IS TABLE OF student.id%TYPE;
    v_ids id_list;
BEGIN
    SELECT id BULK COLLECT INTO v_ids
    FROM student WHERE score > 80;

    FORALL i IN 1..v_ids.COUNT
        UPDATE student SET score = score + 5 WHERE id = v_ids(i);

    RAISE NOTICE '已批量更新%d条记录', SQL%ROWCOUNT;
END;
/
  • 游标FOR循环优先用。自动管理游标生命周期,不用操心CLOSE写没写。
  • AFTER触发器比BEFORE触发器快。因为AFTER触发器执行时数据块已经读过了,不需要再读一遍。

10.3 安全方面要注意

  • 用AUTHID控制权限AUTHID DEFINER 以定义者身份执行,AUTHID CURRENT_USER 以调用者身份执行。根据你的安全需求选择。
  • 触发器里别写COMMIT/ROLLBACK。触发器是在触发它的那条SQL的事务里运行的,自己在里面做事务控制会导致不可预期的行为。
  • 给调用方明确的错误信息 。用 RAISE_APPLICATION_ERROR 而不是静默失败,让调用方知道到底哪里出了问题。

总结

到这里,我们已经把PL/SQL编程最核心的几个部分走了一遍:

  • 存储过程负责干活,适合封装操作类的业务逻辑。
  • 函数负责算值,能直接嵌在SQL语句里使用。
  • 触发器是个自动响应机制,数据一变它就跳出来执行。
  • 是组织代码的利器,把相关的东西打包到一起,接口和实现分离。
  • 游标 让你逐行处理查询结果,异常处理让程序出错了也能优雅应对。

这几个组件搭配起来,足以在数据库层面构建出高效、健壮、好维护的数据处理逻辑。建议从简单的存储过程和函数开始练手,等熟悉了再逐步接触包和触发器------毕竟,一步步来才是学得最稳的方式。

相关推荐
4311媒体网1 小时前
帝国CMS新手入门教程:从零开始掌握企业级建站系统
数据库
韩小兔修媛史1 小时前
Redis面试八股文总结
数据库·redis·面试
小则又沐风a1 小时前
Linux下的Git的上传(版本控制器)
linux·数据库·git
赵渝强老师1 小时前
【赵渝强老师】PostgreSQL的数据预热扩展pg_prewarm
数据库·postgresql
Boop_wu1 小时前
[Mybatis] XML 方式实现 MP 自定义 SQL + 条件构造器
xml·sql·mybatis
小新同学^O^2 小时前
简单学习 --> 数据加密
java·数据库·学习·数据加密
Elastic 中国社区官方博客2 小时前
将 Logstash Pipeline 从 Azure Event Hubs 迁移到 OTel Collector Kafka Receiver
大数据·数据库·人工智能·分布式·elasticsearch·搜索引擎·kafka
Elastic 中国社区官方博客2 小时前
使用 Elasticsearch 与 Kibana 中的 PromQL 调查 Kubernetes 基础设施问题
大数据·数据库·elasticsearch·搜索引擎·信息可视化·kubernetes·全文检索
Tipriest_2 小时前
【TBB】多生产者、多消费者(MPMC) 队列concurrent_queue介绍
网络·数据库