如果你写了三年以上的 SQL,大概率遇到过这种情况------同样的业务逻辑,在十个不同的接口里各写了一遍 SQL。改一个字段名,十个地方全要改。维护成本高、容易遗漏、调试困难。
存储过程就是来解决这个问题的。把一段 SQL 逻辑封装在数据库里,起个名字,调用时传参数即可------和写函数一样。
这篇文章从零讲清楚 MySQL 存储过程:是什么、怎么用、什么时候该用、什么时候不该用。
存储过程是什么?
存储过程是一组预编译的 SQL 语句集合,保存在数据库服务器上,通过名称和参数来调用。你可以把它理解成"数据库里的函数"------封装了一段业务逻辑,接收输入参数,执行一系列 SQL 操作,返回结果。
和普通 SQL 的关键区别:
- 普通 SQL:每次发送到数据库,数据库都要解析、编译、优化、执行。一个复杂查询每次都要走完整流程
- 存储过程:创建时编译一次,后续调用直接执行。省去了解析和编译的时间
- 支持变量、条件判断、循环、异常处理等编程逻辑------它不是"一条 SQL",而是"一段程序"
基本语法
创建存储过程的标准结构:
sql
DELIMITER $$
CREATE PROCEDURE 过程名(IN 参数名 参数类型, OUT 参数名 参数类型)
BEGIN
-- SQL 语句和逻辑
END$$
DELIMITER ;
几个关键点:
DELIMITER $$:把默认的分号结束符改成$$,因为存储过程内部有多条 SQL,每条都以分号结尾,不改分隔符会导致 MySQL 在第一个分号处就停止解析IN参数:传入值OUT参数:传出值INOUT参数:既传入又传出- 参数是可选的,最简单的存储过程可以没有参数
第一个例子:无参数存储过程
sql
DELIMITER $$
CREATE PROCEDURE get_all_active_users()
BEGIN
SELECT id, username, email, created_at
FROM users
WHERE status = 'active'
ORDER BY created_at DESC;
END$$
DELIMITER ;
-- 调用
CALL get_all_active_users();
第二个例子:带 IN 参数的存储过程
sql
DELIMITER $$
CREATE PROCEDURE get_user_by_id(IN user_id INT)
BEGIN
SELECT id, username, email, phone, created_at
FROM users
WHERE id = user_id;
END$$
DELIMITER ;
-- 调用
CALL get_user_by_id(1001);
第三个例子:带 OUT 参数------返回计算结果
sql
DELIMITER $$
CREATE PROCEDURE get_order_stats(
IN user_id INT,
OUT total_orders INT,
OUT total_amount DECIMAL(10,2)
)
BEGIN
SELECT COUNT(*), IFNULL(SUM(amount), 0)
INTO total_orders, total_amount
FROM orders
WHERE user_id = user_id AND status = 'completed';
END$$
DELIMITER ;
-- 调用并获取 OUT 参数
CALL get_order_stats(1001, @order_count, @order_amount);
SELECT @order_count AS '订单数', @order_amount AS '总金额';
INTO 语句把查询结果赋值给 OUT 参数,调用方通过用户变量(@变量名)来接收。
变量、条件判断与循环
存储过程不仅封装查询,还支持编程逻辑。
声明和使用局部变量:
sql
DELIMITER $$
CREATE PROCEDURE check_low_stock(IN threshold INT)
BEGIN
DECLARE low_count INT DEFAULT 0;
SELECT COUNT(*) INTO low_count
FROM products
WHERE stock < threshold;
IF low_count > 0 THEN
SELECT CONCAT('警告:有 ', low_count, ' 件商品库存不足') AS message;
ELSE
SELECT '库存充足' AS message;
END IF;
END$$
DELIMITER ;
循环处理数据------更新过期订单状态:
sql
DELIMITER $$
CREATE PROCEDURE cancel_expired_orders()
BEGIN
DECLARE finished INT DEFAULT 0;
DECLARE order_id_val INT;
DECLARE cur CURSOR FOR
SELECT id FROM orders
WHERE status = 'pending' AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO order_id_val;
IF finished THEN
LEAVE read_loop;
END IF;
UPDATE orders SET status = 'cancelled', updated_at = NOW()
WHERE id = order_id_val;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
这个例子用到了三个高级概念:
- 游标(CURSOR):遍历查询结果集的每一行。先声明游标指向一个 SELECT 查询,然后 OPEN → FETCH 循环 → CLOSE
- CONTINUE HANDLER:当游标取完数据(NOT FOUND)时,把 finished 标记设为 1,避免死循环
- LOOP + LEAVE:循环体,LEAVE 相当于 break
游标是存储过程中处理"逐行操作"的核心机制------批量扣库存、逐条生成日志、逐行校验数据。
异常处理
存储过程中的 SQL 可能因为各种原因失败------外键约束、唯一索引冲突、数据类型不匹配。没有异常处理,一条语句失败会导致整个过程回滚或中断。
sql
DELIMITER $$
CREATE PROCEDURE safe_insert_order(
IN p_user_id INT,
IN p_product_id INT,
IN p_quantity INT,
OUT p_result VARCHAR(50)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_result = 'fail: 订单创建失败';
END;
START TRANSACTION;
-- 检查库存
IF (SELECT stock FROM products WHERE id = p_product_id) < p_quantity THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足';
END IF;
-- 扣库存
UPDATE products SET stock = stock - p_quantity WHERE id = p_product_id;
-- 创建订单
INSERT INTO orders(user_id, product_id, quantity, status, created_at)
VALUES (p_user_id, p_product_id, p_quantity, 'pending', NOW());
COMMIT;
SET p_result = 'success: 订单创建成功';
END$$
DELIMITER ;
-- 调用
CALL safe_insert_order(1001, 2001, 3, @result);
SELECT @result;
关键点:
EXIT HANDLER FOR SQLEXCEPTION:任何 SQL 错误都会触发这个 handler,执行回滚START TRANSACTION ... COMMIT / ROLLBACK:事务保证数据一致性SIGNAL SQLSTATE '45000':手动抛出自定义错误,终止后续执行
三种参数模式对比
| 参数模式 | 方向 | 调用方传入 | 存储过程传出 | 典型用途 |
|---|---|---|---|---|
| IN | 仅传入 | 必须传值 | 不传出 | 查询条件、插入数据 |
| OUT | 仅传出 | 不传值 | 将结果带回 | 返回计算结果、状态 |
| INOUT | 传入并传出 | 必须传值 | 修改后带回 | 计数器累加、值变换 |
sql
-- INOUT 示例:累加计数
DELIMITER $$
CREATE PROCEDURE increment_counter(INOUT counter INT, IN step INT)
BEGIN
SET counter = counter + step;
END$$
DELIMITER ;
-- 调用
SET @my_counter = 10;
CALL increment_counter(@my_counter, 5);
SELECT @my_counter; -- 输出 15
何时查看和删除存储过程
sql
-- 查看某过程的定义
SHOW CREATE PROCEDURE get_user_by_id;
-- 查看所有过程
SHOW PROCEDURE STATUS WHERE Db = 'your_database';
-- 删除
DROP PROCEDURE IF EXISTS get_user_by_id;
-- 修改(MySQL 不支持 ALTER PROCEDURE 修改体,只能删了重建)
DROP PROCEDURE IF EXISTS get_user_by_id;
-- 然后重新 CREATE
真实应用场景
场景一:批量生成月度报表
每个月要给运营出一份用户活跃度报表,以前每次手动写一堆查询导出。现在封装成存储过程,传月份参数即可:
sql
CREATE PROCEDURE generate_monthly_report(IN report_month VARCHAR(7))
BEGIN
-- 新注册用户数
INSERT INTO monthly_reports(month, metric, value)
SELECT report_month, 'new_users', COUNT(*) FROM users
WHERE DATE_FORMAT(created_at, '%Y-%m') = report_month;
-- 活跃用户数
INSERT INTO monthly_reports(month, metric, value)
SELECT report_month, 'active_users', COUNT(DISTINCT user_id) FROM user_actions
WHERE DATE_FORMAT(action_time, '%Y-%m') = report_month;
-- 订单总额
INSERT INTO monthly_reports(month, metric, value)
SELECT report_month, 'total_revenue', IFNULL(SUM(amount), 0) FROM orders
WHERE DATE_FORMAT(created_at, '%Y-%m') = report_month AND status = 'completed';
END
场景二:数据清洗和迁移
从旧表迁移数据到新表,字段需要转换和校验。封装成存储过程,迁移失败自动回滚。
场景三:定期清理过期数据
定时任务(MySQL Event 或 cron)定期调用存储过程,清理 90 天前的日志、30 天前的临时数据。
不适合用存储过程的情况:
- 业务逻辑频繁变动------修改存储过程需要数据库权限,不如在应用层改代码方便
- 需要和外部系统交互------存储过程在数据库里跑,调用不了第三方 API
- 跨数据库操作------存储过程绑定在单个数据库上,跨库操作复杂且不可移植
- 团队不熟悉 SQL 编程------存储过程的调试和版本管理远不如应用代码方便
最佳实践
- 命名规范 :用动词开头,见名知义。
get_,create_,update_,delete_,calculate_,generate_ - 参数命名加前缀 :用
p_开头区分参数和表字段,避免WHERE id = id这种歧义 - 始终加异常处理:即使最简单的过程也加一个基础的 SQLEXCEPTION handler,防止意外中断
- 涉及多表修改用事务:插入、更新、删除涉及两张以上表的操作,务必包在事务里
- 不要写得太长:一个存储过程超过 200 行就该考虑拆分。过程越长,调试越难
- 版本化管理:把 CREATE PROCEDURE 语句放在项目的 SQL 文件夹里,和代码一起做 Git 版本管理
- 不要在存储过程里写业务规则:税率计算、折扣逻辑、积分规则这些易变的逻辑放应用层
总结
存储过程最适合的场景是:逻辑稳定、高频调用、需要保证数据一致性的数据库操作。它不是银弹------业务逻辑放应用层更灵活,但数据密集型的批量操作放存储过程里更高效。
用得好的判断标准很简单:当你发现同样的 SQL 在三个地方的代码里各写了一遍,就是该封装成存储过程的时候。
如果这篇文章帮你看懂了存储过程,欢迎分享给也写 SQL 的朋友。你在项目里用过存储过程吗?遇到过什么坑?评论区聊聊~