一、为什么需要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;
/
注意参数那里的 IN、OUT 关键字,它们决定了参数的"方向":
| 模式 | 干什么 |
|---|---|
| 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;
/
四、函数------能"算"出结果的子程序
函数和存储过程长得像,但有两个关键区别:
- 函数头部必须有
RETURN,声明它返回什么类型。 - 函数体里,每一条执行路径 都必须遇到
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 或者 UPDATE、DELETE 这些语句时,数据库会自动打开一个隐式游标。通过 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.name、rec.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;
/
四个谓词一目了然:INSERTING、UPDATING、UPDATING('列名')、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 COLLECT和FORALL替代逐行循环操作,性能差距可能是十倍甚至百倍。
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语句里使用。
- 触发器是个自动响应机制,数据一变它就跳出来执行。
- 包是组织代码的利器,把相关的东西打包到一起,接口和实现分离。
- 游标 让你逐行处理查询结果,异常处理让程序出错了也能优雅应对。
这几个组件搭配起来,足以在数据库层面构建出高效、健壮、好维护的数据处理逻辑。建议从简单的存储过程和函数开始练手,等熟悉了再逐步接触包和触发器------毕竟,一步步来才是学得最稳的方式。