优化 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 > :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
?
对于每个user_id
的少数 行或DISTINCT ON
的小表通常是最快和最简单的:
对于 许多 行,每个 user_id
和 index 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 PostgreSQL2。带有单独的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
项目。见:
导致每个用户进行一次索引(仅)查找。
不为users
表中缺失的用户返回任何行。通常,强制引用完整性的外键约束会排除这种情况。
此外,log
中没有匹配条目的用户没有行 - 符合原始问题。要将这些用户保留在结果中,请使用 LEFT JOIN LATERAL ... ON true
而不是 CROSS JOIN LATERAL
:
使用 LIMIT n
而不是 LIMIT 1
来检索每个用户多行(但不是全部)。
实际上,所有这些都是一样的:
JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...
不过,最后一个的优先级较低。显式 JOIN
在逗号前绑定。这种微妙的差异可能对更多的连接表很重要。见:
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 查询:所有索引都已经存在