web-dev-qa-db-ja.com

GROUP BYクエリを最適化して、ユーザーごとに最新の行を取得します

Postgres 9.2には、ユーザーメッセージ(簡略化された形式)に関する次のログテーブルがあります。

_CREATE TABLE log (
    log_date DATE,
    user_id  INTEGER,
    payload  INTEGER
);
_

ユーザーごとおよび1日あたり最大1つのレコードが含まれます。 300日間、1日あたり約50万件のレコードがあります。ペイロードは、ユーザーごとに増え続けています(重要な場合)。

特定の日付より前に各ユーザーの最新レコードを効率的に取得したい。私のクエリは次のとおりです。

_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_のユーザー)の結果を取得したい。

これを高速化するために使用する必要がある他のインデックス、または私が望むものを達成するための他の方法はありますか?

42
xpapad

最適な読み取りパフォーマンスを得るには、 マルチカラムインデックス が必要です。

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

インデックスのみのスキャンを可能にするには、必要のない列payloadを追加します。

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

なぜDESC NULLS LAST

fewuser_idまたは小さなテーブルごとの行DISTINCT ONは通常、最も速くて最も簡単です。

-多くuser_idごとの行 index skip scan(またはloose index scan は(はるかに)より効率的です。これはPostgres 12まで実装されていません- Postgres 13の作業は進行中です 。しかし、それを効率的にエミュレートする方法があります。

共通テーブル式 Postgresが必要8.4 +
LATERAL にはPostgresが必要です9.3 +
次のソリューションは、 Postgres Wiki でカバーされているものを超えています。

1.一意のユーザーを持つ個別のテーブルはありません

別のusersテーブルを使用すると、以下の2。のソリューションは通常、よりシンプルで高速になります。先にスキップします。

1a。 LATERAL joinを使用した再帰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ごとに1行だけが保証されている限り、テーブルレイアウトはほとんど問題になりません。例:

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

理想的には、テーブルはlogテーブルと同期して物理的にソートされます。見る:

または、それは重要ではないほど十分に小さい(カーディナリティが低い)。それ以外の場合、クエリ内の行を並べ替えることで、パフォーマンスをさらに最適化できます。 Gang Liangの追加を参照してください。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アイテムを参照できます。見る:

ユーザーごとに1つのインデックス(のみ)のルックアップが行われます。

usersテーブルにないユーザーの行を返しません。通常、参照整合性を強制するforeign key制約はそれを除外します。

また、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にエントリがなくてもallユーザーが含まれます。 combo1に対してNULLを取得します。必要に応じて、外部クエリのWHERE句で簡単にフィルタリングできます。
    Nitpick:外部クエリでは、サブクエリが行を見つけられなかったか、すべての列の値がNULLであるかを区別できません-同じ結果です。このあいまいさを回避するには、サブクエリにNOT NULL列が必要です。

  • 相関サブクエリは、単一値のみを返すことができます。複数の列を複合型にラップできます。しかし、後でそれを分解するために、Postgresはよく知られている複合型を要求します。匿名レコードは、列定義リストを提供する場合にのみ分解できます。
    既存のテーブルの行タイプのような登録済みタイプを使用します。または、CREATE TYPEを使用して明示的に(そして永続的に)複合型を登録します。または、一時テーブル(セッションの終了時に自動的に削除される)を作成して、その行タイプを一時的に登録します。キャスト構文:(log_date, payload)::combo

  • 最後に、同じクエリレベルでcombo1を分解したくありません。クエリプランナの弱点により、これは各列に対してサブクエリを1回評価します(Postgres 12でもまだ当てはまります)。代わりに、サブクエリにし、外部クエリで分解します。

関連:

10万のログエントリと1万のユーザーを使用した4つのクエリすべてのデモ:
db <> fiddle here-pg 11
古い sqlfiddle -pg 9.6

100

これはスタンドアロンの回答ではなく、@ 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フィールドにインデックスがある場合は特に、ソートのコストは最小限です。

5
Gang Liang

おそらく、テーブル上の別のインデックスが役立つでしょう。これを試してください: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
                 );

これにより、ソート/グループ化がインデックス検索に置き換えられます。速いかもしれません。

4
Gordon Linoff