从MySQL入门到现在,我写过不少存储过程,也踩过不少跟变量、流程控制和异常处理有关的坑。尤其是中途接手别人留下的批量处理任务时,经常能看到一种“症状”:过程能跑,但跑完数据对不上账,查到最后要么是变量残留污染了结果,要么是异常没被拦住导致过程直接中断、事务没回滚。这几个功能点本身语法并不难,难的是把它们正确组合起来。这篇内容我按照变量、中断处理机制、流程控制这条主线,把原理、选择和搭配方式,连同实际踩坑经验一起整理出来。
写完存储过程的人都知道,只要开始用存储过程,就必然会接触到变量、条件处理和流程控制三者。它们不是三个孤立的语法点,而是一套组合拳。变量负责保存中间状态,流程控制决定SQL如何“做决定”和“绕圈”,中断处理机制则保证在出错时能按照预期接管逻辑。大多数人翻车,不是因为某个语法不会写,而是这三者的配合关系没理顺。
1. 变量体系:先搞清楚自己手里有几类牌
1.1 三类变量的核心区别,一分钟看懂
MySQL里的变量大致可以分成三类:系统变量、用户变量、局部变量。很多人容易把用户变量和局部变量混在一起,其实它们的生存周期、作用范围和用途差别非常大。系统变量由MySQL服务器自身维护,例如 @@session.wait_timeout、@@global.max_connections,一般用来调整连接或全局行为,普通业务逻辑里很少直接动它。可以用 SHOW VARIABLES 查看,也可以在会话内用 SET SESSION xxx = 值 临时调整。
用户变量以 @ 开头,不需要提前声明,随手就能赋值,例如 SET @current_user = 'admin'。它的作用域是整个会话,连接不关闭、变量就一直在。局部变量则需要用 DECLARE 在存储过程或函数中显式声明,有明确的数据类型和默认值,作用域被限制在当前 BEGIN...END 块内部。
下面这个表格把三类变量的关键点整理在一起,方便对照:
| 变量类型 | 写法示例 | 作用域 | 是否需要声明 | 特点 |
|---|---|---|---|---|
| 系统变量 | @@session.wait_timeout |
全局或当前会话 | 服务器维护 | 不擅长业务场景使用 |
| 用户变量 | @order_total |
当前会话 | 不需要 | 弱类型,容易残留历史值 |
| 局部变量 | DECLARE v_cnt INT DEFAULT 0 |
当前BEGIN块 | 必须声明 | 强类型,块结束即释放 |
1.2 用户变量和局部变量,用错一个就可能翻车
我见过最典型的错误,是在存储过程内部用 @xxx 来保存中间计算结果。单看一次调用可能没问题,但下一次调用时,@xxx 里还留着上一次调用的旧值,如果新流程忘记初始化,就会把上一次的垃圾数据带进这次计算里。比如下面这段逻辑:
sql复制SET @total = 0;
CALL sp_recalc_order_summary(20240101);
-- @total 会产生一个值
CALL sp_recalc_order_summary(20240102);
-- 如果第二个过程里没有初始化 @total,它可能把上一批的结果累加进来
解决思路很简单:存储过程内部需要保存中间状态的,一律用 DECLARE 声明局部变量,不要偷懒用 @。局部变量的生命周期随 BEGIN...END 块结束而结束,每次调用都是干净的。而且 DECLARE 是有明确类型的,比如 DECLARE v_amount DECIMAL(10,2),不容易出现字符串和数字隐式转换的问题。
用户变量也不是不能用,但它更适合在客户端脚本或SQL交互中临时存值,而不是在存储过程内部承担关键计算。如果确实要在存储过程里用用户变量,一定要在开头显式赋值初始化,不要依赖历史值。
1.3 局部变量的赋值方式,不止一种
局部变量的赋值常见两种方式。一种是 SET,简单直接;另一种是 SELECT ... INTO,可以把查询结果写入变量。SET 支持表达式运算和多个变量一起赋值:
sql复制SET v_total = v_unit_price * v_quantity;
SET v_done = 1, v_msg = '完成';
SELECT ... INTO 则需要保证查询结果只有一行,否则会触发错误或产生意外行为。这个坑很隐蔽:当结果集为空时,MySQL会触发 NOT FOUND 条件,如果没准备好对应的处理器,存储过程会报错;当结果集有多行时,默认只取第一行,且会发出警告。
sql复制SELECT amount INTO v_amount
FROM orders
WHERE order_id = p_order_id
LIMIT 1;
所以在写 SELECT ... INTO 之前,先想清楚查询结果可能出现几种情况,再决定要不要配合 NOT FOUND 处理器。我在第3节会详细讲中断处理机制,这两者经常要配合使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 流程控制:让SQL学会“做决定”和“绕圈”
2.1 IF和CASE的选择,看场景不看出处
MySQL的流程控制里,条件判断最常用的就两个:IF 和 CASE。IF 更贴近普通编程语言,支持 IF...ELSEIF...ELSE...END IF;CASE 则有两种形态,一种是简单等值匹配,一种是搜索表达式匹配。
简单等值匹配适合状态值这种固定枚举:
sql复制CASE v_status
WHEN 1 THEN '待处理'
WHEN 2 THEN '处理中'
WHEN 3 THEN '已完成'
ELSE '未知'
END;
搜索 CASE 适合区间判断,比如金额分级:
sql复制CASE
WHEN v_amount > 10000 THEN '大客户'
WHEN v_amount > 1000 THEN '重点客户'
ELSE '普通客户'
END;
我的习惯是:分支超过两三个,且条件比较综合时,用 IF 加上 ELSEIF 更直观;如果就是按某个字段或表达的多个固定取值进行映射,用 CASE 更紧凑。两者在性能上没有本质区别,MySQL最终都会转为条件判断执行,选哪种纯看可维护性。
2.2 三种循环的适用场景,别只知道WHILE
很多新手写存储过程,循环只会用 WHILE。实际MySQL里还有 REPEAT 和 LOOP。它们都能实现循环,但判断时机不一样:
| 循环类型 | 判断方式 | 特点 | 适用场景 |
|---|---|---|---|
WHILE condition DO ... END WHILE |
先判断,再执行 | 条件不满足时一次都不执行 | 大部分循环场景 |
REPEAT ... UNTIL condition END REPEAT |
先执行,后判断 | 至少执行一次 | 至少需要做一次的操作 |
LOOP ... END LOOP |
没有内置终止条件 | 必须用 LEAVE 手动退出 |
配合游标逐行遍历最常用 |
举个例子,遍历游标数据时,我几乎都是用 LOOP 加一个标志位,完全手动控制退出时机,这样在 LEAVE 之前可以做一些收尾判断。
sql复制read_loop: LOOP
FETCH cur INTO v_id;
IF v_done THEN
LEAVE read_loop;
END IF;
-- 业务处理
END LOOP;
2.3 LEAVE和ITERATE:循环里的break和continue
写过Java或C的人一定熟悉 break 和 continue。MySQL里对应的是 LEAVE 和 ITERATE。LEAVE 表示退出当前循环,ITERATE 表示跳过本次循环的剩余语句,直接进入下一轮。
我在批量处理订单时,经常用 ITERATE 跳过不符合校验条件的记录,用 LEAVE 在游标遍历结束时跳出循环。这样写出来的存储过程逻辑非常直观,每条记录要么被处理,要么被跳过,不会出现“多处理”或“漏处理”的情况。
要注意一点:ITERATE 跳过的只是当前循环体剩余部分,不会关闭游标或跳过 FETCH。如果漏掉了 FETCH,下一次循环还是读取同一条记录,就会造成死循环。这是异常常见的问题,我排查过不止一次。
3. 中断处理机制:异常不处理,数据迟早出错
3.1 为什么叫“中断处理机制”
MySQL官方文档里,这一块叫“Condition Handling”,也就是条件处理或声明处理器。很多中文教材把它翻译成中断处理机制,其实和计算机体系结构里的“中断”不完全是一个概念,但理解上可以类比:当SQL执行过程中遇到某些特定条件(比如 NOT FOUND、某个SQLSTATE、某个错误码)时,会中断当前执行流程,跳到预先声明的处理器里继续执行。这个机制和编程语言里的 try...catch 很类似,但使用方式上有MySQL自己的规矩。
中断处理机制解决的核心问题是:遇到错误时,是继续执行后面的语句,还是退出整个块?退出之前要不要做点补救?错误要不要记录?这些如果不在代码里写清楚,MySQL默认行为就是“遇到未捕获错误,直接终止存储过程,并把错误抛给调用方”。很多线上故障就是这样来的。
3.2 声明条件与处理器,顺序有讲究
在使用中断处理机制之前,先理解两个概念:CONDITION 和 HANDLER。CONDITION 是把某个错误码或SQLSTATE打一个可读性更好的别名:
sql复制DECLARE duplicate_key CONDITION FOR 1062;
-- 或者用 SQLSTATE
DECLARE duplicate_key CONDITION FOR SQLSTATE '23000';
HANDLER 则负责声明“当发生某个条件时,怎么处理”:
sql复制DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
END;
MySQL对 DECLARE 的书写顺序有强制要求:变量和条件声明 -> 游标声明 -> 处理器声明。这个顺序不能乱,否则会报语法错误。很多新手习惯把处理器写在最前面,结果一直被 DECLARE ... must come before ... 这类报错劝退。实际上,把这四种声明统一放在 BEGIN 块开头按顺序写,就不会有问题。
条件值可以是具体的错误码、SQLSTATE、SQLWARNING、SQLEXCEPTION 或 NOT FOUND。其中 SQLWARNING 匹配所有以 01 开头的SQLSTATE,SQLEXCEPTION 匹配不以 00 和 01 开头的SQLSTATE,NOT FOUND 匹配以 02 开头的SQLSTATE。
3.3 CONTINUE与EXIT,行为差异影响数据正确性
处理器类型主要有两种:CONTINUE 和 EXIT。CONTINUE 的意思是,处理器执行完之后,继续执行原来出错语句之后的下一条语句;EXIT 的意思是,处理器执行完之后,直接退出整个 BEGIN...END 块。
| 处理器类型 | 出错后行为 | 典型场景 |
|---|---|---|
CONTINUE |
继续执行当前块后面的语句 | 跳过某一条坏数据,继续处理后续记录 |
EXIT |
终止当前块 | 发生严重错误,直接回滚并退出 |
还有一个 UNDO 类型,MySQL目前没有支持,不要写。
最典型的使用方式是:用 CONTINUE HANDLER FOR NOT FOUND SET v_done = 1 配合游标,当 FETCH 读取不到更多行时,设置结束标志位,而不是让过程报错中断。这个模式几乎每个游标循环都会用到。
3.4 SIGNAL、RESIGNAL和GET DIAGNOSTICS
除了被动捕获异常,有时候也需要主动抛出异常。SIGNAL 就是用来干这个的,它可以在存储过程里手动抛出一个指定SQLSTATE的错误:
sql复制SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '金额不能为负数';
SQLSTATE '45000' 是用户自定义错误的保留区域。抛出之后,调用方会收到一个类似普通SQL报错的提示,可以方便地中断后续流程。我在做数据校验时经常用这个方式,一旦发现非法数据,立即终止,避免脏数据继续流通。
RESIGNAL 只能用在处理器内部,它会把当前正在处理的错误信息重新抛出去。这样做的意义在于:可以在处理器里先做记录或日志,再用 RESIGNAL 把原始错误原样抛出,让调用方也知道发生了什么。
GET DIAGNOSTICS 则可以在处理器里拿到详细的错误信息:
sql复制GET DIAGNOSTICS CONDITION 1
@err_sqlstate = RETURNED_SQLSTATE,
@err_no = MYSQL_ERRNO,
@err_msg = MESSAGE_TEXT;
拿到之后可以拼进日志表或返回消息里,排查问题时非常有帮助。
4. 综合实战:一个带异常处理的批量订单处理存储过程
4.1 业务场景与设计思路
光讲语法不落地,看完了很快就忘。我模拟一个常见的业务场景:有一张订单表(orders),每天需要把某个日期下状态为 PENDING 的订单批量标记为已处理,并把处理结果写入订单处理明细表(orders_processed)。要求:
- 使用游标逐行读取订单;
- 遇到金额小于等于0的订单,跳过,但计入失败数;
- 遇到主键冲突(比如重复跑同一天的数据),捕获后继续处理下一条,同时统计失败数;
- 遇到其他未预期异常,回滚整个事务并终止过程;
- 最后输出成功数、失败数和处理消息。
这个场景几乎覆盖了变量、游标、流程控制、中断处理机制的常见组合方式。
4.2 完整代码
sql复制USE testdb;
DELIMITER $$
DROP PROCEDURE IF EXISTS sp_batch_process_orders$$
CREATE PROCEDURE sp_batch_process_orders(
IN p_batch_date DATE,
OUT p_processed INT,
OUT p_failed INT,
OUT p_message VARCHAR(500)
)
BEGIN
-- 1. 变量声明
DECLARE v_order_id INT;
DECLARE v_amount DECIMAL(10, 2);
DECLARE v_done INT DEFAULT 0;
DECLARE v_error_msg VARCHAR(500) DEFAULT NULL;
-- 2. 条件声明
DECLARE duplicate_key CONDITION FOR 1062;
-- 3. 游标声明
DECLARE cur_orders CURSOR FOR
SELECT order_id, amount
FROM orders
WHERE order_date = p_batch_date
AND status = 'PENDING';
-- 4. 处理器声明
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
DECLARE CONTINUE HANDLER FOR duplicate_key
BEGIN
SET v_failed = v_failed + 1;
SET v_error_msg = '主键冲突,跳过该订单';
END;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_message = '发生未预期异常,本次批量处理已回滚';
END;
-- 初始化输出
SET p_processed = 0;
SET p_failed = 0;
SET p_message = '';
START TRANSACTION;
OPEN cur_orders;
read_loop: LOOP
FETCH cur_orders INTO v_order_id, v_amount;
IF v_done THEN
LEAVE read_loop;
END IF;
-- 业务校验:金额必须大于0
IF v_amount <= 0 THEN
SET v_error_msg = '金额非法,跳过该订单';
SET v_failed = v_failed + 1;
ITERATE read_loop;
END IF;
-- 写入处理明细
INSERT INTO orders_processed(order_id, amount, process_date)
VALUES (v_order_id, v_amount, CURDATE());
-- 更新原订单状态
UPDATE orders
SET status = 'PROCESSED'
WHERE order_id = v_order_id;
SET p_processed = p_processed + 1;
END LOOP;
CLOSE cur_orders;
COMMIT;
SET p_message = CONCAT('处理完成:成功 ', p_processed, ' 条,跳过 ', p_failed, ' 条');
END$$
DELIMITER ;
4.3 关键代码逐段拆解
最核心的是处理器组合。CONTINUE HANDLER FOR NOT FOUND SET v_done = 1 是游标循环的“最后一道保险”,当 FETCH 没有数据时,不会报错,只是设置标志位。CONTINUE HANDLER FOR duplicate_key 捕获主键冲突,把失败数加一,然后继续循环。EXIT HANDLER FOR SQLEXCEPTION 捕获所有其他异常,直接回滚并退出,防止留下半截数据。
变量的使用也有讲究。v_order_id、v_amount 用来保存游标当前行的值;v_done 是循环退出标志;p_processed 和 p_failed 是输出参数,同时也是计数器。这里没有用 @ 用户变量,就是为了避免会话残留。
流程控制方面,read_loop: LOOP 配合 LEAVE 实现循环退出,ITERATE 则跳过非法金额的订单。如果把 ITERATE 换成 LEAVE,逻辑就完全变了,遇到第一条非法数据就直接退出整个循环,这是完全不同的业务含义。写的时候一定要确认自己到底要哪种行为。
4.4 三种情况下的运行验证
我在本机MySQL 8.0上建了对应的测试表,分别跑了三种数据:
| 测试场景 | 预期结果 |
|---|---|
| 正常订单(金额大于0,无重复) | 成功数增加,消息提示全部处理成功 |
| 包含金额小于等于0的订单 | 跳过非法订单,成功数和失败数分开统计 |
| 同一天重复执行(主键冲突) | 冲突订单被捕获并计入失败数,其他订单正常处理 |
实测下来,第一次执行正常订单,p_processed 递增正确。第二次在同一日期重新执行时,INSERT 触发主键冲突,被 CONTINUE HANDLER 捕获,p_failed 加一,后续循环继续执行,没有中断。这说明处理器和流程控制配合得很好。
最后我还手动制造了一个非预期错误,比如在过程执行过程中临时删除目标表字段,UPDATE 时报错,这时走到 EXIT HANDLER FOR SQLEXCEPTION,事务回滚,p_message 返回“发生未预期异常,本次批量处理已回滚”。整个过程没有把部分数据提交进去,符合预期。
5. 常见问题与排查经验
5.1 DECLARE只能放在块开头,顺序不能乱
我见过几乎每一个MySQL新手都会遇到这个问题:在存储过程中间想临时声明一个变量,结果报错。这是MySQL的限制,DECLARE 只能出现在 BEGIN...END 块的最前面,而且顺序必须是变量/条件 -> 游标 -> 处理器。
所以写存储过程的习惯是:先把所有声明全部写完,再写可执行逻辑。有些场景确实不灵活,但这也是MySQL的规矩,只能顺应它。如果发现声明太多导致代码可读性差,可以把一个复杂的逻辑拆成多个存储过程或函数,而不是硬塞进一个过程里。
5.2 用户变量残留是隐藏炸弹
如果存储过程内部用 @var 保存临时值,且没有在开头初始化,那么它会继承上一次调用或当前会话中其他SQL设置的旧值。这种问题最可怕的地方在于:第一次跑是对的,第二次跑就错了,而且看起来毫无规律。
排查方法也很简单,在过程开头打印或检查 @var 的当前值,或者干脆把代码里的 @var 全部改成局部变量。我在自己的代码规范里直接要求:存储过程内部一切中间变量必须 DECLARE,除非这个变量本身就是为了给外部会话传递结果。
5.3 异常处理器没触发,先查声明顺序和匹配范围
有时候明明写了处理器,但异常发生后过程还是直接报错中断。这时候排查两个方向:第一,处理器声明是否真的位于该异常发生点之前的执行上下文中?DECLARE 的作用域是整个块,所以只要声明在块内,理论上都覆盖。但如果异常发生在一个内部嵌套的 BEGIN...END 块里,而内部块又恰好有更匹配的处理器,那么外部处理器不会触发。
第二,匹配条件是否准确。比如只写了 NOT FOUND 处理器,但实际错误是 SQLEXCEPTION 类,那是捕获不到的。另外,多个处理器同时匹配时,MySQL会选择最先声明的那一个,所以处理器声明顺序也有实际意义。我建议按照“具体错误码 -> SQLSTATE 类别 -> 通用类别”的顺序去写,具体条件放前面,通用兜底放后面。
5.4 事务与中断处理的配合,决定了数据完整性
中断处理机制本身不负责事务,它只是“流程跳转”。如果你在存储过程里执行了多条写操作,又没有用 START TRANSACTION 包裹,那么每条写语句执行完就自动提交了,一旦后面的语句出错,前面已经提交的数据无法回滚。这也是很多“跑批跑到一半,数据半新半旧”的根源。
所以在多步写操作的存储过程中,我基本都是:START TRANSACTION 开始,EXIT HANDLER FOR SQLEXCEPTION 里 ROLLBACK,正常路径走到最后 COMMIT。如果某些错误属于可接受的边界情况(比如主键冲突),就用 CONTINUE 处理器单独捕获,不进入回滚分支。这样既能保证严重错误可以整体回滚,也允许个别坏数据被安全跳过。
5.5 性能提醒:能不用游标就不用游标
最后提一个和本文主题相关但不是命名的坑:存储过程里只要用了游标,基本就意味着逐行处理,性能天然低于一次性集合操作。能用一条 UPDATE ... JOIN 解决的批量更新,就不要游标逐行改。游标适合的是“行与行之间还有复杂校验、需要跳过或者单独记录错误”的场景。
如果数据量特别大,即使用了游标,也建议分批提交。例如每处理1000行就 COMMIT 一次,避免长事务占用大量锁和undo日志。这个优化和中断处理机制配合时要注意:一旦开启事务并出现异常需要回滚,你要清楚回滚的范围是当前批还是整个批次,设计时要提前规划好。
回到开头那个对不上账的案例。我后来把存储过程里的用户变量全部改成局部变量,给游标循环补上了 NOT FOUND 处理器,又用 CONTINUE 单独接住了主键冲突,整体用事务包起来,问题才算彻底解决。从那之后我养成了一个习惯:写完存储过程,先用两三条最小数据集把正常、边界、异常三种路径都跑一遍,确认没问题再放到线上任务里。这套流程别看简单,救过我很多次。
