在MySQL存储过程中,SELECT ... INTO只能接收单行结果。如果一条查询返回多行,存储过程会直接抛出“Result consisted of more than one row”错误。要顺序处理多行记录,就需要引入游标。游标允许我们把一个查询结果集暂存到内存中,然后一行一行读取、处理、移动指针,直到全部行读取完毕。很多批处理任务,例如按行累加金额、逐条生成日志、按顺序更新状态,都适合使用游标。

游标不是MySQL独有的概念,但MySQL中的游标有明确的限制:只读、只能向前移动、不能随机跳转。它的使用流程固定为声明游标、打开游标、读取数据、关闭游标,其中读取通常放在循环里。下面从声明阶段开始逐项拆解。
一、游标声明、打开、读取、关闭的基本顺序
在MySQL存储过程里声明游标要使用DECLARE语句,语法为DECLARE cursor_name CURSOR FOR select_statement;。这里的select_statement可以是普通SELECT语句,但不能包含INTO子句,因为游标本身负责把结果集暴露给FETCH,而不是靠SELECT INTO接收。游标声明时并不会执行查询,只是把SELECT文本保存下来,真正的执行发生在OPEN阶段。
MySQL对DECLARE语句的顺序有严格要求。变量声明必须在游标声明之前,而DECLARE CONTINUE HANDLER通常要放在游标声明之后。比较保险的顺序是:先声明普通变量,再声明游标,最后声明NOT FOUND处理器。原因是MySQL在解析存储过程体时会检查HANDLER引用的条件,而游标对应的NOT FOUND条件只有在游标存在后才有意义。顺序错了会报语法错误。
整个流程可用三句话概括:OPEN cur;执行查询并初始化结果集;FETCH cur INTO var1, var2;取当前行并把游标指针移动到下一行;CLOSE cur;释放结果集和游标占用的内部资源。如果只声明和打开却不关闭,存储过程结束后MySQL会尝试自动清理,但显式关闭仍然是更加可靠的写法。
DELIMITER $$
CREATE PROCEDURE cursor_skeleton()
BEGIN
-- 先声明接收变量
DECLARE v_id INT;
DECLARE v_name VARCHAR(50);
DECLARE done INT DEFAULT 0;
-- 声明游标
DECLARE cur CURSOR FOR
SELECT id, user_name FROM users;
-- 声明NOT FOUND处理器
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
SET done = 0;
-- 打开游标
OPEN cur;
read_loop: LOOP
-- 读取一行
FETCH cur INTO v_id, v_name;
-- 判断是否读完了
IF done = 1 THEN
LEAVE read_loop;
END IF;
-- 这里处理当前行数据
END LOOP;
-- 关闭游标
CLOSE cur;
END$$
DELIMITER ;
二、完整示例:遍历订单表累加每个用户的消费金额
用一个简单场景演示游标循环。假设存在订单表orders,包含user_id和amount两个字段。目标是在存储过程中计算某个用户的订单总金额,也可以扩展为逐行写入统计表。为了展示游标遍历,这里使用一个临时累加变量接收每行金额。
先创建测试表和测试数据。建表语句和插入数据如下,之后创建一个名为calc_user_total的存储过程。该过程接收一个用户ID参数,通过游标遍历该用户所有订单,并返回累计金额。这样能直观看到游标从打开到关闭的完整过程。
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL
);
INSERT INTO orders (user_id, amount) VALUES
(1, 25.50),
(1, 60.00),
(1, 18.80),
(2, 99.90),
(2, 45.00);
接下来创建带游标的存储过程。注意p_total作为OUT参数,需要在循环开始前初始化,否则可能会返回NULL。
DELIMITER $$
CREATE PROCEDURE calc_user_total(IN p_user_id INT, OUT p_total DECIMAL(10,2))
BEGIN
DECLARE v_amount DECIMAL(10,2);
DECLARE done INT DEFAULT 0;
DECLARE order_cursor CURSOR FOR
SELECT amount FROM orders WHERE user_id = p_user_id;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
SET p_total = 0;
OPEN order_cursor;
sum_loop: LOOP
FETCH order_cursor INTO v_amount;
IF done = 1 THEN
LEAVE sum_loop;
END IF;
SET p_total = p_total + v_amount;
END LOOP;
CLOSE order_cursor;
END$$
DELIMITER ;
执行调用时,先声明一个用户变量@total,然后执行CALL calc_user_total(1, @total);,最后查询SELECT @total;。用户1有三笔订单,金额分别是25.50、60.00和18.80,结果为104.30。也可以在存储过程内部用SELECT p_total;直接输出,但通常使用OUT参数便于程序接收。
这个例子中,如果用户ID不存在任何订单,FETCH会立即触发NOT FOUND,done被设置为1,循环体在第一次判断时就会退出。此时p_total保持初始值0,逻辑依然正确。很多初学者容易把FETCH放在循环条件之前或之后混淆,建议统一采用“先FETCH,再判断done”的结构,避免多处理一行或漏掉一行。
三、NOT FOUND处理器的细节与循环退出机制
NOT FOUND条件并不仅仅由游标的FETCH触发。在MySQL中,任何SELECT语句没有返回行,或者FETCH已经越过了结果集末尾,都会触发NOT FOUND。如果你的循环内部还有SELECT ... INTO,而这个查询恰好没有命中数据,也会把done置为1,导致循环提前退出。解决方式有两个:一是把循环里的查询写成不会触发NOT FOUND的形式,例如使用聚合函数;二是在内部查询后重新根据需要设置标志变量。
判断循环是否继续时,一定先判断done,再处理当前行。因为FETCH在读取完最后一行后,游标指针已经移到了结果集末尾之后,下一次FETCH才会触发NOT FOUND。如果先处理数据再判断,就可能重复处理最后一行或处理到一个空变量。上面例子中的顺序是先FETCH,马上检查done,然后才累加金额,这是安全写法。
在一些复杂场景下,可以使用多个处理器来拦截不同条件。例如DECLARE EXIT HANDLER FOR SQLEXCEPTION配合事务回滚。游标遍历中建议使用CONTINUE HANDLER而不是EXIT HANDLER,因为CONTINUE能让游标读到末尾后继续执行FETCH后面的语句,由我们自己控制跳出循环;EXIT会在NOT FOUND触发时直接退出存储过程体,可能会跳过CLOSE语句。
四、使用游标必须避开的几个坑
第一个坑是变量名与列名冲突。存储过程变量与表列名重名时,SQL语句可能优先解析为变量,导致WHERE user_id = user_id这类条件永远为真或结果意外。例如游标中的SELECT user_id FROM users如果同时存在一个变量user_id,就很容易踩坑。建议所有游标接收变量使用v_前缀,游标名称使用cur_或_cursor后缀,避免与列名冲突。
第二个坑是游标声明顺序。MySQL要求DECLARE语句中变量必须在游标之前,游标必须在HANDLER之前。如果先声明DECLARE CONTINUE HANDLER FOR NOT FOUND再声明游标,MySQL会报错。实际开发中,建议把所有普通变量放在开头,然后是游标声明,最后是HANDLER。这样代码结构更清晰,也符合解析器规则。
第三个坑是性能。游标是逐行处理机制,每一行都要在循环中执行逻辑,如果结果集有几万、几十万行,存储过程的执行时间会明显增加。对于批量更新、批量汇总等需求,优先考虑使用一条SQL的集合操作,比如UPDATE ... JOIN或INSERT ... SELECT。游标更适合结果集较小、每行处理逻辑复杂、必须逐行调用其他过程或进行外部交互的场景。若必须处理大数据量,可以选择在应用层分批查询,或者使用MySQL 8.0的窗口函数降低对游标的依赖。
还要注意游标只能向前读取,不能后退,也不能直接定位到某一行。FETCH只能逐行向下移动。另外,MySQL游标是只读的,不能通过游标执行UPDATE ... WHERE CURRENT OF,这与SQL Server或Oracle的某些用法不同。若需要在遍历过程中更新当前行,必须在循环里根据游标读出的主键执行普通UPDATE语句。
最后强调资源管理。每次OPEN都会在服务器端产生一个结果集,CLOSE会关闭并释放相关内部结构。如果一个存储过程里有多个游标,应该按打开顺序的逆序关闭,虽然MySQL不会强制要求,但这样做能减少资源占用,也让代码意图更明确。已经关闭的游标不能再次FETCH,否则会报游标未打开错误。

