优化GROUP BY查询以检索每个用户的最新记录

xpa*_*pad 45 sql postgresql indexing greatest-n-per-group postgresql-performance

我在Postgres 9.2中有下表(简化形式)

CREATE TABLE log (
    log_date DATE,
    user_id  INTEGER,
    payload  INTEGER
);
Run Code Online (Sandbox Code Playgroud)

它每个用户和每天最多包含一条记录.每天将有大约500,000条记录,为期300天.每个用户的running_total总是在增加.

我想在特定日期之前有效地检索每个用户的最新记录.我的查询是:

SELECT user_id, max(log_date), max(payload) 
FROM log 
WHERE log_date <= :mydate 
GROUP BY user_id
Run Code Online (Sandbox Code Playgroud)

这非常慢.我也尝试过:

SELECT DISTINCT ON(user_id), log_date, payload
FROM log
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC;
Run Code Online (Sandbox Code Playgroud)

具有相同的计划,同样缓慢.

到目前为止,我在user_msg_log(aggr_date)上有一个索引,但没有多大帮助.我应该用什么其他索引来加快速度,还是以任何其他方式实现我的目标?

Erw*_*ter 106

为获得最佳读取性能,您需要一个多列索引:

CREATE INDEX log_combo_idx
ON log (user_id, log_date DESC NULLS LAST);
Run Code Online (Sandbox Code Playgroud)

要使索引仅扫描成为可能,请添加其他不需要的列payload:

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST) INCLUDE (payload);
Run Code Online (Sandbox Code Playgroud)

为什么INCLUDE

对于每个或每个小表的行,DESC NULLS LAST简单user_id是最快和最简单的解决方案之一:

对于许多每行DISTINCT ON一个松散索引扫描将是(多)更有效.这在Postgres中没有实现(至少在Postgres 10中实现),但有一些方法可以模仿它:

1.没有唯一用户的单独表格

以下解决方案超出了Postgres Wiki所涵盖的范围.
随着一个单独的user_id表,在方案2,下面是通常更加简单和快速.

1A.带LATERAL连接的递归CTE

常用表格表达式要求Postgres 8.4+.
users需要Postgres 9.3+.

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST, payload);
Run Code Online (Sandbox Code Playgroud)

这在Postgres的当前版本中是优选的,并且检索任意列很简单.第2a章有更多解释.下面.

1B.具有相关子查询的递归CTE

方便检索单个列整行.该示例使用表的整行类型.其他变体是可能的.

WITH RECURSIVE cte AS (
   (                                -- parentheses required
   SELECT user_id, log_date, payload
   FROM   log
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT l.*
   FROM   cte c
   CROSS  JOIN LATERAL (
      SELECT l.user_id, l.log_date, l.payload
      FROM   log l
      WHERE  l.user_id > c.user_id  -- lateral reference
      AND    log_date <= :mydate    -- repeat condition
      ORDER  BY l.user_id, l.log_date DESC NULLS LAST
      LIMIT  1
      ) l
   )
TABLE  cte
ORDER  BY user_id;
Run Code Online (Sandbox Code Playgroud)

用于测试行值可能会产生误导LATERAL.这仅users在测试行的每一列都返回时返回,如果包含user_id单个log值则会失败.(我的答案中有一段时间我有这个错误.)相反,在前一次迭代中找到了一个行,测试了一行定义的行users(如主键).更多:

2b章中对此查询的更多解释.下面.
相关答案:

2.带独立的log桌子

只要每个相关的行只有一行,表格布局就不重要了LATERAL.例:

WITH RECURSIVE cte AS (
   (                                           -- parentheses required
   SELECT l AS my_row                          -- whole row
   FROM   log l
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT (SELECT l                            -- whole row
           FROM   log l
           WHERE  l.user_id > (c.my_row).user_id
           AND    l.log_date <= :mydate        -- repeat condition
           ORDER  BY l.user_id, l.log_date DESC NULLS LAST
           LIMIT  1)
   FROM   cte c
   WHERE  (c.my_row).user_id IS NOT NULL       -- note parentheses
   )
SELECT (my_row).*                              -- decompose row
FROM   cte
WHERE  (my_row).user_id IS NOT NULL
ORDER  BY (my_row).user_id;
Run Code Online (Sandbox Code Playgroud)

理想情况下,表是物理排序的.看到:

或者它足够小(低基数),这几乎不重要.
否则,在查询中对行进行排序有助于进一步优化性能.见Gang Liang的补充.

2A.JOIN LATERAL加入

CREATE TABLE users (
   user_id  serial PRIMARY KEY
 , username text NOT NULL
);
Run Code Online (Sandbox Code Playgroud)

FROM允许users在同一查询级别引用前面的项目.每个用户只能获得一个索引(-only)查找.

通过在另一个答案中对Gang Liang建议log表格进行排序来考虑可能的改进.如果表的物理排序顺序与索引匹配,则不需要此操作.LEFT JOIN LATERAL ... ON trueCROSS JOIN LATERAL

LIMIT n即使您有条目,也无法获得表中缺少用户的结果LIMIT 1.通常,您将具有强制引用完整性的外键约束来规则.

您也没有为任何没有匹配条目的用户获取行JOIN.这符合你原来的问题.如果需要在结果使用中包含这些行LEFT JOIN LATERAL而不是log:

此表单最适合检索每个用户的多行(但不是全部).只需使用NULL而不是combo1.

实际上,所有这些都会做同样的事情:

SELECT u.user_id, l.log_date, l.payload
FROM   users u
CROSS  JOIN LATERAL (
   SELECT l.log_date, l.payload
   FROM   log l
   WHERE  l.user_id = u.user_id         -- lateral reference
   AND    l.log_date <= :mydate
   ORDER  BY l.log_date DESC NULLS LAST
   LIMIT  1
   ) l;
Run Code Online (Sandbox Code Playgroud)

但后者的优先级较低.WHERE在逗号之前显式绑定.

2B.相关子查询

单行检索单个列的好选择.代码示例:

这同样是可能的多列,但你需要更多的智慧:

JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...
Run Code Online (Sandbox Code Playgroud)
  • NOT NULL上面这样的变体包括所有用户,即使没有条目CREATE TYPE.你得到(log_date, payload)::combocombo1,payload如果需要的话,可以使用外部查询中的子句轻松过滤.
    Nitpick:在外部查询中,您无法区分子查询是否找不到行,或者返回的所有值都是NULL - 结果相同.您必须INCLUDE在子查询中包含一列才能确定.

  • 相关子查询只能返回单个值.您可以将多个列包装为复合类型.但是为了稍后分解它,Postgres需要一种众所周知的复合类型.匿名记录只能在提供列定义列表的情况下进行分解.

  • 使用已注册类型(如现有表的行类型)或创建类型.显式(和永久)注册复合类型DESC NULLS LAST,或创建临时表(在会话结束时自动删除)以临时提供行类型.转换为该类型:user_id

  • 最后,我们不希望DISTINCT ON在同一查询级别上进行分解.由于查询规划器的弱点,这将为每列评估子查询一次(直到Postgres 9.6 - 计划为Postgres 10进行改进).相反,将其作为子查询并在外部查询中进行分解.

有关:

使用100k日志条目和1k用户演示所有4个查询:
SQL Fiddle - pg 9.6
db <> fiddle here - pg 10

  • 我发誓:Erwin Brandstetter 是 PostgreSQL 的首席开发人员。关于这个主题的知识如此丰富,令人印象深刻。 (7认同)

Gor*_*off 6

也许表上的不同索引会有所帮助。试试这个: log(user_id, log_date)。我不肯定 Postgres 会充分利用distinct on.

所以,我会坚持使用该索引并尝试这个版本:

select *
from log l
where not exists (select 1
                  from log l2
                  where l2.user_id = l.user_id and
                        l2.log_date <= :mydate and
                        l2.log_date > l.log_date
                 );
Run Code Online (Sandbox Code Playgroud)

这应该用索引查找代替排序/分组。它可能会更快。


Gan*_*ang 5

这不是一个独立的答案,而是对@Erwin的答案的评论。对于横向连接示例2a,可以通过对users表进行排序以利用上的索引的位置来改进查询log

SELECT u.user_id, l.log_date, l.payload
  FROM (SELECT user_id FROM users ORDER BY user_id) u,
       LATERAL (SELECT log_date, payload
                  FROM log
                 WHERE user_id = u.user_id -- lateral reference
                   AND log_date <= :mydate
              ORDER BY log_date DESC NULLS LAST
                 LIMIT 1) l;
Run Code Online (Sandbox Code Playgroud)

理由是如果user_id值是随机的,则索引查找会很昂贵。通过user_id首先进行排序,随后的横向联接就像对索引的简单扫描log。即使两个查询计划看起来都一样,但是运行时间还是会有很大差异的,尤其是对于大型表。

排序的成本是最小的,特别是如果user_id现场有索引的话。