PostgreSQLの再帰的な子孫の深さ


15

祖先の子孫の深さを計算する必要があります。レコードにがある場合、レコードはobject_id = parent_id = ancestor_idルートノード(祖先)と見なされます。WITH RECURSIVEPostgreSQL 9.4でクエリを実行しようとしています。

データや列を制御しません。データおよびテーブルスキーマは外部ソースから取得されます。テーブルは継続的に成長しています。現在、1日あたり約3万件の記録があります。ツリー内のノードは欠落している可能性があり、ある時点で外部ソースからプルされます。彼らは通常引き込まれますcreated_at DESC順番にますが、データは非同期のバックグラウンドジョブでプルされます。

最初はこの問題に対するコードソリューションがありましたが、現在は500万行以上あり、完了するまでに約30分かかります。

テーブル定義とテストデータの例:

CREATE TABLE objects (
  id          serial NOT NULL PRIMARY KEY,
  customer_id integer NOT NULL,
  object_id   integer NOT NULL,
  parent_id   integer,
  ancestor_id integer,
  generation  integer NOT NULL DEFAULT 0
);

INSERT INTO objects(id, customer_id , object_id, parent_id, ancestor_id, generation)
VALUES (2, 1, 2, 1, 1, -1), --no parent yet
       (3, 2, 3, 3, 3, -1), --root node
       (4, 2, 4, 3, 3, -1), --depth 1
       (5, 2, 5, 4, 3, -1), --depth 2
       (6, 2, 6, 5, 3, -1), --depth 3
       (7, 1, 7, 7, 7, -1), --root node
       (8, 1, 8, 7, 7, -1), --depth 1
       (9, 1, 9, 8, 7, -1); --depth 2

object_idは一意ではありませんが、組み合わせ(customer_id, object_id)は一意であることに注意してください。
次のようなクエリを実行します。

WITH RECURSIVE descendants(id, customer_id, object_id, parent_id, ancestor_id, depth) AS (
  SELECT id, customer_id, object_id, parent_id, ancestor_id, 0
  FROM objects
  WHERE object_id = parent_id

  UNION

  SELECT o.id, o.customer_id, o.object_id, o.parent_id, o.ancestor_id, d.depth + 1
  FROM objects o
  INNER JOIN descendants d ON d.parent_id = o.object_id
  WHERE
    d.id <> o.id
  AND
    d.customer_id = o.customer_id
) SELECT * FROM descendants d;

generation計算された深さとして列を設定したいと思います。新しいレコードが追加されると、生成列は-1に設定されます。parent_idがまだプルされていない場合があります。もしparent_idが存在しない、生成列を-1のままにしておく必要があります。

最終データは次のようになります。

id | customer_id | object_id | parent_id | ancestor_id | generation
2    1             2           1           1            -1
3    2             3           3           3             0
4    2             4           3           3             1
5    2             5           4           3             2
6    2             6           5           3             3
7    1             7           7           7             0
8    1             8           7           7             1
9    1             9           8           7             2

クエリの結果は、生成列を正しい深さに更新することです。

SOに関するこの関連する質問へ回答から作業を始めました。


update再帰CTEの結果を表にしたいですか?
a_horse_with_no_name

はい、生成列をその深さまで更新したいと思います。親がない場合(objects.parent_idがどのobjects.object_idとも一致しない場合)、生成は-1のままになります。

だからancestor_id、すでに設定されているので、あなただけのCTE.depthから世代を割り当てる必要がありますか?

はい、object_id、parent_id、およびancestor_idは、APIから取得したデータからすでに設定されています。生成列を深さに合わせたいと思います。もう1つの注意点として、customer_id 1はobject_id 1を持つことができ、customer_id 2はobject_id 1を持つことができるため、object_idは一意ではありません。テーブルのプライマリIDは一意です。

これは1回限りの更新ですか、それとも成長中のテーブルに継続的に追加していますか?後者の場合のようです。大きな違いをもたらします。また、ルートノードのみが(まだ)欠落しているか、ツリー内のノードがありますか?
アーウィンブランドステッター

回答:


14

あなたが持っているクエリは基本的に正しいです。唯一の間違いは、CTEの2番目の(再帰的)部分にあります。

INNER JOIN descendants d ON d.parent_id = o.object_id

それは他の方法でなければなりません:

INNER JOIN descendants d ON d.object_id = o.parent_id 

オブジェクトをその親(既に見つかっている)と結合したい場合。

したがって、深さを計算するクエリを書くことができます(他に何も変更せず、フォーマットのみ):

-- calculate generation / depth, no updates
WITH RECURSIVE descendants
  (id, customer_id, object_id, parent_id, ancestor_id, depth) AS
 AS ( SELECT id, customer_id, object_id, parent_id, ancestor_id, 0
      FROM objects
      WHERE object_id = parent_id

      UNION ALL

      SELECT o.id, o.customer_id, o.object_id, o.parent_id, o.ancestor_id, d.depth + 1
      FROM objects o
      INNER JOIN descendants d ON  d.customer_id = o.customer_id
                               AND d.object_id = o.parent_id  
      WHERE d.id <> o.id
    ) 
SELECT * 
FROM descendants d
ORDER BY id ;

更新の場合、最後のをSELECT、に置き換えてUPDATE、cteの結果をテーブルに戻します。

-- update nodes
WITH RECURSIVE descendants
    -- nothing changes here except
    -- ancestor_id and parent_id 
    -- which can be omitted form the select lists
    ) 
UPDATE objects o 
SET generation = d.depth 
FROM descendants d
WHERE o.id = d.id 
  AND o.generation = -1 ;          -- skip unnecessary updates

SQLfiddleでテスト済み

追加コメント:

  • the ancestor_idとthe parent_idは選択リストにある必要はありません(祖先は明らかであり、親はその理由を理解するのに少し注意SELECTが必要です)。UPDATE
  • (customer_id, object_id)候補のようですUNIQUE制約のです。データがこれに準拠している場合、そのような制約を追加します。再帰CTEで実行される結合は、一意ではない場合は意味がありません(そうでない場合、ノードは2つの親を持つことができます)。
  • その制約を追加すると、は(一意の)制約の(customer_id, parent_id)候補になりFOREIGN KEYます。あなたはおそらくそうではありませんREFERENCES(customer_id, object_id)ただし、説明によると、新しい行を追加しており、一部の行はまだ追加されていない他の行を参照できるため、そのFK制約を追加する。
  • クエリが大きなテーブルで実行される場合、クエリの効率には確かに問題があります。とにかくほとんどすべてのテーブルが更新されるため、最初の実行ではありません。ただし、2回目は、新しい行(および最初の実行で影響を受けなかった行)のみを更新の対象とする必要があります。CTEはそのまま大きな成果を上げなければなりません。最後のアップデートでは、第一の実行で更新された行が再び更新されないことを確認しますが、CTEはまだ高価な部分です。
    AND o.generation = -1

これらの問題に対処する試みは次のとおりです。CTEを改善して、できるだけ少ない行を考慮し、行を識別する(customer_id, obejct_id)代わりに使用し(id)ます(idクエリから完全に削除されます。最初の更新またはそれ以降として使用できます。

WITH RECURSIVE descendants 
  (customer_id, object_id, depth) 
 AS ( SELECT customer_id, object_id, 0
      FROM objects
      WHERE object_id = parent_id
        AND generation = -1

      UNION ALL

      SELECT o.customer_id, o.object_id, p.generation + 1
      FROM objects o
        JOIN objects p ON  p.customer_id = o.customer_id
                       AND p.object_id = o.parent_id
                       AND p.generation > -1
      WHERE o.generation = -1

      UNION ALL

      SELECT o.customer_id, o.object_id, d.depth + 1
      FROM objects o
      INNER JOIN descendants d ON  o.customer_id = d.customer_id
                               AND o.parent_id = d.object_id
      WHERE o.parent_id <> o.object_id
        AND o.generation = -1
    )
UPDATE objects o 
SET generation = d.depth 
FROM descendants d
WHERE o.customer_id = d.customer_id
  AND o.object_id = d.object_id
  AND o.generation = -1        -- this is not really needed

CTEには3つの部分があることに注意してください。最初の2つは安定した部品です。最初の部分は、まだ更新されておらず、まだ更新されていないルートノードを見つけるため、generation=-1新しく追加されたノードである必要があります。2番目の部分はgeneration=-1、以前に更新された親ノードの子(を含む)を見つけます。
3番目の再帰部分は、前と同様に最初の2つの部分のすべての子孫を見つけます。

SQLfiddle-2でテスト済み


3

@ypercubeはすでに十分な説明を提供しているので、追加しなければならないことを追いかけます。

もし parent_idが存在しない、生成列を-1のままにしておく必要があります。

私はこれを再帰的に適用することになっていると仮定し、木のすなわち残りは常に持っていますgeneration = -1任意の欠落したノードの後に。

ツリー内のノードが(まだ)欠落している可能性がある場合generation = -1、...
...がルートノード
...またはを持つ親を持つ行を見つける必要がありますgeneration > -1
そしてそこから木を横断します。この選択の子ノードgeneration = -1も持つ必要があります。

テイクgeneration1ずつインクリメント親のか、ルート・ノードのために0にフォールバック:

WITH RECURSIVE tree AS (
   SELECT c.customer_id, c.object_id, COALESCE(p.generation + 1, 0) AS depth
   FROM   objects      c
   LEFT   JOIN objects p ON c.customer_id = p.customer_id
                        AND c.parent_id   = p.object_id
                        AND p.generation > -1
   WHERE  c.generation = -1
   AND   (c.parent_id = c.object_id OR p.generation > -1)
       -- root node ... or parent with generation > -1

   UNION ALL
   SELECT customer_id, c.object_id, p.depth + 1
   FROM   objects c
   JOIN   tree    p USING (customer_id)
   WHERE  c.parent_id  = p.object_id
   AND    c.parent_id <> c.object_id  -- exclude root nodes
   AND    c.generation = -1           -- logically redundant, but see below!
   )
UPDATE objects o 
SET    generation = t.depth
FROM   tree t
WHERE  o.customer_id = t.customer_id
AND    o.object_id   = t.object_id;

このように非再帰部分は単一ですSELECTが、論理的には@ypercubeの2つのunion'edと同等ですSELECTです。どちらが速いかわからない場合は、テストする必要があります。
パフォーマンスにとってより重要な点は次のとおりです。

インデックス!

この方法で行を大きなテーブルに繰り返し追加する場合は、部分インデックスを追加します

CREATE INDEX objects_your_name_idx ON objects (customer_id, parent_id, object_id)
WHERE  generation = -1;

これにより、これまでに説明した他のすべての改善(大きなテーブルへの小さな追加の繰り返し)よりもパフォーマンスが向上します。

クエリプランナーが部分インデックスが適用可能であることを理解できるように、CTEの再帰部分にインデックス条件を追加しました(論理的に冗長ですが)。

さらに、おそらくすでに説明UNIQUE(object_id, customer_id)た@ypercubeに制約を設定する必要があります。または、何らかの理由で一意性を課せない場合(理由)、代わりにプレーンインデックスを追加します。インデックスカラムの順序は重要です。


1
あなたと@ypercubeによって提案されたインデックスと制約を追加します。データを見ると、それらが発生しなかった理由はわかりません(場合によっては、parent_idがまだ設定されていない外部キー以外)。また、生成列をNULL可能に設定し、デフォルトを-1ではなくNULLに設定します。その場合、「-1」フィルターは多く
なくなり

@Diggity:残りを適応させれば、NULLはうまく機能するはずです、はい。
アーウィンブランドステッター

@Erwinいいね。私はもともとあなたと似ていると思いました。インデックスON objects (customer_id, parent_id, object_id) WHERE generation = -1;とおそらく別のON objects (customer_id, object_id) WHERE generation > -1;。更新では、更新されたすべての行をあるインデックスから別のインデックスに「切り替える」必要があるため、これがUPDATEの最初の実行に適しているかどうかはわかりません。
ypercubeᵀᴹ

再帰クエリのインデックス作成は非常に困難です。
ypercubeᵀᴹ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.