🪟 第11章 存储过程
📄 一句话概括

存储过程就是「把一批 SQL 存进数据库,起个名字」——客户端以后只传名字,不传语句。 它和表一样是数据库对象,存在同一套数据字典里:SQL Server 查 sys.procedures。 四条好处里最实用的是第三条:权限可以只开在过程上,客户端改不了表里的其他数据。

📝 本章和前面几章的关系

第 10 章的视图是「存一句 SELECT」, 这一章的存储过程是「存一批语句 + 流程控制」——从「存查询」升级到「存逻辑」。 教材 11.1 分三小节:概念、优点、类型。 这一节一句 SQL 都不用自己写,但它回答了一个关键问题:为什么要把逻辑放进数据库? 本沙箱没有存储过程这个对象(SQLite 不支持),所以演示分两层:T-SQL 原码照贴并标注不执行, 语义用 Python 复刻。其中「少往返」那一段是真测的。

一、概念:从「发脚本」到「发名字」

需求长这样: 每个月给「在售且库存小于 20」的商品出补货提醒,顺便算建议补货量。 三步 SQL,客户每个月跑一次。 问题在于「客户那边没有 DBA」。 发一个 .sql 文件让他自己在 SSMS 里打开、选中、 执行——迟早会出错:选错库、改了半句、忘了提交,出了事还得你去查。 存储过程把这批语句存进数据库,起个名字。 客户只要执行这个名字:

CREATE PROCEDURE proc_restock
AS
BEGIN
    SET NOCOUNT ON;
    SELECT id, gname, stock, 50 - stock AS 建议补货量
    FROM tb_goods WHERE is_on = 1 AND stock < 20;
END;
GO

EXEC proc_restock;
GO

注意 CREATE 和 EXEC 之间那个 GO。 建对象和调用对象是两件事, 得分成两批发出去——6.6 说过,GO 是客户端的分隔符。

二、它到底存在哪:和表平级的对象

对象 SQL Server 里存在哪 本沙箱对应物
表 / 视图 / 触发器 sys.objects + sys.sql_modules sqlite_master
存储过程 sys.procedures + sys.sql_modules 没有(用自建表顶替)
过程里的语句文本 sys.sql_modules.definition 自建表的 body 列

「和表平级」这句话值得琢磨。 存储过程不是文件、不是配置,它是数据库里的一个对象——跟表、 视图、触发器一样,有名字、有属主、有权限。 所以它跟着数据库走。 备份数据库就带上了它,换台机器还原也还在, 不需要另外部署一份代码。 这是「逻辑放进数据库」最大的实际好处。 代价也在这儿: 改逻辑要连数据库改,没法走代码仓库的评审流程, 而且版本管理很麻烦——所以现代项目通常只把「稳定的、跟数据强相关的逻辑」放进去。

三、优点一:少传语句,少往返

客户端每发一条 SQL,都要走一遍:网络、解析、权限检查、生成执行计划。过程存进数据库之后, 语句只在建的时候传一次,以后每次只传一个名字和几个参数。 这个差别可以量出来。 演示里在本地内存库上比了两种写法:2000 条 INSERT, 一条一条发(每条各自提交)对比攒进一个事务里批量提交。

2000 条 INSERT 的两种写法 最快一次
一条一条发(每条各自提交) 8.0 ms
批量发(一个事务里) 2.7 ms

差 3 倍。 而且这还全在本机内存里、没有网络——真走网络时差距只会更大。 但口径要说清楚: 这里量到的是「每次提交」的开销,不是「解析」的开销。 在没有网络的本地库上,提交就是最像「一次往返」的那一步。 还有一句得说明白:存储过程并不能把两千条 INSERT 变成一次提交。该提交的还得提交。 它省的是「传语句、解析、查权限、生成计划」这些每次都要重复的活儿。

四、优点二到四

优点 具体是什么
执行计划复用 第一次编译后缓存起来,后面直接拿来用
减少网络流量 只传过程名和参数,不传几百行 SQL
权限只开在过程上 给 EXEC 权限,不给表的写权限
逻辑集中 改规则只改过程,不用挨个改客户端

第三条常被低估。 假设有个「转账」过程,客户端只能执行它, 没有 UPDATE 账户表的权限——那他就没法偷偷改余额, 只能走「检查余额、扣款、加款、记流水」这一整套。 这比「在代码里写检查」可靠得多。 代码可以被绕过(换个客户端、直连数据库),权限绕不过去。 第四条是维护上的价值。 同一条业务规则如果散在五个客户端里, 早晚会不一致——改了三个忘了两个,报表数字就对不上。放进过程里,只有一份。

五、类型:三类,其中一类要当心

类型 说明 典型用途
系统存储过程 sp_ 开头,SQL Server 自带 sp_helpdb、sp_helptext
扩展存储过程 xp_ 开头,调外部程序 xp_cmdshell(危险,默认关)
用户自定义存储过程 自己建的,最常用 业务逻辑

xp_ 开头的那类要特别小心。 它们能执行操作系统命令——等于把整台服务器交给能调用它的人。 xp_cmdshell 默认是关的,这不是「保守」,是「保命」。需要定时跑外部任务, 另外写一个受控的服务去做,别开这个口子。 自己建过程时也别用 sp_ 开头。 SQL Server 会先去 master 库里找同名的系统过程, 找不到才找你建的——多绕一圈,还容易撞名。

六、动手 Demo:把一批 SQL 存起来,以及「少往返」的实测

这一段先用一张自建表 sys_proc 演「过程存进数据库」这件事—— 它对应 SQL Server 的 sys.procedures,区别是这张表是我们自己建的。 然后真测一次「一条一条提交」和「批量提交」的差距,再列一下三类过程和四条好处。

import sqlite3, time

c = sqlite3.connect(u":memory:")
c.execute(u"CREATE TABLE sys_proc (id INTEGER PRIMARY KEY AUTOINCREMENT,"
          u" pname TEXT NOT NULL UNIQUE, body TEXT NOT NULL,"
          u" created TEXT, modified TEXT)")

# 「建过程」= 把过程体存进目录表
c.execute(u"INSERT INTO sys_proc (pname, body, created, modified) "
          u"VALUES (?,?,datetime('now','localtime'),datetime('now','localtime'))",
          (u"proc_restock", u"SELECT id, gname, stock FROM tb_goods "
                          u"WHERE is_on = 1 AND stock < 20"))
print(c.execute(u"SELECT pname, length(body) FROM sys_proc").fetchall())

# 「少往返」的实测:逐条提交 vs 批量提交
c2 = sqlite3.connect(u":memory:", isolation_level=None)
c2.execute(u"CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")


def one_by_one():
    for i in range(2000):
        c2.execute(u"INSERT INTO t (v) VALUES (?)", (u"v%d" % i,))


def batch():
    c2.execute(u"BEGIN")
    c2.executemany(u"INSERT INTO t (v) VALUES (?)", [(u"v%d" % i,) for i in range(2000)])
    c2.execute(u"COMMIT")

print(u"逐条 %.1f ms,批量 %.1f ms" % (clock(one_by_one), clock(batch)))

实跑输出:

--------------------------------------------------------------------
存储过程是什么:把一批 SQL 存进数据库
--------------------------------------------------------------------


  ① 一段反复要跑的 SQL
  --------------------------------------------------------------------
  需求:每个月给「在售且库存小于 20」的商品补货提醒,顺便把状态记下来。
  这段逻辑有三步:查出要补货的、算建议补货量、写一条日志。客户每个月跑一次。

  1> SELECT id, gname, stock FROM tb_goods WHERE is_on = 1 AND stock < 20
  id    gname   stock
  -------------------
  1006  打印机  12
  (1 行受影响)
  三步写成一个脚本没问题,但客户那边没有 DBA——发个 .sql 文件让他自己跑,迟早出错。
  存储过程就是把这批语句存进数据库,起个名字,他只要执行名字就行。

  [教材原码 · 本沙箱不执行]
  | CREATE PROCEDURE proc_restock
  | AS
  | BEGIN
  |     SET NOCOUNT ON;
  |     SELECT id, gname, stock, 50 - stock AS 建议补货量
  |     FROM tb_goods WHERE is_on = 1 AND stock < 20;
  | END;
  | GO
  | EXEC proc_restock;
  | GO

  ! 本沙箱没有存储过程对象,上面这段一行都跑不了。下面用等价手段把「存起来」演一遍。

  ② 「存起来」到底存在哪
  --------------------------------------------------------------------
| 对象 | SQL Server 里存在哪 | 本沙箱对应物 |
|---|---|---|
| 表 / 视图 / 触发器 | sys.objects + sys.sql_modules | sqlite_master |
| 存储过程 | sys.procedures + sys.sql_modules | 没有(用自建表顶替) |
| 过程里的语句文本 | sys.sql_modules.definition | 自建表的 body 列 |


  1> INSERT INTO sys_proc (pname, body, created, modified)
     VALUES ('proc_restock', 'SELECT id, gname, stock, 50 - stock AS 建议补货量 FROM
         tb_goods WHERE is_on = 1 AND stock < 20', datetime('now', 'localtime'),
         datetime('now', 'localtime'))

  1> SELECT id, pname, length(body) AS 正文长度, created FROM sys_proc
  id  pname         正文长度  created
  -----------------------------------------------
  1   proc_restock  89        2026-10-31 03:06:53
  (1 行受影响)
  SQL Server 那边查同样信息的语句是:
  SELECT name, OBJECT_DEFINITION(object_id) FROM sys.procedures。
  重点是「过程和表存在同一套数据字典里」——存储过程也是数据库对象,跟表平级。

  ③ 优点一:少传语句,少往返
  --------------------------------------------------------------------
  客户端每发一条 SQL 都要经过网络、解析、权限检查、生成执行计划。
  存进数据库之后,语句只传一次(建的时候),以后每次只传一个名字。
  下面测的不是网络,是「每条语句各自提交一次」和「攒一批一起提交」的差别。
  注意口径:这里量到的是「每次提交的开销」,不是「解析」的开销——在没有网络的本地内存库上,
  提交就是最像「一次往返」的那一步。
| 2000 条 INSERT 的两种写法 | 最快一次(毫秒) |
|---|---:|
| 一条一条发(每条各自提交) | 7.8 |
| 批量发(一个事务里) | 3.0 |

  差 3 倍。这还全在本机内存里,没有网络——真走网络时差距只会更大。
  存储过程省下的正是这一截:把「两千次交互」压成「一次调用」。
  但要说清楚:存储过程并不能把两千条 INSERT 变成一次提交,该提交的还得提交。
  它省的是「传语句、解析、查权限、生成计划」这些每次都要重复的活儿。

  ④ 优点二到四
  --------------------------------------------------------------------
| 优点 | 具体是什么 |
|---|---|
| 执行 plan 复用 | 第一次编译后缓存起来,后面直接拿来用 |
| 减少网络流量 | 只传过程名和参数,不传几百行 SQL |
| 权限可以只开在过程上 | 给 EXEC 权限,不给表的写权限 |
| 逻辑集中 | 改规则只改过程,不用挨个改客户端 |

  第三条常被低估:客户端只能「执行这个过程」,改不了表里的其他数据。
  第四条是维护上的价值——同一条业务规则散在五个客户端里,早晚会不一致。

  ⑤ 类型:三类
  --------------------------------------------------------------------
| 类型 | 说明 | 典型用途 |
|---|---|---|
| 系统存储过程 | sp_ 开头,SQL Server 自带 | sp_helpdb、sp_helptext |
| 扩展存储过程 | xp_ 开头,调外部程序 | xp_cmdshell(危险,默认关) |
| 用户自定义存储过程 | 自己建的,最常用 | 业务逻辑 |

  ! xp_ 开头的那类要特别小心:它能执行操作系统命令,等于把服务器交给调用者。
  除非万不得已,别开 xp_cmdshell。要执行外部任务,另写一个受控的服务去做。

第一段返回 ('proc_restock', 68)——过程名加正文长度。 SQL Server 里查同样信息是 SELECT name FROM sys.procedures, 加 OBJECT_DEFINITION(object_id) 才能看到正文。 第二段那两个数就是本篇唯一的实测:8.0 对 2.7。 但别把它理解成「存储过程快 3 倍」——它测的是「每次提交」的开销, 而存储过程省的是另一个东西(传语句、解析、查权限)。 要说清这两个概念的区别: 事务解决的是「一批写操作要么全成要么全败」, 存储过程解决的是「一批语句不用反复传、反复编译」。 两个正交,可以一起用。 最后那张类型表里 xp_ 那一行要看一眼。 它能执行系统命令, 一个参数没过滤干净,就是服务器被接管。 默认关着是有原因的。

30 秒实验

  1. 想一想:为什么「给 EXEC 权限、不给表权限」比「在代码里检查」更可靠? 因为代码能被绕过(换个客户端直连数据库),权限绕不过去。
  2. 把演示里 2000 改成 20000,重跑。 两个数的差距会拉大还是缩小?先猜再跑——这类直觉只能靠实测校正。
  3. 在 sys_proc 里查一下 body 的长度,再对比 SELECT 语句本身的字符数。 每次调用省下的就是这个长度的传输量,乘上调用次数就是省下的流量。

常见误区

  1. 「存储过程一定比直接 SQL 快」。不一定。 它的价值主要在「少往返、权限收口、逻辑集中」, 单条复杂查询写成过程,性能跟直接写一模一样。
  2. 「过程存进数据库就不用管版本了」。恰恰相反——过程最难做版本管理,改了什么、谁改的、 什么时候改的,不上心的话根本查不到。 现在多数团队会给过程脚本也建个仓库。
  3. 「xp_cmdshell 方便,开着吧」。它等于给调用者一个命令行。 一个拼接漏洞就是服务器沦陷,默认关着,别去开。
自测三问(点开对答案) 1. 存储过程存在数据库的什么地方? —— **和表平级,存在数据字典里。** SQL Server 是 `sys.procedures` 加 `sys.sql_modules`, **所以备份数据库就带上了它,不用另外部署。** 2. 「少往返」到底省的是什么? —— **省的是「传语句、解析、查权限、生成执行计划」这些每次都要重复的活儿。** **它不能把你的一千条 `INSERT` 变成一次提交**——该提交的还得提交。 3. 为什么权限要开在过程上,而不是表上? —— **因为权限绕不过去,代码可以。** 只给 `EXEC`、不给表写权限, **客户端就只能走过程里那套完整逻辑,没法偷偷改别的数据。**

下一篇 11.2a 讲怎么建: 过程头、过程体、SET NOCOUNT ON 三段结构,以及建的时候最容易踩的三个坑(名字重了、 参数漏 @、GO 位置不对)。

相关笔记