web-dev-qa-db-ja.com

空間結合と範囲ルックアップに最適なインデックス作成戦略

環境

2つのテーブルがあります(以下の例で使用されている実際のテーブルではありません。テストに使用しているおもちゃのDBからのものです):

  • Incidents_2(対象となる列はgeomであり、reported_at(int8)です)
  • tmp_points(対象の列はgeom、日を表すタイムフレーム整数、メートル単位の半径整数)

Tmp_pointsテーブルの各行には場所があり、時間枠内でその近くのインシデントを探しています。それぞれに異なる半径と期間を設定できます。

私のダミーデータには、350000のインシデントと1500のtmp_pointsがあります。

両方のエリア列にGistインデックスがあり、incidents_2.reported_atにbtreeがあります。

インシデントテーブルには6年分のデータが含まれています。 tmp_pointsの最大期間は30日です。

最初のクエリは、コールドランで約6秒で返され、その後は600ms ishでした。インシデントテーブルを2つのパーティションに分割してみました。 1つはクエリの有効範囲をカバーし、もう1つは残りのクエリをカバーします。これは、reported_atで分割されました。

最初のクエリは両方のパーティションをスキャンします。 2番目のクエリは、最新のインシデントの小さいパーティションのみをスキャンします。

explain analyze 
select to_timestamp(i.reported_at), i.id, i.description, i.area, tp.point, tp."name", tp.radius 
from incidents_2 i
join tmp_points tp
on to_timestamp(i.reported_at) >= now() - (tp.days*2 || 'days')::interval
and ST_Dwithin(i.area, tp.point, tp.radius)


explain analyze 
select reported_at, i.id, i.description, i.area, tp.point, tp."name", tp.radius 
from incidents_2 i
join tmp_points tp
    on i.reported_at > 1583586702
    and ST_Dwithin(i.area, tp.point, tp.radius )

問題

2番目のクエリは固定値をとっているのでプランナーはパーティションをノックアウトできることを知っていますが、実際に必要な最初のクエリは必要ありません。

私はこれを書き換えるいくつかの方法を試しましたが、同じ結果を得る方法は考えられませんが、1つのパーティションのみにアクセスします。パーティションに直接アクセスする以外。

QUERY PLAN                                                                                                                                                                                                                                                     |
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
Nested Loop  (cost=0.41..185299.97 rows=51 width=319) (actual time=102.313..662.713 rows=2 loops=1)                                                                                                                                                            |
  ->  Seq Scan on tmp_points tp  (cost=0.00..28.33 rows=1333 width=61) (actual time=0.008..0.259 rows=1333 loops=1)                                                                                                                                            |
  ->  Append  (cost=0.41..138.97 rows=2 width=262) (actual time=0.497..0.497 rows=0 loops=1333)                                                                                                                                                                |
        ->  Index Scan using incidents2_old_area_idx on incidents2_old i  (cost=0.41..137.65 rows=1 width=262) (actual time=0.479..0.479 rows=0 loops=1333)                                                                                                    |
              Index Cond: (area && _st_expand((tp.point)::geography, (tp.radius)::double precision))                                                                                                                                                           |
              Filter: ((to_timestamp((reported_at)::double precision) >= (now() - ((((tp.days * 2))::text || 'days'::text))::interval)) AND ((tp.point)::geography && _st_expand(area, (tp.radius)::double precision)) AND _st_dwithin(area, (tp.point)::geogra|
              Rows Removed by Filter: 90                                                                                                                                                                                                                       |
        ->  Index Scan using incidents2_new_area_idx on incidents2_new i_1  (cost=0.27..1.31 rows=1 width=299) (actual time=0.015..0.015 rows=0 loops=1333)                                                                                                    |
              Index Cond: (area && _st_expand((tp.point)::geography, (tp.radius)::double precision))                                                                                                                                                           |
              Filter: ((to_timestamp((reported_at)::double precision) >= (now() - ((((tp.days * 2))::text || 'days'::text))::interval)) AND ((tp.point)::geography && _st_expand(area, (tp.radius)::double precision)) AND _st_dwithin(area, (tp.point)::geogra|
              Rows Removed by Filter: 1                                                                                                                                                                                                                        |
Planning Time: 0.717 ms                                                                                                                                                                                                                                        |
Execution Time: 662.747 ms                                                                                                                                                                                                                                     |

私の他の唯一の考えは、クエリの具体化されたビューを作成し、定期的に更新することです。これにより、50ミリ秒未満の応答を維持できますが、古いデータが作成されます。私はデータの鮮度についてビジネスと交渉していますが、可能であればとにかくクエリ時にこれを行うことを望みます!

PDATE 16/05いくつかのフィードバックに基づいて、これを少し整理しました。

PGバージョン:11.2.

インシデントテーブル

CREATE TABLE public.incidents_tz (
    id varchar(255) NOT NULL,
    description text NOT NULL,
    area geography NULL,
    reported_at_tz timestamptz NOT NULL,
    CONSTRAINT incidents_tz_pkey PRIMARY KEY (reported_at_tz, id)
)
PARTITION BY RANGE (reported_at_tz);
CREATE INDEX incidents_tz_area_Gist_index ON ONLY public.incidents_tz USING Gist (area);
CREATE INDEX incidentstz_started_at_index ON ONLY public.incidents_tz USING btree (reported_at_tz);

TMPポイントテーブル

CREATE TABLE public.tmp_points (
    point geometry NULL,
    "name" varchar NULL,
    radius int4 NULL,
    days int4 NULL
);
CREATE INDEX tmp_points_st_expand_idx ON public.tmp_points USING Gist (st_expand(point, (radius)::double precision));

私は今、最初の答えで与えられた例を使用しています:

explain analyze
SELECT i.reported_at_tz, i.id, i.description, i.area, tp.point, tp."name", tp.radius, tp.days 
FROM   incidents_tz i
JOIN   tmp_points  tp 
 ON i.reported_at_tz >= now() - interval '1 day' * tp.days  -- 1 day?
 AND ST_Dwithin(i.area, tp.point, tp.radius)

それでも残念ながら結果は計画になります(両方のパーティションを使用しています)。

UERY PLAN                                                                                                                                                                                                                                                     |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
ested Loop  (cost=0.41..57673.48 rows=22 width=298) (actual time=0.241..178.554 rows=6111 loops=1)                                                                                                                                                            |
 ->  Seq Scan on tmp_points tp  (cost=0.00..27.79 rows=1279 width=61) (actual time=0.007..0.159 rows=1279 loops=1)                                                                                                                                            |
 ->  Append  (cost=0.41..45.05 rows=2 width=238) (actual time=0.094..0.138 rows=5 loops=1279)                                                                                                                                                                 |
       ->  Index Scan using incidents_tz_old_area_idx on incidents_tz_old i  (cost=0.41..39.30 rows=1 width=245) (never executed)                                                                                                                             |
             Index Cond: (area && _st_expand((tp.point)::geography, (tp.radius)::double precision))                                                                                                                                                           |
             Filter: ((reported_at_tz >= (now() - ('1 day'::interval * (tp.days)::double precision))) AND ((tp.point)::geography && _st_expand(area, (tp.radius)::double precision)) AND _st_dwithin(area, (tp.point)::geography, (tp.radius)::double precisio|
       ->  Index Scan using incidents_tz_new_area_idx on incidents_tz_new i_1  (cost=0.41..5.74 rows=1 width=211) (actual time=0.093..0.136 rows=5 loops=1279)                                                                                                |
             Index Cond: (area && _st_expand((tp.point)::geography, (tp.radius)::double precision))                                                                                                                                                           |
             Filter: ((reported_at_tz >= (now() - ('1 day'::interval * (tp.days)::double precision))) AND ((tp.point)::geography && _st_expand(area, (tp.radius)::double precision)) AND _st_dwithin(area, (tp.point)::geography, (tp.radius)::double precisio|
             Rows Removed by Filter: 12                                                                                                                                                                                                                       |
lanning Time: 0.314 ms                                                                                                                                                                                                                                        |
xecution Time: 178.857 ms                                                                                                                                                                                                                                     |
2
Mark Stephenson

なぜreported_at (int8)?タイムスタンプの一般的に好ましい実装はtimestamptzです。あなたはコストを節約し、前後に変換する手間を省きます。そして、値の組み込みの健全性チェックがあります。

さらに、それはクエリの主要な問題の根本です。

_...
join tmp_points tp
on to_timestamp(i.reported_at) >= now() - (tp.days*2 || 'days')::interval
..._

これはいくつかの理由で悪いです。

  1. 交換する _(tp.days*2 || 'days')::interval_ _interval '2 days' * tp.days_を使用します。これは、比較的高価な文字列の連結、乗算、および型キャストではなく、単一の乗算です。

  2. さらに重要なことは、次の同等の式を使用して、計算をテーブル列から離します。

    _ON i.reported_at >= EXTRACT (Epoch FROM now() - interval '2 days' * tp.days)
    _

    このように、値は多くの列の値と比較される前にonceを計算する必要があります。式は「引数可能」です。つまり、_reported_at_のインデックスを使用できます。パーティションキーが_reported_at_に基づいている場合、パーティションのプルーニングはオプションです。

クエリ:

_SELECT to_timestamp(i.reported_at), i.id, i.description, i.area, tp.point, tp."name", tp.radius 
FROM   incidents_2 i
JOIN   tmp_points tp ON ST_Dwithin(i.area, tp.point, tp.radius)
WHERE  i.reported_at >= EXTRACT (Epoch FROM now() - interval '2 days' * tp.days);
_

述語は1つのテーブルにのみ適用されるため、WHERE句にも変換しました。 100%同等でありながら、より直感的です。見る:

_incidents_2.reported_at_をtimestamptzとして実装すると、これはより簡単で高速になる可能性があります。

_SELECT i.reported_at, i.id, i.description, i.area, tp.point, tp."name", tp.radius 
FROM   incidents_2 i
JOIN   tmp_points  tp ON ST_Dwithin(i.area, tp.point, tp.radius)
WHERE  i.reported_at >= now() - interval '1 day' * tp.days;  -- 1 day?
_

間隔も半分に切りました。明らかなロジックは、daysの数が1回であるため、イベントをチェックすることです。

応用アドバイスの効果

提案された改善を適用した後、あなたは納得できないようです:

それでも残念ながら結果は計画になります(両方のパーティションを使用しています)。

ただし、実際に実行されるのは、 "new"パーティションのoneプランのみです。まさに私が目指していたもの:

        -> Incidents_tz_old i 
のIncidents_tz_old_area_idxを使用したインデックススキャン(コスト= 0.41..39.30行= 1幅= 245) (実行されなかった)

大胆な強調鉱山。マニュアルからの Partition Pruning に関する大きな引用:

パーティションプルーニングは、特定のクエリの計画中だけでなく、その実行中にも実行できます。これは、サブクエリから取得した値を使用してPREPAREステートメントで定義されたパラメータなど、クエリの計画時に値が不明な式が句に含まれる場合に、より多くのパーティションを枝刈りできるので便利です。ネストされたループ結合の内側でパラメーター化された値を使用します。実行中のパーティションプルーニングは、次のいずれかのタイミングで実行できます。

  • クエリプランの初期化中。実行の初期化フェーズ中に既知のパラメーター値に対して、ここでパーティション・プルーニングを実行できます。この段階でプルーニングされたパーティションは、クエリのEXPLAINまたは_EXPLAIN ANALYZE_には表示されません。 EXPLAIN出力の「削除されたサブプラン」プロパティを確認することで、このフェーズで削除されたパーティションの数を特定できます。

  • クエリプランの実際の実行中。ここでパーティションプルーニングを実行して、実際のクエリ実行中にのみ既知の値を使用してパーティションを削除することもできます。これには、サブクエリからの値と、パラメータ化されたネストされたループ結合からの値など、実行時パラメータからの値が含まれます。これらのパラメーターの値はクエリの実行中に何度も変更される可能性があるため、パーティションプルーニングによって使用されている実行パラメーターの1つが変更されるたびにパーティションプルーニングが実行されます。このフェーズでパーティションがプルーニングされたかどうかを判別するには、_EXPLAIN ANALYZE_出力のloopsプロパティを注意深く検査する必要があります。異なるパーティションに対応するサブプランは、実行中にプルーニングされた回数に応じて、異なる値を持つ可能性があります。 一部が毎回剪定されると、_(never executed)_と表示される場合があります。

大胆な強調が再び私のものです。

_(point, radius)_ in _tmp_points_(_rows=1333_)ごとにネストされたループでインデックスにアクセスするため、Postgresは計画フェーズではパーティションプルーニングを適用できませんが、実行時には適用できます。

その結果、新しいクエリは179ミリ秒で_rows=6111_を取得しましたが、古いクエリは663ミリ秒で_rows=2_(!!)を取得しました。私がこれまでに見たことがあるなら、それは改善です。

個別のパーティションではなく、よりスマートなインデックス?

最新の行のための個別のパーティションは、多くのオーバーヘッドと複雑さを伴います。巨大なテーブルでは、より多くのパーティションを持つ宣言的なパーティション分割mightは依然として意味があります。

しかし、よりスマートなインデックス付けを備えた単一のテーブルを検討してください。手始めに、 multicolumn index のように:

_CREATE INDEX foo ON incidents USING Gist (reported_at_tz, area);
_

通常、より選択的な表現が最初です。追加モジュール_btree_Gist_をインストールする必要があります。見る:

クエリはいくつかの最新の行を排他的に対象としているため、 partial index の方が理にかなっています。残念ながら、現在の時間(now())に応じて、対象の時間枠は移動ターゲットです。これにより、最適化が難しくなります(パーティション化の場合も同様)。一定のカットオフ時間から始めます。

_CREATE INDEX foo ON incidents USING Gist (area, reported_at_tz)
WHERE  reported_at_tz >= '2020-05-01 00:00+0';
_

カットオフ時間_'2020-05-01 00:00+0'_を、パーティションに使用した時間に調整します。

ここで、areaを最初のインデックス式として使用します。 _reported_at_tz_の選択性によっては、それを追加のインデックス式として削除することもできます。

次に、ここを読み続けます:

3