内容提要
本文实测PostgreSQL 17多租户行级安全(RLS):表须启用FORCE RLS,应用角色不得为超级用户或具BYPASSRLS;租户须在显式事务内用SET LOCAL或set_config(…,true)设置,否则PgBouncer事务模式下会串租户。策略成本通常可忽略,但易因函数、子查询、索引失效变慢。RLS不保护外键、唯一约束、共享序列和物化视图。
延伸解读
RLS生效的三个关键条件
文章实测表明,要让行级安全真正隔离租户,必须同时满足三个条件:表启用FORCE ROW LEVEL SECURITY,应用角色不是超级用户且没有BYPASSRLS权限,租户在显式事务内用SET LOCAL或set_config(…, true)设置。缺少FORCE时,表所有者能看到所有行;使用普通SET在PgBouncer事务模式下,下一个客户端会看到上一个租户的267,023张发票。
策略性能:简单策略无开销,复杂策略代价高
最简单的策略几乎不增加成本:统计267,023张发票耗时11.0 ms对11.0 ms。但策略中若使用VOLATILE函数,每次查询可能耗时1,860–1,920 ms;使用成员子查询则需79–99 ms。此外,lower()函数因非leakproof导致索引失效,查询从0.21 ms增至34–45 ms,改用存储生成列可恢复至0.25 ms。
RLS不保护的三个领域
行级安全不保护外键和唯一约束,它们会绕过策略,导致跨租户引用被接受;序列值在所有租户间共享,pg_dump导出会包含共享序列的当前值;物化视图无法启用RLS,其内容取决于刷新时所有者的策略。这些不是bug,而是设计限制,需通过复合键、独立序列或应用层逻辑弥补。
调试RLS问题的实用路径
当查询返回空或错误租户数据时,可按顺序检查:当前事务中current_setting('app.tenant', true)是否返回预期租户;策略是否覆盖该命令和角色;角色是否为表所有者或拥有BYPASSRLS;连接池是否导致设置泄漏;视图所有者是否绕过策略。文章提供了从症状到具体文章的排查路线,帮助快速定位问题。
Q&A
在PostgreSQL中,行级安全(RLS)要真正隔离租户,必须满足哪些关键条件?
根据实测,三个条件决定大多数情况:1)表必须启用FORCE ROW LEVEL SECURITY,且应用连接的角色不能是超级用户或具有BYPASSRLS属性;2)租户必须在显式事务内通过SET LOCAL或set_config(…, true)设置,否则在PgBouncer事务模式下会串租户;3)视图不能由绕过策略的角色拥有,应使用security_invoker = true。此外,每个含tenant_id的表都必须有RLS和策略,且策略不能有WITH CHECK (true)。
PostgreSQL行级安全策略对查询性能有多大影响?
在简单形式下,策略成本可忽略:计数267,023张发票耗时11.0 ms vs 11.0 ms,中位租户最新50条为0.30 ms vs 0.34 ms。但策略中若使用VOLATILE函数、子查询或导致索引失效,性能会显著下降。例如,VOLATILE的PL/pgSQL函数使查询耗时1,860–1,920 ms,而STABLE或包装在(select …)中仅0.29–0.30 ms;子查询memberships耗时79–99 ms;lower()函数导致索引失效,查询从0.21 ms增至34–45 ms。
使用PgBouncer事务模式时,如何避免租户设置泄漏到下一个请求?
必须在显式事务内使用SET LOCAL或set_config(…, true)设置租户,且该事务包含请求的所有查询。实测中,在事务模式后使用普通SET或set_config(…, false),下一个未设置租户的客户端会看到最大租户的267,023张发票;而使用SET LOCAL在显式事务内则返回0行。在Go的pgx中,将set_config(…, true)作为批处理的第一条语句也能在一次往返中保持隔离。
行级安全(RLS)不能保护哪些数据库对象或操作?
RLS不保护三类:1)外键和唯一约束检查会绕过策略,可能接受跨租户引用并泄露行存在信息;2)序列值在租户间共享,pg_dump导出会包含共享序列的当前值;3)物化视图不能启用RLS,其内容取决于所有者策略在刷新时的结果。
在PostgreSQL中,如何正确设置租户变量以确保请求间不泄漏?
应在包含请求所有查询的显式事务内,将set_config('app.tenant', …, true)作为第一条语句执行,并在每次提交后重新设置。这在测试的六个框架(Go pgx、Django、SQLAlchemy、Prisma、Laravel、Drizzle)中均有效。SET LOCAL在纯SQL中效果相同。注意:在事务外设置事务局部变量在多数框架中无效,且Django和Laravel可能因异常处理导致提交失败请求的写入。
为什么RLS策略会导致查询返回空结果或所有行?如何调试?
常见原因包括:租户未在查询执行处设置(如未在事务内设置或提交后失效);角色是表所有者、超级用户或具有BYPASSRLS;视图所有者绕过策略;表未启用RLS;或策略缺少对应命令的USING/WITH CHECK。调试可按顺序检查:1)在相同事务中运行select current_setting('app.tenant', true)确认租户;2)检查策略是否覆盖该命令和角色;3)检查角色权限、视图和表设置。