拡張統計情報とテーブル結合

PostgreSQL Advent Calendar 2018、23日目の記事です。

今更感満載ですが、PostgreSQL 10で導入された拡張統計情報について、直感と異なる挙動だったので調査してみました。小ネタですみません。

TL;DR

  • PostgresSQLのオプティマイザは、カラム間の関数従属性を考慮しない
  • 拡張統計情報を使えば、テーブル探索で関数従属性を考慮できる
  • テーブル結合では考慮されない

行数推定の課題

PostgreSQLのオプティマイザはコストベースであり、アクセスパスツリーを生成して最もコストの低いパスを選択します。

このコスト計算において、条件句が複雑な場合に、実体と沿わないコストを算出してしまう場合があります。具体的には、条件句に AND で複数の条件が指定され、かつ両条件に指定されたカラム間に関数従属性がある場合、実際の行数よりも推定行数が少なく見積もられます。

この時点で???となった方は、ググるか、恐縮ながら以下をさらっとみてもらえるといいかもしれません。

PostgreSQL:行数推定を読み解く/row-estimation - Speaker Deck

拡張統計情報

任意のテーブルがもつカラム間の相関を取得し、オプティマイザに使わせる事ができる機能です。PostgreSQLのバージョン10で導入されました。

以下ではまず、この機能がどういうものか確認してみます。公式ドキュメントの例を使います。

-- テスト用のテーブルt1を作成する
CREATE TABLE t1 (a int, b int);

-- t1にデータを挿入する
INSERT INTO t1
  SELECT i/100, i/500
  FROM generate_series(1,1000000) s(i);

こうすることにより、t1のデータは以下のようになります。

i a b
1 0 0
2 0 0
3 0 0
99 0 0
100 1 0
101 1 0
102 1 0
199 1 0
200 2 0
201 2 0
499 4 0
500 5 1
501 5 1
999 9 1
1000 10 2
1001 10 2

つまり、というかSQLのとおりなんですが、aは100ごとに、bは500ごとに値がインクリメントされます。例えばaが12とわかればbは2と確定しますし、逆にbが2とわかればaは10以上20未満であるとわかります。このとき、カラムaとカラムbは相互に関数従属であるといいます。

この状態で、カラムa、bの両方に対する条件句をもつSQLの実行計画をみてみます。

-- 統計情報を収集する
ANALYZE t1;

-- 実行計画を取得する
EXPLAIN ANALYZE SELECT * FROM t1 WHERE (a = 1) AND (b = 0);
Seq Scan on t1  (cost=0.00..19425.00 rows=1 width=8) (actual time=0.034..113.435 rows=100 loops=1)
  Filter: ((a = 1) AND (b = 0))
  Rows Removed by Filter: 999900
Planning time: 0.155 ms
Execution time: 113.467 ms

t1に対するSeq Scanの実際の結果は100行にもかかわらず、推定行数が1行になっています。これは、カラムaとbの間の関数従属性が考慮されていないためです。つまり、各条件句を独立とみなし、それぞれ別のSQLで取得した結果の和集合としています。

この問題に対する解決策として、拡張統計情報があります。ためしてみます。

-- 拡張統計情報を作成する
CREATE STATISTICS s1 (dependencies) ON a, b FROM t1;

-- 統計情報を収集する
ANALYZE t1;

-- 実行計画を取得する
EXPLAIN ANALYZE SELECT * FROM t1 WHERE (a = 1) AND (b = 0);
Seq Scan on t1  (cost=0.00..19425.00 rows=98 width=8) (actual time=0.020..89.729 rows=100 loops=1)
  Filter: ((a = 1) AND (b = 0))
  Rows Removed by Filter: 999900
Planning time: 0.083 ms
Execution time: 89.750 ms

今度はカラム間の関数従属性が考慮され、行数は正確に見積もられています。

テーブル結合

本題、テーブル結合です。t1と全く同様の方法で、t2とt3という2つのテーブルを作り、これらをクロス結合して実行計画をみてみます。

CREATE TABLE t2 (a2 int, b2 int);
CREATE TABLE t3 (a3 int, b3 int);

INSERT INTO t2 SELECT i/100, i/500 FROM generate_series(1,1000000) s(i);
INSERT INTO t3 SELECT i/100, i/500 FROM generate_series(1,1000000) s(i);

単一条件の場合

まず、t2に対する条件句が単一の場合の実行計画を見てみます。

EXPLAIN ANALYZE SELECT * FROM t2, t3 WHERE (t2.a2 = 1) AND (t2.b2 = t3.b3);
Hash Join  (cost=30832.00..52282.01 rows=48922 width=16) (actual time=614.425..622.979 rows=49900 loops=1)
  Hash Cond: (t2.b2 = t3.b3)
  ->  Seq Scan on t2  (cost=0.00..16925.00 rows=98 width=8) (actual time=0.030..85.213 rows=100 loops=1)
        Filter: (a2 = 1)
        Rows Removed by Filter: 999900
  ->  Hash  (cost=14425.00..14425.00 rows=1000000 width=8) (actual time=475.964..475.965 rows=1000000 loops=1)
        Buckets: 131072  Batches: 16  Memory Usage: 3334kB
        ->  Seq Scan on t3  (cost=0.00..14425.00 rows=1000000 width=8) (actual time=0.024..146.287 rows=1000000 loops=1)
Planning time: 0.331 ms
Execution time: 626.888 ms

t2に対するSeq Scanの推定行数が98となっており、実際には100なので、乖離はありません。結合条件としてはHash Joinが選択されています。

複合条件の場合(拡張統計情報なし)

テーブルt2のカラムa2、b2それぞれに対して条件句を指定します。関数従属性により、実行計画が狂ってしまうのを確認します。

EXPLAIN ANALYZE SELECT * FROM t2, t3 WHERE (t2.a2 = 1) AND (t2.b2 = 0) AND (t2.b2 = t3.b3);
Nested Loop  (cost=0.00..36354.86 rows=486 width=16) (actual time=0.056..8576.923 rows=49900 loops=1)
  ->  Seq Scan on t2  (cost=0.00..19425.00 rows=1 width=8) (actual time=0.032..79.398 rows=100 loops=1)
        Filter: ((a2 = 1) AND (b2 = 0))
        Rows Removed by Filter: 999900
  ->  Seq Scan on t3  (cost=0.00..16925.00 rows=486 width=8) (actual time=0.007..84.883 rows=499 loops=100)
        Filter: (b3 = 0)
        Rows Removed by Filter: 999501
Planning time: 0.113 ms
Execution time: 8580.450 ms

前項のt1と同じく、t2は推定行数が大きくずれ、Seq Scanとなっています。結合に関しては、t2が1行となる予測なので、Nested Loopが選択されています。

複合条件の場合(拡張統計情報あり)

テーブルt2のカラムa2、b2に対して、拡張統計情報を作成します。

CREATE STATISTICS s2 (dependencies) ON a2, b2 FROM t2;
ANALYZE t2;
EXPLAIN ANALYZE SELECT * FROM t2, t3 WHERE (t2.a2 = 1) AND (t2.b2 = 0) AND (t2.b2 = t3.b3);
Nested Loop  (cost=0.00..36945.60 rows=47628 width=16) (actual time=0.045..231.646 rows=49900 loops=1)
  ->  Seq Scan on t3  (cost=0.00..16925.00 rows=486 width=8) (actual time=0.019..119.208 rows=499 loops=1)
        Filter: (b3 = 0)
        Rows Removed by Filter: 999501
  ->  Materialize  (cost=0.00..19425.49 rows=98 width=8) (actual time=0.000..0.205 rows=100 loops=499)
        ->  Seq Scan on t2  (cost=0.00..19425.00 rows=98 width=8) (actual time=0.022..97.727 rows=100 loops=1)
              Filter: ((a2 = 1) AND (b2 = 0))
              Rows Removed by Filter: 999900
Planning time: 0.194 ms
Execution time: 235.587 ms

実行時間が8580msから246msに改善しました。実行計画の中身をみてみると、t2の推定行数が98になっており、実行結果の100行と僅差になっています。それにより、Materializeされるようになりました。Materializeは、t2をSeq Scanした結果をメモリにのせます。これにより、t3とのNested Loopにおいてt2の再スキャンを高速化します。

考察

拡張統計情報によって見事に実行速度が改善したわけですが、Nested Loopではなく単一条件の場合と同じくHash Joinが選択されてもよさそうです。なぜNested Loopのままなのか、PostgreSQLのコードを見てみます。

拡張統計情報を参照するのは clauselist_selectivity() の以下の箇所です。該当コードはここ

 /*
    * Determine if these clauses reference a single relation.  If so, and if
    * it has extended statistics, try to apply those.
    */
    rel = find_single_rel_for_clauses(root, clauses);
    if (rel && rel->rtekind == RTE_RELATION && rel->statlist != NIL)
    {
        /*
        * Perform selectivity estimations on any clauses found applicable by
        * dependencies_clauselist_selectivity.  'estimatedclauses' will be
        * filled with the 0-based list positions of clauses used that way, so
        * that we can ignore them below.
        */
        s1 *= dependencies_clauselist_selectivity(root, clauses, varRelid,
                                                  jointype, sjinfo, rel,
                                                  &estimatedclauses);

        /*
        * This would be the place to apply any other types of extended
        * statistics selectivity estimations for remaining clauses.
        */
    }

    /*
    * Apply normal selectivity estimates for remaining clauses. We'll be
    * careful to skip any clauses which were already estimated above.

スキャン方法の選択時、 src/backend/optimizer/path/costsize.c がこのコードを使っています。

void
set_baserel_size_estimates(PlannerInfo *root, RelOptInfo *rel)
{
    double     nrows;

    /* Should only be applied to base relations */
    Assert(rel->relid > 0);

    nrows = rel->tuples *
        clauselist_selectivity(root,
                               rel->baserestrictinfo,
                               0,
                               JOIN_INNER,
                               NULL);

一方、結合条件について。PostgreSQLのオプティマイザはデフォルトでNested Loopのパスを作り、そのあとにMerge Join、Hash Joinとそれぞれを検討して、そっちのほうが速そうならそっちに置き換えます。 このへんでHash Joinを選択するか判断しているのですが、この段階で上記コードを参照していません。つまり、結合条件の選択に、拡張統計情報はつかわれません。

改めて問題

何が困るかというと、まあそのままなのですが、「巨大なテーブル同士をNested Loopでジョインしてしまう」ことです。これについては、バージョン10以前と同様にpg_hint_planをつかって結合ルールを手動制御せざるを得なさそうです。

そんな人はいないと思いますが、「関数従属な複数カラムに対する条件句」と「テーブル結合」の両方を持つSQLがたくさんあり、pg_hint_planでがんじがらめになっているサービスを運用しており、拡張統計情報をメシア視している方がもし仮に存在するなら、要注意です。

まとめ

普通に結合条件でも考慮されてほしいと思いました。拡張統計情報という壮大な名前なので、今後さらに賢くなっていくことが期待できます。と思っていたらバージョン11では特にアップデートがなかったです。悲しいです。以降、「文句言ってないでOSSなんだから自分でなおせよ」禁止。

参考