【问题标题】:Extract integer array from jsonb faster in Postgres 11+在 Postgres 11+ 中更快地从 jsonb 中提取整数数组
【发布时间】:2022-07-04 03:14:57
【问题描述】:

我正在设计一个表,它有一个jsonb 列实现权限,格式如下:

[
    {"role": 5, "perm": "view"},
    {"role": 30, "perm": "edit"},
    {"role": 52, "perm": "view"}
]

TL;DR

如何将这种jsonb 值转换为整数角色的SQL 数组?在本例中,它将是'{5,30,52}'::int[]。我有一些解决方案,但没有一个足够快。继续阅读...

每个登录用户都有一些角色(一个或多个)。这个想法是使用 int[] 上的重叠运算符 (&&) 过滤记录。

SELECT * FROM data WHERE extract_roles(access) && '{1,5,17}'::int[]

我正在寻找也可以在索引定义中使用的extract_roles 函数/表达式:

CREATE INDEX data_roles ON data USING gin ((extract_roles(access)))

Postgres 中的jsonb 似乎对构建和转换有广泛的支持,但对提取值的支持较少——在这种情况下是 SQL 数组。

我尝试了什么:

create or replace function extract_roles(access jsonb) returns int[]
    language sql
    strict
    parallel safe
    immutable
-- with the following bodies:

-- (0) 629ms
select translate(jsonb_path_query_array(access, '$.role')::text, '[]', '{}')::int[]

-- (1) 890ms
select array_agg(r::int) from jsonb_path_query(access, '$.role') r

-- (2) 866ms
select array_agg((t ->> 'role')::int) from jsonb_array_elements(access) as x(t)

-- (3) 706ms
select f1 from jsonb_populate_record(row('{}'::int[]), jsonb_build_object('f1', jsonb_path_query_array(access, '$.role'))) as x (f1 int[])

使用索引时,查询速度很快。但是这些表达式有两个问题:

  1. 部分功能只有stable而不是immutable;这也适用于cast我可以将我的函数标记为immutable吗?索引定义需要不变性。
  2. 它们很慢;规划器在某些情况下不使用索引,然后查询会变得非常慢(以上时间是在具有 3M 条记录的表上):
explain (analyse)
select id, access
from data
where extract_roles(access) && '{-3,99}'::int[]
order by id
limit 100

使用以下计划(上述所有变体都相同;更喜欢扫描与主键关联的索引,获得排序结果并希望它很快找到其中的 100 个):

Limit  (cost=1000.45..2624.21 rows=100 width=247) (actual time=40.668..629.193 rows=100 loops=1)
  ->  Gather Merge  (cost=1000.45..476565.03 rows=29288 width=247) (actual time=40.667..629.162 rows=100 loops=1)
        Workers Planned: 2
        Workers Launched: 2
        ->  Parallel Index Scan using data_pkey on data  (cost=0.43..472184.44 rows=12203 width=247) (actual time=25.522..513.463 rows=35 loops=3)
              Filter: (extract_roles(access) && '{-3,99}'::integer[])
              Rows Removed by Filter: 84918
Planning Time: 0.182 ms
Execution Time: 629.245 ms

删除LIMIT 子句的速度非常快:

Gather Merge  (cost=70570.65..73480.29 rows=24938 width=247) (actual time=63.263..75.710 rows=40094 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Sort  (cost=69570.63..69601.80 rows=12469 width=247) (actual time=59.870..61.569 rows=13365 loops=3)
        Sort Key: id
        Sort Method: external merge  Disk: 3744kB
        Worker 0:  Sort Method: external merge  Disk: 3232kB
        Worker 1:  Sort Method: external merge  Disk: 3160kB
        ->  Parallel Bitmap Heap Scan on data  (cost=299.93..68722.36 rows=12469 width=247) (actual time=13.823..49.336 rows=13365 loops=3)
              Recheck Cond: (extract_roles(access) && '{-3,99}'::integer[])
              Heap Blocks: exact=9033
              ->  Bitmap Index Scan on data_roles  (cost=0.00..292.44 rows=29926 width=0) (actual time=9.429..9.430 rows=40094 loops=1)
                    Index Cond: (extract_roles(access) && '{-3,99}'::integer[])
Planning Time: 0.234 ms
Execution Time: 77.719 ms

有没有更好更快的方法从jsonb 中提取int[]因为我不能依赖计划器总是使用索引。使用extract_roles 函数的COST 会有所帮助(规划器开始使用LIMIT 1000 的索引),但即使是非常高的值也不会强制使用LIMIT 100 的索引。

评论:

如果没有,我可能会将信息存储在另一列 roles int[] 中,这速度很快,但需要额外的空间并需要额外的处理(可以使用 Postgres 12+ 上生成的列来解决,Azure 仍然不提供,或触发器,或在应用程序逻辑中)。

展望未来,Postgres 15 会有更好的支持吗?可能是JSON_QUERY,但我看不到任何立竿见影的改进,因为它的RETURNING 子句可能指的是整个结果而不是它的元素。

也许jsonb_populate_record 也可以考虑非复合类型(其签名允许),例如:

select jsonb_populate_record(null::int[], '[123,456]'::jsonb)

最接近的两个问题是:


对建议标准化的反应:

标准化可能不可行。但让我们顺着思路走吧。

我假设额外的表看起来像这样:*_perm (id, role, perm)id 上会有一个索引,role 上会有另一个索引。

由于一个用户有多个角色,它可能会为同一个 id 连接多个记录,这会导致数据表中的记录相乘,并强制进行group by 聚合。

group by 不利于性能,因为它会阻止一些优化。我正在设计一个积木。因此,例如可以有两个数据表在起作用:

select pd.*, jsonb_agg(to_jsonb(pp))
from posts_data pd
join posts_perm pp on pd.id = pp.id
where exists(
    select 1
    from comments_data cd on cd.post_id = pd.id
    join comments_perm cp on cp.id = cd.id
    where cd.reputation > 100
    and cp.role in (3,34,52)
    -- no group by needed due to semi-join
)
and cp.role in (3,34,52)
group by pd.id
order by pd.title
limit 10

如果我没记错的话,这个查询需要在排序之前聚合所有记录。这里没有索引可以提供帮助。对于数百万条记录,这永远不会很快。此外,group by 的使用背后有一个重要的逻辑 - 它并不总是需要。

如果我们不需要返回权限而只关心它的存在呢?

select pd.*
from posts_data pd
where exists(
    select 1
    from posts_perm pp on pd.id = pp.id
    where cp.role in (3,34,52)
)
and exists(
    select 1
    from comments_data cd on cd.post_id = pd.id
    where exists(
        select 1
        from comments_perm cp on cp.id = cd.id
        where cp.role in (3,34,52)
    )
    and cd.reputation > 100
)
order by pd.title
limit 10

然后我们不需要任何聚合 - 数据库将简单地发出 SEMI-JOIN。如果title上有索引,数据库可以考虑使用。我们甚至可以在投影中获取权限;像这样:

select pd.*, (select jsonb_agg(to_jsonb(pp)) from posts_perm pp on pd.id = pp.id) perm
...

仅针对少数 (10) 条记录发出嵌套循环连接。我将测试这种方法。

另一种选择是将数据保留在两个表中 - 数据表将仅存储int[] 的角色。然后我们保存一个 JOIN,最后只从权限表中获取。现在我们需要一个支持数组操作的索引——GIN。

select pd.*, (select jsonb_agg(to_jsonb(pp)) from posts_perm pp on pd.id = pp.id) perm
from posts_data pd
where pd.roles && '{3,34,52}'::int[]
and exists(
    select 1
    from comments_data cd on cd.post_id = pd.id
    where cd.roles && '{3,34,52}'::int[]
    and cd.reputation > 100
)
order by pd.title
limit 10

因为我们总是汇总返回记录的所有权限(它们的解释在应用程序中,我们返回所有这些都无关紧要),我们可以将 post_perms 存储为json。因为我们从不需要使用 SQL 中的值,所以将它们直接存储在数据表中似乎是合理的。

稍后我们将需要支持一些批量共享操作来更新许多记录的权限,但这比选择要少得多。因此,我们可以改用jsonb

投影不再需要选择权限:

select pd.*
...

但现在roles 列是多余的 - 我们在同一个表中拥有相同的信息,只是采用 JSON 格式。如果我们可以编写一个只提取角色的函数,我们可以直接对其进行索引。

我们又回到了起点。但看起来extract_roles 函数永远不会很快,所以我们需要保留roles 列。

在同一个表中保留权限的另一个原因是可以使用 Bitmap And 组合多个索引并避免连接。

角色会有很大的偏差。有些将出现在几乎所有行上(管理员可以编辑所有内容),有些则很少见(John Doe 只能访问与他明确共享的这 3 条记录)。我不确定统计数据在 int[] 方法上的效果如何,但到目前为止,我的测试表明,当角色不频繁(高选择性)时使用 GIN 索引。

【问题讨论】:

  • 为什么要使用 json,因为每次提取都会花费时间。
  • 使用正确规范化的数据模型将非常容易(并且可能也更有效)
  • @a_horse_with_no_name - 我尝试探索规范化方法(参见编辑),但尚未运行任何查询。我的想法合理吗?
  • “它们很慢;规划器在某些情况下不使用索引,”您是否在创建表达式索引后分析表?
  • “在 Postgres 12+ 上使用生成的列,Azure 仍然没有提供这些列”我会假设现在没有超过 v11 的任何东西都是一些死胡同的遗留系统,我不想发展反对它了。他们的“灵活服务器”目前升级到 v13。

标签: sql postgresql jsonb


【解决方案1】:

看起来这里的核心问题是WHERE...ORDER BY...LIMIT 的经典问题,规划器假设所有符合条件的行都均匀分布在整个排序中。但这不是这里的情况:满足&& 条件的行选择性地缺乏低编号的“id”。因此,它必须将该索引走得比它认为需要走的更远,然后才能达到 LIMIT。

您无法做任何事情(在任何版本中)来让计划者更好地估计。您可以通过将其重写为order by id+0 来阻止使用该索引。但是,即使它真的更快,它也不会使用该计划,就像管理所有事情的管理员一样。 (顺便说一句,这似乎是个坏主意——可能应该对异常用户进行异常处理,而不是硬塞到正常系统中)。

不可变的提取函数当然很慢,但是如果上述计划问题得到解决,那就没关系了。使函数更快可能需要一些编译代码,Azure 肯定不会让您将 .so 文件链接到他们的托管服务器。

【讨论】:

    【解决方案2】:

    由于 JSON 具有常规结构(int、text),因此我还考虑了两种替代存储:

    • 创建(int, text)的复合类型role并存储数组role[]extract_roles 函数还是需要的;
    • 存储两个数组int[]text[]

    后者获胜的原因如下:

    • 最小磁盘空间(对于需要 seq 扫描的查询很重要);
    • 不需要extract_roles函数——直接存储int数组;
    • 无需功能索引;
    • 轻松附加(但 JSON 也是如此);
    • 我使用的库 (jOOQ) 对数组有很好的绑定,因此使用它们甚至可能比使用 JSON 更愉快。

    缺点是:

    • 更难移除 - 需要取消嵌套和重新聚合。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2020-10-14
      • 2016-03-09
      • 1970-01-01
      • 2019-11-20
      • 1970-01-01
      • 1970-01-01
      • 2016-08-10
      相关资源
      最近更新 更多