web-dev-qa-db-ja.com

大きなテーブルでLATERAL JOINクエリを最適化する

Postgres 9.5を使用しています。複数のWebサイトからのページヒットを記録するテーブルがあります。このテーブルには、2016年1月1日から2016年6月30日までの約3200万行が含まれています。

CREATE TABLE event_pg (
   timestamp_        timestamp without time zone NOT NULL,
   person_id         character(24),
   location_Host     varchar(256),
   location_path     varchar(256),
   location_query    varchar(256),
   location_fragment varchar(256)
);

特定の一連のページヒットを実行したユーザーの数をカウントするクエリを調整しようとしています。このクエリは、「ホームページを表示してから、ヘルプサイトにアクセスして、お礼ページを表示した人」などの質問に答えることを目的としています。結果はこのようになります

╔════════════╦════════════╦═════════════╗
║  home-page ║ help site  ║ thankyou    ║
╠════════════╬════════════╬═════════════╣
║ 10000      ║ 9800       ║1500         ║
╚════════════╩════════════╩═════════════╝

ホームページ9800を表示した10000人がヘルプサイトにアクセスし、1500人がお礼ページにアクセスしたため、数字が減少していることに注意してください。

3ステップシーケンスのSQLでは、次のようにラテラル結合を使用します。

SELECT 
  sum(view_homepage) AS view_homepage,
  sum(use_help) AS use_help,
  sum(thank_you) AS thank_you
FROM (
  -- Get the first time each user viewed the homepage.
  SELECT X.person_id,
    1 AS view_homepage,
    min(timestamp_) AS view_homepage_time
  FROM event_pg X 
  WHERE X.timestamp_ between '2016-04-23 00:00:00.0' and timestamp '2016-04-30 23:59:59.999'
  AND X.location_Host like '2015.testonline.ca'
  GROUP BY X.person_id
) e1 
LEFT JOIN LATERAL (
  SELECT
    Y.person_id,
    1 AS use_help,
    timestamp_ AS use_help_time
  FROM event_pg Y 
  WHERE 
    Y.person_id = e1.person_id AND
    location_Host = 'helpcentre.testonline.ca' AND
    timestamp_ BETWEEN view_homepage_time AND timestamp '2016-04-30 23:59:59.999'
  ORDER BY timestamp_
  LIMIT 1
) e2 ON true 
LEFT JOIN LATERAL (
  SELECT
    1 AS thank_you,
    timestamp_ AS thank_you_time
  FROM event_pg Z 
  WHERE Z.person_id = e2.person_id AND
    location_fragment =  '/file/thank-you' AND
    timestamp_ BETWEEN use_help_time AND timestamp '2016-04-30 23:59:59.999'
  ORDER BY timestamp_
  LIMIT 1
) e3 ON true;

timestamp_person_idlocation列にインデックスがあります。数日または数週間の日付範囲のクエリは非常に高速です(1〜10秒)。遅くなるのは、1月1日から7月30日までの間にすべてのクエリを実行しようとしたときです。1分以上かかります。以下の2つの説明を比較すると、timestamp_インデックスを使用しておらず、代わりにSeq Scanを実行していることがわかります。これは、「常時」に対してクエリを実行しているため、インデックスは何も購入しないため、テーブル内のほぼすべてのレコード。

これで、ラテラル結合のネストされたループの性質により、ループする必要のあるレコードが遅くなることに気付きましたが、このクエリを高速化して、より適切にスケーリングできるようにする方法はありますか?

8
maxTrialfire

序論

  • 奇数のデータ型を使用しています。 character(24)char(n)は古いタイプであり、ほとんどの場合間違った選択です。 person_idにインデックスがあり、繰り返し結合する。 integerは、いくつかの理由ではるかに効率的です。 (またはbigint、テーブルの存続期間中に20億を超える行を書き込む予定の場合。)関連:

  • LIKEはワイルドカードなしでは無意味です。代わりに=を使用してください。もっと早く。
    x.location_Host LIKE '2015.testonline.ca'
    x.location_Host = '2015.testonline.ca'

  • 各サブクエリに値1のダミー列を追加する代わりに、count(e1.*)またはcount(*)を使用します。 (最後の(e3)を除いて、実際のデータは必要ありません。)

  • 文字列リテラルをtimestampにキャストする場合と、しない場合があります(timestamp '2016-04-30 23:59:59.999')。それが理にかなっている場合は、それをall実行するか、実行しないか、実行しないでください。
    そうではありません。 timestamp列と比較すると、とにかく文字列リテラルはtimestampに強制変換されます。したがって、明示的なキャストは必要ありません。

  • Postgresデータ型timestampは、最大6桁の小数桁を含みます。あなたのBETWEEN式は、例外的なケースを残しています。エラーを起こしにくい表現に置き換えました。

インデックス

重要:パフォーマンスを最適化するには、 multicolumn index を作成します。
最初のサブクエリhpの場合:

CREATE INDEX event_pg_location_Host_timestamp__idx
ON event_pg (location_Host, timestamp_);

または、インデックスのみのスキャンを実行できる場合は、インデックスにperson_idを追加します。

CREATE INDEX event_pg_location_Host_timestamp__person_id_idx
ON event_pg (location_Host, timestamp_, person_id);

テーブルの大部分またはすべてにわたる非常にlarge時間範囲の場合、このインデックスが望ましい-hlpサブクエリもサポートしているため、次のいずれかの方法で作成します。

CREATE INDEX event_pg_location_Host_person_id_timestamp__idx
ON event_pg (location_Host, person_id, timestamp_);

tnkの場合:

CREATE INDEX event_pg_location_fragment_timestamp__idx
ON event_pg (location_fragment, person_id, timestamp_);

部分インデックスで最適化

location_Hostおよびlocation_fragmentの述語が定数の場合、特にlocation_*列が大きく見えるため、代わりにはるかに安価な部分インデックスを使用できます

CREATE INDEX event_pg_hp_person_id_ts_idx ON event_pg (person_id, timestamp_)
WHERE  location_Host = '2015.testonline.ca';

CREATE INDEX event_pg_hlp_person_id_ts_idx ON event_pg (person_id, timestamp_)
WHERE  location_Host = 'helpcentre.testonline.ca';

CREATE INDEX event_pg_tnk_person_id_ts_idx ON event_pg (person_id, timestamp_)
WHERE  location_fragment = '/file/thank-you';

考慮してください:

繰り返しますが、person_idintegerまたはbigintを使用すると、これらのインデックスはすべて大幅に小さく、高速になります。

通常、新しいインデックスを作成した後は、テーブルをANALYZEする必要があります。または、autovacuumが起動するまで待機してください。

インデックスのみのスキャン を取得するには、テーブルが十分にVACUUMされている必要があります。概念実証としてVACUUMの直後にテストします。 インデックスのみのスキャン に慣れていない場合は、リンクされたPostgres Wikiページで詳細をお読みください。

基本的なクエリ

私が議論したことを実行する。小さい範囲のクエリ(person_idごとに(few行)):

SELECT count(*)::int           AS view_homepage
     , count(hlp.hlp_ts)::int AS use_help
     , count(tnk.yes)::int     AS thank_you
FROM  (
   SELECT DISTINCT ON (person_id)
          person_id, timestamp_ AS hp_ts
   FROM   event_pg
   WHERE  timestamp_ >= '2016-04-23'
   AND    timestamp_ <  '2016-05-01'
   AND    location_Host = '2015.testonline.ca'
   ORDER  BY person_id, timestamp_
   ) hp
LEFT JOIN LATERAL (
   SELECT timestamp_ AS hlp_ts
   FROM   event_pg y 
   WHERE  y.person_id = hp.person_id
   AND    timestamp_ >= hp.hp_ts
   AND    timestamp_ <  '2016-05-01'
   AND    location_Host = 'helpcentre.testonline.ca'
   ORDER  BY timestamp_
   LIMIT  1
   ) hlp ON true 
LEFT JOIN LATERAL (
   SELECT true AS yes                   -- we only need existence
   FROM   event_pg z
   WHERE  z.person_id = hp.person_id    -- we can use hp here
   AND    location_fragment = '/file/thank-you'
   AND    timestamp_ >= hlp.hlp_ts      -- this introduces dependency on hlp anyways.
   AND    timestamp_ <  '2016-05-01'
   ORDER  BY timestamp_
   LIMIT  1
   ) tnk ON true;

DISTINCT ONは、person_idあたりの数行に対して、多くの場合、より安価です。詳細な説明:

Ifperson_idごとに多くの行がある場合(より大きな時間範囲の可能性が高い)、章のこの回答で説明されている再帰CTE 1aは(はるかに)高速にできます:

以下に統合されているのをご覧ください。

最適なクエリを最適化および自動化する

それは古い難問です。1つのクエリ手法は、小規模なセットに最適で、別のクエリ技術は大きなセットに最適です。あなたの特定のケースでは、最初から非常に優れた指標があります-指定された期間の長さ-を決定するために使用できます。

それをすべてPL/pgSQL関数でラップします。私の実装では、指定された期間が設定されたしきい値よりも長い場合にDISTINCT ONからrCTEに切り替わります。

CREATE OR REPLACE FUNCTION f_my_counts(_ts_low_inc timestamp, _ts_hi_excl timestamp)
  RETURNS TABLE (view_homepage int, use_help int, thank_you int) AS
$func$
BEGIN

CASE
WHEN _ts_hi_excl <= _ts_low_inc THEN
   RAISE EXCEPTION 'Timestamp _ts_hi_excl (1st param) must be later than _ts_low_inc!';

WHEN _ts_hi_excl - _ts_low_inc < interval '10 days' THEN  -- example value !!!
-- DISTINCT ON for few rows per person_id
   RETURN QUERY
   WITH hp AS (
      SELECT DISTINCT ON (person_id)
             person_id, timestamp_ AS hp_ts
      FROM   event_pg
      WHERE  timestamp_ >= _ts_low_inc
      AND    timestamp_ <  _ts_hi_excl
      AND    location_Host = '2015.testonline.ca'
      ORDER  BY person_id, timestamp_
      )
    , hlp AS (
      SELECT hp.person_id, hlp.hlp_ts
      FROM   hp
      CROSS  JOIN LATERAL (
         SELECT timestamp_ AS hlp_ts
         FROM   event_pg
         WHERE  person_id = hp.person_id
         AND    timestamp_ >= hp.hp_ts
         AND    timestamp_ < _ts_hi_excl
         AND    location_Host = 'helpcentre.testonline.ca'  -- match partial idx
         ORDER  BY timestamp_
         LIMIT  1
         ) hlp
      )
   SELECT (SELECT count(*)::int FROM hp)   -- AS view_homepage
        , (SELECT count(*)::int FROM hlp)  -- AS use_help
        , (SELECT count(*)::int            -- AS thank_you
           FROM   hlp
           CROSS  JOIN LATERAL (
              SELECT 1                     -- we only care for existence
              FROM   event_pg
              WHERE  person_id = hlp.person_id
              AND    location_fragment = '/file/thank-you'
              AND    timestamp_ >= hlp.hlp_ts
              AND    timestamp_ < _ts_hi_excl
              ORDER  BY timestamp_
              LIMIT  1
              ) tnk
           );

ELSE
-- rCTE for many rows per person_id
   RETURN QUERY
   WITH RECURSIVE hp AS (
      (  -- parentheses required
      SELECT person_id, timestamp_ AS hp_ts
      FROM   event_pg
      WHERE  timestamp_ >= _ts_low_inc
      AND    timestamp_ <  _ts_hi_excl
      AND    location_Host = '2015.testonline.ca'  -- match partial idx
      ORDER  BY person_id, timestamp_
      LIMIT  1
      )
      UNION ALL
      SELECT x.*
      FROM   hp, LATERAL (
         SELECT person_id, timestamp_ AS hp_ts
         FROM   event_pg
         WHERE  person_id  > hp.person_id  -- lateral reference
         AND    timestamp_ >= _ts_low_inc  -- repeat conditions
         AND    timestamp_ <  _ts_hi_excl
         AND    location_Host = '2015.testonline.ca'  -- match partial idx
         ORDER  BY person_id, timestamp_
         LIMIT  1
         ) x
      )
    , hlp AS (
      SELECT hp.person_id, hlp.hlp_ts
      FROM   hp
      CROSS  JOIN LATERAL (
         SELECT timestamp_ AS hlp_ts
         FROM   event_pg y 
         WHERE  y.person_id = hp.person_id
         AND    location_Host = 'helpcentre.testonline.ca'  -- match partial idx
         AND    timestamp_ >= hp.hp_ts
         AND    timestamp_ < _ts_hi_excl
         ORDER  BY timestamp_
         LIMIT  1
         ) hlp
      )
   SELECT (SELECT count(*)::int FROM hp)   -- AS view_homepage
        , (SELECT count(*)::int FROM hlp)  -- AS use_help
        , (SELECT count(*)::int            -- AS thank_you
           FROM   hlp
           CROSS  JOIN LATERAL (
              SELECT 1                     -- we only care for existence
              FROM   event_pg
              WHERE  person_id = hlp.person_id
              AND    location_fragment = '/file/thank-you'
              AND    timestamp_ >= hlp.hlp_ts
              AND    timestamp_ < _ts_hi_excl
              ORDER  BY timestamp_
              LIMIT  1
              ) tnk
           );
END CASE;

END
$func$  LANGUAGE plpgsql STABLE STRICT;

コール:

SELECT * FROM f_my_counts('2016-01-23', '2016-05-01');

定義により、rCTEはCTEと連携します。また、 CTEs DISTINCT ONクエリを入力しました(コメントの@Lennartで説明した のように )。これにより、CROSS JOINの代わりにLEFT JOINを使用して、各CTEを個別にカウントできるため、各ステップで設定します。これには、反対方向に作用する効果があります。

  • 1つ目は行数を減らしたため、3番目の結合がより安くなるはずです。
  • 一方、CTEのオーバーヘッドが発生し、RAMがかなり必要になります。これは、ユーザーのような大きなクエリでは特に重要な場合があります。

どちらが他よりも優れているかをテストする必要があります。

10