存储过程必须显式接收tenant_id参数,禁止硬编码或依赖会话变量;所有DML语句须严格WHERE过滤,禁用PREPARE动态拼接,配合索引与类型对齐及写入校验机制,方可实现安全多租户隔离。

存储过程必须显式接收 tenant_id 参数
硬编码或依赖会话变量(如 @tenant_id 或 current_setting())在连接池复用场景下极易串租户。MySQL 尤其危险:连接归还后变量残留,下一个租户可能查到前一个的数据。
正确做法是把租户标识作为输入参数强制传入,类型必须与表字段严格一致:
-
IN p_tenant_id VARCHAR(36)(UUID 租户 ID)或IN p_tenant_id BIGINT(数字型) - 所有
SELECT/UPDATE/DELETE语句都显式写WHERE tenant_id = p_tenant_id,禁止省略、子查询推导或用CURRENT_USER反查 - 避免在过程中调用
USER()或SESSION_USER——它们返回数据库账号,不是业务租户
别用 PREPARE/EXECUTE 拼接 SQL
看似灵活,实则引入两类硬伤:SQL 注入风险 + 执行计划缓存失效。常见错误写法:SET @sql = CONCAT('SELECT * FROM orders WHERE tenant_id = ''', p_tenant_id, '''');,一旦 p_tenant_id 是 ' OR 1=1 -- ',整张表就暴露了。
即使改用占位符,PREPARE 仍会为每次调用生成新执行计划,高并发下易触发 max_prepared_stmt_count 限制。更糟的是,PREPARE 语句在连接级生效,未 DEALLOCATE 就归还连接池,可能被下一租户复用旧语句。
安全替代方案:直接写静态 SQL,仅对值使用参数占位符(如 MySQL 的 ?,PostgreSQL 的 $1),让优化器能复用计划。
PostgreSQL 和 SQL Server 不该优先选存储过程
这两者有更原生、更安全的替代机制,硬套存储过程反而绕远路:
- PostgreSQL 应用
RLS(行级安全策略):在orders表上建策略USING (tenant_id = current_setting('app.tenant_id')::uuid),所有访问路径(直查、视图、函数)自动过滤,无需每个过程重复写WHERE - SQL Server 推荐内联表值函数(ITVF):
SELECT * FROM fn_tenant_orders(@tenant_id),支持参数、可内联优化、权限可单独授予,比存储过程更适合封装租户逻辑 - 若仍用 PostgreSQL 存储过程,必须声明为
SECURITY DEFINER才能读取current_setting(),否则权限不足返回空结果
租户字段索引和类型必须提前对齐
存储过程里 WHERE tenant_id = p_tenant_id 能否走索引,取决于底层字段是否被有效利用。常见翻车点:
-
tenant_id字段没索引,或只有单列索引但查询还带status = 'paid',没建(tenant_id, status)复合索引 → 全表扫描 - 应用传入字符串
'123',而字段是INT,存储过程没做CAST(p_tenant_id AS INT)→ 类型不匹配,索引失效甚至报错 - MySQL 中 UUID 租户 ID 用
VARCHAR(36)存储,但没加索引或用错排序规则(如utf8mb4_0900_as_cs影响等值判断)
执行 EXPLAIN 验证查询计划,确认出现 Index Scan 或 range 访问类型,而不是 ALL。
最常被忽略的一点:存储过程只解决“读取时过滤”,不解决“写入时校验”。如果应用 INSERT 时不带 tenant_id,或传了空值,过程不会自动拦截——这得靠表级 BEFORE INSERT 触发器或 CHECK 约束兜底。

















