04-存储过程(下):游标、异常处理与存储函数

存储过程(下):游标、异常处理与存储函数

MySQL 进阶系列 · 第 4 篇(存储过程与函数 · 2/undefined)

本篇是《存储过程与函数》的第 2/undefined 部分。前一部分见第 3 篇《存储过程(上):语法、变量三层体系与流程控制》,建议先读完再来看本篇。

本篇覆盖:九、游标:服务端逐行遍历的真相、十、条件处理程序:存储过程里的 try-catch、十一、存储函数:CREATE FUNCTION、十二、本篇小结与面试速答。


目录


九、游标:服务端逐行遍历的真相

9.1 游标是什么

课件 §5.5:

MySQL 中的游标是一种数据库对象,允许在存储过程和函数中对查询到的结果集进行逐行检索。

sql 复制代码
SELECT * FROM student;
text 复制代码
+----+----------+--------+-----+--------+-------------+----------+
| id | name     | sno    | age | gender | enroll_date | class_id |
+----+----------+--------+-----+--------+-------------+----------+
|  1 | 唐三藏   | 100001 |  18 |      1 | 1986-09-01  |        1 |
|  2 | 孙悟空   | 100002 |  18 |      1 | 1986-09-01  |        1 |
|  3 | 猪悟能   | 100003 |  18 |      1 | 1986-09-01  |        1 |
|  4 | 沙悟净   | 100004 |  18 |      1 | 1986-09-01  |        1 |
|  5 | 宋江     | 200001 |  18 |      1 | 2000-09-01  |        2 |
|  6 | 武松     | 200002 |  18 |      1 | 2000-09-01  |        2 |
|  7 | 李逹     | 200003 |  18 |      1 | 2000-09-01  |        2 |
|  8 | 不想毕业 | 200004 |  19 |      1 | 2000-09-01  |        2 |
+----+----------+--------+-----+--------+-------------+----------+
8 rows in set (0.02 sec)

比如在以上结果集中,可以通过向前移动游标,实现每一行的数据读取。

注意:MySQL 的游标是只读的,不能进行更新操作。

9.2 官方定义的三个特性(决定了它的所有限制)

MySQL 文档给游标定义了三个特性,每一个都对应一条硬限制

特性 官方描述 实际后果
read-only(只读) Cursors are read-only; you cannot update them 不能 UPDATE ... WHERE CURRENT OF cursor (Oracle / SQL Server 可以)。想改数据只能拿游标里读出的主键另写 UPDATE
nonscrollable(不可滚动) 只能单方向遍历,不能跳行、不能反向取 没有 FETCH PRIOR / FETCH ABSOLUTE n / FETCH FIRST。想回退只能重新 OPEN
asensitive(敏感性未定) 「Some implementations copy the underlying data to a temporary location and make the cursor refer to the temporary copy」 可能看到、也可能看不到 遍历期间其他事务对底表的修改。不要依赖任何一种行为

第三条是关键------官方文档在这里亲口承认了「把底层数据拷贝到一个临时位置」。这就是下一节要讲的底层真相。

9.3 深入底层:MySQL 游标是「物化到临时表」的服务端游标

先厘清一个常见误解:MySQL 的游标确实是服务端游标(server-side cursor),不是客户端游标。 OPEN / FETCH / CLOSE 全部在 mysqld 进程内部完成,一次网络往返都没有。

但它和 Oracle 的服务端游标不是一回事

Oracle 服务端游标 MySQL 游标
底层实现 保留 SQL 执行上下文 + 一致性读快照( SCN),按需从数据块取行 把整个结果集物化(materialize)到一张内部临时表
内存占用 只持有游标状态,行数不影响内存 和结果集大小成正比
可滚动 支持(SCROLL 关键字) ❌ 不支持
一致性 打开时刻的快照(一致性读) 物化时刻的快照,之后底表变化看不到

MySQL 游标的 OPEN 语句做了什么? 它执行 DECLARE CURSOR FOR 后面的那条查询,把全部结果行 写进一张内部临时表,然后 FETCH 就是从这张临时表里顺序读一行。

这张临时表受哪些参数约束:

sql 复制代码
-- MySQL 5.7 及之前:内部临时表默认用 MEMORY 引擎
SHOW VARIABLES LIKE 'tmp_table_size';        -- 默认 16M
SHOW VARIABLES LIKE 'max_heap_table_size';   -- 默认 16M
-- 取两者的较小值作为内存上限,超过就转成磁盘上的 MyISAM 临时表

-- MySQL 8.0:默认改用 TempTable 引擎
SHOW VARIABLES LIKE 'internal_tmp_mem_storage_engine';   -- TempTable
SHOW VARIABLES LIKE 'temptable_max_ram';                 -- 默认 1G
SHOW VARIABLES LIKE 'temptable_max_mmap';                -- 默认 1G
-- 超过 temptable_max_ram 先转 mmap 文件,再超过转 InnoDB 磁盘临时表

⚠️ 注意:具体到「游标用的是哪种临时表引擎」,官方文档没有明确写出实现细节,属于源码层面的行为,不同小版本可能有差异,需实测验证 。可以确定的结论是:游标会消耗临时表空间,结果集越大消耗越大,超出内存阈值就落盘。

由此推出「大结果集用游标会非常慢甚至撑爆磁盘」的完整因果链:

text 复制代码
DECLARE c CURSOR FOR SELECT * FROM score WHERE term = '2024-2025-1'   (500 万行)
        │
        ▼ OPEN c
① 执行这条 SELECT,把 500 万行全部物化进内部临时表
        │
② 临时表体积超过 tmp_table_size / temptable_max_ram
        │
        ▼
③ 转成磁盘临时表 → 写入 tmpdir(通常是 /tmp)
        │
        ▼
④ /tmp 空间耗尽 → ERROR 1114 (HY000): The table '/tmp/#sql_xxx' is full
   或者:磁盘 IO 打满,整个实例的所有查询一起变慢
        │
        ▼
⑤ 即使不撑爆,逐行 FETCH 500 万次,每次都要走一遍
   「临时表读一行 → 赋值给局部变量 → 执行业务 SQL」的开销,
   比一条 INSERT ... SELECT 慢几个数量级

这就是「能用集合操作就绝不用游标」这条军规的底层依据。 同一个归档需求,两种写法的对比:

sql 复制代码
-- ❌ 游标版:500 万次 FETCH + 500 万次 INSERT + 500 万行的临时表
OPEN c;
read_loop: LOOP
    FETCH c INTO v_id, v_sno, v_score;
    IF v_done THEN LEAVE read_loop; END IF;
    INSERT INTO score_archive (id, sno, score) VALUES (v_id, v_sno, v_score);
END LOOP read_loop;
CLOSE c;

-- ✅ 集合版:一条 SQL,走 InnoDB 批量写,优化器自己选最优路径
INSERT INTO score_archive (id, sno, score, term)
SELECT id, sno, score, term
FROM score
WHERE term = '2024-2025-1';

没有中间临时表,没有逐行开销,还利用了 InnoDB 的批量插入优化(change buffer、顺序写页)。 实测差距通常在 10~100 倍量级。

一句话理解 MySQL 游标 :它不是「指向结果集的指针」,而是「先把整个结果集复制一份到临时表,再从临时表里顺序读」。理解了这一点,你就再也不会拿它去处理大结果集。
💡 实战建议:只在以下三种情况用游标 ------① 每行的处理逻辑无法用 SQL 表达 (比如要拼接动态 SQL、要按行调用不同的分支逻辑);② 结果集很小 (几十到几千行);③ 需要逐行提交来控制事务大小(第十章的归档实战就是这个场景)。除此以外,一律用集合操作。

9.4 语法与 DECLARE 的顺序规则

课件 §5.5.1:

  • 使用游标之前必须先声明游标,之后使用 OPENFETCHCLOSE 语句来打开游标、获取游标记录和关闭游标。
  • 游标必须在条件处理程序之前被声明,并且变量必须在游标或条件处理程序之前被声明。
sql 复制代码
DECLARE 游标名 CURSOR FOR 查询语句;      -- 声明游标
OPEN 游标名;                             -- 打开游标
FETCH 游标名 INTO 变量 [, 变量] ...;     -- 获取游标记录
CLOSE 游标名;                            -- 关闭游标

四条铁律:

text 复制代码
BEGIN
    ① DECLARE 变量 / DECLARE 条件       ← 最先
    ② DECLARE 游标 CURSOR FOR ...       ← 中间
    ③ DECLARE ... HANDLER ...           ← 最后
    ④ OPEN / FETCH / CLOSE / 业务语句
END
铁律 违反了会怎样
变量必须在游标之前声明 ERROR 1064。因为游标的 FETCH ... INTO 变量 要引用变量,编译时必须先在符号表里找到它
游标必须在 handler 之前声明 ERROR 1064。handler 是给游标 FETCH 到末尾时的 NOT FOUND 兜底的,MySQL 的存储程序解析器要求「被服务的对象」先出现在声明区
同一作用域内游标名唯一 重名报 ERROR 1323: Cursor 'xxx' already exists
FETCH 的变量个数必须和 SELECT 列数完全一致 少了报 ERROR 1225,多了也一样。类型不匹配会静默做类型转换,可能截断数据

⚠️ 注意:这里要纠正一个流传很广的错误解释 。很多人说「handler 要引用游标的 NOT FOUND 条件,所以编译器需要先看到游标」------这个说法不准确。NOT FOUND 是一个条件类别 (所有以 02 开头的 SQLSTATE),不绑定某个具体游标。真正的原因是:MySQL 对存储程序的声明区做了固定的三段式解析(变量/条件 → 游标 → handler),这是语法规则本身规定的;而它的设计动机是------handler 的语句体里可以引用变量、条件名甚至游标,所以被引用者必须已经声明过。

游标的作用域和生命周期:

事件 游标状态
DECLARE 只是登记,不执行查询,不占资源
OPEN 执行查询并物化到临时表,资源开始占用
FETCH 从临时表读下一行
CLOSE 释放临时表
过程结束但没 CLOSE MySQL 会自动关闭 所有未关闭的游标,不会泄漏。但显式 CLOSE 是好习惯 ,尤其在循环里反复 OPEN 的场景
同一游标重复 OPEN 而不 CLOSE ERROR 1325: Cursor 'xxx' is already open

9.5 反面教材:没有 handler 的死循环(课件 §5.5.2)

课件给了一个故意出错的例子,非常有教学价值。

示例:传入班级编号,查询学生表中属于该班级的学生信息,并将符合条件的学生信息写入到一张新表中

  • 新表及字段 t_student_class(id, student_name, class_name)

实现逻辑:定义变量接收结果集中每一行列的值 → 声明游标接收结果集 → 创建新表 → 开启游标 → 从游标中获取记录 → 向新表写入数据 → 关闭游标

sql 复制代码
DELIMITER //

CREATE PROCEDURE p11(IN class_id INT)
BEGIN
    -- 首先声明变量
    DECLARE student_name VARCHAR(20);      -- 定义学生姓名变量
    DECLARE class_name   VARCHAR(20);      -- 定义班级名变量

    -- 定义游标
    DECLARE s_cursor CURSOR FOR
        SELECT s.`name` student_name, c.`name` class_name
        FROM student s, class c
        WHERE s.class_id = c.id AND s.class_id = class_id;

    -- 创建新表
    DROP TABLE IF EXISTS t_student_class;
    CREATE TABLE IF NOT EXISTS t_student_class (
        id           BIGINT PRIMARY KEY AUTO_INCREMENT,
        student_name VARCHAR(20),
        class_name   VARCHAR(20)
    );

    -- 开启游标
    OPEN s_cursor;

    -- 遍历结果集
    WHILE TRUE DO
        FETCH s_cursor INTO student_name, class_name;
        INSERT INTO t_student_class VALUES (NULL, student_name, class_name);
    END WHILE;

    -- 关闭游标
    CLOSE s_cursor;
END //

DELIMITER ;
sql 复制代码
-- 调用存储过程,出错
CALL p11(1);
text 复制代码
ERROR 1329 (02000): No data - zero rows fetched, selected, or processed

课件的解释:

由于 while 循环的退出条件是 true,此时是一个死循环,当游标遍历完成之后继续向后遍历,发现没有记录,所以报错,可以通过条件处理程序解决。

把这段代码的问题逐条拆开,你会发现它一口气踩了五个坑:

# 后果
1 WHILE TRUE DO 没有退出条件 死循环FETCH 到末尾时 MySQL 不是报错而是设置 NOT FOUND 条件 (SQLSTATE '02000')。没有 handler 承接,条件就升级成错误 1329 抛给客户端
2 没有 DECLARE CONTINUE HANDLER FOR NOT FOUND 无法感知遍历结束 。这是游标的标配,缺了就必然出事
3 过程体里执行了 DROP TABLE + CREATE TABLE DDL 会隐式提交当前事务(第十章详解)。如果这个过程被包在事务里,事务保护形同虚设
4 参数名 class_id 和列名 class_id 完全同名 WHERE s.class_id = c.id AND s.class_id = class_id ------ 最后那个 class_id 解析成了列名而不是参数 !MySQL 的命名解析优先级是「列名 > 变量名」。这句实际等价于 s.class_id = s.class_id恒为真,返回全部 8 行而不是某个班的 4 行
5 结果集只有 8 行却用游标 一条 INSERT ... SELECT 就能搞定

第 4 个坑尤其值得警惕------它不报错,静默返回错误的数据。修复方法就是第八章的命名规范:

sql 复制代码
CREATE PROCEDURE p11(IN p_class_id INT)
BEGIN
    DECLARE v_student_name VARCHAR(20);
    DECLARE v_class_name   VARCHAR(20);

    DECLARE s_cursor CURSOR FOR
        SELECT s.`name`, c.`name`
        FROM student s JOIN class c ON s.class_id = c.id
        WHERE s.class_id = p_class_id;        -- ✅ 前缀一区分,歧义消失
    ...
END;

9.6 正确写法:游标 + NOT FOUND handler(课件 §5.6.2)

课件接着给出了修复版:

sql 复制代码
DELIMITER //

CREATE PROCEDURE p12(IN p_class_id INT)
BEGIN
    -- ① 首先声明变量
    DECLARE v_student_name VARCHAR(20);        -- 学生姓名
    DECLARE v_class_name   VARCHAR(20);        -- 班级名
    DECLARE is_done        BOOL DEFAULT FALSE; -- 游标结束标识

    -- ② 定义游标(必须在变量之后)
    DECLARE s_cursor CURSOR FOR
        SELECT s.`name` student_name, c.`name` class_name
        FROM student s JOIN class c ON s.class_id = c.id
        WHERE s.class_id = p_class_id;

    -- ③ 定义条件处理程序(必须在游标之后)
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET is_done := TRUE;

    -- ④ 下面是可执行语句
    DROP TABLE IF EXISTS t_student_class;
    CREATE TABLE IF NOT EXISTS t_student_class (
        id           BIGINT PRIMARY KEY AUTO_INCREMENT,
        student_name VARCHAR(20),
        class_name   VARCHAR(20)
    );

    OPEN s_cursor;

    read_loop: LOOP
        -- 获取游标记录
        FETCH s_cursor INTO v_student_name, v_class_name;
        -- 退出循环
        IF is_done THEN
            LEAVE read_loop;
        END IF;
        -- 写入到新表
        INSERT INTO t_student_class VALUES (NULL, v_student_name, v_class_name);
    END LOOP read_loop;

    CLOSE s_cursor;
END //

DELIMITER ;

CALL p12(1);
SELECT * FROM t_student_class;
text 复制代码
+----+--------------+--------------+
| id | student_name | class_name   |
+----+--------------+--------------+
|  1 | 唐三藏       | 计算机1班    |
|  2 | 孙悟空       | 计算机1班    |
|  3 | 猪悟能       | 计算机1班    |
|  4 | 沙悟净       | 计算机1班    |
+----+--------------+--------------+
4 rows in set (0.00 sec)

这个模板的每一行都是必要的,逐段讲解:

代码段 用了什么知识点 不这么写会怎样
DECLARE is_done BOOL DEFAULT FALSE; 游标结束标志BOOLTINYINT(1) 的同义词 没有它就无法把「handler 被触发」这个事实传递给循环。注意必须 DEFAULT FALSE,否则初值 NULL,IF is_done THEN 永远不成立 → 死循环
DECLARE CONTINUE HANDLER FOR NOT FOUND SET is_done := TRUE; CONTINUE 而不是 EXIT EXIT 会直接跳出整个 BEGIN...END 块,CLOSE s_cursor 就不会执行(虽然 MySQL 会自动关,但逻辑被打断)。CONTINUE 只是「执行完 handler 后继续下一条语句」,流程自然走到 IF is_done
FETCHIF is_done 之前 先取,再判断 如果先判断再 FETCH,第一次进循环时 is_done 还是 FALSE,会漏掉「空结果集」的情况;更严重的是顺序错了会多插入一行重复数据 (因为 FETCH 失败时变量保持上一次的值)
LEAVE read_loop; 第七章的标签跳出 没有标签的 LOOP 无法退出
OPEN / CLOSE 配对 游标生命周期 CLOSE 时 MySQL 会在过程结束时自动关闭,不泄漏;但在循环内反复 OPEN 同一个游标 时会报 ERROR 1325: Cursor is already open
INSERT ... VALUES (NULL, ...) 让自增主键自动分配 写具体数字会和已有数据冲突

⚠️ 注意:FETCH 失败(触发 NOT FOUND)时,目标变量保持上一次 FETCH 的值,不会被清空 。这就是为什么「先 FETCH 再判断 is_done」的顺序至关重要------如果顺序反了,最后一行会被重复插入一次。这是游标编程最经典的 off-by-one bug。

9.7 集合版改写:同一个需求的正确姿势

前面反复说「能用集合操作就别用游标」,现在给出 p12 的等价集合写法:

sql 复制代码
-- 一条 SQL 干掉整个存储过程
DROP TABLE IF EXISTS t_student_class;
CREATE TABLE t_student_class (
    id           BIGINT PRIMARY KEY AUTO_INCREMENT,
    student_name VARCHAR(20),
    class_name   VARCHAR(20)
) ENGINE = InnoDB;

INSERT INTO t_student_class (student_name, class_name)
SELECT s.`name`, c.`name`
FROM student s JOIN class c ON s.class_id = c.id
WHERE s.class_id = 1;

对比一下代价:

游标版 p12 集合版
内部临时表 有(物化游标结果集)
语句执行次数 1(OPEN)+ N(FETCH)+ N(INSERT)+ 1(CLOSE)= 2N + 2 1
每行的开销 临时表读 → 变量赋值 → 解析 INSERT → 优化 → 执行 一次批量写
4 行数据 10 次语句执行 1 次
500 万行数据 1000 万次语句执行 + 500 万行临时表 1 次(可能触发大事务,需分批,见第十章)

划重点游标存在的唯一理由是「每行的处理逻辑无法用一条 SQL 表达」。 如果你能在循环体里写出一条完整的 INSERT / UPDATE,那这条 SQL 大概率可以直接带 WHERE 一次性处理所有行。写游标之前,先花五分钟想想能不能不用。


十、条件处理程序:存储过程里的 try-catch

10.1 三个概念

课件 §5.6:

  • 定义条件是事先定义程序执行过程中可能遇到的问题
  • 处理程序定义了在遇到问题时应当采取的处理方式
  • 使用条件处理程序保证存储过程或函数在遇到警告或错误时能继续执行,可以增强程序处理问题的能力,避免程序异常停止运行。

翻译成 Java 的对应关系,一目了然:

MySQL 存储过程 Java 作用
DECLARE cond_name CONDITION FOR ... class MyException extends Exception 给错误起个名字
DECLARE handler_action HANDLER FOR ... stmt catch (MyException e) { ... } 捕获并处理
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = ... throw new MyException("...") 主动抛错
RESIGNAL throw e;(原样重抛) 把捕获到的错误再抛出去
GET DIAGNOSTICS CONDITION 1 ... e.getMessage() / e.getErrorCode() 取出错误详情
CONTINUE HANDLER catch 后继续往下走 处理完继续执行后续语句
EXIT HANDLER catch + return / 跳出 try 块 处理完退出当前 BEGIN...END

10.2 语法

sql 复制代码
-- ① 定义条件(可选,给错误码起个别名)
DECLARE condition_name CONDITION FOR condition_value

condition_value:
    mysql_error_code                        -- MySQL 数字错误码,如 1062
  | SQLSTATE [VALUE] sqlstate_value         -- 5 字符 SQLSTATE,如 '23000'

-- ② 定义处理程序
DECLARE handler_action HANDLER
    FOR condition_value [, condition_value] ...
    statement_handler

handler_action:
    CONTINUE        -- 继续执行当前程序
  | EXIT            -- 终止执行当前程序
  | UNDO            -- ★ MySQL 不支持,见 10.5

condition_value:
    mysql_error_code                        -- MySQL 错误码
  | SQLSTATE [VALUE] sqlstate_value         -- 状态码
  | condition_name                          -- ① 里声明的条件名
  | SQLWARNING                              -- 所有以 01 开头的 SQLSTATE 代码
  | NOT FOUND                               -- 所有以 02 开头的 SQLSTATE 代码
  | SQLEXCEPTION                            -- 所有没有被 SQLWARNING 或 NOT FOUND 捕获的 SQLSTATE

错误码参考官方网站:https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html

10.3 SQLSTATE 与 MySQL errno 的双轨制

MySQL 有两套并行的错误编码体系,这是理解条件处理程序的关键:

SQLSTATE MySQL errno
来源 ANSI SQL 标准 MySQL 私有
形式 5 个字符 ,如 '23000' 整数 ,如 1062
结构 前 2 位是类别(class),后 3 位是子类 无结构,纯编号
写法 DECLARE HANDLER FOR SQLSTATE '23000' DECLARE HANDLER FOR 1062
可移植性 跨数据库通用 只有 MySQL 认
粒度 粗(一个 class 覆盖几十种错误) 细(一个 errno 对应一种具体错误)

SQLSTATE 类别表:

class 含义 对应 handler 简写
'00' 成功(不是错误) ---
'01' 警告 SQLWARNING
'02' 未找到(no data) NOT FOUND
'03' ~ '99' 异常 SQLEXCEPTION

三个必须记住的特殊 SQLSTATE:

SQLSTATE 含义
'02000' NOT FOUND------游标 FETCH 到末尾、SELECT INTO 返回 0 行
'23000' 完整性约束违反(主键冲突、非空约束、外键失败、唯一键冲突全归这一类)
'45000' 用户自定义异常的保留 SQLSTATE ------SIGNAL 只能抛这个(或者其他未保留的 45xxx

常用 MySQL errno:

errno 含义 对应 SQLSTATE
1062 ER_DUP_ENTRY 主键 / 唯一键冲突 '23000'
1048 ER_BAD_NULL_ERROR 列不能为 NULL '23000'
1452 ER_NO_REFERENCED_ROW_2 外键约束失败(插入子表时父表无对应行) '23000'
1451 ER_ROW_IS_REFERENCED_2 删除父表行时被外键引用 '23000'
1213 ER_LOCK_DEADLOCK 死锁,InnoDB 已自动回滚 '40001'
1205 ER_LOCK_WAIT_TIMEOUT 锁等待超时 'HY000'
1329 ER_SP_FETCH_NO_DATA 游标无数据 '02000'
1305 ER_SP_DOES_NOT_EXIST 过程/函数不存在 '42000'
1146 ER_NO_SUCH_TABLE 表不存在 '42S02'

两套体系可以混用,但要知道匹配优先级:

sql 复制代码
DELIMITER //
CREATE PROCEDURE p_handler_priority()
BEGIN
    DECLARE v_log VARCHAR(100) DEFAULT '';

    -- 三个 handler 都能匹配 1062(SQLSTATE '23000')
    DECLARE CONTINUE HANDLER FOR 1062        SET v_log := 'errno 1062';
    DECLARE CONTINUE HANDLER FOR SQLSTATE '23000' SET v_log := 'SQLSTATE 23000';
    DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET v_log := 'SQLEXCEPTION';

    INSERT INTO student (id, name, sno) VALUES (1, '重复', '100001');  -- 主键冲突
    SELECT v_log;
END //
DELIMITER ;

MySQL 的匹配规则是「最具体的优先」 :具体 errno > 具体 SQLSTATE > NOT FOUND/SQLWARNING > SQLEXCEPTION。上面会输出 errno 1062

⚠️ 注意:同一个 handler_action + 同一个 condition 不能声明两次 ,会报 ERROR 1328: Duplicate handler condition。但 CONTINUE HANDLER FOR 1062EXIT HANDLER FOR 1062 共存是允许的------只有 action 匹配的那个会执行(具体行为需实测验证,不建议这么写)。

10.4 定义条件:给错误码起名字

sql 复制代码
DELIMITER //
CREATE PROCEDURE p_with_condition()
BEGIN
    DECLARE v_msg VARCHAR(200) DEFAULT '';

    -- 先定义条件名(必须在 handler 之前)
    DECLARE dup_key CONDITION FOR 1062;
    DECLARE fk_fail CONDITION FOR SQLSTATE '23000';

    -- handler 里用条件名,可读性大幅提升
    DECLARE CONTINUE HANDLER FOR dup_key SET v_msg := '主键冲突,跳过这行';
    DECLARE CONTINUE HANDLER FOR fk_fail SET v_msg := '外键约束失败';

    INSERT INTO student (id, name, sno) VALUES (1, '重复', '100001');
    SELECT v_msg;      -- → '主键冲突,跳过这行'
END //
DELIMITER ;

为什么要多此一举定义条件名? 因为半年后你看到 HANDLER FOR dup_key 立刻知道在处理什么,看到 HANDLER FOR 1062 还得去查表。这是存储过程里唯一能提升可读性的手段之一,值得用。

10.5 CONTINUE / EXIT / UNDO:三种 handler 行为

handler_action 行为 类比
CONTINUE 执行完 handler 语句后,继续执行 handler 之后的下一条语句 catch 后不 return,继续往下
EXIT 执行完 handler 语句后,立即退出 handler 所在的那个 BEGIN ... END 复合语句块 catch + return
UNDO MySQL 不支持 ---

CONTINUE vs EXIT 的实际差别:

sql 复制代码
DELIMITER //
CREATE PROCEDURE p_continue_vs_exit()
BEGIN
    DECLARE v_step VARCHAR(20) DEFAULT '';
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        SET v_step := 'handler 执行了';
        SELECT v_step;                 -- 这条会输出
    END;

    SET v_step := 'before';
    SELECT 1/0;                        -- 触发 SQLEXCEPTION(除零在严格模式下报错)
    SET v_step := 'after';             -- ❌ EXIT handler → 这一句不会执行
    SELECT v_step;                     -- ❌ 也不会执行
END //
DELIMITER ;

换成 CONTINUE HANDLER,则 SET v_step := 'after' 和后面的 SELECT 都会执行

选型建议:

场景 用哪个
游标遍历结束(NOT FOUND) CONTINUE------要继续走到 IF is_done THEN LEAVE 那一行
可容忍的单行错误(重复键跳过、脏数据忽略) CONTINUE------记录日志后继续处理下一行
不可恢复的错误(要回滚、要通知调用方) EXIT------立即停止,避免在错误状态下继续跑
需要清理资源(关游标、恢复会话变量) EXIT + handler 里做清理 + RESIGNAL
UNDO:文档里写了但 MySQL 没实现

课件的语法列表里只给了 CONTINUEEXIT 两个,这是准确的 。MySQL 官方文档的语法定义里保留了 UNDO 这个选项,但明确标注 MySQL 不支持

UNDO 的设计意图是「handler 执行完后自动回滚 当前 BEGIN...END 块里已经执行的语句」------这是 SQL 标准的要求,Oracle 等数据库实现了,MySQL 从未实现

sql 复制代码
DECLARE UNDO HANDLER FOR SQLEXCEPTION SET v_msg := 'err';
-- 官方文档:UNDO is not supported in MySQL
-- 实际行为随版本不同:要么解析器直接拒绝,要么接受声明但完全不执行任何回滚
-- 具体表现需实测验证

⚠️ 注意:生产代码里永远不要写 UNDO HANDLER MySQL 不会帮你自动回滚,需要回滚就必须自己显式写 ROLLBACK,而且要小心 10.8 节的隐式提交陷阱。

10.6 最危险的反模式:吞异常

这是全篇最需要记住的一条。看这段代码:

sql 复制代码
DELIMITER //
CREATE PROCEDURE p_swallow()
BEGIN
    -- ❌❌❌ 空的 CONTINUE handler,把一切异常吞掉
    DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END;

    START TRANSACTION;
    UPDATE account SET balance = balance - 100 WHERE id = 1;   -- 假设这里失败了
    UPDATE account SET balance = balance + 100 WHERE id = 2;   -- 这句照跑
    COMMIT;
END //
DELIMITER ;
sql 复制代码
CALL p_swallow();
text 复制代码
Query OK, 0 rows affected (0.01 sec)

调用方看到 Query OK,完全不知道出过错。 但实际发生的是:

  • 第一句 UPDATE 因为某种原因失败了(比如 id=1 不存在、余额不足触发 CHECK、锁超时)
  • handler 把错误吞了,流程继续
  • 第二句 UPDATE 照常执行,id=2 的余额凭空多了 100
  • COMMIT 提交
  • 账户 1 的钱没扣,账户 2 的钱多了------账目永久不平,而且没有任何日志、没有任何报错

这就是「吞异常(swallowing exceptions) 」,和应用层「catch (Exception e) { } 空 catch 块」是完全同一个反模式。

正确的三种处理方式:

sql 复制代码
-- ✅ 方式一:走明确的备用业务路径,并且记录下来
DECLARE CONTINUE HANDLER FOR 1062
BEGIN
    INSERT INTO dup_key_log (proc_name, err_time, err_msg)
    VALUES ('p_archive', NOW(), '主键冲突,已跳过该行');
END;

-- ✅ 方式二:回滚 + RESIGNAL 抛出去,让调用方决定
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
    ROLLBACK;
    RESIGNAL;      -- 保留原始错误信息原样抛出
END;

-- ✅ 方式三:回滚 + 设置状态 OUT 参数,让调用方检查
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
    ROLLBACK;
    SET p_status  := -1;
    SET p_message := '归档失败,已回滚';
END;

划重点handler 的语句体永远不能为空。 每写一个 handler,问自己三个问题:① 这个错误我可以忽略吗?② 忽略之后数据还一致吗?③ 调用方需要知道吗?三个问题里只要有一个答「不」,就必须 RESIGNAL 或者设置状态参数。

10.7 SIGNAL / RESIGNAL / GET DIAGNOSTICS

SIGNAL:主动抛错
sql 复制代码
SIGNAL [condition_value] [SET signal_information_item [, ...] ...]
sql 复制代码
DELIMITER //
CREATE PROCEDURE p_signal(IN p_score INT)
BEGIN
    IF p_score < 0 OR p_score > 100 THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = '分数必须在 0~100 之间',
                MYSQL_ERRNO  = 5001;
    END IF;
    SELECT '校验通过';
END //
DELIMITER ;

CALL p_signal(150);
text 复制代码
ERROR 5001 (45000): 分数必须在 0~100 之间

三个要点:

  1. SIGNAL 只接受 SQLSTATE,不接受 MySQL errno 作为 condition_value(SIGNAL 1062 是语法错误)。errno 要通过 SET MYSQL_ERRNO = ... 指定。
  2. SQLSTATE 不能以 '00' 开头 (那是「成功」)。用户自定义异常约定用 '45000'
  3. 可以 SET 的信息项:MESSAGE_TEXTMYSQL_ERRNOCLASS_ORIGINSUBCLASS_ORIGINCONSTRAINT_NAMETABLE_NAMECOLUMN_NAMESCHEMA_NAMECURSOR_NAME 等。

MESSAGE_TEXT 最大 128 字符,超出会被截断并产生一个警告。想传更长的上下文,用多个信息项或者写日志表。

RESIGNAL:把捕获到的错误原样重抛
sql 复制代码
RESIGNAL [condition_value] [SET signal_information_item ...]
sql 复制代码
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
    ROLLBACK;
    INSERT INTO error_log (proc_name, err_time) VALUES ('p_archive', NOW());
    RESIGNAL;        -- ← 保留原始的 SQLSTATE、errno、MESSAGE_TEXT 原样抛出
END;

RESIGNALSIGNAL 的区别: SIGNAL凭空造一个新错误RESIGNAL把当前诊断区里已经存在的错误重新抛出 。用 RESIGNAL 的好处是调用方看到的错误信息和没有 handler 时完全一样,不会因为你在中间加了一层处理而丢失原始上下文。

sql 复制代码
RESIGNAL SET MESSAGE_TEXT = CONCAT('归档失败:', @original_msg);  -- 也可以改写信息再抛

⚠️ 注意:RESIGNAL 只能在 handler 内部使用 ,在没有激活的 handler 上下文里调用会报 ERROR 1644: RESIGNAL when handler not active

GET DIAGNOSTICS:取出错误详情
sql 复制代码
GET [CURRENT] DIAGNOSTICS
{
    statement_information_item [, ...]                    -- 语句级信息
  | CONDITION condition_number condition_information_item [, ...]  -- 条件级信息
}
sql 复制代码
DELIMITER //
CREATE PROCEDURE p_get_diag()
BEGIN
    DECLARE v_errno  INT    DEFAULT 0;
    DECLARE v_state  CHAR(5)    DEFAULT '';
    DECLARE v_msg    VARCHAR(200) DEFAULT '';
    DECLARE v_rows   INT    DEFAULT 0;

    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        -- ★ 取出第一个条件的详细信息
        GET DIAGNOSTICS CONDITION 1
            v_errno = MYSQL_ERRNO,
            v_state = RETURNED_SQLSTATE,
            v_msg   = MESSAGE_TEXT;

        -- 语句级信息:受影响行数
        GET DIAGNOSTICS v_rows = ROW_COUNT;

        SELECT v_errno AS errno, v_state AS sqlstate,
               v_msg AS message, v_rows AS row_count;
        RESIGNAL;
    END;

    INSERT INTO student (id, name, sno) VALUES (1, '重复', '100001');
END //
DELIMITER ;

CALL p_get_diag();
text 复制代码
+-------+----------+------------------------------------------+-----------+
| errno | sqlstate | message                                  | row_count |
+-------+----------+------------------------------------------+-----------+
|  1062 | 23000    | Duplicate entry '1' for key 'PRIMARY'    |         0 |
+-------+----------+------------------------------------------+-----------+
ERROR 1062 (23000): Duplicate entry '1' for key 'student.PRIMARY'

常用信息项:

级别 信息项 说明
语句级 ROW_COUNT 上一条语句影响的行数(-1 表示出错)
条件级 MYSQL_ERRNO MySQL 数字错误码
RETURNED_MYSQL_ERRNO handler 里用这个(表示「触发 handler 的那个条件」的 errno)
MESSAGE_TEXT 错误消息文本
RETURNED_SQLSTATE handler 里用这个
CLASS_ORIGIN / SUBCLASS_ORIGIN 'ISO 9075''MySQL'
CONSTRAINT_NAME / TABLE_NAME / COLUMN_NAME 违反约束时可用
CURSOR_NAME 游标相关错误时可用

💡 实战建议:CONDITION 1 是第一个条件,不是最后一个。 诊断区是一个条件栈,可能同时积累多个警告。想拿最新的那个,先用 GET DIAGNOSTICS @cnt = NUMBER;(8.0 支持)取条件总数,再 GET DIAGNOSTICS CONDITION @cnt ...。绝大多数场景下 CONDITION 1 就够了。

10.8 隐式提交陷阱:你写的 ROLLBACK 可能是假的

这是存储过程里最阴险的一个坑。MySQL 有一批语句会「隐式提交」当前事务 ------执行它们之前,MySQL 会先自动 COMMIT 一次。

会导致隐式提交的语句(在存储过程里同样生效):

类别 语句
DDL CREATE TABLE / ALTER TABLE / DROP TABLE / TRUNCATE TABLE / RENAME TABLE / CREATE INDEX / DROP INDEX / CREATE PROCEDURE / DROP PROCEDURE / CREATE VIEW / CREATE TRIGGER
权限与账号 GRANT / REVOKE / SET PASSWORD / CREATE USER / DROP USER / ALTER USER
表管理 ANALYZE TABLE / OPTIMIZE TABLE / REPAIR TABLE / CHECK TABLE
事务与锁 START TRANSACTION(会先提交上一个!)/ COMMIT / ROLLBACK / LOCK TABLES / UNLOCK TABLES
管理语句 KILL / SHUTDOWN / RESET / FLUSH / SET autocommit = 1
数据加载 LOAD DATA

看这个真实的翻车例子:

sql 复制代码
DELIMITER //
CREATE PROCEDURE p_implicit_commit_trap()
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;                                  -- ← 这个 ROLLBACK 已经没用了!
        SELECT '出错了,已回滚' AS msg;
    END;

    START TRANSACTION;
    INSERT INTO student (name, sno, class_id) VALUES ('新生A', '300001', 1);   -- ✅ 插入成功

    -- ❌ 致命的一句:DDL 会隐式提交,上面的 INSERT 已经落盘了
    CREATE TABLE IF NOT EXISTS tmp_backup (id BIGINT);

    INSERT INTO student (id, name, sno) VALUES (1, '冲突', '100001');           -- ❌ 主键冲突
END //
DELIMITER ;

CALL p_implicit_commit_trap();
text 复制代码
+------------------+
| msg              |
+------------------+
| 出错了,已回滚   |
+------------------+

看起来回滚成功了?没有。 验证一下:

sql 复制代码
SELECT * FROM student WHERE sno = '300001';
text 复制代码
+----+--------+--------+------+--------+-------------+----------+
| id | name   | sno    | age  | gender | enroll_date | class_id |
+----+--------+--------+------+--------+-------------+----------+
|  9 | 新生A  | 300001 | NULL |   NULL | NULL        |        1 |
+----+--------+--------+------+--------+-------------+----------+
1 row in set (0.00 sec)

「新生A」还在! 因为 CREATE TABLE 已经把它提交了,后面的 ROLLBACK 回滚的是一个空事务。你以为有事务保护,其实完全没有------这比没有事务更危险,因为你不再做额外的补偿。

三条规避原则:

  1. 存储过程里绝对不要写 DDL。 建表、改表放在过程外面,作为独立的部署步骤。第九章 p11 / p12 里在过程体内 DROP TABLE + CREATE TABLE,从教学角度能跑,从生产角度是严重反模式
  2. GRANT / REVOKE / OPTIMIZE TABLE / ANALYZE TABLE 同理,全部移出事务边界。
  3. 需要临时表就用 CREATE TEMPORARY TABLE ------注意:CREATE TEMPORARY TABLE 在 MySQL 里同样会导致隐式提交 (它属于 DDL),所以它也不能放在事务中间。真正的做法是:过程外面建好临时表,过程里面只读写。

10.9 综合实战:分批数据归档存储过程

现在把前面所有知识点串成一个生产级的存储过程。

业务场景:score 表里某个学期的成绩归档到 score_archive,同时统计每个班级的平均分写入 class_stat。要求:分批提交 (避免大事务)、异常回滚当前批次幂等可重跑

准备表结构
sql 复制代码
-- 成绩表(源表)
DROP TABLE IF EXISTS score;
CREATE TABLE score (
    id BIGINT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(10) NOT NULL,
    class_id BIGINT NOT NULL, cname VARCHAR(20) NOT NULL,
    grade DECIMAL(5,2) NOT NULL, term VARCHAR(20) NOT NULL COMMENT '学期,如 2024-2025-1',
    archived TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0未归档 1已归档',
    KEY idx_term_archived (term, archived), KEY idx_class_term (class_id, term)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- 归档表(结构同源表,保留源 id,不自增)
DROP TABLE IF EXISTS score_archive;
CREATE TABLE score_archive (
    id BIGINT NOT NULL, sno VARCHAR(10) NOT NULL, class_id BIGINT NOT NULL,
    cname VARCHAR(20) NOT NULL, grade DECIMAL(5,2) NOT NULL,
    term VARCHAR(20) NOT NULL, archived_time DATETIME NOT NULL,
    PRIMARY KEY (id), KEY idx_term (term)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- 班级统计表(第 5 篇《触发器(上):语法精讲与审计日志实战》里也有同名演示表,列不同,两者互相独立)
DROP TABLE IF EXISTS class_stat;
CREATE TABLE class_stat (
    id BIGINT PRIMARY KEY AUTO_INCREMENT, class_id BIGINT NOT NULL,
    term VARCHAR(20) NOT NULL, stu_cnt INT NOT NULL DEFAULT 0,
    avg_score DECIMAL(5,2) NOT NULL DEFAULT 0.00, stat_time DATETIME NOT NULL,
    UNIQUE KEY uk_class_term (class_id, term)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- 调试日志表(2.3 节说的「生产环境唯一可靠的调试手段」)
DROP TABLE IF EXISTS debug_log;
CREATE TABLE debug_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT, proc_name VARCHAR(64), step VARCHAR(64),
    var_name VARCHAR(64), var_value TEXT, created DATETIME NOT NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
存储过程本体
sql 复制代码
DELIMITER //

CREATE PROCEDURE p_archive_score(
    IN  p_term      VARCHAR(20),     -- 要归档的学期
    IN  p_batch_size INT,            -- 每批处理多少行
    OUT p_total     INT,             -- 实际归档了多少行
    OUT p_status    INT,             -- 0 成功 / -1 失败
    OUT p_message   VARCHAR(200)     -- 结果说明
)
BEGIN
    -- ══════════ ① 声明区:变量 → 游标 → handler,顺序不能乱 ══════════
    DECLARE v_batch_no    INT           DEFAULT 0;
    DECLARE v_affected    INT           DEFAULT 0;
    DECLARE v_last_id     BIGINT        DEFAULT 0;   -- 分批游标:按主键推进
    DECLARE v_max_id      BIGINT        DEFAULT 0;   -- 本批的主键上界
    DECLARE v_err_no      INT           DEFAULT 0;
    DECLARE v_err_msg     VARCHAR(200)  DEFAULT '';

    -- 不可恢复错误:回滚当前批次 + 记录日志 + 设置状态 + RESIGNAL
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        GET DIAGNOSTICS CONDITION 1
            v_err_no = MYSQL_ERRNO, v_err_msg = MESSAGE_TEXT;

        ROLLBACK;                                    -- 只回滚当前这一批

        INSERT INTO debug_log (proc_name, step, var_name, var_value, created)
        VALUES ('p_archive_score', 'FATAL', CONCAT('batch_', v_batch_no),
                CONCAT(v_err_no, ':', v_err_msg), NOW());

        SET p_status  := -1;
        SET p_message := CONCAT('第 ', v_batch_no, ' 批失败,已回滚。errno=',
                                v_err_no, ' msg=', v_err_msg);
        -- 这里选择不再 RESIGNAL:因为要用 OUT 参数把状态传回去。
        -- 如果调用方是命令行脚本,改成 RESIGNAL 更合适。
    END;

    -- 主键冲突(重复归档):可容忍,记日志后继续
    DECLARE CONTINUE HANDLER FOR 1062
    BEGIN
        INSERT INTO debug_log (proc_name, step, var_name, var_value, created)
        VALUES ('p_archive_score', 'DUP_KEY', CONCAT('batch_', v_batch_no),
                '归档表已存在该 id,跳过', NOW());
    END;

    -- ══════════ ② 可执行区 ══════════
    SET p_total   := 0;
    SET p_status  := 0;
    SET p_message := '';

    -- 参数校验:用 SIGNAL 主动抛错(第六章学的)
    IF p_term IS NULL OR p_term = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_term 不能为空';
    END IF;
    IF p_batch_size IS NULL OR p_batch_size <= 0 OR p_batch_size > 100000 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'p_batch_size 必须在 1~100000 之间';
    END IF;

    batch_loop: LOOP
        SET v_batch_no := v_batch_no + 1;

        START TRANSACTION;

        -- ★ 第一步:算出本批要处理的主键上界(按主键升序取 p_batch_size 行)
        --   存储程序里的 LIMIT 可以直接用参数/局部变量,普通 SQL 不行
        SELECT IFNULL(MAX(id), 0) INTO v_max_id
        FROM (
            SELECT id FROM score
            WHERE term = p_term AND archived = 0 AND id > v_last_id
            ORDER BY id
            LIMIT p_batch_size
        ) t;

        IF v_max_id = 0 THEN
            COMMIT;
            LEAVE batch_loop;              -- 没有待归档数据了,正常结束
        END IF;

        -- ★ 第二步:集合操作搬一批,不用游标
        INSERT INTO score_archive (id, sno, class_id, cname, grade, term, archived_time)
        SELECT s.id, s.sno, s.class_id, s.cname, s.grade, s.term, NOW()
        FROM score s
        WHERE s.term = p_term AND s.archived = 0
          AND s.id > v_last_id AND s.id <= v_max_id;

        SET v_affected := ROW_COUNT();     -- 必须紧跟在 DML 之后调用

        -- ★ 第三步:标记源表已归档(单表 UPDATE,走 idx_term_archived)
        UPDATE score
        SET archived = 1
        WHERE term = p_term AND archived = 0
          AND id > v_last_id AND id <= v_max_id;

        -- ★ 第四步:推进主键游标位置
        SET v_last_id := v_max_id;
        SET p_total   := p_total + v_affected;

        COMMIT;                            -- ★ 每批一次提交,控制事务大小

        INSERT INTO debug_log (proc_name, step, var_name, var_value, created)
        VALUES ('p_archive_score', CONCAT('batch_', v_batch_no), 'affected',
                CAST(v_affected AS CHAR), NOW());

        IF v_affected < p_batch_size THEN
            LEAVE batch_loop;              -- 不足一批,说明已经处理完
        END IF;
    END LOOP batch_loop;

    -- ══════════ ③ 统计班级平均分(集合操作 + UPSERT)══════════
    START TRANSACTION;

    INSERT INTO class_stat (class_id, term, stu_cnt, avg_score, stat_time)
    SELECT class_id,
           p_term,
           COUNT(DISTINCT sno),
           ROUND(AVG(grade), 2),
           NOW()
    FROM score
    WHERE term = p_term
    GROUP BY class_id
    ON DUPLICATE KEY UPDATE               -- 幂等:重跑时更新而不是报错
        stu_cnt   = VALUES(stu_cnt),
        avg_score = VALUES(avg_score),
        stat_time = VALUES(stat_time);

    COMMIT;

    SET p_message := CONCAT('归档完成,共 ', p_total, ' 行,分 ', v_batch_no, ' 批');
END //

DELIMITER ;
调用
sql 复制代码
CALL p_archive_score('2024-2025-1', 5000, @total, @status, @msg);
SELECT @total AS 归档行数, @status AS 状态, @msg AS 说明;
text 复制代码
+----------+------+--------------------------------------------+
| 归档行数 | 状态 | 说明                                       |
+----------+------+--------------------------------------------+
|    12000 |    0 | 归档完成,共 12000 行,分 3 批             |
+----------+------+--------------------------------------------+
逐段讲解:每一处为什么这么写
代码段 用了什么知识点 不这么写会怎样
DECLARE 区顺序:变量 → EXIT HANDLERCONTINUE HANDLER 4.3.2 三段式 顺序错了直接 ERROR 1064,创建都过不去
EXIT HANDLER FOR SQLEXCEPTION 10.5:不可恢复错误用 EXIT CONTINUE 会在错误状态下继续跑后面的批次,错误被放大
GET DIAGNOSTICS CONDITION 1 v_err_no = MYSQL_ERRNO, ... 10.7:取出 errno 和消息 没有它,日志里只有「失败了」三个字,根本不知道什么原因
ROLLBACK 在 handler 里 只回滚当前批次,之前已提交的批次保留 这是故意的 ------归档是幂等的(archived 标志 + ON DUPLICATE KEY),失败后重跑会跳过已归档的行
CONTINUE HANDLER FOR 1062 10.3:具体 errno 优先于 SQLEXCEPTION 重复归档时会被 EXIT HANDLER 抓住直接终止整个归档,太脆弱
SIGNAL SQLSTATE '45000' 做参数校验 10.7 不校验的话 p_batch_size = 0 会导致死循环或者一次搬完全表
LOOP + LEAVE batch_loop 第七章 退出条件复杂(两处 LEAVE),WHILE 表达不了
INSERT ... SELECT ... LIMIT 不用游标 9.3:集合操作 用游标逐行搬会慢 10~100 倍,还要物化临时表
SET v_affected := ROW_COUNT() ROW_COUNT() 返回上一条 DML 影响的行数 必须在 DML 之后立刻调用 ------中间插入任何其他语句(包括 GET DIAGNOSTICS)都会覆盖它
COMMIT 在每批末尾 分批提交 见下面的「大事务危害」
ORDER BY s.id + id > v_last_id 按主键推进的分批模式 LIMIT offset, n 时 offset 越大扫描越多(深分页问题),100 万行之后每批都要扫百万行。按主键推进永远是索引范围扫描
ON DUPLICATE KEY UPDATE 幂等性 重跑时报 1062,虽然有 handler 兜着但会污染日志
过程体内没有任何 DDL 10.8 隐式提交陷阱 一旦写了 CREATE TABLE,前面所有的 START TRANSACTION / ROLLBACK 全部失效
为什么必须分批提交:大事务的四个危害
sql 复制代码
-- ❌ 反面写法:一个事务搬 500 万行
START TRANSACTION;
INSERT INTO score_archive SELECT * FROM score WHERE term = '2024-2025-1';
DELETE FROM score WHERE term = '2024-2025-1';
COMMIT;
危害 底层机制
① undo log 暴涨 InnoDB 要为每一行被修改的记录写 undo log(用于回滚和 MVCC)。500 万行的 undo 可能几个 GB,撑爆 undo 表空间;而且事务不提交,purge 线程无法清理旧版本 ,版本链越拉越长,所有读操作都要沿着链回溯,全库查询变慢
② 锁持有时间过长 DELETE FROM score WHERE term = 'xxx' 会锁住所有匹配行(如果 term 上没索引,锁全表 )。500 万行的锁持有几分钟,期间所有对 score 表的写操作全部阻塞,innodb_lock_wait_timeout(默认 50 秒)一到就大面积报 ERROR 1205: Lock wait timeout exceeded
③ 主从延迟爆炸 binlog 里一个 500 万行的大事务,从库要单线程回放 (同一个事务内部无法并行)。主库花 5 分钟提交,从库就得花 5 分钟甚至更久回放,期间主从延迟 5 分钟以上,所有读从库的业务全部读到旧数据(详见第 17 篇)
④ 回滚代价巨大 如果事务跑到 90% 时失败,InnoDB 要回滚 450 万行------回滚通常比正向执行还慢(要逐行应用 undo)。这段时间表基本不可用

分批提交的工程权衡:

text 复制代码
一个 500 万行的大事务          vs          1000 批 × 5000 行
─────────────────────────────────────────────────────────────
原子性:全有或全无 ✅                      原子性:每批全有或全无 ⚠️
undo log:几 GB                            undo log:每批几 MB
锁持有:几分钟                             锁持有:每批几十毫秒
主从延迟:5 分钟+                          主从延迟:几乎无感
失败恢复:全部重来(几小时)                失败恢复:只重跑失败批次(几十秒)

分批提交牺牲了「全局原子性」,换来了可用性和可恢复性。 这在工程上叫「用一点 RPO(可能丢失的数据量)换可用性 」------而且因为归档操作被设计成幂等 的(archived 标志 + ON DUPLICATE KEY),中断后重跑不会造成数据错误,所以这个牺牲是划算的。

💡 实战建议:批大小的选择 。太小(100 行)→ 提交次数多,每次提交要刷 redo log(innodb_flush_log_at_trx_commit=1 时是一次 fsync),IO 反而成为瓶颈。太大(10 万行)→ 又回到大事务问题。经验值 1000~10000 行 ,具体看单行大小和磁盘性能。另外,批与批之间可以加一个 DO SLEEP(0.05); 给在线业务让路。
⚠️ 注意:DELETE 之后表并不会变小。 InnoDB 只是把被删的行所在的页标记为「可复用」,表空间文件(.ibd)不会收缩 。归档完 500 万行后 score.ibd 还是原来的大小。想真正回收空间必须重建表:

sql 复制代码
OPTIMIZE TABLE score;                       -- 内部实现就是重建表
-- 或者等价写法(8.0 推荐,支持 Online DDL,不长时间锁表)
ALTER TABLE score ENGINE = InnoDB;

两者都会重建整张表 ,需要额外的磁盘空间(约等于表大小)和较长的执行时间,必须在业务低峰期做,而且要确认磁盘剩余空间足够。


十一、存储函数:CREATE FUNCTION

11.1 定义与语法

课件 §5.7:

MySQL 存储函数是有返回值的存储过程,参数只能是 IN 类型,类似于内置函数。存储函数与存储过程的主要区别在于存储函数必须有返回值,而存储过程则不一定。

sql 复制代码
CREATE FUNCTION 存储函数名称 ([参数列表])
    RETURNS type
    [characteristic ...]
BEGIN
    -- SQL 语句
    RETURN ...;
END;

characteristic:
    [NOT] DETERMINISTIC      -- 表示相同的输入参数总是产生 [不同] 相同的结果
  | NO SQL                   -- 不包含 SQL 语句
  | READS SQL DATA           -- 包含读取数据的语句,如 SELECT
  | MODIFIES SQL DATA        -- 包含写入数据的语句,如 UPDATE、DELETE
  | CONTAINS SQL             -- 包含 SQL 语句(默认值,不读不写)

-- 使用存储函数
SELECT 存储函数名称 ([参数列表]);

RETURNSRETURN 的区别(面试常考):

RETURNS type RETURN expr
位置 函数签名里,声明返回类型 函数体里,返回语句
数量 有且只有一个,必须写 可以有多条(配合 IF 分支提前返回)
返回值个数 --- 只能返回一个标量值,不能返回结果集、不能返回多列
漏写的后果 不写 RETURNS → 语法错误 没有任何 RETURN 执行到 → 返回 NULL,且只产生一个警告,不报错

⚠️ 注意:函数没有 RETURN 语句时不会报错,只会返回 NULL。 这是「静默失败」的又一个案例。写函数时必须保证所有分支路径都有 RETURN ,或者在函数末尾放一个兜底 RETURN

11.2 ERROR 1418:log_bin_trust_function_creators

课件 §5.7.1:

在 MySQL 8.0 版本中,如果 binlog 是开启的,那么在定义存储函数时,需要指定 characteristic 特性,否则会报错。

课件的练习原样重现了这个错误(完整代码见 11.4)------不写 characteristic 直接创建 fun1,得到:

text 复制代码
ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA
in its declaration and binary logging is enabled (you *might* want to use the less safe
log_bin_trust_function_creators variable)

根因分析(这是本章最需要理解的地方):

text 复制代码
① binlog 开启,且 binlog_format = STATEMENT
        ↓
② binlog 里记录的是「SQL 语句原文」,而不是「每一行的变更结果」
        ↓
③ 主库执行  SELECT fun1(RAND() * 100)  → 假设算出 42,写进表里
   binlog 记录的却是  SELECT fun1(RAND() * 100)
        ↓
④ 从库回放这条语句,RAND() 重新求值 → 算出 77
        ↓
⑤ ★ 主从数据不一致,而且永远不会自动修复

非确定性函数的三种典型来源:

来源 例子
内置非确定函数 NOW() / CURDATE() / RAND() / UUID() / SYSDATE() / LAST_INSERT_ID()
读取会变化的表 SELECT balance FROM account WHERE id = 1------两次调用之间余额可能变了
依赖会话状态 @@session.xxx、用户变量 @var

所以 MySQL 的策略是:要么你声明这个函数的特征(等于你向 MySQL 承诺它的行为),要么你设置 log_bin_trust_function_creators = 1(等于你向 MySQL 担保所有函数都安全)。

sql 复制代码
-- 查看当前设置
SHOW VARIABLES LIKE 'log_bin_trust_function_creators';   -- 默认 OFF
SHOW VARIABLES LIKE 'binlog_format';                     -- MySQL 8.0 默认 ROW

-- 全局打开「信任函数创建者」(需要 SUPER / SYSTEM_VARIABLES_ADMIN)
SET GLOBAL log_bin_trust_function_creators = 1;

⚠️ 注意:binlog_format = ROW 时,这个限制实际上不会造成主从不一致 (ROW 格式记录的是行的前后镜像,不重放函数)。但 MySQL 仍然会做这个检查 ------因为函数创建时无法保证将来 binlog_format 不被改回 STATEMENT。正确的做法是老实声明 characteristic,而不是打开 log_bin_trust_function_creators 后者等于把安全网撤掉,一旦有人把 binlog_format 改回 STATEMENT 就会静默产生主从不一致。binlog 三种格式的详细对比见第 13 篇。

characteristic 怎么选:

你的函数干了什么 该声明什么
纯计算,同样输入永远同样输出(如 fun1 累加、金额转大写) DETERMINISTIC
读了表(如「根据学号查总分」) READS SQL DATA (通常再加 NOT DETERMINISTIC
写了表 MODIFIES SQL DATA
不含任何 SQL(纯 SET / IF / WHILE NO SQL
含 SQL 但既不读也不写数据 CONTAINS SQL(默认)

💡 实战建议:「根据学号返回总分」这类函数一定要声明 NOT DETERMINISTIC + READS SQL DATA 很多人图省事写 DETERMINISTIC------但它读的是表,表数据会变,同样的学号在补考之后总分就不一样了 。写 DETERMINISTIC 是在给 MySQL 一个假承诺,优化器可能据此做缓存/常量折叠,产生错误结果。

11.3 存储函数的禁区(和存储过程的差异总表)

能力 存储过程 存储函数
返回值 可有可无(通过 OUT 参数或结果集) 必须有,且只能一个标量
参数方向 IN / OUT / INOUT 只能是 IN,且不能写 IN 关键字
参数写法 IN p_id INT p_id INT(写 IN p_id INT 是语法错误)
返回结果集(裸 SELECT ✅ 可以 禁止 ,报 ERROR 1415: Not allowed to return a result set from a function
SELECT ... INTO 变量 ✅(这是函数里读表的唯一方式)
CALL 别的存储过程 禁止(保守结论,需实测验证版本)
显式/隐式 COMMIT / ROLLBACK 禁止(SQL 标准不要求支持,MySQL 明确不允许)
DDL(CREATE/ALTER/DROP/TRUNCATE ⚠️ 语法允许但会隐式提交 禁止
动态 SQL(PREPARE/EXECUTE/DEALLOCATE PREPARE ✅(5.7 起允许) 禁止
LOCK TABLES / UNLOCK TABLES ❌ 禁止 ❌ 禁止
递归调用自己 ✅ 允许,受 max_sp_recursion_depth 限制(默认 0 = 禁止) 永远禁止
能在 SQL 表达式里被调用 ❌(只能 CALL ✅(SELECT f(x)WHERE f(x) > 0ORDER BY f(x)
特征子句强制要求 可选 binlog 开启时必填(ERROR 1418)

为什么函数不能有 COMMIT / ROLLBACK 因为函数是嵌在别人的 SQL 语句里执行的

sql 复制代码
UPDATE account SET balance = balance - f_calc_fee(id) WHERE id = 1;

如果 f_calc_fee 里面 COMMIT 了,那这条 UPDATE在执行到一半时被提交 ------调用方的事务边界被彻底破坏,而且调用方完全无从察觉。函数必须是「事务透明」的,这是设计上的必然。

11.4 练习一:1 累加到 n(课件 §5.7.2)

sql 复制代码
DELIMITER //

-- ❌ 第一次尝试:不写 characteristic
CREATE FUNCTION fun1(n INT) RETURNS INT
BEGIN
    DECLARE total INT DEFAULT 0;
    WHILE n > 0 DO
        SET total := total + n;
        SET n := n - 1;
    END WHILE;
    RETURN total;
END //

DELIMITER ;
text 复制代码
ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA
in its declaration and binary logging is enabled
sql 复制代码
DELIMITER //

-- ✅ 第二次:指定 characteristic
CREATE FUNCTION fun1(n INT)
    RETURNS INT
    DETERMINISTIC                    -- ← 纯计算,同输入同输出,声明 DETERMINISTIC 是准确的
    NO SQL                           -- ← 函数体里没有任何读写表的 SQL
BEGIN
    DECLARE total INT DEFAULT 0;
    WHILE n > 0 DO
        SET total := total + n;
        SET n := n - 1;
    END WHILE;
    RETURN total;                    -- ← 返回语句
END //

DELIMITER ;

-- 调用存储函数
SELECT fun1(100);
text 复制代码
+------------+
| fun1(100)  |
+------------+
|       5050 |
+------------+
代码段 用了什么知识点 不这么写会怎样
(n INT) 没有 IN 关键字 11.3:函数参数默认全是 IN,且不能写 IN (IN n INT) 直接 ERROR 1064 语法错误
RETURNS INT 声明返回类型 漏写 → 语法错误。注意是 RETURNS(带 S),不是 RETURN
DETERMINISTIC NO SQL 11.2:binlog 开启时必填 不写报 ERROR 1418
RETURN total; 返回语句,只能返回一个值 漏写 → 函数返回 NULL,只有一个警告,静默失败
SELECT fun1(100); 函数在 SQL 表达式里被调用 存储过程做不到这一点,只能 CALL

11.5 练习二:根据学号返回总分

sql 复制代码
DELIMITER //

CREATE FUNCTION f_total_score(p_sno VARCHAR(10))
    RETURNS DECIMAL(6,2)
    NOT DETERMINISTIC                -- ← 读表,数据会变,不能声明 DETERMINISTIC
    READS SQL DATA                   -- ← 包含 SELECT
    SQL SECURITY INVOKER             -- ← 以调用者权限执行,见 11.7
    COMMENT '根据学号返回该生所有课程总分'
BEGIN
    DECLARE v_total DECIMAL(6,2) DEFAULT 0.00;

    -- ★ 只能用 SELECT ... INTO,不能裸 SELECT(会报 ERROR 1415)
    -- ★ 用聚合函数保证永远返回一行,规避 NOT FOUND
    SELECT IFNULL(SUM(grade), 0) INTO v_total
    FROM score
    WHERE sno = p_sno;

    RETURN v_total;
END //

DELIMITER ;

SELECT f_total_score('100001') AS 唐三藏总分;
SELECT sno, name, f_total_score(sno) AS total FROM student;

注意最后那句 SELECT sno, name, f_total_score(sno) FROM student;------这是存储函数最典型也最危险的用法。 它对 student 表的每一行 都调用一次函数,函数内部又对 score 表做一次 SUM。8 个学生 = 8 次子查询。500 万个学生 = 500 万次子查询

这就是「在 SELECT 列表里调用函数导致性能雪崩」的原理。 等价的高效写法是用 JOIN + GROUP BY 一次算完:

sql 复制代码
SELECT s.sno, s.name, IFNULL(SUM(sc.grade), 0) AS total
FROM student s
LEFT JOIN score sc ON sc.sno = s.sno
GROUP BY s.sno, s.name;

11.6 在 WHERE 里调用函数:索引直接失效

比 11.5 更严重的是WHERE 里调用函数

sql 复制代码
-- ❌ 索引失效,全表扫描
SELECT * FROM score WHERE f_total_score(sno) > 200;

-- ❌ 同样失效:对列做函数运算
SELECT * FROM student WHERE YEAR(enroll_date) = 2000;
SELECT * FROM student WHERE LEFT(sno, 3) = '100';

原理: B+ 树索引是按列的原始值排序 的。YEAR(enroll_date) 是对列做了一次运算,运算结果在索引里根本不存在 ,而且运算破坏了原有的有序性------优化器无法用「有序」这个前提做范围查找,只能退化成全表扫描 + 逐行计算函数

sql 复制代码
EXPLAIN SELECT * FROM student WHERE YEAR(enroll_date) = 2000;
text 复制代码
+----+-------------+---------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table   | type | possible_keys | key  | key_len | ref  | rows | Extra       |
+----+-------------+---------+------+---------------+------+---------+------+------+-------------+
|  1 | SIMPLE      | student | ALL  | NULL          | NULL | NULL    | NULL |    8 | Using where |
+----+-------------+---------+------+---------------+------+---------+------+------+-------------+

type = ALL + key = NULL------索引一个都没用上

改写方式:把函数从列上移到值上,保持列的「裸奔」状态。

sql 复制代码
-- ✅ 走 idx_enroll_date 索引
SELECT * FROM student
WHERE enroll_date >= '2000-01-01' AND enroll_date < '2001-01-01';

-- ✅ 8.0 可以用函数索引(把函数结果也建成索引)
ALTER TABLE student ADD INDEX idx_enroll_year ( (YEAR(enroll_date)) );

索引失效的完整清单(隐式类型转换、LIKE '%x'OR!=、最左前缀断裂等)详见第 7 篇《索引底层原理与 EXPLAIN 十二字段》。

11.7 DEFINER 与 SQL SECURITY:一个真实的提权漏洞

每个存储过程和函数都有一个 DEFINER(创建者)和一个 SQL SECURITY 属性:

sql 复制代码
SHOW CREATE FUNCTION f_total_score\G
text 复制代码
Create Function: CREATE DEFINER=`root`@`localhost` FUNCTION `f_total_score`(...)
SQL SECURITY:    INVOKER
SQL SECURITY 执行时用的权限 默认值
DEFINER 创建者的权限 这是默认值
INVOKER 调用者自己的权限 需要显式声明

默认 DEFINER 意味着一条完整的提权链:

text 复制代码
① DBA 用 root 账号创建了 p_delete_student()
   → DEFINER = `root`@`localhost`,SQL SECURITY = DEFINER(默认)
        ↓
② DBA 只给应用账号 app_user 授了这一个过程的 EXECUTE 权限
   GRANT EXECUTE ON PROCEDURE youju_demo.p_delete_student TO 'app_user'@'%';
        ↓
③ 但过程体里写的是  DELETE FROM student WHERE id = p_id;
   而且没有任何参数校验
        ↓
④ app_user 自己没有任何表权限,但它 CALL p_delete_student(任意值) 时,
   ★ 是以 root 的身份在执行 ★
        ↓
⑤ 如果过程体里还有别的语句(比如误写了一个无条件 DELETE,
   或者过程体被人改过),app_user 就能间接操作 root 能碰的所有数据

这就是 SQL SECURITY DEFINER 的隐患:它让「EXECUTE 权限」变成了「创建者的全部相关权限」的代理。 存储过程越复杂、创建者权限越高,这个代理面就越大。

防御措施:

sql 复制代码
-- ① 不要用 root 创建业务存储过程,用专门的低权限账号
CREATE DEFINER = 'sp_owner'@'localhost' PROCEDURE p_xxx(...)

-- ② 明确声明 SQL SECURITY INVOKER,让调用者用自己的权限
CREATE PROCEDURE p_xxx(...) SQL SECURITY INVOKER BEGIN ... END;

-- ③ 如果必须用 DEFINER,过程体内要做严格的参数校验和白名单
IF p_id IS NULL OR p_id <= 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '非法 id';
END IF;
跨环境迁移时 DEFINER 会直接报错

mysqldump 导出的存储过程 SQL 长这样(注意那个版本条件注释):

sql 复制代码
/*!50003 DROP PROCEDURE IF EXISTS `p12` */;
/*!50017 DEFINER=`root`@`localhost`*/
/*!50003 CREATE PROCEDURE `p12`(IN class_id INT) BEGIN ... END */;

/*!50017 ... */版本条件注释 :MySQL 版本 >= 5.0.17 时会执行注释里的内容,其他数据库(或者更老的 MySQL)会当普通注释忽略。

导入到一个没有 root@localhost 账号 的环境(比如云 RDS,root 被禁用,只有 admin@%)时:

text 复制代码
ERROR 1449 (HY000): The user specified as a definer ('root'@'localhost') does not exist

批量清洗命令(迁移前必做):

bash 复制代码
# ① 导出
mysqldump -h src_host -u root -p \
    --routines --triggers --events --no-data --no-create-info \
    youju_demo > routines.sql

# ② 去掉所有 DEFINER 子句(导入时会用当前登录账号作为 DEFINER)
sed -i 's/DEFINER=`[^`]*`@`[^`]*`//g' routines.sql

# ③ 或者统一替换成目标环境的账号
sed -i 's/DEFINER=`[^`]*`@`[^`]*`/DEFINER=`app_rw`@`%`/g' routines.sql

# ④ 导入
mysql -h dst_host -u admin -p youju_demo < routines.sql

💡 实战建议:把 ② 这一步写进部署脚本,永久化。 每次跨环境(开发 → 测试 → 生产)迁移都会遇到这个问题,手工改迟早漏一次。漏了的后果是导入中途失败,而存储过程之间有依赖时,失败会造成「一半创建成功一半没有」的状态,比全部失败更难排查。

11.8 练习三:金额数字转中文大写

财务系统的经典需求------1234.56壹仟贰佰叁拾肆元伍角陆分这个逻辑无法用一条 SQL 表达(需要逐位拆解 + 条件拼接),是存储函数真正合适的场景。

sql 复制代码
DELIMITER //

CREATE FUNCTION f_money_to_chinese(p_amount DECIMAL(15,2))
    RETURNS VARCHAR(200)
    DETERMINISTIC                    -- 纯计算,同输入同输出
    NO SQL                           -- 函数体不读写任何表
    COMMENT '金额数字转中文大写'
BEGIN
    DECLARE v_digits   VARCHAR(30) DEFAULT '零壹贰叁肆伍陆柒捌玖';
    DECLARE v_units    VARCHAR(30) DEFAULT '元拾佰仟万拾佰仟亿';
    DECLARE v_int_part BIGINT       DEFAULT 0;
    DECLARE v_dec_part INT          DEFAULT 0;
    DECLARE v_str      VARCHAR(30)  DEFAULT '';
    DECLARE v_result   VARCHAR(200) DEFAULT '';
    DECLARE v_len      INT          DEFAULT 0;
    DECLARE v_i        INT          DEFAULT 1;
    DECLARE v_ch       VARCHAR(2)   DEFAULT '';
    DECLARE v_digit    INT          DEFAULT 0;
    DECLARE v_zero_flag BOOL        DEFAULT FALSE;

    -- ① 参数校验:用 SIGNAL 主动抛错
    IF p_amount IS NULL OR p_amount < 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '金额不能为负数或 NULL';
    END IF;
    IF p_amount >= 1000000000000 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '金额超出支持范围(万亿)';
    END IF;

    -- ② 拆分整数部分和小数部分(用 ROUND 避免浮点误差)
    SET v_int_part := FLOOR(p_amount);
    SET v_dec_part := ROUND((p_amount - v_int_part) * 100);

    -- ③ 整数部分:从高位到低位逐位翻译
    SET v_str := CAST(v_int_part AS CHAR);
    SET v_len := CHAR_LENGTH(v_str);

    IF v_int_part = 0 THEN
        SET v_result := '零元';
    ELSE
        WHILE v_i <= v_len DO
            SET v_digit := CAST(SUBSTRING(v_str, v_i, 1) AS UNSIGNED);
            SET v_ch    := SUBSTRING(v_digits, v_digit + 1, 1);

            IF v_digit = 0 THEN
                -- 连续 0 只保留一个「零」,且末尾的 0 不读
                SET v_zero_flag := TRUE;
            ELSE
                IF v_zero_flag THEN
                    SET v_result  := CONCAT(v_result, '零');
                    SET v_zero_flag := FALSE;
                END IF;
                SET v_result := CONCAT(v_result, v_ch,
                                       SUBSTRING(v_units, v_len - v_i + 1, 1));
            END IF;
            SET v_i := v_i + 1;
        END WHILE;
    END IF;

    -- ④ 小数部分:角、分
    IF v_dec_part = 0 THEN
        SET v_result := CONCAT(v_result, '整');
    ELSE
        IF FLOOR(v_dec_part / 10) > 0 THEN
            SET v_result := CONCAT(v_result,
                SUBSTRING(v_digits, FLOOR(v_dec_part / 10) + 1, 1), '角');
        ELSEIF v_int_part > 0 THEN
            SET v_result := CONCAT(v_result, '零');     -- 1005.06 → ...元零陆分
        END IF;

        IF v_dec_part % 10 > 0 THEN
            SET v_result := CONCAT(v_result,
                SUBSTRING(v_digits, v_dec_part % 10 + 1, 1), '分');
        END IF;
    END IF;

    RETURN v_result;
END //

DELIMITER ;
sql 复制代码
SELECT f_money_to_chinese(1234.56) AS r1,
       f_money_to_chinese(1005.06) AS r2,
       f_money_to_chinese(0)       AS r3,
       f_money_to_chinese(100000000.00) AS r4;
text 复制代码
+------------------------+------------------+--------+------------------------------+
| r1                     | r2               | r3     | r4                           |
+------------------------+------------------+--------+------------------------------+
| 壹仟贰佰叁拾肆元伍角陆分 | 壹仟零伍元零陆分 | 零元整 | 壹亿元整                     |
+------------------------+------------------+--------+------------------------------+
代码段 用了什么知识点 不这么写会怎样
p_amount DECIMAL(15,2) 金额必须用 DECIMAL,绝不用 FLOAT/DOUBLE DOUBLE0.1 + 0.2 != 0.3,财务系统会算错钱(第 1 篇 11.3 节讲过)
DETERMINISTIC NO SQL 11.2:纯计算函数的正确声明 漏写报 ERROR 1418
SUBSTRING(v_digits, v_digit + 1, 1) MySQL 字符串下标从 1 开始,不是 0 v_digit 会取到前一个字符,全部翻译错
WHILE v_i <= v_len DO ... SET v_i := v_i + 1; 第七章 WHILE 漏了 SET v_i := v_i + 1 就是死循环 ,会话卡死只能 KILL
IF v_digit = 0 THEN SET v_zero_flag := TRUE; 中文大写的「连续零只读一个」规则 不处理的话 1005 会变成「壹仟零零伍元」,不合规
CAST(SUBSTRING(...) AS UNSIGNED) 字符转数字 MySQL 会隐式转换,但显式 CAST 更安全 ,避免 sql_mode 严格模式下报警告
SIGNAL SQLSTATE '45000' 10.7:参数校验 传负数进来会得到一串乱码大写金额,财务单据出错

💡 实战建议:这类「纯计算、不碰表、逻辑无法用一条 SQL 表达」的需求,才是存储函数的最佳战场。 反过来,任何「读表再算」的需求(11.5 的总分函数),都应该优先考虑 JOIN + GROUP BY。判断标准很简单:函数体里有没有 FROM?有 → 大概率应该改写成 SQL;没有 → 用函数很合适。


十二、本篇小结与面试速答

这一篇我们从「存储过程到底是什么」出发,一路走到了游标的临时表物化、条件处理程序的 SQLSTATE / errno 双轨制、以及存储函数与 binlog 的博弈。

如果只能记住五句话,就是这五句:

  1. DELIMITER 是客户端命令,服务端不认识它------它唯一的作用是让过程体能作为一整条报文发出去。
  2. MySQL 的游标是「把结果集物化到临时表再顺序读」,所以大结果集用游标会撑爆临时空间。能用集合操作就绝不用游标。
  3. DECLARE 区是三段式:变量/条件 → 游标 → handler,顺序错了创建都过不去。
  4. handler 的语句体永远不能为空 ------吞异常会让调用方看到 Query OK 而数据已经不一致,这和 Java 里的空 catch 块是同一个反模式。
  5. 存储过程体内绝对不能写 DDL ------CREATE/DROP/TRUNCATE 会隐式提交事务,让你精心写的 ROLLBACK 变成一句空话。

12.1 知识地图

text 复制代码
存储过程与函数
├── 是什么 / 为什么
│   ├── 定义:注册在数据库里的一段带名字的 SQL 程序,CALL 触发
│   ├── 「编译」的真相:只存解析树 sp_instr,无字节码、无执行计划缓存
│   │                 sp_cache_size(每线程,默认 256K)只缓存指令树
│   ├── 真收益:减少网络往返 + 减少 SQL 文本传输(不是「预编译」)
│   ├── 优点:性能 / 代码重用 / 权限隔离 / 事务管理 / 降低耦合
│   └── 缺点:难移植 / 难调试 / 占数据库 CPU / 无法水平扩展 / 无版本管理
│             └── 大厂禁用真凶:把计算压到了最难扩容的那一层
├── 语法
│   ├── DELIMITER ← 纯客户端行为,服务端不认识
│   ├── CREATE PROCEDURE  sp_name ([IN|OUT|INOUT p type]) [characteristic] BEGIN...END
│   ├── CALL sp_name(...)          ← 过程体所有结果集依次返回
│   ├── SHOW CREATE PROCEDURE / SHOW PROCEDURE STATUS / information_schema.ROUTINES
│   └── DROP PROCEDURE [IF EXISTS] ← 授权连带失效;无依赖检查;无 ALTER 改逻辑
├── 变量三层体系
│   ├── 系统变量 @@global.xxx / @@session.xxx
│   │   ├── GLOBAL 改了不影响已有连接,只影响新连接;重启失效(除非 SET PERSIST)
│   │   └── 不写前缀默认 SESSION
│   ├── 用户变量 @var ← 会话级 / 免声明 / 动态类型 / 未初始化为 NULL
│   └── 局部变量 DECLARE v type DEFAULT x ← 块级 / 必须声明 / 静态类型 / 无 DEFAULT 则 NULL
│       └── := 永远是赋值;= 只在 SET / UPDATE SET 里是赋值,其他地方是比较
├── 流程控制(★ 全是「语句」,只能活在存储程序里)
│   ├── IF ... THEN / ELSEIF / ELSE ... END IF;      ← IF() 是函数,别混
│   ├── CASE val WHEN ... END CASE;   (语法一:等值)
│   ├── CASE WHEN cond ... END CASE;  (语法二:搜索)← CASE...END 是表达式
│   │                                  无 ELSE 且不匹配 → ERROR 1321
│   ├── WHILE cond DO ... END WHILE;     先判断后执行,可能 0 次
│   ├── REPEAT ... UNTIL cond END REPEAT; 先执行后判断,至少 1 次,条件语义相反
│   └── [label:] LOOP ... END LOOP;  + LEAVE(break) / ITERATE(continue)
├── 参数
│   ├── IN     值传递,改内部副本外面看不到
│   ├── OUT    入口被强制置 NULL,出口写回;CALL 时必须传用户变量
│   └── INOUT  双向;唯一能「带值进去再带值出来」的
├── 游标(服务端、只读、不可滚动、asensitive)
│   ├── DECLARE c CURSOR FOR select_stmt;  / OPEN / FETCH INTO / CLOSE
│   ├── 底层:物化到内部临时表
│   │   ├── 5.7-:MEMORY 引擎,超 tmp_table_size/max_heap_table_size → 磁盘 MyISAM
│   │   └── 8.0 :TempTable 引擎,超 temptable_max_ram → mmap → InnoDB 磁盘临时表
│   ├── 必须配 DECLARE CONTINUE HANDLER FOR NOT FOUND SET is_done := TRUE
│   ├── FETCH 失败时目标变量保持上一次的值 → 必须「先 FETCH 再判断 is_done」
│   └── 军规:能用集合操作就绝不用游标
├── 条件处理程序(存储过程的 try-catch)
│   ├── DECLARE cond_name CONDITION FOR {errno | SQLSTATE 'xxxxx'}
│   ├── DECLARE {CONTINUE|EXIT|UNDO} HANDLER FOR cond_value stmt
│   │   ├── CONTINUE 继续下一条 / EXIT 退出当前 BEGIN...END 块
│   │   └── UNDO ★ MySQL 不支持(文档写了但没实现)
│   ├── 双轨制:SQLSTATE('02000'/'23000'/'45000') ↔ errno(1329/1062/1048/1452)
│   │   └── 匹配优先级:具体 errno > 具体 SQLSTATE > NOT FOUND/SQLWARNING > SQLEXCEPTION
│   ├── SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT=..., MYSQL_ERRNO=...
│   ├── RESIGNAL(只能在 handler 内,原样重抛保留上下文)
│   ├── GET DIAGNOSTICS CONDITION 1 v1=MYSQL_ERRNO, v2=MESSAGE_TEXT
│   └── 反模式:空 handler 吞异常 → 调用方看到 Query OK,数据已不一致
├── 事务陷阱
│   ├── DDL / TRUNCATE / GRANT / OPTIMIZE / START TRANSACTION 会隐式提交
│   ├── 大事务四害:undo 暴涨 / 锁持有过久 / 从库单线程回放延迟 / 回滚比执行还慢
│   └── 对策:分批 + 每批提交 + 幂等设计(用一点 RPO 换可用性)
│       └── DELETE 不缩表:InnoDB 只标记页可复用,需 OPTIMIZE TABLE / ALTER...ENGINE=InnoDB
├── 存储函数
│   ├── CREATE FUNCTION f(p type) RETURNS type [characteristic] BEGIN ... RETURN expr; END
│   ├── RETURNS 声明类型(必写)vs RETURN 返回语句(只返回一个标量)
│   ├── 参数只能是 IN 且不能写 IN 关键字;不能有 OUT/INOUT
│   ├── 禁区:裸 SELECT 结果集(1415) / CALL / COMMIT-ROLLBACK / DDL / PREPARE-EXECUTE
│   ├── 不能递归(max_sp_recursion_depth 只对过程生效)→ 用 8.0 WITH RECURSIVE
│   ├── ERROR 1418 + log_bin_trust_function_creators
│   │   └── 根因:非确定函数在 STATEMENT binlog 下主从重放结果不一致
│   ├── WHERE 里调函数 → 破坏 B+ 树有序性 → 索引失效全表扫描
│   └── DEFINER / SQL SECURITY DEFINER|INVOKER → 提权漏洞 + 跨环境导入 ERROR 1449
└── 调试四招(没有断点调试器)
    ├── SELECT 打印中间变量 + \G 竖排
    ├── 写 debug_log 表(生产唯一可靠手段)
    ├── SIGNAL 抛带上下文的错
    └── 用户变量 @debug_xxx 把值带出过程外

12.2 面试速答卡(课件面试题 1~11)

Q1:存储过程的作用是什么?

30 秒版 :存储过程是一组为完成特定功能的 SQL 语句集,编译后存在数据库里,用 CALL 名字(参数) 触发在服务端执行。它的核心作用有五个:预编译后重复调用减少解析开销、把多步操作合并成一次网络往返、把业务逻辑集中在数据库便于维护、通过 GRANT EXECUTE 做权限隔离、以及在服务端实现复杂事务逻辑。

加分点 :要指出 MySQL 的「预编译」是打折扣的------它只把过程体解析成 sp_instr 指令树存进数据字典,没有字节码也没有执行计划缓存 ,过程体里每条 SQL 每次执行仍要重新解析优化。所以 MySQL 存储过程的性能收益主要来自减少网络往返,不是来自预编译 ,这一点和 Oracle / SQL Server 不同。同时要能说出大厂禁用它的真实原因:它把 CPU 密集逻辑压到了整个架构里最难水平扩展的那一层

Q2:如何创建一个存储过程?

30 秒版 :先 DELIMITER // 换掉客户端的语句分隔符,然后 CREATE PROCEDURE 名字([IN|OUT|INOUT 参数名 类型]) BEGIN ... END //,最后 DELIMITER ; 换回来。调用用 CALL 名字(参数),查看用 SHOW CREATE PROCEDURE 名字,删除用 DROP PROCEDURE [IF EXISTS] 名字

加分点 :一定要能解释为什么要 DELIMITER ------DELIMITER纯客户端命令,服务端语法解析器里没有这个 token (用 JDBC 执行会直接报语法错误)。过程体内部全是 ;,不换分隔符的话客户端会在第一个 ; 处就把不完整的 CREATE PROCEDURE 发出去,导致四连报错。还要补一句:JDBC / MyBatis 里创建存储过程绝对不能写 DELIMITER,驱动直接发完整报文。

Q3:MySQL 中的变量都有哪几种?

30 秒版 :三种。系统变量 是 MySQL 服务器的配置变量,分 GLOBAL(影响所有新连接)和 SESSION(只影响当前连接),写法是 @@global.xxx / @@session.xxx用户自定义变量 以单个 @ 开头,会话级、不用声明、动态类型、未初始化时为 NULL;局部变量DECLARE 声明,只在存储过程/函数/触发器的 BEGIN...END 块内有效,静态类型。

加分点 :说清三者的生命周期差异 ------GLOBAL 活到进程结束、SESSION 和用户变量活到连接断开、局部变量活到一次 CALL 结束。再补一个高频坑:改 GLOBAL 对已存在的连接无效 ,只影响之后新建的连接,所以生产调参数要 SET GLOBAL + SET SESSION 两处都改,或者用 8.0 的 SET PERSIST(写进 mysqld-auto.cnf,重启仍生效,优先级高于 my.cnf)。

Q4:如何定义一个变量?

30 秒版 :用户变量用 SET @var := 值;SELECT 列名 INTO @var FROM 表 WHERE ...,不需要声明,赋值即创建。局部变量用 DECLARE 变量名 类型 [DEFAULT 默认值];,必须写在 BEGIN...END 块的最前面,赋值用 SET v := 值;SELECT ... INTO v。系统变量用 SET [GLOBAL|SESSION] 变量名 = 值;

加分点推荐用 := 而不是 = 赋值 ,因为在 SQL 表达式里 = 是比较运算符,:= 才是赋值------SELECT @c = 5; 是在比较,返回 NULL 且不给 @c 赋值,静默失败不报错 。再补:局部变量不写 DEFAULT 时初值是 NULL 不是 0NULL + 100 还是 NULL,累加类逻辑全部报废,这是存储过程第一大 bug 来源。

Q5:MySQL 中使用变量是否需要提前声明?

30 秒版分情况 。用户自定义变量 @var 不需要声明 ,第一次赋值时自动创建,类型也是动态的。局部变量必须用 DECLARE 提前声明 ,而且必须写在 BEGIN...END 块的最前面,否则报 ERROR 1064。系统变量是 MySQL 内置的,本来就不需要声明。

加分点 :把局部变量的声明顺序讲全------三段式:① 变量和条件声明 → ② 游标声明 → ③ handler 声明 → ④ 可执行语句 ,顺序错了是编译期错误 ,创建时就过不去。另外要点出命名空间冲突 这个大坑:MySQL 里参数名、局部变量名、列名共享命名空间,且列名优先级更高WHERE class_id = class_id 会变成自己和自己比较、恒为真、静默返回全表。规范是参数加 p_ 前缀、变量加 v_ 前缀。

Q6:MySQL 中的参数分为哪几种?

30 秒版 :三种。IN 是输入类型,调用时要传入的值,也是默认类型OUT 是输出类型,可以作为存储过程的返回值;INOUT 既可以输入也可以输出。OUT / INOUT 参数在 CALL只能传用户变量 ,传字面量报 ERROR 1414

加分点 :讲传递语义------IN值传递 ,过程内改的是副本,外面看不到;OUT 参数进入过程时会被强制置为 NULL (调用方传的初值被丢弃),所以想「带值进去再带值出来」必须用 INOUT,用 OUT 会得到 NULL + 10 = NULL 的静默失败。再补一句:存储函数的参数只能是 IN,而且不能写 IN 关键字,也不能有 OUT/INOUT。

Q7:用过游标吗?游标的作用是什么?

30 秒版 :游标是一种数据库对象,允许在存储过程和函数中对查询到的结果集逐行检索 。四步用法:DECLARE 游标名 CURSOR FOR 查询语句 声明、OPEN 打开、FETCH 游标名 INTO 变量列表 取一行、CLOSE 关闭。必须配合 DECLARE CONTINUE HANDLER FOR NOT FOUND SET is_done := TRUE,然后用 LOOP + IF is_done THEN LEAVE 遍历。MySQL 的游标是只读的,不能更新。

加分点 :这题的加分全在底层。① 官方定义的三个特性------只读(不能 WHERE CURRENT OF)、不可滚动(只能单向,没有 FETCH PRIOR)、asensitive(可能看到也可能看不到遍历期间的底表变化) ;② MySQL 游标的实现是把结果集物化到内部临时表 ,5.7 及之前受 tmp_table_size / max_heap_table_size 约束、超出转磁盘 MyISAM,8.0 改用 TempTable 引擎、超出 temptable_max_ram 转 mmap 再转 InnoDB 磁盘临时表------所以大结果集用游标会撑爆临时空间甚至报 ERROR 1114: The table is full ;③ FETCH 到末尾时 MySQL 不是报错而是设置 NOT FOUND 条件 ,没有 handler 就无法知道何时停止,会死循环;④ FETCH 失败时目标变量保持上一次的值 ,所以必须「先 FETCH 再判断 is_done」,否则最后一行会被重复处理;⑤ 军规:能用集合操作(INSERT...SELECT / UPDATE...JOIN)就绝不用游标,实测差 10~100 倍。

Q8:了解条件处理程序吗?介绍一下如何使用。

30 秒版 :条件处理程序就是存储过程里的 try-catch。语法是 DECLARE handler_action HANDLER FOR condition_value statement,其中 handler_actionCONTINUE(处理完继续执行后续语句)和 EXIT(处理完退出当前 BEGIN...END 块);condition_value 可以是 MySQL 错误码(如 1062)、SQLSTATE '23000'、事先用 DECLARE ... CONDITION FOR 声明的条件名、或者 SQLWARNING / NOT FOUND / SQLEXCEPTION 三个类别简写。配套语句有 SIGNAL(主动抛错)、RESIGNAL(原样重抛)、GET DIAGNOSTICS(取错误详情)。

加分点 :① 双轨制 ------SQLSTATE 是 5 字符 ANSI 标准码('02000' NOT FOUND、'23000' 完整性约束违反、'45000' 用户自定义异常),MySQL errno 是数字码(1062 主键冲突、1048 列不能为 NULL、1452 外键失败),两套可以混用,匹配优先级是最具体的优先:具体 errno > 具体 SQLSTATE > 类别简写 ;② UNDO 在 MySQL 里不支持 ,官方文档语法里保留了但从未实现,需要回滚必须自己写 ROLLBACK;③ 最危险的反模式是空 handler 吞异常 ------DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN END 会让调用方看到 Query OK 而数据已经不一致,正确做法是走明确的备用业务路径并记日志,或者 ROLLBACK + RESIGNAL;④ 隐式提交陷阱 ------过程体里的 DDL / TRUNCATE / GRANT 会隐式提交当前事务,让你写的 ROLLBACK 变成空话。

Q9:存储函数与存储过程的区别是什么?

30 秒版 :存储函数是有返回值的存储过程 ,用 CREATE FUNCTION ... RETURNS type 定义,参数只能是 IN 类型 ,通过 SELECT f(x) 在 SQL 表达式里调用;存储过程用 CREATE PROCEDURE,参数有 IN/OUT/INOUT 三种,通过 CALL 调用,可以返回结果集也可以不返回。主要区别在于存储函数必须有返回值,而存储过程不一定。

加分点 :能列出完整的差异表才算答透------① RETURNS 声明类型(必写)vs RETURN 返回语句(只能返回一个标量,漏写会静默返回 NULL);② 函数参数不能写 IN 关键字,不能有 OUT/INOUT;③ 函数禁止返回结果集 (裸 SELECTERROR 1415),只能 SELECT ... INTO;④ 函数体内禁止 CALL、禁止显式/隐式 COMMIT/ROLLBACK、禁止 PREPARE/EXECUTE 动态 SQL、禁止 DDL------因为函数嵌在别人的 SQL 里执行,必须事务透明 ;⑤ 函数永远不能递归调用自己max_sp_recursion_depth 只对过程生效,递归查询要用 8.0 的 WITH RECURSIVE;⑥ binlog 开启时函数必须声明 characteristic ,否则 ERROR 1418

Q10:如何查看数据库中创建的存储过程?

30 秒版 :三种方式。① SELECT * FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '库名'; 查指定库的所有存储过程和函数;② SHOW CREATE PROCEDURE 存储过程名; 查看完整的创建语句(含源码);③ SHOW PROCEDURE STATUS LIKE 'p%'; 查看状态信息。函数对应的是 SHOW CREATE FUNCTIONSHOW FUNCTION STATUS

加分点 :① 补充 information_schema.ROUTINE_PRIVILEGES 可以查权限授予情况,performance_schema.events_statements_current 可以查当前正在执行的过程(排查卡死用);② 元数据的存储位置有版本变化 ------MySQL 5.7 及之前存在 mysql.proc 表(MyISAM),8.0 移除了这张表,统一放进 InnoDB 数据字典(物理文件 mysql.ibd,字典表 mysql.routines);③ SHOW CREATE PROCEDURE 的输出里会带上创建时的 sql_mode / character_set_client / collation_connection 快照 ,执行时用的是这个快照而不是调用者的会话设置,这是「测试环境好好的、生产环境报错」的一个隐蔽原因;④ 输出里的 DEFINER=root@localhost`` 在跨环境导入时会报 ERROR 1449,要用 sed 批量清洗。

Q11:什么是触发器?

30 秒版 :触发器是一个与表关联的数据库对象 ,在对表进行 INSERTUPDATEDELETE 操作时自动触发并执行定义时指定的 SQL 语句。它可以在操作之前(BEFORE)或之后(AFTER)执行,这称为触发时间。MySQL 支持 6 种触发器(3 种事件 × 2 个时机)。

加分点 :这题属于下一篇的范围,详见第 5 篇《触发器(上):语法精讲与审计日志实战》 。那里会讲:MySQL 只有行级触发器FOR EACH ROW 是强制子句,没有语句级触发器)、OLD / NEW 伪记录的用法、触发器体内的禁区、触发器和触发语句在同一个事务里、以及 MySQL 8.0.12 起一张表同一事件同一时机可以建多个触发器(用 FOLLOWS / PRECEDES 控制顺序)等底层机制。

12.3 一张速查表收尾

你想做的事 正确姿势 反面教材
逐行处理结果集 先想能不能一条 SQL 搞定 无脑上游标
遍历游标 LOOP + NOT FOUND handler + LEAVE WHILE TRUE DO
处理异常 记日志 + ROLLBACK + RESIGNAL 或设状态 OUT 参数 CONTINUE HANDLER 吞掉
事务保护 过程体内只写 DML 过程体内写 CREATE TABLE
处理百万行数据 按主键分批 + 每批 COMMIT 一个大事务搬到底
返回单个计算值 存储函数 + 正确的 characteristic OUT 参数硬凑
读表再算 JOIN + GROUP BY WHERE / SELECT 列表里调函数
迁移到别的环境 sed 清掉 DEFINER + 改用 SQL SECURITY INVOKER 直接导入 mysqldump 产物
调试 debug_log 表 + @debug_xxx 用户变量 SELECT 打印然后忘了删
命名 参数 p_ 前缀、变量 v_ 前缀 参数名和列名同名


下一篇:第 5 篇《触发器(上):语法精讲与审计日志实战》


本文参考了公开的 MySQL 进阶教学资料整理撰写,并在其基础上补充了 InnoDB 存储引擎、优化器、事务与锁等底层机制的分析。

相关推荐
念何架构之路1 小时前
beego ORM 源码(下):SQL 生成与方言
数据库·sql·beego
企业数字化笔记1 小时前
固定资产还有借用记录能报废吗?Java前置检查、停止折旧与SQL验收
java·开发语言·sql
bksczm2 小时前
MySQL基础篇之索引
数据库·sql·mysql
bksczm3 小时前
MySQL基础篇之事务
linux·数据库·sql·mysql
马立杰3 小时前
mysql中Illegal mix of collations for operation “UNION”错误的解决方法
数据库·sql·网络安全
bksczm3 小时前
MySQL基础篇之视图与用户管理
linux·数据库·sql·mysql
xcLeigh14 小时前
聊聊国产化替换:好用数据迁移工具KDMS怎么帮咱们搞定评估难
数据库·sql·数据迁移·kes·kdms
独泪了无痕17 小时前
SQL函数实战:GREATEST与LEAST的技巧
数据库·sql·mysql
Databend19 小时前
Databend 原生数据血缘:追溯指标来源,检查变更影响
大数据·数据库·sql