内容提要
实测 Supabase RLS 中 auth.uid() 与 (select auth.uid()) 的差异:在百万行表且无索引时,count 查询从 923ms 降至 42ms,原因是包装后只执行一次;有索引时两者均为 0.1–0.3ms。查询自带 user_id 过滤时也很快。建议为策略列建索引,仍用 (select …) 包装 auth.uid() 和 auth.jwt(),并将角色检查改用 to 子句。
延伸解读
索引才是性能分水岭
实测显示,在 user_id 上建索引后,无论是否用 (select auth.uid()) 包装,所有查询都降到 0.1–0.3ms。无索引时包装才带来 923ms 到 42ms 的差距。这说明优化 RLS 策略时,优先为策略比较的列建索引,比纠结函数写法更有效。索引让条件变成 Index Cond,只计算一次,而非逐行求值。
包装生效的边界条件
包装只在条件无法利用索引、退化为逐行 Filter 时明显有效。此时内联的 auth.uid() 会每行执行 current_setting、jsonb 解析和类型转换,百万行就是百万次。包装后生成 InitPlan,只执行一次。若查询自身已带 user_id 过滤,规划器可能将其合并为 One-Time Filter,效果与包装相当,但策略仍需为忘记过滤的查询兜底。
角色检查应改用 to 子句
额外用 auth.jwt() ->> 'role' = 'authenticated' 做角色检查是最昂贵的模式,无索引时达 1592ms,比仅 uid 检查的 923ms 更慢。包装两个函数后降到 42ms 左右。文章建议将角色检查改为策略的 to 子句,因为策略本已限定 to authenticated,叠加角色检查只增加成本。
实测的局限与适用提醒
结论基于 PostgreSQL 17.11/18.6 的一次性容器、默认配置和特定数据分布(每用户 100 行)。文章未覆盖 PostgREST 实际事务设置、托管平台、联表策略,以及索引存在但规划器仍选顺序扫描的情况。因此,索引加包装是稳妥起点,但生产环境仍需结合自身查询模式验证执行计划。
Q&A
Supabase RLS 里 auth.uid() 和 (select auth.uid()) 到底有什么区别?
auth.uid() 是内联的 SQL 函数,在策略中会被展开成表达式;用 (select auth.uid()) 包装后,PostgreSQL 会生成 InitPlan,使函数体只执行一次,而不是每行执行一次。在无索引的百万行表上,count 查询从 923ms 降到 42ms。
为什么 (select auth.uid()) 只在没有索引的时候才明显提速?
有索引时,内联表达式会成为索引条件(Index Cond),每次扫描只计算一次,所以两种写法都只要 0.1–0.3ms。无索引时条件变成顺序扫描上的 Filter,每行都要计算,包装后变成 InitPlan 只算一次,因此差距巨大。
在 Supabase RLS 策略里,给 user_id 建索引能带来多大提升?
建索引是最大的优化杠杆:在百万行表上,无论用 auth.uid() 还是 (select auth.uid()),所有查询都从数百毫秒降到 0.1–0.3ms。
查询里自己带上 user_id 过滤条件,还需要用 (select auth.uid()) 吗?
查询自带 user_id 过滤时,规划器会把它和策略合并成 One-Time Filter,只计算一次函数,耗时约 40ms,与包装效果相当。但不能依赖每个查询都这么做,策略是为那些忘记过滤的查询兜底的,所以仍建议包装。
用 auth.jwt() 做角色检查为什么慢?该怎么改?
同时检查 auth.jwt() ->> 'role' = 'authenticated' 的策略最慢,无包装时达 1592ms,因为角色检查在 RLS 下仍逐行执行。建议把 auth.jwt() 也用 (select …) 包装,或者干脆去掉角色检查,改用策略的 to 子句指定角色。
根据实测,Supabase RLS 策略的最佳实践是什么?
首先为策略比较的列(如 user_id、tenant_id)建索引;其次仍然用 (select …) 包装 auth.uid() 和 auth.jwt(),有索引时无额外开销,无索引时能兜底;最后把角色检查写成 to 子句,而不是在策略里用 auth.jwt() 判断。