🦏 第13章 存储程序
📄 一句话概括

CREATE PROCEDURE 名字(参数) BEGIN … END, 用 CALL 名字(参数) 调。 它是「存在库里的、能带参数的一段 SQL」。 DELIMITER $$ 那几行是给客户端看的,不是给数据库看的—— 因为过程体里的分号会被客户端当成语句结束。 本沙箱没有存储过程。 等价物是宿主语言的函数: 参数就是 IN、返回值就是 OUT、executescript 就是过程体。

📝 本章和前面几章的关系

上一篇把四样东西过了一遍,这一篇细讲最常用的那样:存储过程。 它是四样里唯一「人能主动叫」的—— 触发器、事件都是自动跑的,函数只能嵌在 select 里。 所以要讲「怎么把一段逻辑封装起来反复调」,就得讲它。

一、MySQL 里长什么样

create procedure 名字(in 参数 类型, out 参数 类型) begin … end, 调用是 call 名字(值, @变量)。 外面那几行 delimiter $$ 和 delimiter ; 最容易让人懵。 它们不是 SQL 语句,是客户端命令 ——告诉客户端「接下来到 $$ 才算一条语句结束」。 因为过程体里本来就有分号,客户端一见到分号就以为语句完了、急着发出去, 于是还没到 end 就被截断。 这不是数据库的毛病,是「怎么判断一条语句结束」这个老问题。 在应用代码里反而不用 delimiter:整段字符串是一次性送给驱动的, 驱动知道「这是一个 create procedure」,会自己找到结尾。 只有命令行和 Navicat 这类交互式客户端才需要它。

二、三个方向的参数:IN、OUT、INOUT

in 是输入:调用时把值传进去,过程里改它不影响外面。 这是默认方向,不写就是 in。 out 是输出: 过程里给它赋值,调用完外面能读到。 用 @变量 接—— call add_student('张伟', 175.5, @new_id); select @new_id;。 inout 是两头: 传进去一个值,过程里改,改完再拿出来。 用得最少,但「传一个计数器进去、加一、拿出来」这种场景只能用它。 这三个方向是 MySQL 自己发明的,别的数据库叫法不一样(SQL Server 用 output, Oracle 用 in out)。 所以要跨库的代码,最好别依赖这些细节。

三、过程体里能写什么

能写普通的 select/insert/update/delete,也能写流程控制: if … then … else … end if、while … do … end while、case、loop。 能声明局部变量:declare 变量名 类型 default 初值;。 注意 declare 必须写在 begin 之后、其他语句之前, 写后面会报错——这一条和很多语言「变量随处声明」的习惯不一样。 还能写异常处理:declare … handler for sqlexception begin … end, 相当于 try … catch。 这是 MySQL 里唯一能「抓住错误继续跑」的地方, 普通 SQL 语句一错就整条回滚。 注意「不能做的事」:存储过程里不能建表、不能改表结构(动态 SQL 除外, 要走 prepare/execute)。 所以「用存储过程做数据库迁移」这个想法是不通的。

四、本沙箱没有,等价物是「宿主语言的函数」

create procedure 在这里报 syntax error, delimiter、declare、set @变量、call 也全是语法错。 一个都不认。 但「存储过程」这件事的本质很简单: 一段能带参数、能被反复调用的 SQL 逻辑。 这东西在 Python 里就是一个函数。 于是对应关系很直接:in 参数 = 函数的普通参数,out 参数 = 函数的返回值, begin … end 里的多条语句 = 函数体里的多条 execute。 call = 调那个函数。 差在哪? 差在「在哪跑」:存储过程在数据库进程里跑,一段逻辑一趟网络都不到; Python 函数里每 execute 一次就是一趟。 局域网里可以忽略,跨机房要算一算。

五、批量:一次送一整段

存储过程里常写几十条语句,这边的对应物是 executescript: 一次送一整段,中间的分号不会被当成结束。 这就是 Python 版的 delimiter—— execute 一次只认一条语句(送两条会报 You can only execute one statement at a time), executescript 才认一整段。 一个容易忽略的差别:executescript 会先隐式 commit 一次, 再执行整段。 所以它和事务配合时要小心,别指望把它塞进一个 begin 里当成原子操作。

六、查看、修改、删除(MySQL 侧)

查看:show procedure status 列所有过程,show create procedure 名字 看它的定义, information_schema.routines 是原始表。注意 show procedure status 需要 execute 权限, 没权限就一行都看不到——这常被误认为「过程不存在」。 修改: MySQL 没有 alter procedure 改过程体这条语句。 要改就得 drop 再 create。 和第 11.3 篇「视图没有 alter view」一模一样。 alter procedure 只能改「特性」—— 比如 sql security、注释,改不了逻辑。 删除:drop procedure if exists 名字;。 if exists 建议都带——迁移脚本要能重复跑。 本沙箱这边「查看」就是看代码文件、「修改」就是改函数、「删除」就是删函数。 连 if exists 都不用写,因为这些压根不在数据库里。 这也是「逻辑放应用层」的一个隐性好处:工具链是现成的。

七、动手 Demo:用 Python 函数当存储过程,再试四个不认的词

Demo 四段:写一个函数当「存储过程」并调用、用它插一行取回新 id、executescript 一次送四条语句、 最后逐条试 MySQL 那四个词。 第一段和第三段的对照是关键: execute 只认一条,executescript 认一整段——这就是 Python 这边对 delimiter 的回答。

import sqlite3
import _q46 as Q
c = Q.con()

# 一个「存储过程」:校验 + 插入 + 交出新的 id
def add_student(conn, name, height):
    cur = conn.cursor()
    cur.execute("INSERT INTO students (name, height) VALUES (?, ?)",
                (name, height))
    conn.commit()
    return cur.lastrowid

new_id = add_student(c, "张伟", 175.5)     # 相当于 CALL + OUT 参数
print("新 id =", new_id)
Q.show(c, "SELECT id, name, height FROM students")

# 一次送一整段,分号不会被当成结束
c.executescript("""
    CREATE TABLE a (x INT);
    CREATE TABLE b (y INT);
    INSERT INTO a VALUES (1), (2), (3);
    INSERT INTO b SELECT x * 10 FROM a;
""")
Q.show(c, "SELECT * FROM b")

# MySQL 过程语言里的这几个词,这里一个都不认
for s in ("DELIMITER $$", "DECLARE x INT", "SET @a = 1", "CALL p1()"):
    try:
        c.execute(s)
    except sqlite3.Error as e:
        print(s, "->", e)

实跑输出:

--------------------------------------------------------------------
第六十八课:存储过程——MySQL 里的那个 call
--------------------------------------------------------------------

  MySQL 里长什么样
  MySQL 原码(本沙箱不执行):
  | DELIMITER $$
  | CREATE PROCEDURE add_student(
  |     IN  p_name   VARCHAR(20),
  |     IN  p_height DECIMAL(4,1),
  |     OUT p_id     INT
  | )
  | BEGIN
  |     INSERT INTO students (name, height) VALUES (p_name, p_height);
  |     SET p_id = LAST_INSERT_ID();
  | END $$
  | DELIMITER ;
  **调用:`CALL add_student('张伟', 175.5, @new_id); SELECT @new_id;`**
  **`delimiter $$` 这几行是给客户端看的,不是给数据库看的。** **过程体里本来就有分号**,
  **客户端一见到分号就以为语句结束了**,**所以要先把「语句结束符」临时改成 `$$`。**
  **这是 MySQL 命令行和 Navicat 这类客户端的老问题**,
  **在应用代码里调 API 时反而不需要 `delimiter`**——**因为整段字符串是一次性送过去的。**

  这里没有 CREATE PROCEDURE
  SQL> CREATE PROCEDURE add_student(IN p_name TEXT)
  报错:near "PROCEDURE": syntax error
  SQL> DELIMITER $$
  报错:near "DELIMITER": syntax error
  SQL> DECLARE x INT
  报错:near "DECLARE": syntax error
  SQL> SET @a = 1
  报错:near "SET": syntax error
  SQL> CALL p1()
  报错:near "CALL": syntax error
  **四条全报 `syntax error`**:`procedure`、`delimiter`、`declare`、`set @变量`、
  `call`——**这些词这里一个都不认。**
  **`sqlite3` 的设计取向是「库要小、要好嵌」**,**所以把「过程语言」这一整层砍掉了**,只留触发器。
  **这不是能力不足,是取舍。**

  等价做法:用宿主语言写「过程」
  **存储过程本质上就是「一段能带参数、能被反复调用的 SQL 逻辑」。**
  **这东西在 Python 里就是一个函数。**
  **`add_student(c, '张伟', 175.5)` 一句调用,内部插入一行并返回新 id。**
  **参数就是形参、返回值就是 OUT 参数。**
  **跑一次看看:**
  返回的 id 是 1。
  SQL> SELECT id, name, height FROM students
  id     name     height
  -----------------------
  1      张伟     175.50
  (1 行)
  **这就是「存储过程」的全部作用**:
  **把「插入 + 取新 id + 提交」包成一个动作。** **区别只是它在应用进程里,不在数据库进程里。**

  批量:executescript 一次送多条
  **存储过程里常写几十条语句**,**这边的对应物是 `executescript`**:
  **一次送一整段,中间的分号不会被当成结束。**
  SQL> SELECT * FROM b
  y
  ---
  10
  20
  30
  (3 行)
  **注意 `(1), (2), (3)` 这种多行插入是 SQLite 从 3.7.11 起才有的**,
  **MySQL 上一直可以这么写。** **`insert into b select …` 两边都支持。**

  IN、OUT、INOUT 怎么模拟
  **`in` 参数**就是函数的普通参数,**`out` 参数**就是返回值。
  **一个返回值不够用,就返回元组或者字典。**
  **`inout` 就是「传进去、改完再拿出来」**,
  **Python 里用一个可变对象(列表、字典)或者返回新值都行。**
  **这一层语言本身就有,不需要数据库提供。**
  **反过来说,这也是「把逻辑放应用层」更省事的地方**:
  **参数怎么传、怎么返回,编程语言早就解决了**,
  **数据库的过程语言还得自己发明一套 `in`/`out`/`inout`。**

  查看、修改、删除(MySQL 侧)
  MySQL 原码(本沙箱不执行):
  | SHOW PROCEDURE STATUS WHERE Db = 'mydb';
  | SELECT routine_name, created FROM information_schema.routines
  |   WHERE routine_type = 'PROCEDURE';
  | SHOW CREATE PROCEDURE add_student;
  | DROP PROCEDURE IF EXISTS add_student;
  **MySQL 没有「修改存储过程」这条语句**:
  **要改就得 `drop` 再 `create`**——
  **和第 11.3 篇「视图没有 `alter view`,只能先删再建」一模一样。**
  **本沙箱这边「查看」就是看代码文件、「修改」就是改函数、「删除」就是删函数**,
  **连 `if exists` 都不用写**——**因为这些东西压根不在数据库里。**

第一段调用返回 1,插入成功,select 能看到张伟 175.5。 第二段 executescript 建了两张表、插了三行、把 a 的数据乘 10 灌进 b, b 里是 10/20/30。 第三段四个词全报 syntax error,分别指向 DELIMITER、DECLARE、SET、CALL。

30 秒实验

  1. execute 送两条语句会报什么?executescript 为什么不报?
  2. 把 add_student 改成「先查重、有就更新、没有才插入」,它对应存储过程里的哪个写法?
  3. 为什么 declare 必须写在 begin 之后、其他语句之前?

常见误区

  1. 在应用代码里也写 delimiter。 那是客户端命令,驱动不认,会直接报语法错。
  2. 以为 alter procedure 能改逻辑。 它只能改特性,改逻辑得 drop 再 create。
  3. 用存储过程做建表、改表结构。 过程体里不许 DDL,要走动态 SQL,得不偿失。
自测三问(点开对答案) 1. `delimiter $$` 是给谁看的? —— 给客户端看的,不是给数据库看的。它临时改掉「语句结束符」,免得过程体里的分号把语句截断。 2. `in`、`out`、`inout` 分别是什么? —— `in` 只进不出(默认),`out` 只出不进(用 @变量接),`inout` 两头都通。 3. 本沙箱里「存储过程」的等价物是什么?差别在哪? —— 宿主语言的函数:参数当 `in`、返回值当 `out`、多条 `execute` 当过程体。 差别是每 `execute` 一次就一趟网络,而存储过程全程在数据库进程里。

存储过程要人叫它才跑,下一篇讲一种不用叫、能直接写进 select 的:13.3 存储函数。

相关笔记