MySQL 存储过程、游标、存储函数与触发器最佳实践
作者:callNull
本文将全面介绍 MySQL 存储过程、游标、存储函数、触发器的概念、语法、应用场景以及在现代开发中的定位。这四者是 MySQL 可编程对象的核心,无论你是面试准备还是项目实战,读完这篇文章你将对它们建立起清晰、完整的认知。
一、什么是存储过程
1.1 概念定义
存储过程(Stored Procedure) 是一组为了完成特定功能而预先编译好的 SQL 语句集合,存储在数据库服务器中,客户端通过指定存储过程的名字并给出参数(如果有)来调用执行。
用一个通俗的类比来理解:
- 没有存储过程:你每次去餐厅都要一步一步告诉厨师——先放油、再放蒜、然后放肉、翻炒三分钟……
- 有存储过程:你只需要说"来一份宫保鸡丁",厨师已经知道所有步骤了
本质上,存储过程就是数据库端的"函数",把一段可重复使用的业务逻辑封装起来,对外暴露一个调用接口。
1.2 核心特征
| 特征 | 说明 |
|---|---|
| 预编译 | 创建时编译一次,后续调用直接执行编译后的代码 |
| 持久化存储 | 存储在数据库的系统表中,数据库重启后依然存在 |
| 参数化 | 支持输入(IN)、输出(OUT)、输入输出(INOUT)三种参数模式 |
| 流程控制 | 支持变量声明、条件判断、循环等编程语言的基本结构 |
| 事务支持 | 内部可以使用事务控制(BEGIN / COMMIT / ROLLBACK) |
1.3 存储过程 vs 普通 SQL
┌─────────────────────────────────────────────────────────────┐ │ 普通 SQL 执行流程 │ ├─────────────────────────────────────────────────────────────┤ │ 客户端 → 发送SQL语句 → 数据库解析 → 编译 → 优化 → 执行 → 返回 │ │ (每次都要重复上述全部步骤) │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ 存储过程执行流程 │ ├─────────────────────────────────────────────────────────────┤ │ 第一次:客户端 → CALL → 解析 → 编译 → 优化 → 执行 → 返回 │ │ 后续次:客户端 → CALL → 直接执行已编译代码 → 返回 │ │ (省去了解析、编译、优化步骤) │ └─────────────────────────────────────────────────────────────┘
二、存储过程有什么用
2.1 五大核心作用
① 封装复杂业务逻辑
将多条 SQL 语句和逻辑判断封装为一个整体,对外只暴露一个名字和参数列表,隐藏内部实现细节。
-- 不用存储过程:客户端需要发送多条SQL SELECT balance FROM account WHERE id = 1; -- 客户端判断余额是否足够 UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; INSERT INTO transfer_log VALUES (...); -- 用存储过程:一条命令搞定 CALL TransferMoney(1, 2, 100.00, @status);
② 提升执行效率
- 存储过程在首次执行时被编译,后续调用跳过解析和编译阶段
- 减少了客户端与数据库之间的网络往返(只传一个 CALL 命令,而不是一大段 SQL)
③ 增强安全性
- 可以只授予用户执行存储过程的权限,而不给予直接操作表的权限
- 防止 SQL 注入:参数传入存储过程时,不会被当作 SQL 代码执行
④ 减少网络传输
假设一个业务需要执行 10 条 SQL,如果不用存储过程需要 10 次网络往返;用存储过程只需要 1 次网络调用。
⑤ 保证数据一致性
存储过程内部可以包含事务控制,保证一组操作要么全部成功,要么全部回滚。
2.2 典型应用场景
| 场景 | 说明 |
|---|---|
| 银行转账 | 扣款 + 加款 + 记流水,必须在事务中完成 |
| 批量数据处理 | 定时结算、对账、数据迁移 |
| 复杂报表统计 | 多表关联 + 聚合计算 + 分组排序 |
| 权限控制 | DBA 封装好存储过程,开发人员只能调用 |
| ETL 数据清洗 | 数据仓库中从源表加工到目标表 |
三、快速入门
3.1 基础语法
-- 修改结束符(因为存储过程内部有分号)
DELIMITER //
CREATE PROCEDURE 过程名([参数列表])
BEGIN
-- SQL 语句和逻辑
END //
-- 恢复结束符
DELIMITER ;为什么需要 DELIMITER?
MySQL 默认用;作为语句结束符。存储过程体内包含多个;,如果不改结束符,MySQL 会在第一个;处就认为语句结束了。所以我们临时将结束符改为//,定义完后再改回;。
3.2 参数类型
存储过程支持三种参数模式:
CREATE PROCEDURE 过程名(
IN 输入参数名 数据类型, -- 调用方传入,过程内只读
OUT 输出参数名 数据类型, -- 过程内写入,调用方获取
INOUT 双向参数名 数据类型 -- 调用方传入,过程内修改后调用方可获取
)
| 模式 | 方向 | 类比 |
|---|---|---|
| IN | 调用方 → 存储过程 | 函数的入参 |
| OUT | 存储过程 → 调用方 | 函数的返回值 |
| INOUT | 双向传递 | 引用传递 |
3.3 快速实战 6 例
示例 1:最简单的存储过程(无参数)
DELIMITER //
CREATE PROCEDURE HelloWorld()
BEGIN
SELECT 'Hello, Stored Procedure!' AS message;
END //
DELIMITER ;
-- 调用
CALL HelloWorld();输出:
+---------------------------+ | message | +---------------------------+ | Hello, Stored Procedure! | +---------------------------+
示例 2:带输入参数(IN)—— 根据 ID 查用户
DELIMITER //
CREATE PROCEDURE GetUserById(IN p_id INT)
BEGIN
SELECT id, username, email, create_time
FROM users
WHERE id = p_id;
END //
DELIMITER ;
-- 调用
CALL GetUserById(1);
CALL GetUserById(5);示例 3:带输出参数(OUT)—— 统计用户数量
DELIMITER //
CREATE PROCEDURE GetUserCount(OUT p_count INT)
BEGIN
SELECT COUNT(*) INTO p_count FROM users;
END //
DELIMITER ;
-- 调用
CALL GetUserCount(@total);
SELECT @total AS user_count;注意:
@total是 MySQL 的用户变量(Session 级别),用@前缀声明,可以在同一个会话中跨语句使用。
示例 4:输入输出参数(INOUT)—— 累加计数器
DELIMITER //
CREATE PROCEDURE Accumulate(INOUT p_counter INT, IN p_increment INT)
BEGIN
SET p_counter = p_counter + p_increment;
END //
DELIMITER ;
-- 调用
SET @counter = 100;
CALL Accumulate(@counter, 50);
SELECT @counter; -- 输出 150
CALL Accumulate(@counter, 30);
SELECT @counter; -- 输出 180示例 5:条件判断 + 变量声明
DELIMITER //
CREATE PROCEDURE EvaluateScore(IN p_score INT, OUT p_level VARCHAR(20))
BEGIN
IF p_score >= 90 THEN
SET p_level = '优秀';
ELSEIF p_score >= 80 THEN
SET p_level = '良好';
ELSEIF p_score >= 60 THEN
SET p_level = '及格';
ELSE
SET p_level = '不及格';
END IF;
END //
DELIMITER ;
-- 调用
CALL EvaluateScore(85, @level);
SELECT @level; -- 输出:良好示例 6:循环 + 游标(遍历结果集)
DELIMITER //
CREATE PROCEDURE BatchUpdateStatus()
BEGIN
DECLARE v_id INT;
DECLARE v_done INT DEFAULT 0;
-- 声明游标
DECLARE cur CURSOR FOR
SELECT id FROM orders WHERE status = 'pending' AND create_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
-- 声明结束处理器
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_id;
IF v_done = 1 THEN
LEAVE read_loop;
END IF;
-- 将超过30天的待处理订单自动取消
UPDATE orders SET status = 'cancelled' WHERE id = v_id;
END LOOP;
CLOSE cur;
END //
DELIMITER ;
-- 调用
CALL BatchUpdateStatus();3.4 流程控制语句速查
| 语句 | 作用 | 语法 |
|---|---|---|
| IF | 条件判断 | IF ... THEN ... ELSEIF ... ELSE ... END IF |
| CASE | 多分支选择 | CASE WHEN ... THEN ... END CASE |
| WHILE | 前置条件循环 | WHILE 条件 DO ... END WHILE |
| REPEAT | 后置条件循环 | REPEAT ... UNTIL 条件 END REPEAT |
| LOOP | 无条件循环 | LOOP ... END LOOP(配合 LEAVE 退出) |
| LEAVE | 退出循环 | LEAVE 循环标签 |
| ITERATE | 跳过本次循环 | ITERATE 循环标签(类似 continue) |
四、存储过程中的事务与异常处理
4.1 事务控制
DELIMITER //
CREATE PROCEDURE TransferMoney(
IN p_from_id INT,
IN p_to_id INT,
IN p_amount DECIMAL(10,2),
OUT p_status VARCHAR(50)
)
BEGIN
-- 声明异常处理:发生任何 SQL 异常时回滚
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_status = '转账失败:发生异常';
END;
-- 余额校验
DECLARE v_balance DECIMAL(10,2);
SELECT balance INTO v_balance FROM account WHERE id = p_from_id FOR UPDATE;
IF v_balance < p_amount THEN
SET p_status = '转账失败:余额不足';
ELSE
START TRANSACTION;
UPDATE account SET balance = balance - p_amount WHERE id = p_from_id;
UPDATE account SET balance = balance + p_amount WHERE id = p_to_id;
INSERT INTO transfer_log(from_id, to_id, amount, transfer_time)
VALUES (p_from_id, p_to_id, p_amount, NOW());
COMMIT;
SET p_status = '转账成功';
END IF;
END //
DELIMITER ;4.2 异常处理器类型
-- 遇到异常后继续执行后续语句
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET @err = 1;
-- 遇到异常后退出当前 BEGIN...END 块
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SELECT '执行出错' AS error_msg;
END;
-- 处理特定错误码
DECLARE CONTINUE HANDLER FOR 1062 -- 重复键错误
SET @duplicate = 1;五、存储过程的管理操作
-- ========== 查看 ========== -- 查看当前数据库所有存储过程 SHOW PROCEDURE STATUS WHERE Db = 'your_database'; -- 查看某个存储过程的创建语句 SHOW CREATE PROCEDURE TransferMoney; -- 从系统表查询 SELECT * FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = 'your_database' AND ROUTINE_TYPE = 'PROCEDURE'; -- ========== 修改 ========== -- MySQL 不支持 ALTER PROCEDURE 修改过程体 -- 只能先删除再重新创建 DROP PROCEDURE IF EXISTS TransferMoney; -- 然后重新 CREATE PROCEDURE ... -- ========== 删除 ========== DROP PROCEDURE IF EXISTS GetUserById;
六、游标(Cursor)详解
6.1 什么是游标
游标(Cursor) 是数据库提供的一种机制,用于对查询结果集进行逐行处理。普通的 SELECT 语句一次性返回所有满足条件的行,而游标则像一个"指针",可以在结果集上一行一行地移动,逐条读取并处理数据。
通俗类比:
SELECT * FROM orders→ 把所有订单数据一股脑全取出来- 游标 → 像超市收银员,一件一件扫描商品,处理完一件再拿下一件
游标通常与存储过程配合使用,在需要对每一行数据做差异化处理时发挥作用。
6.2 游标的使用四步骤
MySQL 中使用游标必须严格按照以下四个步骤进行:
① DECLARE → 声明游标(绑定一条 SELECT 语句,此时不执行) ② OPEN → 打开游标(执行 SELECT,将结果集加载到内存) ③ FETCH → 逐行读取(将当前行数据存入变量,指针下移) ④ CLOSE → 关闭游标(释放结果集占用的内存)
6.3 基础语法
DELIMITER //
CREATE PROCEDURE cursor_demo()
BEGIN
-- 1. 声明局部变量(用于存放每行数据)
DECLARE v_id INT;
DECLARE v_name VARCHAR(50);
DECLARE v_done INT DEFAULT 0; -- 游标结束标志
-- 2. 声明游标(绑定 SELECT,注意:此时并不执行查询)
DECLARE cur CURSOR FOR
SELECT id, username FROM users WHERE status = 1;
-- 3. 声明结束处理器(FETCH 到末尾触发 NOT FOUND,将 v_done 置 1)
-- 注意:HANDLER 必须声明在游标之后
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
-- 4. 打开游标(此时才真正执行 SELECT 查询)
OPEN cur;
-- 5. 循环读取
read_loop: LOOP
FETCH cur INTO v_id, v_name; -- 读取当前行到变量,指针后移
IF v_done = 1 THEN -- 没有更多行,退出循环
LEAVE read_loop;
END IF;
-- 处理当前行的业务逻辑
-- ...
END LOOP read_loop;
-- 6. 关闭游标(释放资源)
CLOSE cur;
END //
DELIMITER ;⚠️ DECLARE 的顺序非常重要:MySQL 规定 DECLARE 语句必须按以下顺序出现,顺序错误会报编译错误:
- 局部变量(
DECLARE var TYPE) - 游标(
DECLARE cur CURSOR FOR) - 异常处理器(
DECLARE HANDLER)
6.4 游标的特性
| 特性 | 说明 |
|---|---|
| 只读性 | MySQL 游标只能读取数据,不能通过游标直接修改行(需单独写 UPDATE) |
| 单向滚动 | 只能向前逐行移动,不能后退或随机跳转到某一行 |
| 局部性 | 只能在存储过程、存储函数或触发器内部使用,不能单独执行 |
| 内存占用 | 打开游标会将完整结果集加载到内存,结果集过大时存在 OOM 风险 |
6.5 实战示例
示例 1:遍历用户列表自动写通知日志
DELIMITER //
CREATE PROCEDURE sp_send_notification_log()
BEGIN
DECLARE v_user_id INT;
DECLARE v_email VARCHAR(100);
DECLARE v_done INT DEFAULT 0;
DECLARE cur CURSOR FOR
SELECT id, email FROM users WHERE is_active = 1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
user_loop: LOOP
FETCH cur INTO v_user_id, v_email;
IF v_done = 1 THEN
LEAVE user_loop;
END IF;
INSERT INTO notification_log(user_id, email, send_time, content)
VALUES (v_user_id, v_email, NOW(), '系统有重要通知,请及时查看。');
END LOOP user_loop;
CLOSE cur;
END //
DELIMITER ;示例 2:游标 + 条件判断 —— 差异化更新用户积分
DELIMITER //
CREATE PROCEDURE sp_update_user_points()
BEGIN
DECLARE v_id INT;
DECLARE v_orders INT;
DECLARE v_done INT DEFAULT 0;
DECLARE cur CURSOR FOR
SELECT u.id, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'completed'
GROUP BY u.id;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur;
point_loop: LOOP
FETCH cur INTO v_id, v_orders;
IF v_done = 1 THEN
LEAVE point_loop;
END IF;
-- 根据完成订单数量差异化增加积分
IF v_orders >= 100 THEN
UPDATE users SET points = points + 500 WHERE id = v_id;
ELSEIF v_orders >= 50 THEN
UPDATE users SET points = points + 200 WHERE id = v_id;
ELSEIF v_orders >= 10 THEN
UPDATE users SET points = points + 50 WHERE id = v_id;
END IF;
END LOOP point_loop;
CLOSE cur;
END //
DELIMITER ;示例 3:遍历部门同步统计数据(游标 + 聚合查询)
DELIMITER //
CREATE PROCEDURE sp_sync_department_stats()
BEGIN
DECLARE v_dept_id INT;
DECLARE v_emp_count INT;
DECLARE v_avg_salary DECIMAL(10,2);
DECLARE v_done INT DEFAULT 0;
DECLARE dept_cur CURSOR FOR
SELECT id FROM departments;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN dept_cur;
dept_loop: LOOP
FETCH dept_cur INTO v_dept_id;
IF v_done = 1 THEN
LEAVE dept_loop;
END IF;
-- 对每个部门单独做聚合查询
SELECT COUNT(*), IFNULL(AVG(salary), 0)
INTO v_emp_count, v_avg_salary
FROM employees
WHERE department_id = v_dept_id;
-- 更新统计表(存在则更新,不存在则插入)
INSERT INTO department_stats(dept_id, emp_count, avg_salary, updated_at)
VALUES (v_dept_id, v_emp_count, v_avg_salary, NOW())
ON DUPLICATE KEY UPDATE
emp_count = v_emp_count,
avg_salary = v_avg_salary,
updated_at = NOW();
END LOOP dept_loop;
CLOSE dept_cur;
END //
DELIMITER ;6.6 游标的性能问题与替代方案
游标的本质是逐行处理(Row-by-Row),而关系型数据库的优化器是为**集合操作(Set-based)**设计的,两者背道而驰。
| 方案 | 适用场景 | 性能 |
|---|---|---|
| 游标逐行处理 | 每行逻辑复杂、无法用单条 SQL 表达 | 慢 |
| 集合 SQL(批量 UPDATE/INSERT) | 批量相同操作 | 快 |
| 分批处理(LIMIT + 循环) | 超大数据量的批量操作 | 较快 |
能用集合 SQL 解决的,绝不用游标:
-- ❌ 用游标逐行更新(慢,不推荐) -- 声明游标 → 循环 FETCH → 逐条 UPDATE ... -- ✅ 用一条集合 SQL 批量更新(快,推荐) UPDATE orders SET status = 'cancelled' WHERE status = 'pending' AND create_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
游标合理的使用场景:
- 每行的处理逻辑确实无法用一条 SQL 表达(如需要调用存储过程、做复杂条件分支)
- 数据量较小(几千到几万行以内)
- ETL / 数据迁移中需要逐行加工转换
七、存储函数(Function)详解
7.1 什么是存储函数
存储函数(Stored Function) 和存储过程同样是存储在数据库中的预编译代码块。核心区别在于:存储函数必须通过 RETURN 返回一个值,并且可以像 MySQL 内置函数(NOW()、LENGTH()、IFNULL() 等)一样,直接嵌入 SQL 语句中使用。
-- 内置函数的使用方式 SELECT LENGTH(username), UPPER(email) FROM users; -- 自定义存储函数可以做到完全一样的事 SELECT fn_mask_phone(phone), fn_get_age_group(age) FROM users; SELECT * FROM users WHERE fn_is_vip(id) = 1;
7.2 基础语法
DELIMITER //
CREATE FUNCTION 函数名(参数1 数据类型, 参数2 数据类型, ...)
RETURNS 返回值类型
[特性关键字]
BEGIN
-- 函数体(只有 IN 参数,没有 OUT/INOUT)
RETURN 返回值;
END //
DELIMITER ;特性关键字(至少声明一个,MySQL 要求必须明确声明函数特性):
| 关键字 | 含义 |
|---|---|
DETERMINISTIC | 相同输入总是返回相同输出(纯函数,如字符串处理、数学计算) |
NOT DETERMINISTIC | 相同输入可能返回不同输出(默认值,如使用了 NOW()、RAND()) |
NO SQL | 函数体中不包含任何 SQL 语句 |
READS SQL DATA | 函数体包含 SELECT 读操作,但不修改数据 |
MODIFIES SQL DATA | 函数体包含 INSERT/UPDATE/DELETE 写操作 |
⚠️ 权限注意:MySQL 5.x 默认禁止创建存储函数(因为在 binlog 开启时,不确定性函数可能引发主从不一致)。需要 DBA 执行:
SET GLOBAL log_bin_trust_function_creators = 1;
MySQL 8.0 对此限制已大幅放宽。
7.3 调用方式对比
-- 存储过程:必须用 CALL,不能嵌入 SQL
CALL sp_get_user_by_id(1);
-- 存储函数:像内置函数一样随处可用
SELECT fn_mask_phone('13812345678'); -- 直接查询
SELECT id, fn_mask_phone(phone) FROM users; -- SELECT 字段中
SELECT * FROM users WHERE fn_get_age_group(age) = '青年'; -- WHERE 条件中
INSERT INTO report SELECT fn_calc_score(id) FROM users; -- INSERT...SELECT 中7.4 实战示例
示例 1:字符串处理函数 —— 手机号中间四位脱敏
DELIMITER //
CREATE FUNCTION fn_mask_phone(p_phone VARCHAR(20))
RETURNS VARCHAR(20)
DETERMINISTIC
BEGIN
-- 13812345678 → 138****5678
IF p_phone IS NULL OR LENGTH(p_phone) != 11 THEN
RETURN p_phone; -- 不符合格式,原样返回
END IF;
RETURN CONCAT(
LEFT(p_phone, 3),
'****',
RIGHT(p_phone, 4)
);
END //
DELIMITER ;
-- 使用:直接嵌入 SELECT
SELECT id, username, fn_mask_phone(phone) AS masked_phone FROM users;示例 2:业务函数 —— 根据年龄返回用户年龄段
DELIMITER //
CREATE FUNCTION fn_get_age_group(p_age INT)
RETURNS VARCHAR(10)
DETERMINISTIC
BEGIN
DECLARE v_group VARCHAR(10);
CASE
WHEN p_age < 18 THEN SET v_group = '未成年';
WHEN p_age < 30 THEN SET v_group = '青年';
WHEN p_age < 50 THEN SET v_group = '中年';
ELSE SET v_group = '老年';
END CASE;
RETURN v_group;
END //
DELIMITER ;
-- 使用:嵌入 ORDER BY 和 GROUP BY 也没问题
SELECT fn_get_age_group(age) AS age_group, COUNT(*) AS cnt
FROM users
GROUP BY fn_get_age_group(age);示例 3:查询函数 —— 获取用户历史订单总金额
DELIMITER //
CREATE FUNCTION fn_get_user_total_amount(p_user_id INT)
RETURNS DECIMAL(12,2)
READS SQL DATA
BEGIN
DECLARE v_total DECIMAL(12,2) DEFAULT 0.00;
SELECT IFNULL(SUM(amount), 0.00)
INTO v_total
FROM orders
WHERE user_id = p_user_id
AND status = 'completed';
RETURN v_total;
END //
DELIMITER ;
-- 使用:在 WHERE 中筛选高价值用户
SELECT id, username, fn_get_user_total_amount(id) AS total_amount
FROM users
WHERE fn_get_user_total_amount(id) > 10000
ORDER BY total_amount DESC;示例 4:递归函数 —— 计算斐波那契数列
DELIMITER //
CREATE FUNCTION fn_fibonacci(n INT)
RETURNS BIGINT
DETERMINISTIC
BEGIN
IF n <= 0 THEN RETURN 0; END IF;
IF n = 1 THEN RETURN 1; END IF;
RETURN fn_fibonacci(n - 1) + fn_fibonacci(n - 2);
END //
DELIMITER ;
-- 使用前需设置最大递归深度(默认为0,即不允许递归)
SET max_sp_recursion_depth = 20;
SELECT fn_fibonacci(10); -- 输出 557.5 存储函数的管理
-- 查看所有存储函数 SHOW FUNCTION STATUS WHERE Db = 'your_database'; -- 查看函数定义 SHOW CREATE FUNCTION fn_mask_phone; -- 从系统表查询 SELECT ROUTINE_NAME, ROUTINE_TYPE, DTD_IDENTIFIER, ROUTINE_DEFINITION FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = 'your_database' AND ROUTINE_TYPE = 'FUNCTION'; -- 删除函数 DROP FUNCTION IF EXISTS fn_mask_phone;
7.6 存储过程 vs 存储函数 完整对比
| 对比维度 | 存储过程(Procedure) | 存储函数(Function) |
|---|---|---|
| 返回值 | 通过 OUT 参数返回,可有多个 | 必须有且只有一个 RETURN 返回值 |
| 调用方式 | CALL procedure_name() | SELECT function_name() |
| 能否嵌入 SQL | ❌ 不能嵌入 SELECT/WHERE/JOIN | ✅ 可以像内置函数一样使用 |
| 参数类型 | IN / OUT / INOUT | 只有 IN |
| 能否控制事务 | ✅ 可以使用 COMMIT/ROLLBACK | ❌ 不能显式控制事务 |
| 使用游标 | ✅ 支持 | ✅ 支持 |
| 侧重点 | 执行一系列操作(流程控制) | 计算并返回单个值(可复用计算逻辑) |
选择原则:
- 需要做事情(执行增删改、控制事务、复杂流程)→ 存储过程
- 需要算东西(计算一个值,嵌入到 SQL 中直接用)→ 存储函数
八、触发器(Trigger)详解
8.1 什么是触发器
触发器(Trigger) 是与数据库表关联的特殊存储程序。当表上发生特定的数据变更操作(INSERT、UPDATE、DELETE)时,触发器会自动执行,无需手动调用,完全由数据库引擎驱动。
通俗类比:
- 存储过程 → 你主动按开关开灯
- 触发器 → 人体感应灯,有人进来自动亮
触发器就像数据库层的事件监听器 / 钩子函数——就像 Java 中的 @EventListener,触发器监听的是数据变更事件,一旦发生就自动响应。
8.2 触发器的类型
MySQL 触发器按两个维度分类,两两组合共 6 种触发时机:
按时机(WHEN):
| 时机 | 说明 | 常见用途 |
|---|---|---|
BEFORE | SQL 操作执行之前触发 | 数据校验、自动填充、阻止非法操作 |
AFTER | SQL 操作执行之后触发 | 日志记录、数据同步、统计更新 |
按操作(EVENT):INSERT / UPDATE / DELETE
8.3 基础语法
DELIMITER //
CREATE TRIGGER trigger_name
{BEFORE | AFTER} {INSERT | UPDATE | DELETE}
ON table_name
FOR EACH ROW
BEGIN
-- 触发器逻辑
END //
DELIMITER ;关键说明:
FOR EACH ROW:行级触发器,每影响一行就触发一次(MySQL 只支持行级,不支持语句级)- 触发器名建议使用
trg_表名_时机_操作格式,如trg_users_after_insert
8.4 NEW 和 OLD 关键字
触发器中通过 NEW 和 OLD 访问数据变更前后的行:
| 触发类型 | NEW(新数据) | OLD(旧数据) |
|---|---|---|
| INSERT | ✅ 新插入的行 | ❌ 不可用 |
| UPDATE | ✅ 更新后的新值 | ✅ 更新前的旧值 |
| DELETE | ❌ 不可用 | ✅ 被删除的行 |
在 BEFORE 触发器中,还可以通过 SET NEW.column = value 修改即将写入数据库的值:
-- 在 BEFORE INSERT 中预处理数据 SET NEW.username = LOWER(NEW.username); -- 用户名统一转小写 SET NEW.create_time = NOW(); -- 自动填充创建时间
8.5 实战示例
示例 1:AFTER INSERT —— 自动记录新增日志
-- 操作日志表
CREATE TABLE operation_log (
id INT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(50) NOT NULL,
operation VARCHAR(20) NOT NULL,
record_id INT,
operate_time DATETIME NOT NULL,
detail TEXT
);
DELIMITER //
CREATE TRIGGER trg_users_after_insert
AFTER INSERT ON users
FOR EACH ROW
BEGIN
INSERT INTO operation_log(table_name, operation, record_id, operate_time, detail)
VALUES (
'users',
'INSERT',
NEW.id,
NOW(),
CONCAT('新增用户:', NEW.username, ',邮箱:', NEW.email)
);
END //
DELIMITER ;示例 2:AFTER UPDATE —— 只记录关键字段的变更
DELIMITER //
CREATE TRIGGER trg_users_after_update
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
-- 只有 status 字段变更时才写日志,避免无效日志
IF OLD.status != NEW.status THEN
INSERT INTO operation_log(table_name, operation, record_id, operate_time, detail)
VALUES (
'users',
'UPDATE',
NEW.id,
NOW(),
CONCAT('用户[', NEW.username, ']状态变更:', OLD.status, ' → ', NEW.status)
);
END IF;
END //
DELIMITER ;示例 3:BEFORE INSERT —— 数据校验 + 自动填充
DELIMITER //
CREATE TRIGGER trg_orders_before_insert
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
-- 校验:订单金额不能为负数
IF NEW.amount <= 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '订单金额必须大于0';
END IF;
-- 自动填充创建时间
IF NEW.create_time IS NULL THEN
SET NEW.create_time = NOW();
END IF;
-- 自动生成订单号
IF NEW.order_no IS NULL OR NEW.order_no = '' THEN
SET NEW.order_no = CONCAT(
'ORD',
DATE_FORMAT(NOW(), '%Y%m%d%H%i%s'),
LPAD(NEW.user_id, 6, '0')
);
END IF;
END //
DELIMITER ;
SIGNAL语句:在触发器或存储过程中主动抛出错误,阻止当前操作并回滚事务。SQLSTATE '45000'是用户自定义错误的标准状态码。
示例 4:AFTER DELETE —— 删除前自动归档备份
DELIMITER //
CREATE TRIGGER trg_orders_after_delete
AFTER DELETE ON orders
FOR EACH ROW
BEGIN
-- 被删除的数据自动归档到备份表
INSERT INTO orders_archive(
id, user_id, amount, status, create_time, archived_at
)
VALUES (
OLD.id,
OLD.user_id,
OLD.amount,
OLD.status,
OLD.create_time,
NOW()
);
END //
DELIMITER ;示例 5:BEFORE UPDATE —— 阻止非法状态流转
DELIMITER //
-- 订单状态只能单向流转:pending → paid → shipped → completed
-- 已完成或已取消的订单不允许再修改状态
CREATE TRIGGER trg_orders_before_update
BEFORE UPDATE ON orders
FOR EACH ROW
BEGIN
IF OLD.status = 'completed' AND NEW.status != 'completed' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '已完成的订单不允许修改状态';
END IF;
IF OLD.status = 'cancelled' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '已取消的订单不允许再操作';
END IF;
END //
DELIMITER ;8.6 触发器的完整执行流程
以执行一条 UPDATE orders SET status='paid' WHERE id=1 为例: ┌──────────────────────────────────────────────────────────────────┐ │ 1. BEFORE UPDATE 触发器执行 │ │ → 可以通过 SET NEW.xxx 修改即将写入的值 │ │ → 可以通过 SIGNAL 抛错阻止本次操作(整个语句回滚) │ ├──────────────────────────────────────────────────────────────────┤ │ 2. 实际执行 UPDATE 语句(数据写入磁盘) │ ├──────────────────────────────────────────────────────────────────┤ │ 3. AFTER UPDATE 触发器执行 │ │ → 数据已确定写入,适合做日志记录、数据同步 │ │ → 若此处抛错,整个事务(含 UPDATE)一起回滚 │ └──────────────────────────────────────────────────────────────────┘
MySQL 5.7+ 支持同一张表、同一事件有多个触发器,按创建先后顺序依次执行。
8.7 触发器的限制
| 限制 | 说明 |
|---|---|
| 不能调用有 OUT 参数的存储过程 | 触发器内部无法获取存储过程的输出参数 |
| 不能显式控制事务 | 不能写 START TRANSACTION / COMMIT / ROLLBACK,但触发器与触发它的语句在同一事务中 |
| 不能对同表做递归操作 | users 表的触发器中不能再 UPDATE users(会无限递归报错) |
| 不能使用动态 SQL | 不能使用 PREPARE / EXECUTE 执行动态语句 |
| 错误影响 | BEFORE 触发器抛错 → 阻止原操作;AFTER 触发器抛错 → 整个事务回滚 |
8.8 触发器的管理
-- 查看当前数据库所有触发器
SHOW TRIGGERS FROM your_database;
-- 查看特定表的触发器
SHOW TRIGGERS FROM your_database LIKE 'orders';
-- 查看触发器定义
SHOW CREATE TRIGGER trg_users_after_insert;
-- 从系统表查询(可获取更详细的信息)
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE,
ACTION_TIMING, ACTION_STATEMENT
FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = 'your_database';
-- 删除触发器
DROP TRIGGER IF EXISTS trg_users_after_insert;8.9 为什么现代开发不推荐滥用触发器
| 问题 | 详细说明 |
|---|---|
| 行为隐蔽 | 一条 INSERT 背后可能触发多个触发器,调用方完全不知情,Bug 难以追踪 |
| 调试极难 | 触发器自动执行,日志里只显示原始 SQL,触发器内部的问题很难定位 |
| 性能陷阱 | 批量操作时每行都触发一次(FOR EACH ROW),百万行操作 = 百万次触发器调用 |
| 级联风险 | 触发器 A 修改表 B → 表 B 的触发器 B 又触发……级联链路极难维护 |
| 业务逻辑分散 | 同一业务规则,一半在 Java Service,一半在数据库触发器,职责不清 |
| 测试困难 | 单元测试时很难 Mock 触发器行为,集成测试成本高 |
推荐的替代方案:
- 审计日志 → 使用 AOP(
@Aspect)在 Service 层统一拦截记录 - 数据校验 → 放在 Java Bean Validation(
@NotNull、@Min等)或 Service 层 - 数据同步 → 使用消息队列(MQ)做异步解耦,比触发器更健壮
九、为什么现代开发中存储过程用得越来越少?
这是面试中经常会被问到的问题。存储过程在 2000~2010 年代被大量使用,但如今在互联网项目中已经很少见了。原因如下:
9.1 微服务架构的兴起
2005年的架构:
┌─────────┐ ┌──────────────────┐
│ 客户端 │ ──→ │ 单体应用 + DB │ ← 业务逻辑大量放在存储过程中
└─────────┘ └──────────────────┘
2020年的架构:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 客户端 │ ──→ │ 服务A │ │ 服务B │ │ 服务C │
└─────────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌───┴───┐ ┌───┴───┐ ┌───┴───┐
│ DB-A │ │ DB-B │ │ DB-C │
└───────┘ └───────┘ └───────┘微服务架构下,业务逻辑必须放在服务层(Java/Go/Python),而不是数据库中。因为:
- 跨服务的事务无法用数据库存储过程解决
- 每个服务独立部署、独立演进
9.2 可维护性差
| 问题 | 说明 |
|---|---|
| 调试困难 | 不像 Java 可以打断点、单步调试,存储过程只能通过 SELECT 打印中间变量 |
| 缺乏 IDE 支持 | 没有代码补全、没有重构工具、没有静态分析 |
| 可读性差 | SQL 本身不是面向对象语言,复杂逻辑写起来冗长晦涩 |
| 测试困难 | 不能用 JUnit/Mockito 等框架进行单元测试 |
9.3 版本管理困难
- 存储过程存在数据库里,不在代码仓库中
- 无法用 Git 做版本控制、Code Review、分支管理
- 多人协作时容易冲突,且冲突难以发现
虽然可以把存储过程的 SQL 文件放到 Git 里,但执行和部署仍然需要额外的工具支持,远不如 Java 代码的 CI/CD 流程成熟。
9.4 数据库可移植性差
-- MySQL 的存储过程语法
DELIMITER //
CREATE PROCEDURE ...
BEGIN
DECLARE v INT;
...
END //
-- Oracle 的存储过程语法(PL/SQL)
CREATE OR REPLACE PROCEDURE ...
IS
v NUMBER;
BEGIN
...
END;
-- SQL Server 的存储过程语法(T-SQL)
CREATE PROCEDURE ...
AS
BEGIN
DECLARE @v INT;
...
END三种数据库的语法完全不同。如果项目有更换数据库的可能(如从 MySQL 迁移到 PostgreSQL),所有存储过程都要重写。
而 Java + MyBatis/JPA 的方案,换数据库只需要改 SQL 方言配置,业务代码基本不动。
9.5 水平扩展受限
- 应用层扩展:加几台服务器即可(无状态,容易扩展)
- 数据库层扩展:读写分离、分库分表都非常复杂
把业务逻辑放在存储过程中 = 把压力集中在数据库上。而数据库是整个系统中最难水平扩展的组件。
9.6 人才和协作问题
- 精通存储过程开发的 DBA 人才稀缺
- 开发人员和 DBA 之间的协作成本高
- 出了 Bug,开发说是存储过程的问题,DBA 说是调用方的问题——职责不清
9.7 ORM 框架的成熟
MyBatis、JPA/Hibernate、Spring Data 等 ORM 框架已经非常成熟:
// MyBatis —— 用 XML 或注解写 SQL,逻辑在 Java 中
@Mapper
public interface UserMapper {
@Select("SELECT * FROM users WHERE id = #{id}")
User findById(int id);
}
// JPA —— 连 SQL 都不用写
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByAgeGreaterThan(int age);
}这些框架让 Java 代码直接操作数据库变得非常简单,存储过程的"封装"优势不再明显。
十、那什么时候还在用存储过程?
虽然互联网项目越来越少用,但存储过程并没有消亡。以下场景仍然活跃:
10.1 仍在使用的场景
| 场景 | 原因 |
|---|---|
| 传统企业系统(ERP/银行/保险) | 历史遗留,存储过程已运行多年,不敢轻易重构 |
| 批量数据处理 | 百万/千万级数据的批量更新,存储过程比应用层逐条处理快得多 |
| 数据仓库 / ETL | 数据清洗、加工、聚合,天然适合在数据库端完成 |
| 复杂报表 | 涉及多表关联、多级汇总的报表查询 |
| DBA 运维脚本 | 数据库巡检、空间清理、权限管理等运维操作 |
| 安全性要求极高 | 不允许应用层直接访问表,只能通过存储过程操作数据 |
10.2 选择建议
┌─────────────────────────────────────────────────────────────┐ │ 什么时候用存储过程? │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ✅ 用: │ │ • 大批量数据处理(ETL、定时任务) │ │ • 对性能要求极致的数据库操作 │ │ • 需要严格权限控制,不允许直接操作表 │ │ • 数据库平台固定,不考虑迁移 │ │ │ │ ❌ 不用: │ │ • 常规 CRUD 业务逻辑 │ │ • 微服务架构项目 │ │ • 需要频繁迭代的业务 │ │ • 需要跨数据库平台的项目 │ │ • 团队没有熟悉存储过程的 DBA │ │ │ └─────────────────────────────────────────────────────────────┘
十一、Java 中如何调用存储过程
虽然现代项目不推荐大量使用存储过程,但作为 Java 开发者,你仍然需要知道怎么调用它。
11.1 JDBC 原生调用
// 调用带输入输出参数的存储过程
try (Connection conn = dataSource.getConnection();
CallableStatement cs = conn.prepareCall("{CALL GetUserCount(?)}")) {
// 注册输出参数
cs.registerOutParameter(1, Types.INTEGER);
// 执行
cs.execute();
// 获取输出参数
int count = cs.getInt(1);
System.out.println("用户总数:" + count);
}11.2 MyBatis 调用
<!-- Mapper XML -->
<select id="getUserCount" statementType="CALLABLE" resultType="map">
{CALL GetUserCount(#{count, mode=OUT, jdbcType=INTEGER})}
</select>// Mapper 接口
@Mapper
public interface UserMapper {
void getUserCount(Map<String, Object> params);
}
// 使用
Map<String, Object> params = new HashMap<>();
userMapper.getUserCount(params);
System.out.println(params.get("count"));11.3 Spring JdbcTemplate 调用
@Autowired
private JdbcTemplate jdbcTemplate;
public int getUserCount() {
SimpleJdbcCall call = new SimpleJdbcCall(jdbcTemplate)
.withProcedureName("GetUserCount");
Map<String, Object> result = call.execute();
return (Integer) result.get("p_count");
}11.4 JPA / Hibernate 调用
@Entity
@NamedStoredProcedureQuery(
name = "User.getUserCount",
procedureName = "GetUserCount",
parameters = {
@StoredProcedureParameter(mode = ParameterMode.OUT, name = "p_count", type = Integer.class)
}
)
public class User { ... }
// 调用
StoredProcedureQuery query = entityManager.createNamedStoredProcedureQuery("User.getUserCount");
query.execute();
Integer count = (Integer) query.getOutputParameterValue("p_count");十二、存储过程的最佳实践(如果你必须使用)
如果你的项目确实需要用到存储过程,遵循以下规范可以减少后续的维护痛苦:
12.1 命名规范
-- 推荐:动词 + 名词,前缀表明类型 sp_transfer_money -- sp = stored procedure sp_get_user_by_id sp_batch_cancel_orders sp_calculate_monthly_report -- 不推荐 proc1 my_procedure test
12.2 编写规范
DELIMITER //
CREATE PROCEDURE sp_example(
IN p_user_id INT, -- 参数用 p_ 前缀
OUT p_result VARCHAR(50)
)
COMMENT '示例存储过程:简要说明功能'
BEGIN
-- 局部变量用 v_ 前缀
DECLARE v_count INT DEFAULT 0;
DECLARE v_status VARCHAR(20);
-- 业务逻辑...
-- 1. 每个步骤加注释
-- 2. 适当的错误处理
-- 3. 避免嵌套过深(建议不超过3层)
END //
DELIMITER ;12.3 其他建议
- 保持存储过程短小:单个存储过程不超过 200 行,太长就拆分
- 避免在存储过程中写业务逻辑:只做数据操作,不做业务判断
- 一定要加异常处理:避免事务不提交也不回滚
- 把 DDL 文件纳入版本管理:将所有存储过程的 CREATE 语句放入项目的
sql/目录 - 编写变更日志:每次修改存储过程,在注释中记录修改日期和原因
十三、面试高频问题
存储过程相关
Q1:什么是存储过程?它的优缺点是什么?
存储过程是存储在数据库中的预编译 SQL 语句集合,通过 CALL 语句调用。优点是执行效率高、减少网络传输、增强安全性;缺点是可移植性差、调试困难、不利于版本管理。
Q2:IN、OUT、INOUT 参数的区别?
IN 是输入参数(默认),调用方传入值,过程内只读;OUT 是输出参数,过程内赋值后调用方获取;INOUT 是双向参数,调用方传入、过程内修改、调用方再获取结果。
Q3:为什么现在不推荐使用存储过程?
微服务架构下业务逻辑必须在应用层;存储过程难以调试、测试和版本管理;不同数据库语法不兼容导致迁移成本高;把逻辑放在数据库中不利于水平扩展;团队协作成本高。
Q4:如何在 Java 中调用存储过程?
可以用 JDBC 的
CallableStatement、MyBatis 的statementType="CALLABLE"、Spring 的SimpleJdbcCall、或 JPA 的@NamedStoredProcedureQuery。
游标相关
Q5:什么是游标?使用游标的四个步骤是什么?
游标是对查询结果集进行逐行处理的数据库机制。四个步骤:① DECLARE 声明游标(绑定 SELECT 语句)② OPEN 打开游标(执行查询,加载结果集)③ FETCH 逐行读取(将当前行存入变量,指针后移)④ CLOSE 关闭游标(释放内存)。
Q6:MySQL 游标有哪些特性和限制?
MySQL 游标是只读的,不能通过游标直接修改行;是单向滚动的,只能向前逐行移动;只能在存储过程/函数/触发器内使用;打开游标会将完整结果集加载到内存,结果集过大时存在 OOM 风险。
Q7:游标存在性能问题,什么时候应该用游标,什么时候不用?
能用集合 SQL(如
UPDATE ... WHERE)解决的绝对不用游标。适合用游标的场景:每行逻辑复杂且确实无法用单条 SQL 表达、数据量较小(几千到几万行)、ETL 中需要逐行加工转换的场景。
Q8:MySQL 中 DECLARE 语句的顺序有什么要求?
MySQL 规定
DECLARE语句必须按固定顺序出现,否则编译报错:① 先声明局部变量(DECLARE var TYPE)② 再声明游标(DECLARE cur CURSOR FOR)③ 最后声明异常处理器(DECLARE HANDLER)。
存储函数相关
Q9:存储过程和存储函数的区别?
对比项 存储过程 存储函数 调用方式 CALL sp_name()SELECT fn_name()返回值 OUT 参数,可有多个 必须有且只有一个 RETURN 能否嵌入 SQL 不能 可以 事务控制 可以 不能显式控制 侧重点 执行操作 计算返回值
Q10:创建存储函数时 DETERMINISTIC 关键字是什么意思?
DETERMINISTIC表示相同输入总是产生相同输出(纯函数,如字符串处理、数学计算);NOT DETERMINISTIC(默认)表示相同输入可能返回不同结果(如使用了NOW()、RAND())。在 binlog 开启时,不确定性函数可能导致主从不一致,MySQL 5.x 需要设置log_bin_trust_function_creators = 1。
触发器相关
Q11:什么是触发器?MySQL 共有几种触发器?
触发器是与表关联的特殊存储程序,当表发生 INSERT、UPDATE、DELETE 时自动执行。按时机分
BEFORE和AFTER,按操作分INSERT/UPDATE/DELETE,两两组合共 6 种触发时机。
Q12:触发器中 NEW 和 OLD 关键字的区别?
INSERT 触发器中只有
NEW(新插入的行);DELETE 触发器中只有OLD(被删除的行);UPDATE 触发器中两者都有(OLD是更新前,NEW是更新后)。在BEFORE触发器中可以用SET NEW.col = val修改即将写入的值。
Q13:为什么现代开发不推荐滥用触发器?
触发器隐式执行不透明,Bug 难以追踪;批量操作时每行都触发一次,性能差;级联触发链路极难维护和调试;单元测试时很难 Mock 触发器行为;业务逻辑分散在数据库和应用层,职责不清。推荐用 AOP 替代审计日志,用 Bean Validation 替代数据校验,用 MQ 替代数据同步。
Q14:BEFORE 触发器和 AFTER 触发器分别适合什么场景?
BEFORE:适合数据校验(抛错阻止非法操作)、自动填充字段(如创建时间)、防止非法状态流转。AFTER:适合写操作日志、更新统计数据、实现删除归档备份。
十四、总结
| 可编程对象 | 本质 | 调用方式 | 当前定位 |
|---|---|---|---|
| 存储过程 | 执行一组操作的 SQL 代码块 | CALL sp_name() | 批量处理、ETL、传统企业系统 |
| 游标 | 逐行遍历结果集的指针 | 配合存储过程/函数使用 | 每行逻辑差异化处理,优先用集合 SQL |
| 存储函数 | 计算并返回单个值的代码块 | SELECT fn_name() | 可复用的计算逻辑,嵌入 SQL 中直接用 |
| 触发器 | 数据变更时自动执行的事件钩子 | 自动触发,不能手动调用 | 严格限制使用,推荐用 AOP/MQ 替代 |
共同的历史定位:这四者都是数据库时代将业务逻辑下沉到数据库的产物。在现代微服务架构中,它们的使用均大幅减少,业务逻辑应该回归应用层,让数据库回归它的本职工作——存储和检索数据。
| 场景 | 建议方案 |
|---|---|
| 审计日志 | AOP + 日志表,而非触发器 |
| 数据校验 | Bean Validation + Service 层,而非 BEFORE 触发器 |
| 数据同步 | MQ 异步解耦,而非触发器级联操作 |
| 批量处理 | 存储过程 / 存储函数 + 游标 |
| 常规 CRUD | MyBatis / JPA + Service 层 Java 代码 |
📌 推荐阅读:如果你对 Java 后端技术栈感兴趣,可以继续阅读本系列其他文章,涵盖 JVM、Spring、Nacos 等核心知识点。
到此这篇关于MySQL 存储过程、游标、存储函数与触发器示例详解的文章就介绍到这了,更多相关mysql存储过程、游标、触发器内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
