web-dev-qa-db-ja.com

PostgresはWHERE a IN(...)ORDER BY b LIMIT Nに劣ったインデックスを使用することがあります

約50億行のPostgreSQLテーブルがあり、適切なインデックスが見つからず、特定のLIMIT操作で主キースキャンを実行するという厄介な習慣が生じています。

この問題は通常、ORDER BY .. LIMIT ..句(Djangoページネーションの一般的なパターン))に現れます。ここで、LIMITは、インデックスに一致する結果の比較的小さなサブセットです。極端な例はこれです:

SELECT * FROM mcqueen_base_imagemeta2 
  WHERE image_id IN ( 123, ... )
  ORDER BY id DESC
  LIMIT 1;

このIN句の項目は〜20で、image_idのインデックスと一致する行の合計は16です。

EXPLAINは、image_idインデックスがなく、代わりに5B行のPKスキャンを実行することを示しています。

制限(cost = 0.58..4632.03 rows = 1 width = 28)
-> mcqueen_base_imagemeta2でmcqueen_base_imagemeta2_pkeyを使用して後方にインデックススキャン(cost = 0.58..364597074.75 rows = 78722 width = 28)
フィルター: (image_id = ANY( '{123、...}' :: bigint []))

LIMIT2に増やすと、期待どおりに機能します。

制限(コスト= 7585.92..7585.93行= 2幅= 28)
->ソート(コスト= 7585.92..7782.73行= 78722幅= 28)
ソートキー:id DESC 
-> mcqueen_base_imagemeta2でmcqueen_base_imagemeta2_image_id_616fe89cを使用したインデックススキャン(cost = 0.58..6798.70 rows = 78722 width = 28)
 Index Cond:(image_id = ANY( '{123、...}' :: bigint [ ]))

これは、インデックスが〜3000行と一致し、制限が100に設定されているクエリでも発生するため、現実の世界では簡単に何かが発生しますREST APIページネーション。

テーブルの定義は次のとおりです。

mcqueen=# \d mcqueen_base_imagemeta2
                                       Table "public.mcqueen_base_imagemeta2"
      Column       |           Type           |                              Modifiers                               
-------------------+--------------------------+----------------------------------------------------------------------
 id                | bigint                   | not null default nextval('mcqueen_base_imagemeta2_id_seq'::regclass)
 created_at        | timestamp with time zone | not null
 image_id          | bigint                   | not null
 key_id            | smallint                 | not null
 source_version_id | smallint                 | not null
Indexes:
    "mcqueen_base_imagemeta2_pkey" PRIMARY KEY, btree (id)
    "mcqueen_base_imagemeta2_image_id_616fe89c" btree (image_id)
    "mcqueen_base_imagemeta2_key_id_a4854581" btree (key_id)
    "mcqueen_base_imagemeta2_source_version_id_f9b0513e" btree (source_version_id)
Foreign-key constraints:
    "mcqueen_base_imageme_image_id_616fe89c_fk_mcqueen_b" FOREIGN KEY (image_id) REFERENCES mcqueen_base_image(id) DEFERRABLE INITIALLY DEFERRED
    "mcqueen_base_imageme_key_id_a4854581_fk_mcqueen_b" FOREIGN KEY (key_id) REFERENCES mcqueen_base_metakey(id) DEFERRABLE INITIALLY DEFERRED
    "mcqueen_base_imageme_source_version_id_f9b0513e_fk_mcqueen_b" FOREIGN KEY (source_version_id) REFERENCES mcqueen_base_metasourceversion(id) DEFERRABLE INITIALLY DEFERRED

私はチューニングに関しては初心者ですが、統計のデフォルトはそのテーブルのサイズに達していないため、PKスキャンはインデックススキャンよりも高速であると単純に考えています。

5
Arne Claassen

78722が見つかると考えていますが、実際には16が見つかるため、いくつかの悪い計画につながる可能性があります。

In-listの値が統計テーブルのMCVリストに存在しない場合、n_distinct値を使用して頻度を推測します。これはおそらくかなりずれています(そのことについての私の質問には答えませんでした)。これを行う方法は、MCV頻度リストでカバーされていないタプルの数を取得し、MCVリストにリストされていない個別の値の数で割ります。つまり、基本的にはntuples * (1-sum of MCF) / (n_distinct - length of MCF)です。この簡略化された式はNULLを無視します。

@ErwinBrandstetterが示唆するように、統計のサンプルサイズを増やすことでMCVリストのサイズを増やすことで、状況を改善できる可能性があります。これにより、n_distinct推定の精度も向上する可能性があります。しかし、60億行の場合、サンプルサイズを十分に増やすことができない場合があります。また、同じページで発生する可能性が高い重複する値とともにimage_idがまとめられている場合、PostgreSQLで使用されるサンプリング方法は、n_distinctの計算に関してかなり偏っており、これはサンプルサイズを増やすだけで修正に抵抗します。

これを修正するより簡単な方法は、n_distinctを手動で修正することです。

alter table mcqueen_base_imagemeta2 alter column image_id set (n_distinct=1000000000);
analyze mcqueen_base_imagemeta2;

この方法では、ANALYZEに必要な時間やストレージは増えませんが、サンプルサイズを増やす方法と同じように、成功する可能性が高くなります。

5
jjanes

どうして?

LIMIT 1の場合、PostgresはORDER BYをサポートするインデックスをトラバースする方が高速であると推定し、最初の行が見つかるまでフィルタリングを続けることができます。これは、数行以上が適格であり、そのうちの1つがORDER BYに従って早期にポップアップする限り、高速です。ただし、適格な行が早期にポップアップしない場合は(非常に)遅くなり、適格な行がまったくない場合は最悪のシナリオになります。小さなLIMITについても同様です。

Postgresは最も一般的な値(MCVリスト)に関する統計を収集しますが、最も一般的な値については収集しません-明白な理由により、これは多すぎて役に立たないでしょう。また、デフォルトでは、列間の相関に関する統計はありません。 (ID番号は通常無相関であるため、手動で作成できますが、いずれにしてもユースケースに適合しません。)

したがって、Postgresは一般的な推定に基づいて決定を行う必要があります。あるインデックスから別のインデックスに切り替えるスイートスポットを特定するのは非常に困難です。これはさらに難しくなりますが、多くのアイテムを含むimage_id IN (123, ... )のような述語では、ほとんどの場合、ほとんどまたはまったくないか、存在しません。しかし、リストに十分な数を入れると、Postgresは最終的に他のインデックスをたどると最初のヒットがより早く見つかると予想します。

ソリューション?

より大きな統計ターゲットを使用すると、状況を多少改善できる可能性があります。

ALTER TABLE mcqueen_base_imagemeta2 ALTER image_id SET STATISTICS 2000;

これにより(とりわけ)列のMCVリストのサイズが増加し、より一般的な(より少ない)値の特定に役立ちます。しかし、これは問題の一般的な解決策ではなく、ANALYZEとクエリの計画が少し高くなります。関連:

最新バージョン(まもなくPostgres 12になる)にアップグレードすると、一般的なパフォーマンスが向上し、プランナーがよりスマートになります。

カーディナリティー、値の頻度、アクセスパターンなどに応じて、回避策にはさまざまな手法があります... ORDER BYインデックスを完全に無効にする Laurenzの例 isone根本的な回避策-長いリストで逆効果または、非常に一般的なimage_idORDER BYインデックスは、実際にははるかに高速です。

関連:

ケースの回避策

50億行、フィルターリストの約20 image_id、小さいLIMITのように、指定された数値でうまく機能するはずです。 LIMIT 1と短いリストの場合に最も効率的ですが、小さいLIMITと管理しやすいリストのサイズに適しています。

SELECT m.*
FROM   unnest( '{123, ...}'::bigint[]) i(image_id)
CROSS  JOIN LATERAL (
   SELECT m.id
   FROM   mcqueen_base_imagemeta2 m
   WHERE  m.image_id = i.image_id
   ORDER  BY m.id DESC
   LIMIT  1  -- or N
   ) m
ORDER  BY id DESC
LIMIT  1;  -- or N

リストをarrayおよびunnest()として提供します。または、VALUES式を使用します。関連:

(image_id, id DESC)のマルチカラムインデックスでこれをサポートすることが不可欠です。

可能性があります次に、mcqueen_base_imagemeta2_image_id_616fe89cだけで既存のインデックス(image_id)を削除します。見る:

これにより、image_idごとに1つの非常に高速なインデックス(のみ)スキャンが実行されます。そして、最後の(非常に)安価な並べ替え手順です。

image_idごとにN行をフェッチすることで、外部クエリで必要なすべての行が確保されます。If単一のimage_idあたりより少ない行のみが結果に含まれる可能性があるというメタ知識がある場合、それに応じてネストされたLIMITを減らすことができます。

さておき

(Djangoページネーションの一般的なパターン)

LIMITOFFSETによる改ページ調整?最初のページは問題ありませんが、その後は悪い考えです。

7

簡単な解決策は、ORDER BY条件。セマンティクスは変更されませんが、PostgreSQLはインデックスを使用できなくなります。

SELECT * FROM mcqueen_base_imagemeta2 
  WHERE image_id IN ( 123, ... )
  ORDER BY id + 0 DESC
  LIMIT 1;
2
Laurenz Albe