行级安全(Row-Level Security)是 PostgreSQL 提供给多租户场景的一把利器——在数据库层面直接隔离不同租户的数据,省去应用层反复过滤的麻烦。但很多团队真正用上 RLS 后,第一个撞上的墙不是安全,而是性能。查询慢得离谱,索引仿佛失效,原因指向一个不起眼的函数属性:LEAKPROOF。Laurenz Albe 的这篇文章把这个问题拆得很透,也提出了一个值得社区争论的核心问题——leakproof 的门槛到底该设多高?
两种行级隔离手段
PostgreSQL 提供了两个相关特性来隐藏表中不该被看到的行:安全屏障视图(Security Barrier View)和行级安全策略(RLS)。两者的思路一致——在扫描表时自动追加过滤条件,但适用场景各有侧重。
安全屏障视图
从 PostgreSQL 9.2 引入。核心机制是:只要视图的 owner 有权限访问底层表,任何对视图有权限的用户就能通过视图查询,无需直接授权底层表。
CREATE TABLE account (
account_nr bigint PRIMARY KEY,
owner text NOT NULL,
amount numeric(15,2) NOT NULL
);
CREATE INDEX account_owner_idx ON account (owner);
CREATE VIEW my_account AS
SELECT account_nr, owner, amount
FROM account
WHERE owner = current_user;
GRANT SELECT, INSERT ON my_account TO PUBLIC;
每个数据库角色只能看到自己的账户行,即便他们对 account 表毫无权限。你甚至可以在视图上授予 INSERT——插入的行会落入底层表。如果想防止"插入了自己看不到的行"这种尴尬情况,加上 check option:
ALTER VIEW my_account SET (check_option = cascaded);
行级安全策略
从 PostgreSQL 9.5 引入,弥补视图的几处不足:视图难以叠加多条策略、对数据修改的支持有限、也无法对 SELECT 和 INSERT 施加不同规则。RLS 更灵活:
ALTER TABLE account ENABLE ROW LEVEL SECURITY;
ALTER TABLE account FORCE ROW LEVEL SECURITY;
CREATE POLICY account_sel ON account
FOR SELECT TO public USING (owner = current_user);
CREATE POLICY account_ins ON account
FOR INSERT TO public WITH CHECK (owner = current_user);
GRANT SELECT, INSERT ON account TO PUBLIC;
PostgreSQL 实现的方式很直接——扫描表时自动追加条件。执行计划会显示:
EXPLAIN (COSTS OFF) SELECT * FROM account;
QUERY PLAN
--------------------------
Seq Scan on account
Filter: (owner = CURRENT_USER)
注意豁免规则:表 owner(除非 FORCE)、superuser、带 BYPASSRLS 属性的角色,以及 row_security = off 时,都不受 RLS 约束。
leakproof:安全与性能的枢纽
没有 leakproof 会怎样?
假设你写了一个"会泄露信息"的函数——比如对某些参数值抛出包含参数内容的错误消息。攻击者只需让优化器把这个函数安排在 RLS 条件之前执行,就能绕过行级隔离。
理论上,可以强制优化器永远先执行 RLS 条件。但代价惊人:一条简单的 WHERE account_nr = 42 也无法走主键索引了,因为 bigint 等值比较的底层函数 int8eq 会先于 RLS 条件执行。这意味着几乎所有索引扫描都会失效。
LEAKPROOF 属性的定义
PostgreSQL 的解决方案是给函数打上 LEAKPROOF 标签:
LEAKPROOF 表示函数没有副作用,不会通过返回值以外的渠道泄露关于参数的信息。例如,对某些参数值抛出错误而对其他值不抛出的函数,或在错误消息中包含参数值的函数,都不是 leakproof。
打上 LEAKPROOF 的函数和运算符被信任,可以在 RLS/安全屏障条件之前执行。不带参数或参数不来自受保护表的函数,也无需标记 leakproof 即可提前执行。
对于视图,还需显式启用安全屏障:
ALTER VIEW my_account SET (security_barrier = on);
只有确实承担安全职责的视图才该开启——普通视图没必要牺牲性能。
保守的现状
PostgreSQL 传统上偏向谨慎——宁可慢也不冒数据风险。v18 标准安装中只有 345 个 leakproof 函数,绝大多数是基础类型的比较函数。实际效果是:只有基于基础类型的 B-tree 索引扫描能被推到 RLS 条件之前。这意味着大量使用 RLS 的用户遭遇性能滑坡,成为该特性推广的主要绊脚石。
EXPLAIN (ANALYZE):最显眼的侧信道
文档明确说了:安全屏障视图和 RLS 只保证"不可见的行不会被传给可能不安全的函数",不保证用户无法通过其他手段推断隐藏数据。侧信道攻击是真实存在的。
最简单的演示:
SET enable_seqscan = off;
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS OFF)
SELECT * FROM account WHERE owner = 'duff';
Index Scan using account_owner_idx on account (...)
Index Cond: (owner = 'duff'::text)
Filter: (owner = CURRENT_USER)
Rows Removed by Filter: 1
Execution Time: 0.076 ms
owner = 'duff' 用了 leakproof 的 text 等值运算符,所以优化器把它推到了 RLS 条件之前,走上了索引扫描。但 Rows Removed by Filter: 1 直接告诉你:数据库里存在一个 owner 为 duff 的账户。泄露信息的不是运算符,是 EXPLAIN 本身。
其他侧信道同样存在:
pg_stat_get_tuples_returned()诊断函数(无并发访问时可直接推断行数)- 时间攻击:反复执行同一语句,RLS 过滤额外行带来的执行时间增量可以被统计出来
- 执行计划差异:过滤掉不同数量的行可能导致优化器选择不同计划,计划本身成为信息源
Albe 曾提议在涉及 RLS 时将 EXPLAIN (ANALYZE) 限制为 pg_read_all_stats 角色成员。社区的反驳是:堵一个洞可能让人误以为 RLS 变得更安全了,而其他侧信道依然敞开;同时限制 EXPLAIN 对性能调优的伤害可能大于有限的安全收益——如果每个开发者都申请加入 pg_read_all_stats,反而削弱安全。Albe 最终撤回了补丁,但仍不完全认同"因为还有别的洞就不堵这个大洞"的逻辑。
leakproof 的门槛该多高?
文档的措辞很严格:任何会泄露参数信息的行为——包括抛出包含参数值的错误消息——都让函数不能标记为 leakproof。Albe 提出了一个具体案例:内存分配失败时的错误消息。
/* dsa.c 中的错误消息示例 */
ERROR: out of memory
DETAIL: Failed on DSA request of size 1234.
如果分配的内存量取决于某个数据值的大小,这条错误消息就泄露了关于该数据的信息。攻击者可以对服务器施加精确的内存压力,使分配失败,从而推断隐藏数据的尺寸。
但 Albe 质疑这种场景的实际可利用性:如果你不能控制数据库服务器、不能执行任意 SQL,怎么施加受控的内存压力?反过来,如果把 OOM 错误也算作泄露,那几乎所有涉及内存分配的函数都不能标记 leakproof,RLS 的性能将更加不可用。
他的观点是:只有当攻击者能通过向 SQL 语句(由应用控制)提供参数值来触发泄露信息的错误时,才应该拒绝 leakproof 标记。更高的标准会让一个本该好用的特性在实际应用中变得无用。
实践建议:如何正确使用 RLS
基于以上分析,行级安全不应该被视为"能抵御执行任意 SQL 的用户"的铜墙铁壁。正确的使用姿势是:
- 在应用层控制 SQL——用户只能影响参数值,不能自由构造查询。RLS 在这个场景下是可靠的。
- 对承担安全职责的视图启用 security_barrier,普通视图不要开。
- 谨慎标记自定义函数为 LEAKPROOF——只有确实不泄露任何参数信息的函数才该标记,且只有超级用户能设置。
- 不要向不受信任的用户暴露 EXPLAIN (ANALYZE)——即便社区没堵这个洞,你可以在应用层控制。
- 关注侧信道——如果数据敏感性极高(比如医疗、金融),RLS 可能不够,考虑更严格的隔离方案(如分库)。
一个可运行的完整示例,展示 RLS 的基本配置和 leakproof 对执行计划的影响:
-- 1. 创建表和索引
CREATE TABLE account (
account_nr bigint PRIMARY KEY,
owner text NOT NULL,
amount numeric(15,2) NOT NULL
);
CREATE INDEX account_owner_idx ON account (owner);
-- 2. 插入测试数据
INSERT INTO account VALUES
(1, 'alice', 100.00),
(2, 'bob', 200.00),
(3, 'carol', 300.00);
-- 3. 创建受限角色
CREATE ROLE rls_user;
GRANT SELECT, INSERT ON account TO rls_user;
-- 4. 启用 RLS
ALTER TABLE account ENABLE ROW LEVEL SECURITY;
ALTER TABLE account FORCE ROW LEVEL SECURITY;
-- 5. 创建策略:每个用户只能看自己的行
CREATE POLICY account_sel ON account
FOR SELECT TO rls_user USING (owner = current_user);
-- 6. 用 rls_user 身份测试
SET ROLE rls_user;
-- 6a. 简单查询:owner 等值条件用了 leakproof 运算符,可以走索引
EXPLAIN (COSTS OFF) SELECT * FROM account WHERE owner = current_user;
-- 预期:Index Scan,条件被推到 RLS filter 之前
-- 6b. 用非 leakproof 函数的条件,优化器必须先执行 RLS filter
EXPLAIN (COSTS OFF) SELECT * FROM account WHERE lower(owner) = lower(current_user);
-- 预期:Seq Scan 或 Filter 顺序不同,性能可能显著下降
-- 7. 恢复身份
RESET ROLE;
-- 8. 清理(可选)
DROP ROLE rls_user;
DROP TABLE account;
第 6b 步中 lower() 不是 leakproof 函数,优化器不敢把它推到 RLS 条件之前,查询可能退化为顺序扫描。这正是很多 RLS 用户遭遇性能问题的根源——一旦 WHERE 条件里出现非 leakproof 的函数或运算符,索引优势就消失了。
核心取舍很清晰:leakproof 标记越严格,RLS 越安全但越慢;标记越宽松,性能越好但侧信道风险更大。Albe 的建议是放宽到"应用可控参数值无法触发泄露"的程度,这个标准是否合理,值得每个在生产中使用 RLS 的团队思考。