PostgreSQL 的行级安全(Row-Level Security,RLS)把权限规则放进数据库,能显著降低应用层漏加租户条件的风险。但策略并不是“免费过滤器”:函数的 volatility 标记、一个看似多余的 SELECT 包装、成员关系子查询,以及函数是否 LEAKPROOF,都可能改变 PostgreSQL 17 的执行计划。
讨论 RLS 性能时,不应只问“有没有额外开销”,而要看策略是否让优化器还能使用索引、缓存表达式结果,并把相关子查询变成成本可控的访问路径。
策略函数是执行一次,还是每行执行一次
多租户系统常把当前用户或租户放进会话变量,再由策略函数读取:
CREATE FUNCTION app.current_tenant_id()
RETURNS bigint
LANGUAGE sql
STABLE
AS $$
SELECT current_setting('app.tenant_id', true)::bigint
$$;
这里的 STABLE 不只是文档注释。它告诉优化器:在同一条 SQL 语句中,函数结果不会变化。若函数被错误标记为 VOLATILE,PostgreSQL 必须假设每次调用都可能得到不同结果,于是更难把它当成索引扫描的固定参数,也可能对大量候选行重复调用。
不过,STABLE 并不等价于“保证全查询只执行一次”。常见的进一步优化,是在策略里加一层标量子查询:
CREATE POLICY tenant_policy ON documents
USING (
tenant_id = (SELECT app.current_tenant_id())
);
这层 (SELECT ...) 可能让计划器生成 InitPlan,先计算一次租户 ID,再把结果作为参数用于后续扫描。对比直接调用:
CREATE POLICY tenant_policy ON documents
USING (
tenant_id = app.current_tenant_id()
);
两种写法是否产生不同计划,仍要以当前 PostgreSQL 版本、函数定义和查询形态下的 EXPLAIN 为准。不要机械地给所有函数套 SELECT,而应检查计划中是否出现 InitPlan、索引条件,以及实际扫描行数。
可以直接运行的对比实验
下面的脚本会创建 100 万条测试数据、一个租户索引和 RLS 策略。请使用 PostgreSQL 17 的管理员账号在测试数据库中运行;脚本会创建 app_user 角色和 documents 表,名称冲突时需要先修改。
DROP TABLE IF EXISTS public.documents CASCADE;
DROP SCHEMA IF EXISTS app CASCADE;
DROP ROLE IF EXISTS app_user;
CREATE ROLE app_user NOLOGIN;
CREATE SCHEMA app;
GRANT USAGE ON SCHEMA app TO app_user;
CREATE FUNCTION app.current_tenant_id()
RETURNS bigint
LANGUAGE sql
STABLE
AS $$
SELECT current_setting('app.tenant_id', true)::bigint
$$;
CREATE TABLE public.documents (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
title text NOT NULL
);
INSERT INTO public.documents (tenant_id, title)
SELECT (n % 100) + 1, 'document-' || n
FROM generate_series(1, 1000000) AS g(n);
CREATE INDEX documents_tenant_id_idx
ON public.documents (tenant_id);
ANALYZE public.documents;
GRANT SELECT ON public.documents TO app_user;
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_policy ON public.documents
FOR SELECT TO app_user
USING (
tenant_id = (SELECT app.current_tenant_id())
);
SET ROLE app_user;
SET app.tenant_id = '42';
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT count(*)
FROM public.documents;
RESET ROLE;
接着可以移除 SELECT 包装,重新测试:
DROP POLICY tenant_policy ON public.documents;
CREATE POLICY tenant_policy ON public.documents
FOR SELECT TO app_user
USING (
tenant_id = app.current_tenant_id()
);
SET ROLE app_user;
SET app.tenant_id = '42';
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT count(*)
FROM public.documents;
RESET ROLE;
比较时重点观察:
- 是否使用
documents_tenant_id_idx; - 计划中是否出现 InitPlan;
Rows Removed by Filter是否异常增大;- shared buffer 命中和读取数量;
- 多次预热后的执行时间,而不是第一次运行的单个数字。
还可以复制函数,分别声明为 VOLATILE 和 STABLE,再对同一查询执行多轮测试。修改 volatility 必须符合函数的真实语义,不能只为获取更快的计划而撒谎。
成员关系子查询:问题通常不在 EXISTS,而在访问路径
现实中的策略往往不是简单的 tenant_id = current_tenant_id(),而是检查用户是否属于某个团队:
CREATE POLICY document_membership ON documents
USING (
EXISTS (
SELECT 1
FROM team_members tm
WHERE tm.team_id = documents.team_id
AND tm.user_id = (SELECT app.current_user_id())
)
);
这种策略可能对主表的许多候选行执行成员关系检查。关键是让子查询能通过索引快速回答“这条成员关系是否存在”:
CREATE UNIQUE INDEX team_members_user_team_idx
ON team_members (user_id, team_id);
列顺序应跟真实访问方式匹配。如果系统经常先按 user_id 找出该用户的全部团队,(user_id, team_id) 通常更合适;如果多数查询从团队出发检查成员,则还要评估 (team_id, user_id)。不要凭习惯建立两个重复索引,因为写入成本和存储空间同样真实存在。
当成员关系复杂、层级很深或策略嵌套多张同样启用了 RLS 的表时,可以这样实践:
- 先单独对成员关系查询执行
EXPLAIN (ANALYZE, BUFFERS); - 确认连接列的数据类型完全一致,避免隐式转换破坏索引条件;
- 考虑把用户可访问的团队 ID 预先求出,再参与主查询;
- 谨慎使用
SECURITY DEFINER辅助函数,并固定search_path、限制执行权限、审计函数所有者。
LEAKPROOF 为什么会影响索引使用
RLS 不只是在查询后面偷偷追加一个普通 WHERE。它还建立了安全边界:为了防止恶意表达式通过错误信息或副作用推断本应不可见的数据,PostgreSQL 通常只允许标记为 LEAKPROOF 的函数在 RLS 检查之前执行。
可以检查某个函数的 volatility 和 leakproof 属性:
SELECT
oid::regprocedure AS function_name,
provolatile,
proleakproof
FROM pg_proc
WHERE oid IN (
'pg_catalog.lower(text)'::regprocedure,
'pg_catalog.current_setting(text,boolean)'::regprocedure
);
假设应用存在表达式索引:
CREATE INDEX documents_tenant_lower_title_idx
ON documents (tenant_id, lower(title));
查询如下:
SELECT id, title
FROM documents
WHERE lower(title) = 'quarterly report';
如果用户条件依赖非 leakproof 函数,优化器可能不能在 RLS 安全检查之前应用该条件,从而无法完整使用预期的表达式索引,或者只能使用索引的一部分。具体结果取决于策略、索引定义和 PostgreSQL 版本,因此应通过 EXPLAIN (ANALYZE, BUFFERS, VERBOSE) 验证,而不是仅凭“已经建了索引”判断。
不要为了强制索引,把普通业务函数随意声明成 LEAKPROOF。只有超级用户能设置该属性,而且它是一项安全承诺:函数不得通过错误、参数相关消息或其他可观察行为泄露数据。错误标记可能直接破坏 RLS 的安全模型。
上线前的 RLS 性能检查表
RLS 更适合被当成查询计划的一部分,而不是独立的权限配置。上线前至少检查以下事项:
- 策略中的辅助函数是否使用了正确的
IMMUTABLE、STABLE或VOLATILE声明; - 重复不变的函数调用能否通过
(SELECT ...)形成一次性 InitPlan; - 租户列、用户列和成员关系连接列是否有与访问方向匹配的索引;
- 用户查询中的非 leakproof 函数是否阻止了谓词下推或表达式索引;
- 测试是否以受限角色执行,而不是使用会绕过 RLS 的表所有者或超级用户;
- 连接池是否在每个事务中可靠设置并清理租户上下文;
- 身份会话变量是否只能由可信服务端设置,避免客户端伪造租户 ID。
最值得警惕的不是 RLS 固定增加了多少毫秒,而是一次策略改动让索引扫描退化为全表扫描。把策略 SQL、索引和 EXPLAIN 计划一起纳入代码审查与性能回归测试,才能让行级安全同时保持可靠和可扩展。