web-dev-qa-db-ja.com

PostgreSQLのツリー構造と再帰的なCTE最適化

ルートから特定のノードへのパスをクエリできるように、またはサブブランチ内のすべてのノードを見つけることができるように、PostgreSQL(8.4)でツリー構造を表現しようとしています。

ここにテストテーブルがあります:

CREATE TABLE tree_data_1 (
    forest_id TEXT NOT NULL,
    node_id TEXT NOT NULL,
    parent_id TEXT,
    node_type TEXT,
    description TEXT,
    PRIMARY KEY (forest_id, node_id),
    FOREIGN KEY (forest_id, parent_id) REFERENCES tree_data_1 (forest_id, node_id)
);
CREATE INDEX tree_data_1_forestid_parent_idx ON tree_data_1(forest_id, parent_id);
CREATE INDEX tree_data_1_forestid_idx ON tree_data_1(forest_id);
CREATE INDEX tree_data_1_nodeid_idx ON tree_data_1(node_id);
CREATE INDEX tree_data_1_parent_idx ON tree_data_1(parent_id);

各ノードは(forest_id, node_id)で識別されます(別のフォレストに同じ名前の別のノードが存在する場合があります)。各ツリーはルートノード(parent_idはnull)から始まりますが、私はフォレストごとに1つしか期待していません。

これは、再帰CTEを使用するビューです。

CREATE OR REPLACE VIEW tree_view_1 AS
    WITH RECURSIVE rec_sub_tree(forest_id, node_id, parent_id, depth, path, cycle) AS (
        SELECT td.forest_id, td.node_id, td.parent_id, 0, ARRAY[td.node_id], FALSE FROM tree_data_1 td
        UNION ALL
        SELECT td.forest_id, rec.node_id, td.parent_id, rec.depth+1, td.node_id || rec.path, td.node_id = ANY(rec.path)
            FROM tree_data_1 td, rec_sub_tree rec
            WHERE td.forest_id = rec.forest_id AND rec.parent_id = td.node_id AND NOT cycle
     )
     SELECT forest_id, node_id, parent_id, depth, path
         FROM rec_sub_tree;

これは ドキュメントの例 のわずかに変更されたバージョンであり、forest_idを考慮に入れており、再帰的なSELECTではなくrec.node_idを返します。 td.node_idはどうなるでしょう。

ルートから特定のノードへのパスを取得するには、このクエリを使用できます。

SELECT * FROM tree_view_1 WHERE forest_id='Forest A' AND node_id='...' AND parent_id IS NULL

サブツリーを取得するには、このクエリを使用できます。

SELECT * FROM tree_view_1 WHERE forest_id='Forest A' AND parent_id='...'

特定のフォレスト内の完全なツリーを取得します。

SELECT * FROM tree_view_1 WHERE forest_id='Forest A' AND parent_id IS NULL

最後のクエリは、次のクエリプランを使用します( explain.depesz.com で表示可能):

 CTE Scan on rec_sub_tree  (cost=1465505.41..1472461.19 rows=8 width=132) (actual time=0.067..62480.876 rows=133495 loops=1)
   Filter: ((parent_id IS NULL) AND (forest_id = 'Forest A'::text))
   CTE rec_sub_tree
     ->  Recursive Union  (cost=0.00..1465505.41 rows=309146 width=150) (actual time=0.048..53736.585 rows=1645992 loops=1)
           ->  Seq Scan on tree_data_1 td  (cost=0.00..6006.16 rows=247316 width=82) (actual time=0.034..975.796 rows=247316 loops=1)
           ->  Hash Join  (cost=13097.90..145331.63 rows=6183 width=150) (actual time=2087.065..5842.870 rows=199811 loops=7)
                 Hash Cond: ((rec.forest_id = td.forest_id) AND (rec.parent_id = td.node_id))
                 ->  WorkTable Scan on rec_sub_tree rec  (cost=0.00..49463.20 rows=1236580 width=132) (actual time=0.017..915.814 rows=235142 loops=7)
                       Filter: (NOT cycle)
                 ->  Hash  (cost=6006.16..6006.16 rows=247316 width=82) (actual time=1871.964..1871.964 rows=247316 loops=7)
                       ->  Seq Scan on tree_data_1 td  (cost=0.00..6006.16 rows=247316 width=82) (actual time=0.017..872.725 rows=247316 loops=7)
 Total runtime: 62978.883 ms
(12 rows)

予想通り、これはあまり効率的ではありません。一部のインデックスが使用されていないように思われることもあります。

このデータは頻繁に読み取られるがほとんど変更されない(おそらく数週間ごとに少し変更される)ことを考えると、そのようなクエリやデータ表現を最適化するために考えられる手法は何ですか?

EDIT:また、深さ優先の順序でツリーを取得します。 ORDER BY pathを使用すると、上記のクエリの速度も大幅に低下します。


サンプルPythonプログラムにテーブルにテストデータを追加するプログラム (Psycopg2 が必要)、おそらく、より現実的な状況で期待するよりも少し多い:

from uuid import uuid4
import random
import psycopg2

random.seed(1234567890)
min_depth = 3
max_depth = 6
max_sub_width = 10
next_level_prob = 0.7

db_connection = psycopg2.connect(database='...')
cursor = db_connection.cursor()
query = "INSERT INTO tree_data_1(forest_id, node_id, parent_id) VALUES (%s, %s, %s)"

def generate_sub_tree(forest_id, parent_id=None, depth=0, node_ids=[]):
    if not node_ids:
        node_ids = [ str(uuid4()) for _ in range(random.randint(1, max_sub_width)) ]
    for node_id in node_ids:
        cursor.execute(query, [ forest_id, node_id, parent_id ])
        if depth < min_depth or (depth < max_depth and random.random() < next_level_prob):
            generate_sub_tree(forest_id, node_id, depth+1)

generate_sub_tree('Forest A', node_ids=['Node %d' % (i,) for i in range(10)])
generate_sub_tree('Forest B', node_ids=['Node %d' % (i,) for i in range(10)])

db_connection.commit()
db_connection.close()
6
Bruno

これらのデータをめったに変更する必要がない場合は、CTEの結果をテーブルに格納し、このテーブルに対してクエリを実行するだけです。一般的なクエリに基づいてインデックスを定義できます。
次にTRUNCATEを入力し、必要に応じて再入力します(およびANALYZE)。

一方、ビューではなく個別のストアドプロシージャにCTEを配置できる場合、条件を最終的なSELECTではなくCTE部分に簡単に配置できます(これは基本的にtree_view_1に対してクエリを実行することです) )、再帰に関与する行がはるかに少なくなるようにします。クエリプランから、PostgreSQLは、実際とはかけ離れたいくつかの仮定に基づいて行番号を推定し、おそらく次善のプランを生成するように見えます。この効果は、SPソリューションでいくらか減らすことができます。

[〜#〜] edit [〜#〜]見落としがあるかもしれませんが、非再帰的な用語では行をフィルタリングしないことに気づきました。おそらく、そこにルートノードのみを含めたい(WHERE parent_id IS NULL)-この方法では、行と再帰がはるかに少なくなると予想します。

編集2コメントから少しずつ明らかになったので、元の質問の再帰を逆に考えました。ここで私はルートノードから始めて、再帰を深くすることを意味します。

2
dezso