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

CALL 过程(...) 调过程,SELECT 函数(...) 调函数,两者不能互换。 函数能进 WHERE,所以能参与筛选;过程不能,只能把条件写进自己的 SQL 里。 过程里的几条语句是一伙的:要么全成、要么全不成,事务边界得自己划。

📝 本章和前面几章的关系

14.2 讲调用,教材只有两小节:调过程、调函数。这一篇补上「为什么函数能进 SQL、过程不能」。 上一篇讲怎么建,这一篇讲怎么用。 下一篇讲怎么看——三篇合起来才是过程的一生。

一、CALL 与 SELECT 的分工

对象 怎么调 能进 SQL 吗
存储过程 CALL p(...) 不能
存储函数 SELECT f(...) 能

「不能」是硬限制:SELECT 后面、WHERE 后面都放不进一个过程。 因为过程可能改数据、可能返回多个结果集,而 SQL 表达式里只能放「一个值」。 反过来想就通了:能在表达式里出现的东西,必须「输入一组值、输出一个值」。 过程不满足这个形状,所以进不去。

二、过程:按名字调,可以带回一批数据

调 p_list_cat(1),拿回两行:螺丝刀、电钻。

id name price
1 螺丝刀 25.0
2 电钻 380.0

过程可以返回结果集,而且可以返回不止一个——教材里那种「先查总数、再查明细」的写法。 这是它和函数最大的区别:函数的输出是一个值,过程的输出可以是一张表。 MySQL 客户端会把过程里的每个 SELECT 都当成一个结果集依次显示。 用程序调的时候要小心:fetchall() 只拿到第一个结果集,后面的要另外取。

三、函数:能进 SQL,所以能进 WHERE

把 goods_level(price) 放进 WHERE,直接筛出「高价」的两行。 这一句换成 MySQL 的存储函数,写法一模一样。

写法 结果
WHERE goods_level(price) = '高价' 电钻、交换机
WHERE price >= 300 同样两行

两种写法的结果是同一个,但代价不同:函数版要在每一行上算一次。 八行看不出差别,八百万行时这个开销就是实打实的。 所以有一条经验:函数里别放重活,能在 WHERE 里直接写的条件就别包成函数。 它还会让索引失效——第 10 章那条「函数包在列上,索引就废了」在这一样成立。

四、三种参数方向,调用写法不一样

模式 调用写法 拿到的
IN f(100.0) 113.0
OUT f(空容器) 函数往里写
INOUT f([50]) 100

IN 只进不出;OUT 得先准备一个容器接结果;INOUT 是同一个容器进去、改完出来。 Python 里这三种靠「返回值、容器、容器」凑出来,MySQL 里是三个关键字声明的。 MySQL 的调用写法要特别记一下:OUT 参数传的是用户变量,CALL p(1, @total), 调完还要再 SELECT @total 才拿得到值。这一步很容易漏,漏了就以为过程没算出结果。

五、事务边界:过程里的语句是一伙的

插三条:第一条、第二条正常,第三条主键撞了。 结果行数从 5 开始,回滚之后还是 5——前两条一起没了。

时机 行数
整段执行之前 5
回滚之后 5

这就是「事务边界」:过程里的几条语句要么全成、要么全不成。 但要注意——这个边界是你划的,不是过程自带的。 MySQL 里过程体不会自动开事务,START TRANSACTION 和 COMMIT 得自己写。 「过程自带事务」是一个很常见的误解,它的后果是:中途失败时前面几条已经落库了。

六、动手 Demo:CALL、SELECT,和三种参数方向

这一段把「调用」拆成四件事跑:过程带回结果集、函数进 WHERE、三种参数方向各调一次、 最后用一段故意失败的事务看边界在哪。

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

# ① 过程:按名字调,带回一批行
def list_by_cat(cat_id):
    return c.execute(u"SELECT id, name, price FROM tb_goods "
                      u"WHERE cat_id = ?", (cat_id,)).fetchall()

print(list_by_cat(1))

# ② 函数:注册进 SQL,就能进 WHERE
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 FROM tb_goods "
                u"WHERE goods_level(price) = '高价'").fetchall())

# ③ 三种参数方向
def in_only(price):          # IN:只进不出
    return round(price * 1.13, 2)

def out_only(out):           # OUT:结果写进调用方给的容器
    out.append(300 // 3)
    return out[-1]

def inout(box):              # INOUT:同一个容器进出
    box[0] = box[0] * 2
    return box[0]

print(in_only(100.0), out_only([]), inout([50]))

# ④ 事务边界:第三条撞主键,前两条一起回滚
c.execute(u"BEGIN")
try:
    c.execute(u"INSERT INTO tb_goods (name) VALUES ('甲')")
    c.execute(u"INSERT INTO tb_goods (name) VALUES ('乙')")
    c.execute(u"INSERT INTO tb_goods (id, name) VALUES (1, '撞了')")
    c.execute(u"COMMIT")
except sqlite3.IntegrityError as e:
    c.execute(u"ROLLBACK")
    print(e)
print(c.execute(u"SELECT COUNT(*) FROM tb_goods").fetchone()[0])

实跑输出:

--------------------------------------------------------------------
第六十一课:怎么调——CALL、SELECT,和三种参数方向
--------------------------------------------------------------------

  **教材 14.2 讲调用:`CALL` 调过程,`SELECT` 调函数。**
  **沙箱两个都没有,但「按名字调用」这件事用 Python 一样能演,而且能演出差别。**
  **这一课的重点是那个差别:函数能进 SQL,过程不能。**

  一、CALL 与 SELECT 的分工
  --------------------------------------------------------------------

  对象      怎么调           能出现在 SQL 里吗
  --------------------------------------------
  存储过程  `CALL p(...)`    不能
  存储函数  `SELECT f(...)`  能
  **「不能」这一条是硬限制:`SELECT` 后面、`WHERE` 后面都放不进一个过程。**
  **因为过程可能改数据、可能返回多个结果集,而 SQL 表达式里只能放「一个值」。**

  二、过程:按名字调,可以带回一批数据
  --------------------------------------------------------------------

  id  name    price
  -----------------
  1   螺丝刀  25.0
  2   电钻    380.0
  **过程可以返回结果集,而且可以返回不止一个(教材里那种「先查总数再查明细」的写法)。**
  **这是它和函数最大的区别:函数的返回值只有一个值,过程的输出可以是一张表。**

  三、函数:能进 SQL,所以能进 WHERE
  --------------------------------------------------------------------

  mysql> SELECT name FROM tb_goods WHERE goods_level(price) = '高价'
  name
  ------
  电钻
  交换机
  2 rows in set
  **上面这句在 MySQL 里就是把 `goods_level(price)` 换成一个存储函数,写法一模一样。**
  **函数能进 `WHERE`,所以它能参与筛选;过程不能,只能自己把条件写进 SQL 里。**
  **代价是函数会在每一行上被调一次。**
  **几行看不出什么,五百万行时这个开销就明显了——所以函数里别放重活。**

  四、三种参数方向,调用的写法不一样
  --------------------------------------------------------------------

  模式     调用写法    拿到的
  ---------------------------------
  `IN`     `f(100.0)`  113.0
  `OUT`    `f(容器)`   算出来的结果
  `INOUT`  `f(box)`    100
  **`IN` 只进不出;`OUT` 得先准备一个容器接结果;`INOUT` 是同一个容器进去、改完出来。**
  **Python 里这三种靠「返回值、容器、容器」凑出来,MySQL 里是三个关键字声明的。**
  **最容易记错的是 `OUT`:调用方要准备好接,不然结果就丢了。**
  **MySQL 那边写 `CALL p(1, @total)`,调完再 `SELECT @total` 才拿得到。**

  五、事务边界:过程里的几条语句是一伙的
  --------------------------------------------------------------------
  **第三条插不进去,报的是 `UNIQUE constraint failed: tb_goods.id`。**

  时机          行数
  ------------------
  整段执行之前  5
  回滚之后      5
  **前两条插入被一起回滚了,行数一个没变。**
  **这就是「事务边界」:过程里的几条语句要么全成、要么全不成。**
  **MySQL 里这个过程体是包在 `START TRANSACTION` 和 `COMMIT` 之间的,**
  **而过程本身不会替你开事务——这一点常被误解成「过程自带事务」。**

第一组两行是过程带回的结果集,cat_id = 1 的两件商品。 过程能带回一张表,函数只能带回一个值——差别就在这。 第二组两行验证了「函数能进 WHERE」,这和直接写 price >= 300 结果一样。 一样的结果、不同的代价:函数版每行都要算一次。 第三组三个数 113.0、100、100——第三个是把 50 翻倍得来的。 三个数各走一条路,正好对应三种参数方向。 第四组最后那个数是 5,和插之前一样:前两条被一起回滚了。 报错原文是 UNIQUE constraint failed: tb_goods.id,说明是第三条撞的。

30 秒实验

  1. 把 out_only([]) 改成 out_only(None),看会报什么错,想想为什么。
  2. 把函数那段改成 WHERE price >= 300,比对结果和开销。
  3. 把第三条 INSERT 换成一条语法错误的语句,看 IntegrityError 还抓不抓得住。

常见误区

  1. 把过程写进 WHERE,报语法错之后以为是自己参数写错了。
  2. 以为过程自带事务,中途失败时前面几条已经落库还不知道。
  3. 调完 OUT 参数忘了再查一次用户变量,以为过程没算出结果。
自测三问(点开对答案) 1. 为什么过程不能写进 `WHERE`? —— 因为 SQL 表达式里只能放「输入一组值、输出一个值」的东西。 过程可能改数据、可能返回多个结果集,形状不对,所以放不进去。 2. `OUT` 参数在调用时最容易犯什么错? —— 忘了传一个能接结果的容器(MySQL 里是用户变量),或者调完忘了 `SELECT @变量` 取值。 结果不是没算出来,是没人接住。 3. 过程会自动开事务吗? —— 不会。事务边界得自己在过程体里写 `START TRANSACTION` 和 `COMMIT`。 以为它自带事务,中途失败时前面几条就已经落库了。

能建能调了,下一篇讲「怎么看」:过程存在哪、源码怎么查、参数怎么翻。

相关笔记