SQL 存储过程
本文以 MySQL 8.x 为例。不同数据库(例如 SQL Server、Oracle、PostgreSQL)的存储过程语法和权限模型并不完全相同。 这篇文章讲什么:MySQL 存储过程从创建到调用的完整流程——语法、特征声明、 权限模型,以及 PHP 调用时最容易踩的坑(多结果集没处理完导致"连接中毒")。
适合谁看:学过 SQL 基础、想用存储过程封装业务逻辑的开发者; 刚接触 mysqli 多结果集处理、被 Commands out of sync 折磨过的人。
阅读时间:约 15 分钟,全程代码示例,可跟着跑。
附带收获:存储过程相关的权限模型(SQL SECURITY)与防注入注意事项。
代码
DELIMITER //
CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
READS SQL DATA
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END //
DELIMITER ;
总体结构如下:
CREATE PROCEDURE 过程名称(
参数方向 参数名称 数据类型
)
过程特征
BEGIN
SQL 语句;
END
存储过程是保存在数据库服务器中的一段 SQL 程序。创建完成后,可以通过 CALL 重复调用,而不必让应用程序每次都重新发送其中的全部 SQL 语句。
DELIMITER
DELIMITER(译:分隔符)用于临时更改 MySQL 客户端识别的语句结束符。
MySQL 客户端通常使用 ; 判断一条输入是否结束。但是创建存储过程时,过程内部的语句也会使用 ;。为了防止客户端误以为整个 CREATE PROCEDURE 已经结束,并提前把不完整的内容发送给服务器,需要暂时将结束符改成 //:
DELIMITER //
CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END //
DELIMITER ;
创建完成后,再通过 DELIMITER ; 将客户端分隔符改回分号。
需要特别注意:
- DELIMITER 是 mysql 命令行客户端及部分图形化工具识别的命令,不是发送给 MySQL 服务器执行的 SQL 语句;
- // 不是固定写法,也可以选择其他不容易与过程体冲突的字符,但要注意不要使用他\\因为他是MySQl中的转义字符;
- 在 PHP 中通过 MySQLi 创建过程时,通常直接把完整的 CREATE PROCEDURE 字符串发送给服务器,不应把 DELIMITER 一起发送。
MySQL:定义存储程序与 DELIMITER
CREATE PROCEDURE
CREATE PROCEDURE 用于创建存储过程,例如:
CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
| CREATE PROCEDURE | 创建存储过程 |
| get_user_by_id | 存储过程名称 |
| IN | 输入参数 |
| p_user_id | 参数名称(自拟,只要符合命名规则) |
| INT | 参数类型 |
这句话表示创建一个名为 get_user_by_id() 的存储过程。它接收一个整数参数,参数名为 p_user_id。
存储过程参数有三种方向:
| IN | 调用者向存储过程传入值;默认方向 |
| OUT | 存储过程向调用者输出值 |
| INOUT | 调用者先传入值,存储过程可以修改后再输出 |
如果不写参数方向,MySQL 会按 IN 处理。不过显式写出 IN 更容易阅读。
READS SQL DATA
READS SQL DATA 表示:这个存储过程预计会读取数据库数据,但不修改数据。
相关声明还有:
| NO SQL | 过程体不包含 SQL 语句 |
| CONTAINS SQL | 包含 SQL,但不读取或修改表数据 |
| READS SQL DATA | 读取数据,例如执行 SELECT |
| MODIFIES SQL DATA | 可能修改数据,例如执行 INSERT、UPDATE、DELETE |
在 MySQL 中,这些属于存储过程的特征声明。服务器不会严格根据它们限制过程体的实际行为。即使错误地将一个包含 UPDATE 的过程声明为 READS SQL DATA,该声明本身也不一定阻止更新操作,因此它不能代替权限控制。
这些声明是可选的。省略后,存储过程通常仍然可以正常运行:
CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END
所以写上它的主要意义是表达设计意图,方便阅读、维护和分析,而不是建立一道安全边界。
MySQL:CREATE PROCEDURE
BEGIN … END
BEGIN … END 用于定义一个复合语句块:
BEGIN
SELECT first_name, last_name
FROM users
WHERE user_id = p_user_id;
END //
过程体只有一条简单 SQL 语句时,不一定必须使用 BEGIN … END;需要写多条语句、声明局部变量、使用条件判断或循环时,通常使用它将这些语句组合起来。
一个语句块可以包含多条语句:
BEGIN
DECLARE user_count INT DEFAULT 0;
SELECT COUNT(*)
INTO user_count
FROM users
WHERE user_id = p_user_id;
SELECT user_count;
END
DECLARE 声明必须出现在所属 BEGIN … END 语句块的开头,位于普通可执行语句之前。局部变量如果没有指定 DEFAULT,初始值为 NULL。
这里的 SELECT COUNT(*) 无论是否找到用户都会产生一行结果,因此很适合赋值给 user_count。
如果普通的 SELECT … INTO 可能返回多行,需要特别小心:
- 返回一行:正常赋值;
- 没有返回行:产生 No data 警告,变量保持原值;
- 返回多行:产生 Result consisted of more than one row 错误。
所以 SELECT … INTO 的查询条件最好能够保证至多返回一行,例如根据主键查询;如果只是随意加上 LIMIT 1,还应配合 ORDER BY 明确究竟选择哪一行。
MySQL:DECLARE · MySQL:SELECT … INTO
调用
使用 CALL 调用存储过程:
CALL get_user_by_id(1);
可能得到类似结果:
+————+———–+
| first_name | last_name |
+————+———–+
| Mion | Peng |
+————+———–+
1 row in set
过程名称和参数需要与创建时的定义一致。对于没有参数的过程,MySQL 同时支持 CALL procedure_name 和 CALL procedure_name(),不过写上括号通常更清楚。
也可以明确指定数据库:
CALL my_database.get_user_by_id(1);
CALL 是 MySQL 调用存储过程的标准方式。
MySQL:CALL
在 PHP 代码中调用
<?php
$id = filter_input(
INPUT_GET,
'id',
FILTER_VALIDATE_INT,
[
'options' => [
'min_range' => 1,
],
]
);
if ($id === false || $id === null) {
exit('非法用户 ID');
}
$stmt = mysqli_prepare(
$conn,
'CALL get_user_by_id(?)'
);
if ($stmt === false) {
exit('存储过程调用准备失败');
}
mysqli_stmt_bind_param($stmt, 'i', $id);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
if ($result !== false) {
while ($row = mysqli_fetch_assoc($result)) {
echo htmlspecialchars(
(string) $row['first_name'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
echo ' ';
echo htmlspecialchars(
(string) $row['last_name'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
mysqli_free_result($result);
}
// CALL 可能产生额外结果,需要在复用该语句或连接前处理完。
while (mysqli_stmt_more_results($stmt)) {
// 检查该语句对象 `mysqli_stmt $stmt`是否还有未提取的结果集?如果存在,继续下一批次处理;
// 没有则结束循环流程并清理资源(防止内存泄漏)。返回布尔值。
mysqli_stmt_next_result($stmt);
$extraResult = mysqli_stmt_get_result($stmt);
if ($extraResult instanceof mysqli_result) {
mysqli_free_result($extraResult);
}
}
mysqli_stmt_close($stmt);
存储过程不会自动解决 PHP 调用位置的 SQL 注入问题。传入的数据值仍应使用参数化查询,不能把用户输入直接拼接进 CALL 语句。
另外,即使调用位置正确使用了参数绑定,如果存储过程内部又使用字符串拼接构造动态 SQL,过程内部仍然可能产生 SQL 注入。因此需要同时检查:
CALL 除了返回过程内部 SELECT 产生的结果集,还会返回调用状态。过程也可能产生多个结果集。如果没有处理完这些结果就继续使用连接,MySQLi 可能出现 Commands out of sync。上面的循环用于消费剩余结果。
mysqli_stmt_get_result() 和 mysqli_stmt_more_results() 依赖 mysqlnd。如果运行环境没有 mysqlnd,需要改用 mysqli_stmt_bind_result() 等方式读取结果。
PHP:mysqli_stmt_next_result()
连接中毒
我们上面说了如果连接没有处理完就会报错,下面动手验证一下。首先在 MySQL 里创建一个返回多个结果集的存储过程(过程体里每一条不带 INTO 的 SELECT 都会成为一批结果集):
SQL > delimiter //
SQL > CREATE PROCEDURE multi_result()
–> READS SQL DATA
–> BEGIN
–> SELECT id FROM users WHERE id < 5;
–> SELECT COUNT(*) AS total FROM users;
–> SELECT 'hello' AS msg, 42 AS num;
–> END //
SQL > delimiter ;
SQL > CALL multi_result;
+—-+
| id |
+—-+
| 1 |
| 2 |
| 3 |
| 4 |
+—-+
4 rows in set (0.0383 sec)
+——-+
| total |
+——-+
| 11 |
+——-+
1 row in set (0.0383 sec)
+——-+—–+
| msg | num |
+——-+—–+
| hello | 42 |
+——-+—–+
1 row in set (0.0383 sec)
我们可以看到返回了三个结果集。下面编写一个 PHP 代码,只读取其中第一批结果,剩余的结果集不做任何处理,再复用该连接执行新查询,看看会发生什么:
<?php
$conn = mysqli_connect('localhost', 'account', 'password', 'RUNOOB');
$stmt = mysqli_prepare($conn, 'call multi_result();');
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
while ($row = mysqli_fetch_assoc($result)) {
echo "ID:" . $row['id'] . "<br>";
}
mysqli_free_result($result);
// 只读了第 1 批,剩余 2 批 + 状态包都没处理,直接复用连接 ↓
$res = mysqli_query($conn, 'SELECT 4 as ok;');
if ($res === false) {
echo mysqli_error($conn);
} else {
echo "读取成功";
}
mysqli_close($conn);
?>
运行结果:
ID:1
ID:2
ID:3
ID:4
Commands out of sync; you can't run this command now
这就可以发现,没有处理剩余结果集的情况下,复用该连接就会无辜报错,这就是"连接中毒"。
那么加上消费剩余结果集的循环再试一次:
<?php
$conn = mysqli_connect('localhost', 'account', 'password', 'RUNOOB');
$stmt = mysqli_prepare($conn, 'call multi_result();');
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
while ($row = mysqli_fetch_assoc($result)) {
echo "ID:" . $row['id'] . "<br>";
}
mysqli_free_result($result);
// 把剩余的结果集(包括最后的调用状态包)全部消费掉
while (mysqli_stmt_more_results($stmt)) {
mysqli_stmt_next_result($stmt);
$extra = mysqli_stmt_get_result($stmt);
if ($extra instanceof mysqli_result) {
mysqli_free_result($extra);
}
}
$res = mysqli_query($conn, 'SELECT 4 as ok;');
if ($res === false) {
echo mysqli_error($conn);
} else {
echo '读取成功';
}
mysqli_close($conn);
?>
我们消耗结果集之后就读取成功了:
ID:1
ID:2
ID:3
ID:4
读取成功
坑 1:为什么消耗结果集时会报 bool given 警告?
如果你把上面循环里的 if($extra instanceof mysqli_result) 去掉,直接写:
while (mysqli_stmt_more_results($stmt)) {
mysqli_stmt_next_result($stmt);
$extra = mysqli_stmt_get_result($stmt);
mysqli_free_result($extra); // ← 没有 instanceof 检查
}
运行时会看到:
Warning: mysqli_free_result() expects parameter 1 to be mysqli_result, bool given in …
原因在于:CALL 返回的并不全是结果集。 一个 CALL multi_result() 实际会向客户端发送一串东西:
| 1 | 第 1 批结果集(id < 5 的记录) | mysqli_result 对象 |
| 2 | 第 2 批结果集(total 计数) | mysqli_result 对象 |
| 3 | 第 3 批结果集(hello / 42) | mysqli_result 对象 |
| 4 | 状态包(影响行数、警告数等调用状态) | false |
mysqli_stmt_get_result() 的设计是:“这次真的有结果集,就返回对象;没有(比如轮到状态包),就返回 false”。
循环最后一次进来时,轮到的是状态包,$extra 变成了 false——它不是一个结果集对象,却直接被塞给了 mysqli_free_result(),于是报 Warning。“最后也消耗完了”,是因为循环把状态包也跳过去了,连接因此恢复正常;但跳的过程中踩了一次空。
坑 2:$extra instanceof mysqli_result 是什么意思?
这是一句类型检查,逐词拆开:
| $extra | 上一步 mysqli_stmt_get_result() 拿到的值 |
| instanceof | PHP 运算符:“左边的对象,是不是右边这个类的实例?” |
| mysqli_result | mysqli 的结果集类(对象类型) |
整句含义:“$extra 真的是一个结果集对象吗?” 是 → 才执行 mysqli_free_result();不是(比如 false)→ 跳过。
$extra = mysqli_stmt_get_result($stmt);
if ($extra instanceof mysqli_result) { // 只有真的是结果集才释放
mysqli_free_result($extra);
}
这就是防御性写法:多一行检查,正好挡掉状态包这个坑。所以"消耗完结果集"的完整定义是:把每一批结果集都读走或释放,连最后的调用状态包也要跳过,连接才算真正干净,可以安全复用。
删除与查找存储过程
使用 DROP PROCEDURE 删除存储过程:
DROP PROCEDURE get_user_by_id;
更推荐加上 IF EXISTS:
DROP PROCEDURE IF EXISTS get_user_by_id;
这样即使过程不存在,也不会产生“找不到存储过程”的错误,只会产生提示。
删除前可以查看当前数据库的存储过程:
SHOW PROCEDURE STATUS
WHERE Db = DATABASE();
如果需要查看某个过程的完整创建语句,可以使用:
SHOW CREATE PROCEDURE get_user_by_id;
它对于检查参数、过程体、DEFINER 和 SQL SECURITY 等信息很有用。能否看到完整定义还取决于当前账号拥有的权限。
存储过程向调用者产生结果的方式
返回结果集
DROP PROCEDURE IF EXISTS get_user_by_id;
DELIMITER //
CREATE PROCEDURE get_user_by_id(IN p_user_id INT)
READS SQL DATA
BEGIN
SELECT name, age, birthday
FROM users
WHERE id = p_user_id;
END //
DELIMITER ;
调用时,过程内部没有使用 INTO 的 SELECT 会把查询结果集发送给调用者,适用于返回一行或多行数据。
一个过程可以执行多个这样的 SELECT,因此也可能返回多个结果集。应用程序必须逐个读取或清理。
使用 OUT 输出参数
DROP PROCEDURE IF EXISTS get_user_age;
DELIMITER //
CREATE PROCEDURE get_user_age(
IN p_user_id INT,
OUT p_age INT
)
READS SQL DATA
BEGIN
SELECT age
INTO p_age
FROM users
WHERE id = p_user_id;
END //
DELIMITER ;
调用并查看输出:
CALL get_user_age(1, @user_age);
SELECT @user_age;
因为第二个参数对应 OUT 参数,所以调用者需要提供一个可以接收结果的变量。
@user_age 是 MySQL 用户定义的会话变量:
- 以 @ 开头;
- 属于当前数据库连接(session);
- 不需要提前声明;
- 可以被存储过程赋值;
- 调用后可以通过 SELECT @user_age 查看;
- 断开该连接后,不应再依赖它的值;
- 它不同于通过 DECLARE 创建的局部变量,局部变量只在所属存储程序语句块中有效。
这里的查询应该最多返回一行。如果 id 不是唯一值并返回多行,SELECT … INTO 会报错。
通过修改数据库产生效果
DELIMITER //
CREATE PROCEDURE activate_user(IN p_user_id INT)
MODIFIES SQL DATA
BEGIN
UPDATE users
SET is_active = 1
WHERE id = p_user_id;
END //
DELIMITER ;
调用:
CALL activate_user(1);
这类过程不一定返回业务数据,而是通过 INSERT、UPDATE 或 DELETE 完成数据库操作。严格来说,这是过程产生的副作用,不是“返回数据”的第三种方式。调用者可以根据驱动提供的信息或 ROW_COUNT() 了解最后一条语句影响的行数。
如果过程包含多条修改语句,并且这些操作必须一起成功或一起失败,还应根据业务需要设计事务和异常处理,不能仅仅因为代码写在同一个存储过程中,就认为它们一定会自动回滚。
事务与异常处理示例:
DELIMITER //
CREATE PROCEDURE transfer(
IN p_from INT,
IN p_to INT,
IN p_amount DECIMAL(10,2)
)
MODIFIES SQL DATA
BEGIN
— 声明一个 EXIT handler,SQL 异常时自动回滚
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL; — 把错误重新抛出给调用者
END;
START TRANSACTION;
UPDATE accounts SET balance = balance – p_amount WHERE id = p_from;
UPDATE accounts SET balance = balance + p_amount WHERE id = p_to;
COMMIT;
END //
DELIMITER ;
关键是 DECLARE EXIT HANDLER FOR SQLEXCEPTION + ROLLBACK:如果任何一条 UPDATE 失败,整个事务会自动回滚,不会出现"账户 A 扣了钱但账户 B 没收到的"半成品状态。
存储过程与参数化查询的本质区别
初学者容易把存储过程直接当成"保存在服务器端的参数化查询",因为从调用方式看,两者确实相似:
— 参数化查询(Prepared Statement)
PREPARE stmt FROM 'SELECT name FROM users WHERE id = ?';
EXECUTE stmt USING @uid;
— 存储过程
CALL get_user_name(@uid);
但这只是表面相似。它们的设计目标、存在位置和安全性约束完全不同。
对比总表
| 存在位置 | 当前连接的临时对象;连接关闭则消失 | 持久保存在数据库的 mysql.proc 系统表中 |
| 生命周期 | 当前会话 | 跨会话、跨连接,直到被 DROP |
| 主要目的 | 安全地传递参数、避免 SQL 注入 | 封装多条 SQL 为一个可复用的逻辑单元 |
| 语句数量 | 通常对应一条 SQL | 可以包含多条语句、变量、条件、循环、事务 |
| 创建权限 | 不需要特殊权限 | 需要 CREATE ROUTINE |
| 调用权限 | 同连接用户本身的表权限 | 需要 EXECUTE;过程体按 DEFINER/INVOKER 执行 |
| 跨应用共享 | 不能 | 多个应用可以通过 CALL 共用一个过程 |
| 被攻破后果 | 影响当前会话的查询 | 持久化后门、高权限 DEFINER 提权、持续数据泄露 |
核心区别
1. 存在位置不同
参数化查询是连接内的临时对象:
PREPARE stmt FROM 'SELECT ?';
— 连接关闭后 stmt 自动消失
存储过程写入数据库后永久存在,直到显式删除:
SHOW PROCEDURE STATUS WHERE Db = DATABASE();
— 只要没有 DROP,换个客户端连接照样可以 CALL
2. 设计目标不同
参数化查询解决的是如何安全地把值传给一条 SQL。它的安全价值在于将数据与代码分离——? 占位符里的内容永远是数据,永远不会被当成 SQL 执行。
存储过程解决的是如何把多条 SQL 逻辑封装在服务器端,实现批量操作、业务校验、权限隔离等。它不天然安全——过程内部照样可以写出不安全的 SQL。
3. 权限面不同
参数化查询不引入新的权限概念:你能执行什么 SQL,取决于你连接数据库时用的账号有什么表权限。
存储过程引入了额外的权限层:
- 创建过程需要 CREATE ROUTINE
- 调用过程需要 EXECUTE
- 过程体内的操作按 SQL SECURITY DEFINER(默认)使用创建者的权限执行
这就带来一个关键安全事实:**一个只有 SELECT 权限的低权限用户,如果被授予了某个高权限 DEFINER 过程的 EXECUTE 权限,就可以通过调用这个过程间接完成他本不该直接执行的操作。如果这个过程内部还有动态 SQL 拼接,后果会被 DEFINER 的高权限放大。
4. 被攻破后的影响不同
参数化查询被注入(比如错误地拼接了表名):
- 只影响当前连接
- 权限受制于当前连接用户
- 连接关闭后不再持续
存储过程被注入(过程内部拼接动态 SQL):
- 攻击载荷持久化在数据库里,随时可以再次触发
- 如果 DEFINER 是高权限账号,注入的 SQL 以高权限执行
- 可以悄悄修改过程体自身,形成持久化后门
- 数据库重启后依然存在
实践中怎么区分
| 单条查询/插入/更新/删除,只需防注入 | 参数化查询就够了 |
| 多条 SQL 必须一起执行(如转账) | 存储过程 + 事务 |
| 需要向多个应用暴露统一数据接口 | 存储过程 |
| 需要利用 SQL SECURITY DEFINER 做权限隔离 | 存储过程 |
| 不需要复用、不需要事务、不需要权限隔离 | 不需要存储过程 |
原则:不要因为"别人都在用存储过程"而用存储过程。 它增加了一层权限管理负担,增加了持久化攻击面——只有当它带来的好处(封装、事务、权限隔离)确实值得这层负担时,才值得引入。
存储过程权限与 SQL SECURITY
与存储过程直接相关的常见权限如下:
| CREATE ROUTINE | 创建存储过程或函数 |
| ALTER ROUTINE | 修改过程特征或删除存储过程/函数 |
| EXECUTE | 调用存储过程或函数 |
这里最容易误解的是:activate_user() 内部执行了 UPDATE,不代表调用者因此需要 ALTER ROUTINE。
ALTER ROUTINE 管理的是存储过程自身的定义,例如修改过程特征或删除过程;它不是“允许过程修改表数据”的权限。过程执行 UPDATE 时究竟检查谁的表权限,取决于 SQL SECURITY:
DELIMITER //
CREATE PROCEDURE activate_user(IN p_user_id INT)
SQL SECURITY DEFINER
MODIFIES SQL DATA
BEGIN
UPDATE users
SET is_active = 1
WHERE id = p_user_id;
END //
DELIMITER ;
| SQL SECURITY DEFINER | 使用 DEFINER 账号的权限;这是默认值 |
| SQL SECURITY INVOKER | 使用调用者自己的权限 |
DEFINER 是谁? DEFINER 就是创建这个过程时使用的账号。如果用 root 创建,DEFINER 就是 root——任何人 CALL 这个过程都能以 root 权限执行里面的 SQL。所以 SQL SECURITY DEFINER 本身不是问题,拿谁当 DEFINER 才是问题。
不指定时的默认值:省略 SQL SECURITY 子句,MySQL 默认按 DEFINER 处理。INVOKER 必须显式写出,MySQL 不会自动帮你换成更安全的模式。
在默认的 DEFINER 模式下:
- 调用者需要对该过程具有 EXECUTE 权限;
- 过程体中的 UPDATE 根据 DEFINER 账号的权限执行;
- 调用者不一定需要直接拥有 users 表的 UPDATE 权限。
在 INVOKER 模式下:
- 调用者需要 EXECUTE 权限;
- 调用者还必须拥有过程体实际需要的表权限,例如 UPDATE。
这说明存储过程可以用来提供受控的数据库操作接口,但并不意味着 Web 应用必须使用类似 db_owner 的高权限账号。db_owner 是 SQL Server 中常见的角色名称,不应直接套用到 MySQL 权限模型中。
更合理的原则是最小权限:
- Web 应用账号只获得业务所需的权限;
- 如果使用 SQL SECURITY DEFINER,DEFINER 账号也只授予过程体真正需要的权限,不应使用高权限管理员账号;
- 明确限制谁拥有 EXECUTE 权限;
- 避免在高权限 DEFINER 过程中拼接不可信输入形成动态 SQL;
- 定期检查 DEFINER 账号是否仍然存在,避免形成孤立的存储对象。
SQL SECURITY 不负责限制权限。 它只回答"运行时用谁的权限",不负责"那个账号有什么权限"。"只给 DEFINER 过程体需要的权限"这件事,是你通过 GRANT 手动完成的,跟 SQL SECURITY 本身无关。
实践中可以专门建一个权限很窄的账号来创建过程:
— 专用账号,权限刚好够用,没有多余
CREATE USER 'proc_owner'@'localhost' IDENTIFIED BY 'xxx';
GRANT CREATE ROUTINE, ALTER ROUTINE ON mydb.* TO 'proc_owner'@'localhost';
GRANT SELECT, UPDATE ON mydb.users TO 'proc_owner'@'localhost';
然后用 proc_owner 登录创建过程,DEFINER 就是 proc_owner——即使攻击者控制了调用端,他通过过程能做的操作也不会超出 proc_owner 的权限范围。
默认情况下,MySQL 会把 ALTER ROUTINE 和 EXECUTE 自动授予过程创建者;如果服务器关闭了 automatic_sp_privileges,这一行为会改变,因此不能简单理解为“所有用户默认都有”或“所有用户默认都没有”执行权限。
MySQL:存储过程权限 · MySQL:存储对象访问控制
存储过程与安全性的关系
存储过程既不天然安全,也不天然危险,关键取决于如何编写和授权。
它可能带来的安全价值包括:
- 把复杂数据库操作集中在服务器端,减少不同应用重复实现;
- 只向应用开放有限的过程调用,而不是直接开放所有表权限;
- 集中进行业务校验、事务处理和审计。
它也可能带来风险:
- 使用高权限 DEFINER,让低权限调用者间接完成过多操作;
- 在过程内部拼接用户输入形成动态 SQL,产生 SQL 注入(见下方示例);
- 过程逻辑复杂但缺少事务、异常处理或权限检查;
- 应用没有处理完多个结果集,造成连接状态异常;
- 错误地认为“使用存储过程”本身就等于完成安全防护。
过程内部动态 SQL 注入示例:即使调用处使用了参数绑定,只要过程内部用 PREPARE / EXECUTE 拼接不可信输入,仍然可能产生注入。
— 危险的写法(DEFINER 为高权限时尤其危险)
DELIMITER //
CREATE PROCEDURE unsafe_search(IN p_table VARCHAR(64))
SQL SECURITY DEFINER
CONTAINS SQL
BEGIN
SET @sql = CONCAT('SELECT * FROM ', p_table);
PREPARE stmt FROM @sql; — 拼接处就是注入点
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END //
DELIMITER ;
p_table 的值 users; DROP TABLE admin; — 会变成两条语句。这类场景下,应避免拼接表名/列名等标识符;确实需要动态标识符时,务必通过白名单严格校验。
因此,存储过程安全需要同时考虑参数化、输入验证、DEFINER/INVOKER、最小权限、事务和错误处理。
注意
1. 把 DELIMITER 理解为 MySQL 服务器的 SQL 语句
DELIMITER 是客户端命令,用来控制客户端何时把完整文本发送给服务器,并不是存储过程语法本身。在 PHP 里把它发送给服务器通常会报语法错误。
2. 认为含有 UPDATE 的过程需要 ALTER ROUTINE
ALTER ROUTINE 用于修改或删除存储过程本身,不负责授权过程修改表。执行 UPDATE 时使用谁的表权限,由 SQL SECURITY DEFINER/INVOKER 决定。
3. 不要把“修改数据库”称为存储过程返回数据的方式
UPDATE 产生的是数据库状态变化,也就是副作用;它不是返回值。存储过程真正向调用者传递数据的常见方式是结果集以及 OUT/INOUT 参数,另外调用者还能取得受影响行数等状态信息。
4. 认为 Web 应用为了调用存储过程必须拥有数据库所有者权限
MySQL 可以通过 EXECUTE、SQL SECURITY 和精细的表权限实现最小权限,不需要让 Web 应用以数据库管理员身份运行。
5. 不要认为 PHP 取得第一个结果集后就已经处理完 CALL
存储过程可能产生多个结果集,CALL 本身还有状态结果。如果不读取或清理剩余结果,复用连接时可能出现 Commands out of sync。
6. “存储过程可以防 SQL 注入”的边界
调用位置安全,并不保证过程内部安全。只要过程内部仍然把不可信输入拼接进动态 SQL,就仍然可能产生注入。


