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

Posted

技术标签:

【中文标题】优化 GROUP BY 查询以检索每个用户的最新行【英文标题】:Optimize GROUP BY query to retrieve latest row per user 【发布时间】:2014-10-21 14:02:40 【问题描述】:

我在 Postgres 9.2 中有以下用户消息(简化形式)的日志表:

CREATE TABLE log (
    log_date DATE,
    user_id  INTEGER,
    payload  INTEGER
);

每个用户每天最多包含一条记录。在 300 天内,每天将有大约 500K 条记录。每个用户的有效负载都在不断增加(如果重要的话)。

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

SELECT user_id, max(log_date), max(payload) 
FROM log 
WHERE log_date <= :mydate 
GROUP BY user_id

这非常慢。我也试过:

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

计划相同,速度同样慢。

到目前为止,我在 log(log_date) 上只有一个索引,但没有多大帮助。

我有一个包含所有用户的users 表。我还想检索一些用户(payload &gt; :value)的结果。

我应该使用任何其他索引来加快速度,或者任何其他方式来实现我想要的吗?

【问题讨论】:

尝试在(user_id, aggr_date) 上建立一个索引,或者在user_id 上单独尝试一个索引。另外对于任何与性能相关的问题,请阅读以下内容:wiki.postgresql.org/wiki/Slow_Query_Questions "SELECT user_id, max(log_date), max(payload) FROM log WHERE log_date 【参考方案1】:

为了获得最佳读取性能,您需要multicolumn index:

CREATE INDEX log_combo_idx
ON log (user_id, log_date DESC NULLS LAST);

要使 index only scans 成为可能,请在 covering index 中使用 INCLUDE 子句(Postgres 11 或更高版本)添加其他不需要的列 payload

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST) INCLUDE (payload);

见:

Do covering indexes in PostgreSQL help JOIN columns?

旧版本的回退:

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST, payload);

为什么是DESC NULLS LAST

Unused index in range of dates query

对于每个user_id少数 行或DISTINCT ON 的小表通常是最快和最简单的:

Select first row in each GROUP BY group?

对于 许多 行,每个 user_idindex skip scan (or loose index scan) (很多)效率更高。这在 Postgres 12 - work is ongoing for Postgres 14 之前还没有实现。但是有一些方法可以有效地模拟它。

Common Table Expressions 需要 Postgres 8.4+LATERAL 需要 Postgres 9.3+。 以下解决方案超出了Postgres Wiki 所涵盖的范围。

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

使用单独的users 表,下面2. 中的解决方案通常更简单、更快。跳过。

1a。带有LATERAL 的递归 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;

这很容易检索任意列,并且在当前的 Postgres 中可能是最好的。更多解释在下面的2a.章中。

1b。具有相关子查询的递归 CTE

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;

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

要断言在上一次迭代中找到了一行,请测试单个 NOT NULL 列(如主键)。

第 2b 章对此查询的更多解释。下面。

相关:

Query last N related rows per row GROUP BY one column, while sorting by another in PostgreSQL

2。带有单独的users

只要保证每个相关user_id 恰好有一行,表格布局就无关紧要了。示例:

CREATE TABLE users (
   user_id  serial PRIMARY KEY
 , username text NOT NULL
);

理想情况下,该表的物理排序与log 表同步。见:

Optimize Postgres timestamp query range

或者它足够小(低基数),它几乎不重要。否则,对查询中的行进行排序有助于进一步优化性能。 See Gang Liang's addition. 如果users 表的物理排序顺序恰好与log 上的索引匹配,这可能无关紧要。

2a。 LATERAL加入

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;

JOIN LATERAL 允许在同一查询级别引用前面的 FROM 项目。见:

What is the difference between LATERAL JOIN and a subquery in PostgreSQL?

导致每个用户进行一次索引(仅)查找。

不为users 表中缺失的用户返回任何行。通常,强制引用完整性的外键约束会排除这种情况。

此外,log 中没有匹配条目的用户没有行 - 符合原始问题。要将这些用户保留在结果中,请使用 LEFT JOIN LATERAL ... ON true 而不是 CROSS JOIN LATERAL

Call a set-returning function with an array argument multiple times

使用 LIMIT n 而不是 LIMIT 1 来检索每个用户多行(但不是全部)。

实际上,所有这些都是一样的:

JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...

不过,最后一个的优先级较低。显式 JOIN 在逗号前绑定。这种微妙的差异可能对更多的连接表很重要。见:

"invalid reference to FROM-clause entry for table" in Postgres query

2b。相关子查询

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

Optimize groupwise maximum query

多列也可以这样做,但您需要更多聪明才智:

CREATE TEMP TABLE combo (log_date date, payload int);

SELECT user_id, (combo1).*              -- note parentheses
FROM (
   SELECT u.user_id
        , (SELECT (l.log_date, l.payload)::combo
           FROM   log l
           WHERE  l.user_id = u.user_id
           AND    l.log_date <= :mydate
           ORDER  BY l.log_date DESC NULLS LAST
           LIMIT  1) AS combo1
   FROM   users u
   ) sub;

与上面的LEFT JOIN LATERAL 一样,此变体包括所有 用户,即使log 中没有条目。你会得到NULL for combo1,如果需要,你可以在外部查询中使用WHERE 子句轻松过滤。Nitpick:在外部查询中,你无法区分子查询是否没有'找不到行或所有列值恰好为 NULL - 结果相同。您需要在子查询中使用 NOT NULL 列来避免这种歧义。

相关子查询只能返回一个单个值。您可以将多个列包装成一个复合类型。但是为了稍后分解它,Postgres 需要一个众所周知的复合类型。只有提供列定义列表才能分解匿名记录。 使用注册类型,如现有表的行类型。或者使用CREATE TYPE 显式(并且永久地)注册一个复合类型。或者创建一个临时表(在会话结束时自动删除)以临时注册其行类型。转换语法:(log_date, payload)::combo

最后,我们不想在同一查询级别分解combo1。由于查询计划器的弱点,这将为每列评估一次子查询(在 Postgres 12 中仍然如此)。相反,将其设为子查询并在外部查询中分解。

相关:

Get values from first and last row per group

使用 100k 日志条目和 1k 用户演示所有 4 个查询:dbfiddle here - pg 11旧 sqlfiddle

【讨论】:

我发誓:Erwin Brandstetter 是 PostgreSQL 的首席开发人员。关于这个主题的知识如此丰富。 让我开心。 “带有横向连接的递归 CTE”非常棒。永远不会想到这样做。 @Erwin 假设日志表包含一个枚举列 logType -> SYSTEM_LOG、APPLICATION_LOG、DATABASE_LOG。你能告诉我在这种情况下如何使用 1a 为每个用户获取三个最新的 logType 吗?【参考方案2】:

这不是一个独立的答案,而是对@Erwin 的answer 的评论。对于 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;

基本原理是,如果user_id 值是随机的,则索引查找成本很高。通过首先对user_id 进行排序,随后的横向连接就像对log 的索引进行简单扫描一样。尽管两个查询计划看起来很相似,但运行时间会有很大差异,尤其是对于大型表。

排序的成本最低,尤其是在user_id 字段上有索引的情况下。

【讨论】:

如果用例合适,这可能是一个有效的改进。我在答案中添加了指向此添加的指针。【参考方案3】:

也许表上的不同索引会有所帮助。试试这个: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
                 );

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

【讨论】:

以上是关于优化 GROUP BY 查询以检索每个用户的最新行的主要内容,如果未能解决你的问题,请参考以下文章

为每个用户选择最新条目而不使用 group by (postgres)

SQL Group By and Order -- 检索表中最新条目的详细信息

优化 PostgreSQL 中的 JOIN -> GROUP BY 查询:所有索引都已经存在

无论如何,他们是不是要向使用 group by 返回特定列的最新行的查询添加连接

MySQL 查询优化 Group By with Max

优化 SQL:如何重写此查询以提高性能? (使用子查询,摆脱 GROUP BY?)