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

时间:2014-08-27 20:31:50

标签: sql postgresql indexing greatest-n-per-group postgresql-performance

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

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

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

我希望在特定日期之前有效地检索每个用户的最新记录。我的疑问是:

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 > :value)的结果。

我是否应该使用其他任何索引加快速度,或以其他任何方式实现我想要的目标?

3 个答案:

答案 0 :(得分:92)

为获得最佳阅读效果,您需要multicolumn index

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

要使 index only scans 成为可能,请添加其他不需要的列payload

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

为什么DESC NULLS LAST

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

对于 许多 行,每user_idindex skip scan (or loose index scan)(更高)效率更高。这并没有实现到Postgres 11 - work is ongoing for Postgres 12。但有一些方法可以有效地模仿它。

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章中有关此查询的更多说明。下方。

相关:

2。使用单独的users

只要每个相关user_id只有一行得到保证,表格布局就不重要了。例如:

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

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

或者它足够小(低基数),这几乎不重要。否则,在查询中对行进行排序有助于进一步优化性能。 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项。参见:

每个用户查找一个索引(-only)。

users表中缺少的用户不返回任何行。通常,强制引用完整性的外键约束会将其排除在外。

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

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

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

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

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

2B。相关子查询

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

多列也是如此,但您需要更多智能:

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获得combo1,如果需要,可以使用外部查询中的WHERE子句轻松过滤。
    Nitpick:在外部查询中,您无法区分子查询是否未找到行或所有列值恰好为NULL - 结果相同。您需要在子查询中使用NOT NULL列来避免这种歧义。

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

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

相关:

使用100k日志条目和1k用户演示所有4个查询:
SQL Fiddle - 第9.6页 db&lt;&gt;小提琴here - 第11页

答案 1 :(得分:4)

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

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

答案 2 :(得分:4)

这不是一个独立的答案,而是对@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字段上有索引,则排序成本很低。