PostgreSQL 17 行级安全性能实测:哪些 RLS 写法会拖慢查询

2026-10-01 30 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

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 更适合被当成查询计划的一部分,而不是独立的权限配置。上线前至少检查以下事项:

  1. 策略中的辅助函数是否使用了正确的 IMMUTABLE、STABLE 或 VOLATILE 声明;
  2. 重复不变的函数调用能否通过 (SELECT ...) 形成一次性 InitPlan;
  3. 租户列、用户列和成员关系连接列是否有与访问方向匹配的索引;
  4. 用户查询中的非 leakproof 函数是否阻止了谓词下推或表达式索引;
  5. 测试是否以受限角色执行,而不是使用会绕过 RLS 的表所有者或超级用户;
  6. 连接池是否在每个事务中可靠设置并清理租户上下文;
  7. 身份会话变量是否只能由可信服务端设置,避免客户端伪造租户 ID。

最值得警惕的不是 RLS 固定增加了多少毫秒,而是一次策略改动让索引扫描退化为全表扫描。把策略 SQL、索引和 EXPLAIN 计划一起纳入代码审查与性能回归测试,才能让行级安全同时保持可靠和可扩展。


相关推荐