为什么行级安全性 (RLS) 不使用索引?

Why is Row Level Security (RLS) not using indexes?

我收到了患者和治疗师的申请。他们都在同一个 users table。患者应该能够看到他们的治疗师,治疗师应该能够看到他们的患者。

我已经设置了一个带有成对用户 ID 的物化视图 (user_access_pairs),如果两个用户在视图中有一行那么这意味着他们应该可以互相访问。

database> \d user_access_pairs
+----------+---------+-------------+
| Column   | Type    | Modifiers   |
|----------+---------+-------------|
| id1      | integer |             |
| id2      | integer |             |
+----------+---------+-------------+
Indexes:
    "index_user_access_pairs" UNIQUE, btree (id1, id2)

这是 users table 的定义,它还有一堆与此问题无关的列。

database> \d users
+-----------------------------+-----------------------------+-----------------------------------------------------+
| Column                      | Type                        | Modifiers                                           |
|-----------------------------+-----------------------------+-----------------------------------------------------|
| id                          | integer                     |  not null default nextval('users_id_seq'::regclass) |
| first_name                  | character varying(255)      |                                                     |
| last_name                   | character varying(255)      |                                                     |
+-----------------------------+-----------------------------+-----------------------------------------------------+
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)

我已经创建了一个 RLS 策略来限制 users 可以被谁使用 jwt 令牌读取。

create policy select_users_policy
  on public.users
  for select using (
    (current_setting('jwt.claims.user_id'::text, true)::integer, id) in (
      select id1, id2 from user_access_pairs
    )
  );

这似乎合乎逻辑,但我的表现很糟糕。尽管那里有索引,但查询计划程序对 user_access_pairs 进行了顺序扫描。

database> set jwt.claims.user_id to '2222';
database> explain analyze verbose
    select first_name, last_name
    from users
+------------------------------------------------------------------------------------------------------------------------------------+
| QUERY PLAN                                                                                                                         |
|------------------------------------------------------------------------------------------------------------------------------------|
| Seq Scan on public.users  (cost=231.84..547.19 rows=2386 width=14) (actual time=5.481..6.418 rows=2 loops=1)                       |
|   Output: users.first_name, users.last_name                                                                                        |
|   Filter: (hashed SubPlan 1)                                                                                                       |
|   Rows Removed by Filter: 4769                                                                                                     |
|   SubPlan 1                                                                                                                        |
|     ->  Seq Scan on public.user_access_pairs  (cost=0.00..197.67 rows=13667 width=8) (actual time=0.005..1.107 rows=13667 loops=1) |
|           Output: user_access_pairs.id1, user_access_pairs.id2                                                                     |
| Planning Time: 0.072 ms                                                                                                            |
| Execution Time: 6.521 ms                                                                                                           |
+------------------------------------------------------------------------------------------------------------------------------------+

但是,如果我切换到绕过 RLS 的超级用户角色并手动应用相同的过滤器,我将获得更好的性能。不应该是一样的吗?

database> set jwt.claims.user_id to '2222';
database> explain analyze verbose
   select first_name, last_name
   from users
   where (current_setting('jwt.claims.user_id'::text, true)::integer, id) in (
     select id1, id2 from user_access_pairs
   )
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| QUERY PLAN
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Nested Loop  (cost=4.59..27.86 rows=2 width=14) (actual time=0.041..0.057 rows=2 loops=1)
|   Output: users.first_name, users.last_name
|   Inner Unique: true
|   ->  Bitmap Heap Scan on public.user_access_pairs  (cost=4.31..11.26 rows=2 width=4) (actual time=0.029..0.036 rows=2 loops=1)
|         Output: user_access_pairs.id1, user_access_pairs.id2
|         Filter: ((current_setting('jwt.claims.user_id'::text, true))::integer = user_access_pairs.id1)
|         Heap Blocks: exact=2
|         ->  Bitmap Index Scan on index_user_access_pairs  (cost=0.00..4.31 rows=2 width=0) (actual time=0.018..0.018 rows=2 loops=1)
|               Index Cond: (user_access_pairs.id1 = (current_setting('jwt.claims.user_id'::text, true))::integer)
|   ->  Index Scan using users_pkey on public.users  (cost=0.28..8.30 rows=1 width=18) (actual time=0.008..0.008 rows=1 loops=2)
|         Output: users.id, users.email, users.encrypted_password, users.first_name, users.last_name, users.roles_mask, users.reset_password_token, users.reset_password_sent_at, users.remember_created_at, users.sign_in_count, users.current_sign_in_at, users.last_sign_in_at,
|         Index Cond: (users.id = user_access_pairs.id2)
| Planning Time: 0.526 ms
| Execution Time: 0.116 ms
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

为什么 RLS 在进行查询时不使用索引?

PS 我正在使用 PostgreSQL 版本 12.4

database> select version()
+-------------------------------------------------------------------------------------------------------------------------------+
| version                                                                                                                       |
|-------------------------------------------------------------------------------------------------------------------------------|
| PostgreSQL 12.4 (Ubuntu 12.4-0ubuntu0.20.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.3.0-10ubuntu2) 9.3.0, 64-bit |
+-------------------------------------------------------------------------------------------------------------------------------+

编辑

感谢劳伦斯的回复。它提高了很多性能。 但我仍在进行一些序列扫描。

这是 Laurenz 建议的更新后的政策。

create policy select_users_policy
  on public.users
  for select using (
    exists (
      select 1
      from user_access_pairs
      where
        id1 = current_setting('jwt.claims.user_id'::text, true)::integer
        and id2 = users.id
    )
  );

使用 RLS 查询这个 table 仍然给我在 users table 上的序列扫描,即使策略中的 exists 查询正在使用索引。

database> set jwt.claims.user_id to '2222';
database> explain analyze verbose
  select first_name, last_name
  from users
+-------------------------------------------------------------------------------------------------------------------------------------------------------+
| QUERY PLAN                                                                                                                                            |
|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| Seq Scan on public.users  (cost=0.00..40048.81 rows=2394 width=14) (actual time=0.637..1.216 rows=2 loops=1)                                          |
|   Output: users.first_name, users.last_name                                                                                                           |
|   Filter: (alternatives: SubPlan 1 or hashed SubPlan 2)                                                                                               |
|   Rows Removed by Filter: 4785                                                                                                                        |
|   SubPlan 1                                                                                                                                           |
|     ->  Index Only Scan using index_user_access_pairs on public.user_access_pairs  (cost=0.29..8.31 rows=1 width=0) (never executed)                  |
|           Index Cond: ((user_access_pairs.id1 = (current_setting('jwt.claims.user_id'::text, true))::integer) AND (user_access_pairs.id2 = users.id)) |
|           Heap Fetches: 0                                                                                                                             |
|   SubPlan 2                                                                                                                                           |
|     ->  Bitmap Heap Scan on public.user_access_pairs user_access_pairs_1  (cost=4.31..11.26 rows=2 width=4) (actual time=0.075..0.083 rows=2 loops=1) |
|           Output: user_access_pairs_1.id2                                                                                                             |
|           Recheck Cond: (user_access_pairs_1.id1 = (current_setting('jwt.claims.user_id'::text, true))::integer)                                      |
|           Heap Blocks: exact=2                                                                                                                        |
|           ->  Bitmap Index Scan on index_user_access_pairs_on_id1  (cost=0.00..4.31 rows=2 width=0) (actual time=0.064..0.064 rows=2 loops=1)         |
|                 Index Cond: (user_access_pairs_1.id1 = (current_setting('jwt.claims.user_id'::text, true))::integer)                                  |
| Planning Time: 0.572 ms                                                                                                                               |
| Execution Time: 1.295 ms                                                                                                                              |
+-------------------------------------------------------------------------------------------------------------------------------------------------------+

这是在没有 RLS 的情况下“手动”完成的相同查询以供比较。这次没有序列扫描,性能明显更好(尤其是当 运行 在更大的数据集上时)

database> set jwt.claims.user_id to '2222';
database> explain analyze verbose
    select first_name, last_name
    from users
    where exists (
       select 1
       from user_access_pairs
       where
         id1 = current_setting('jwt.claims.user_id'::text, true)::integer
         and id2 = users.id
     )

+---------------------------------------------------------------------------------------------------------------------------------------------+
| QUERY PLAN                                                                                                                                  |
|---------------------------------------------------------------------------------------------------------------------------------------------|
| Nested Loop  (cost=4.59..27.86 rows=2 width=14) (actual time=0.020..0.033 rows=2 loops=1)                                                   |
|   Output: users.first_name, users.last_name                                                                                                 |
|   Inner Unique: true                                                                                                                        |
|   ->  Bitmap Heap Scan on public.user_access_pairs  (cost=4.31..11.26 rows=2 width=4) (actual time=0.013..0.016 rows=2 loops=1)             |
|         Output: user_access_pairs.id1, user_access_pairs.id2                                                                                |
|         Recheck Cond: (user_access_pairs.id1 = (current_setting('jwt.claims.user_id'::text, true))::integer)                                |
|         Heap Blocks: exact=2                                                                                                                |
|         ->  Bitmap Index Scan on index_user_access_pairs_on_id1  (cost=0.00..4.31 rows=2 width=0) (actual time=0.010..0.010 rows=2 loops=1) |
|               Index Cond: (user_access_pairs.id1 = (current_setting('jwt.claims.user_id'::text, true))::integer)                            |
|   ->  Index Scan using users_pkey on public.users  (cost=0.28..8.30 rows=1 width=18) (actual time=0.006..0.006 rows=1 loops=2)              |
|         Output: users.id, users.email, users.encrypted_password, users.first_name, users.last_name, users.roles_mask                        |
|         Index Cond: (users.id = user_access_pairs.id2)                                                                                      |
| Planning Time: 0.464 ms                                                                                                                     |
| Execution Time: 0.075 ms                                                                                                                    |
+---------------------------------------------------------------------------------------------------------------------------------------------+

我猜想查询规划器会同样对待这两个查询。为什么它们不同,如​​何避免序列扫描?

我无法指出其中的区别,但我认为您应该通过更明智的政策制定更好的计划:

CREATE POLICY select_users_policy ON public.users
  FOR SELECT
  USING (
     EXISTS (SELECT 1 FROM user_access_pairs
             WHERE id1 = current_setting('jwt.claims.user_id'::text, true)
               AND id2 = users.id)
  );

我想提一下,基于用户可以随时更改的占位符变量的行级安全性是有问题的安全性。

之所以没有看到与没有 RLS 策略的看似等效的查询相同的计划,是因为子查询上拉发生 之前 RLS 策略被考虑在内。这是计划者的怪癖。

总而言之,不幸的是,RLS 策略与子查询的组合不是彼此的朋友 performance-wise。

供您参考,比较以下两个查询时可以看到类似的表现形式:

SELECT ... FROM my_table WHERE                     EXISTS(SELECT ...);
SELECT ... FROM my_table WHERE CASE WHEN true THEN EXISTS(SELECT ...) END;

此处,虽然两个查询是等价的,但第二个查询会生成子查询的(散列)子计划,因为不必要的 CASE WHEN true 的折叠是在 after 子查询上拉。

免责声明:我从 IRC #postgresql 上的 RhodiumToad 获得了此信息,但 explained/simplified 这是我自己的话。

this comment 的作者(通过反复试验)提出了将子查询转换为 ARRAY 的解决方案。完全不确定它是否适用于您的情况,但只是表明非常意想不到的技巧显然会吓到优化器来完成它的工作。

所以你可以试试:

create policy select_users_policy
on public.users
for select using (
  users.id = any (
    array(
        select id1
        from user_access_pairs
        where 
            id1 = current_setting('jwt.claims.user_id'::text, true)::integer
            and id2 = users.id
        )
    )
);

很尴尬,但谁知道...

问题中没有说明,但我假设从 public.users 读取是从另一个 API-facing 模式(我们称之为 api)触发的。

一个人在 subZero Slack 上分享了:

I ran into the same problem and defined the RLS based on my api views which solved the seq scan problem. But it's a bit of a pain to maintain when making changes to these views, because for the migration I have to first drop the RLS policies, change the view and then re-create the policies. ... When there's subqueries involved in the RLS I use the api views.

因此,他们使用完全相同的规则,但引用了 api.fooapi.bar views instead of public.fooandpublic.bar` 表。

对于你的情况,你可以尝试:

create policy select_users_policy
  on public.users
  for select using (
    exists (
      select 1
      from api.user_access_pairs
      where
        id1 = current_setting('jwt.claims.user_id'::text, true)::integer
        and id2 = api.users.id
    )
  );

因此,这是假设您在 api 架构镜像 public.users 中有一个 users 视图,并将 user_access_pairs 也移动到 api(或者创建一个引用它的视图)。

我不清楚这是否有效,因为查询首先是从 api 模式中的 view/function 触发的,因此在该模式中引用视图在某种程度上不那么令人困惑查询优化器,或者这只是让优化器启动的一个技巧,而不管查询是如何产生的。 (在我看来,后者似乎更有可能,但谁知道呢。)

subZero Slack 上的另一位用户分享了一个解决方案,该解决方案基于将当前用户权限的 look-up 包装在一个函数中。在您的情况下,类似于:

create policy select_users_policy
  on public.users
  for select using (
    id IN (
      select * from current_user_read_users()
   )
  );

你会创建一个 current_user_read_users() 函数,它从 jwt 中查找 user_id 和 returns 当前用户可能阅读的一组用户,基于 user_access_pairs.

此函数与 user_access_pairs 视图具有相同的所有者,或者函数是用 SECURITY DEFINER 声明的(以便它绕过 RLS),这可能重要也可能不重要。可能重要的部分只是将子查询拉出到一个函数中(以某种方式帮助优化器),但是报告的其他事情有助于解决其他性能问题。

最后,您可能想尝试将其放在 api 视图中,如 我报告的那样。

一个警告:

there is a circular dependency issue on the permission table itself, so there I had to do one special case policy. That one didn’t have any performance issues, though, so it was fine.

(请注意,在他们的情况下,权限由管理员用户保存在 table、editable 中,而不是像您的情况那样生成.)

一个解决方案(基于 this post,它有其他几个很好的建议和基准)是根本不使用 RLS,而是将过滤构建到视图中:

create view api.allowed_users
with (security_barrier)
as
  select id, first_name, last_name, favorite_color
  from public.users
  join user_access_pairs uap
    on uap.id1 = current_setting('jwt.claims.user_id'::text, true)::integer

您已经在 user_access_pairs 视图中表达了您的访问策略,因此可以说 RLS 规则并没有真正添加任何内容。

security_barrier 是为了防止潜在的信息泄露,但会带来性能成本,因此请考虑您的情况是否有必要。)