🛢️ 第14章 存储过程与存储函数
📄 一句话概括

过程是「收进名字里的一段 SQL」,函数是「能写进 SQL 的一个值」。 CREATE PROCEDURE、CALL、DELIMITER 这些词沙箱一个都不认,只能复刻语义。 DELIMITER 是客户端指令,不是 SQL 语法——它报语法错误,报得对。

📝 本章和前面几章的关系

14.1 是全书第一个「沙箱完全跑不了」的章节。教材讲建过程、建函数、变量、光标四件事。 这一篇的做法是:能复刻的用 Python 复刻并真跑(参数、返回值、光标、事务), 复刻不了的贴 MySQL 原码并标明不执行。 前面第 12 章讲视图,那是「把一句 SELECT 存起来」;这一篇更进一步,把一整段逻辑存进去。

一、五个关键字,沙箱一个都不认

先把话说清楚:SQLite 没有存储过程和存储函数。 五个关键字挨个试,全报语法错误,报的位置都在关键字上。

写法 沙箱的反应
CREATE PROCEDURE near "PROCEDURE": syntax error
CREATE FUNCTION near "FUNCTION": syntax error
CALL p1() near "CALL": syntax error
DELIMITER $$ near "DELIMITER": syntax error
BEGIN ... END near "SELECT": syntax error

注意这不是「不支持某个特性」,而是根本不认识这些词。 所以从这一篇开始,教材里的过程代码一律走「贴原码 + 说明」的路子,不假装能跑。 能真跑的是「过程这个概念」:一段 SQL 收进一个名字,带参数、能被反复调用、 能返回一个值或一批行。 这些用 Python 一样能演出来,而且演出来的差别跟 MySQL 是一致的。

二、过程的本质:有名字的一段 SQL

写一个「加一件商品」的动作,包成有名字的东西,然后连调三次。 调用方只需要知道名字和参数,不用重复写那句 INSERT。

时机 行数 total_changes
调用之前 5 —
连调三次之后 8 8

行数从 5 变 8,total_changes 累加到 8。 注意这个数不是「这次插了几行」,是「这个连接一共改了几行」——这是 SQLite 的口径。 MySQL 里对应的是 ROW_COUNT():它给的是「上一条语句影响了几行」。 同名不同义这件事在本课出现过很多次,这里又是一处。

三、函数必须返回值,而且能进 SQL

把「按价格分档」写成一个函数,注册进连接之后就能直接写在 SQL 里。 SELECT name, price, goods_level(price) 这样跑,八行结果各带一个档位。

c.create_function(u"goods_level", 1, goods_level)
c.execute(u"SELECT name, price, goods_level(price) FROM tb_goods")

这是这一篇最像 MySQL 的一步:函数能出现在 SELECT 列表里,也能出现在 WHERE 里。 MySQL 的存储函数就是这种用法,写法一模一样。

对比项 过程 函数
有没有返回值 没有(可带回结果集) 必须有
能不能写在 SQL 里 不能 能
怎么调 CALL 名字(...) SELECT 名字(...)

「过程不能写进 SQL」是硬限制,不是习惯问题。 因为过程可能改数据、可能返回多个结果集,而 SQL 表达式里只能放「一个值」。

四、三种参数模式

参数的「方向」有三类,MySQL 用三个关键字声明,沙箱只能靠调用约定表达。

模式 意思 Python 里的写法
IN 只进不出(默认) 普通参数
OUT 带一个结果出来 传个容器进去
INOUT 进去、改完、再出来 同一个容器

实测三组:IN 传 100.0 拿回 113.0;OUT 传空容器拿回 100; INOUT 传 [70] 拿回 140。 三个数互不相同,正好说明「方向」确实不一样。 最容易记错的是 OUT:调用方要准备好接,不然结果就丢了。 MySQL 那边写 CALL p(1, @total),调完还要 SELECT @total 才看得到。

五、变量:三种作用域

教材在这一节讲了三种变量,分清它们比记语法重要。

种类 写法 活多久
用户变量 @name 整个连接
局部变量 DECLARE name 过程体那一对 BEGIN ... END
参数 IN name INT 调用期间

第一种带 @,是「连接级」的,换个连接就没了;第二种只能写在过程体开头, 而且必须写在所有语句之前;第三种是调用时传进来的。 常见的错法是「把 DECLARE 写在语句中间」——MySQL 要求它必须在开头一段, 顺序错了报的不是「位置不对」,而是语法错误,很难看出原因。

六、光标:一行一行地处理

光标就是把结果集摊开,一行一行地拿。 沙箱里就是 fetchone() 循环,取完给 None,循环结束。

商品 库存
螺丝刀 200
电钻 30
手套 500
安全帽 60
交换机 8
卷尺 100
口罩 900
网钳 40

八行逐行累加,合计 1838。 这里用光标是「为了演光标」——真要求合计,一句 SUM(stock) 就够了,而且快得多。 光标的正当用途是「每行都要做点别的事」:逐行调接口、逐行算复杂规则、逐行写日志。 MySQL 里要写四步:DECLARE ... CURSOR、OPEN、FETCH、CLOSE, 还要加一个 CONTINUE HANDLER FOR NOT FOUND,不然取到最后一行会报错退出。

七、DELIMITER 为什么必须换

教材里每一段过程代码的第一行都是 DELIMITER $$,很多人直接照抄不知道为什么。

DELIMITER $$
CREATE PROCEDURE p1()
BEGIN
  SELECT 1;
  SELECT 2;
END$$

原因很实际:过程体里本来就有分号,而客户端默认拿分号当「这条语句结束」。 不换结束符,客户端读到第一个分号就把半截语句发出去,必然报语法错。 DELIMITER $$ 的意思是「从现在起,看到 $$ 才算一条语句结束」。 它属于 mysql 命令行客户端,不是 SQL 语法——用程序调接口时根本不需要写它。

八、动手 Demo:没有存储过程,就把语义复刻出来

这一段先让五个关键字各报一次错,把「沙箱没有」这件事定下来; 然后用 Python 复刻过程、函数、三种参数模式、光标,每一段都真跑。 最后贴 MySQL 的游标与 DELIMITER 原码。

import sqlite3
c = sqlite3.connect(u":memory:")

# ① 五个关键字,一个都不认
for sql in [u"CREATE PROCEDURE p1() BEGIN SELECT 1; END",
            u"CREATE FUNCTION f1(x INT) RETURNS INT RETURN x",
            u"CALL p1()", u"DELIMITER $$"]:
    try:
        c.execute(sql)
    except sqlite3.Error as e:
        print(e)

# ② 过程:有名字的一段 SQL
def add_goods(name, cat_id, price, stock):
    c.execute(u"INSERT INTO tb_goods (name, cat_id, price, stock) "
              u"VALUES (?, ?, ?, ?)", (name, cat_id, price, stock))
    return c.total_changes

print(c.execute(u"SELECT COUNT(*) FROM tb_goods").fetchone()[0])
for nm, cid, pr, st in [(u"卷尺", 1, 15.0, 100), (u"口罩", 2, 2.0, 900)]:
    print(add_goods(nm, cid, pr, st))
print(c.execute(u"SELECT COUNT(*) FROM tb_goods").fetchone()[0])

# ③ 函数:注册进 SQL,就能出现在 SELECT 里
def goods_level(price):
    return u"高价" if price >= 300 else (u"中价" if price >= 50 else u"低价")

c.create_function(u"goods_level", 1, goods_level)
print(c.execute(u"SELECT name, goods_level(price) FROM tb_goods").fetchall())

# ④ 光标:一行一行拿
cur = c.execute(u"SELECT name, stock FROM tb_goods ORDER BY id")
total = 0
while True:
    row = cur.fetchone()
    if row is None:
        break
    total += row[1]
print(total)

实跑输出:

--------------------------------------------------------------------
第六十课:写过程之前,先承认沙箱没有这个过程
--------------------------------------------------------------------

  **教材 14.1 讲四件事:建过程、建函数、变量、光标。**
  **沙箱这四个关键字一个都不认,所以这一课先看清「不认」长什么样。**
  **看清之后,用 Python 把同样的语义复刻一遍——过程是「有名字的一段 SQL」,**
  **这个概念跟语言无关,换个宿主照样能演。**

  一、五个关键字,沙箱一个都不认
  --------------------------------------------------------------------

  写法      沙箱的反应
  ----------------------------------------
  建过程    near "PROCEDURE": syntax error
  建函数    near "FUNCTION": syntax error
  调过程    near "CALL": syntax error
  换结束符  near "DELIMITER": syntax error
  语句块    near "SELECT": syntax error
  **五行全是语法错误,报的位置都在关键字上:`near "PROCEDURE"`、`near "CALL"`。**
  **这说明它不是「不支持某个特性」,而是根本不认识这些词。**
  **所以从这一课开始,凡教材里的过程代码一律走「贴原码 + 说明」的路子,不假装能跑。**

  二、过程的本质:有名字的一段 SQL
  --------------------------------------------------------------------

  时机          行数  `total_changes`
  -----------------------------------
  调用之前      5     —
  连调三次之后  8     8
  **三次调用,行数从 5 变 8,`total_changes` 一路累加到 8。**
  **注意最后那个数不是「这次插了几行」,是「这个连接一共改了几行」——这是 SQLite 的口径。**
  **过程的好处就在这儿:调用方只需要知道「名字 + 参数」,不用重复写那句 `INSERT`。**
  **它是把 SQL 收进一个名字里,跟它跑在数据库里还是跑在应用里没关系。**

  三、函数必须返回值,而且能进 SQL
  --------------------------------------------------------------------

  mysql> SELECT name, price, goods_level(price) AS lv FROM tb_goods
  name   price lv
  -----------------
  螺丝刀 25    低价
  电钻   380   高价
  手套   8     低价
  安全帽 120   中价
  交换机 680   高价
  卷尺   15    低价
  口罩   2     低价
  网钳   45    低价
  8 rows in set
  **这是这一课最像 MySQL 的一步:Python 函数注册进连接之后,就能写在 SQL 里。**
  **MySQL 的存储函数也是这样用的——`SELECT` 列表里能写,`WHERE` 里也能写。**

  对比项             过程                    函数
  -------------------------------------------------------------
  有没有返回值       没有(可以带回结果集)  必须有
  能不能写在 SQL 里  不能                    能
  怎么调             `CALL 名字(...)`        `SELECT 名字(...)`

  四、三种参数模式
  --------------------------------------------------------------------

  模式   这次传的  Python 里的写法  拿到的
  ----------------------------------------
  IN     `100.0`   普通参数         113.0
  OUT    空容器    函数往里写       100
  INOUT  `[70]`    同一个容器进出   140
  **`IN` 是最常见的:只进不出,算完给个结果就完事。**
  **`OUT` 和 `INOUT` 在 Python 里没有专门语法,只能靠返回值或者传个容器进去。**
  **MySQL 那边是三个关键字写在参数前面,`OUT total INT` 这样。**
  **沙箱没有参数声明这一层,所以「模式」这件事只能靠调用约定表达。**

  五、光标:一行一行地处理
  --------------------------------------------------------------------

  商品    库存
  ------------
  螺丝刀  200
  电钻    30
  手套    500
  安全帽  60
  交换机  8
  卷尺    100
  口罩    900
  网钳    40
  **这就是光标:`fetchone()` 一次取一行,取完给 `None`,循环结束。**
  **MySQL 里要写 `DECLARE 游标 CURSOR FOR SELECT ...`、`OPEN`、`FETCH`、`CLOSE` 四步,**
  **还要声明一个 `CONTINUE HANDLER` 处理「取不到数据了」这个事件。**
  **合计库存 1838。**
  **注意这里是「逐行累加」,不是 `SUM()`——光标的用处就是「每行都要做点别的事」,**
  **只求和的话一句 `SUM` 就够了,别用光标。**

  [教材原码 · 本沙箱不执行]
  | DECLARE cur CURSOR FOR SELECT name, stock FROM tb_goods;
  | DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
  | OPEN cur;
  | read_loop: LOOP
  |   FETCH cur INTO v_name, v_stock;
  |   IF done THEN LEAVE read_loop; END IF;
  |   SET total = total + v_stock;
  | END LOOP;
  | CLOSE cur;
  教材原码,MySQL 的游标要四步 + 一个异常处理器


  六、DELIMITER 为什么必须换
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | DELIMITER $$
  | CREATE PROCEDURE p1()
  | BEGIN
  |   SELECT 1;
  |   SELECT 2;
  | END$$
  教材原码,第一条永远是 DELIMITER,不是 CREATE

  **原因很实际:过程体里本来就有分号,而客户端默认拿分号当「这条语句结束」。**
  **不换结束符,客户端读到第一个分号就把半截语句发给服务器,必然报语法错。**
  **`DELIMITER $$` 的意思是「从现在起,看到 `$$` 才算一条语句结束」。**
  **它是 `mysql` 命令行客户端的功能,不是 SQL 语法——所以写进程序里调接口是不需要的。**
  **这一条最容易糊涂:`DELIMITER` 不是 SQL 语句,是客户端的指令。**
  **沙箱把它报成语法错误,报得对——它本来就不该出现在 SQL 里。**

第一组五行报错都落在关键字上,near "PROCEDURE"、near "CALL"、near "DELIMITER"。 这说明它连词法都不认,不是「功能缺失」。 第二组三个数:调用前 5 行,三次调用后 8 行,total_changes 累加到 8。 注意它一直在累加,不会清零——这是「这个连接一共改了几行」,不是「这条语句影响几行」。 第三组八行是函数进了 SELECT 的结果,每行带一个档位。 这一步和 MySQL 的存储函数用法完全一致。 第四组最后一个数是库存合计。 它由逐行累加得到,跟一句 SUM 的结果一样,但过程多跑了七次循环—— 这就是「光标只在必须逐行处理时才用」的原因。

30 秒实验

  1. 把注册函数的参数个数从 1 改成 2,再用一个参数的写法调它,看报什么错。
  2. 把光标那段改成 SELECT SUM(stock),比对结果是不是同一个数。
  3. 把 add_goods 的 INSERT 故意写错一个列名,看报错落在哪一层。

常见误区

  1. 以为 DELIMITER 是 SQL 语句,写进程序里调接口,结果报语法错。
  2. 以为 ROW_COUNT() 和 SQLite 的 total_changes 是一回事,一个是「这次」,一个是「累计」。
  3. 求个合计也上光标,多写二十行代码、还慢了一个数量级。
自测三问(点开对答案) 1. 过程和函数最本质的区别是什么? —— 函数必须返回一个值,而且能写进 SQL(`SELECT` 列表、`WHERE` 都能放); 过程没有返回值,只能 `CALL`,但可以带回一个甚至多个结果集。 2. `DELIMITER` 是 SQL 语句吗? —— 不是,它是 `mysql` 命令行客户端的指令。 作用是改「语句结束符」,好让过程体里的分号不被当成语句结尾。用程序调接口时不需要它。 3. 什么情况下才该用光标? —— 每一行都要做不同的事(调接口、写日志、复杂逐行计算)时才用。 只是求和、求平均这类,一句聚合函数就够了,光标慢得多。

过程建好了,下一篇讲怎么调:CALL 与 SELECT 的分工,以及三种参数方向怎么传。

相关笔记