MySQL 存储过程详解:从入门到实战

如果你写了三年以上的 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 编程------存储过程的调试和版本管理远不如应用代码方便

最佳实践

  1. 命名规范 :用动词开头,见名知义。get_, create_, update_, delete_, calculate_, generate_
  2. 参数命名加前缀 :用 p_ 开头区分参数和表字段,避免 WHERE id = id 这种歧义
  3. 始终加异常处理:即使最简单的过程也加一个基础的 SQLEXCEPTION handler,防止意外中断
  4. 涉及多表修改用事务:插入、更新、删除涉及两张以上表的操作,务必包在事务里
  5. 不要写得太长:一个存储过程超过 200 行就该考虑拆分。过程越长,调试越难
  6. 版本化管理:把 CREATE PROCEDURE 语句放在项目的 SQL 文件夹里,和代码一起做 Git 版本管理
  7. 不要在存储过程里写业务规则:税率计算、折扣逻辑、积分规则这些易变的逻辑放应用层

总结

存储过程最适合的场景是:逻辑稳定、高频调用、需要保证数据一致性的数据库操作。它不是银弹------业务逻辑放应用层更灵活,但数据密集型的批量操作放存储过程里更高效。

用得好的判断标准很简单:当你发现同样的 SQL 在三个地方的代码里各写了一遍,就是该封装成存储过程的时候。


如果这篇文章帮你看懂了存储过程,欢迎分享给也写 SQL 的朋友。你在项目里用过存储过程吗?遇到过什么坑?评论区聊聊~

相关推荐
爱笑鱼2 小时前
Binder(四):ioctl(BINDER_WRITE_READ) 之后,事务怎样到达目标进程?
android
AFinalStone2 小时前
Android 7系统休眠唤醒(二)开机全链路—BootROM到Launcher
android·电源管理·休眠唤醒
Mr YiRan2 小时前
Android NDK开发之统计到未被回收的图片
android
星空露珠3 小时前
28种颜色对应名称,
开发语言·数据库·算法·游戏·lua
浮江雾3 小时前
Flutter第十七节-----路由管理(3)
android·开发语言·前端·javascript·flutter·入门
admin and root3 小时前
「移动安全」安卓APP 反编译&frida脱壳技巧分享
android·开发语言·python·web安全·微信小程序·移动安全·攻防演练
踏月的造梦星球4 小时前
DM8 DSC 单机双实例部署
运维·开发语言·数据库
Database_Cool_4 小时前
企业级多模态分析计算引擎选型:首选 AnalyticDB MySQL 向量 + SQL + 实时一体化方案
数据库·sql·mysql
SelectDB4 小时前
从 Oracle、Hadoop 到 Apache Doris:哥瑞利泛半导体智能制造数据平台实践
数据库