web-dev-qa-db-ja.com

PostgreSQLの再帰的な子孫の深さ

祖先から子孫の深さを計算する必要があります。レコードにobject_id = parent_id = ancestor_id、それはルートノード(祖先)と見なされます。 WITH RECURSIVE PostgreSQLで実行されるクエリ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に関するこの関連質問への回答 から作業を開始しました。

15
Diggity

あなたが持っているクエリは基本的に正しいです。唯一の間違いは、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 ;
_

更新の場合は、最後のSELECTUPDATEに置き換え、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

追加コメント:

  • _ancestor_id_と_parent_id_を選択リストに含める必要はありません(祖先は明白ですが、親を理解するには少し注意が必要です)。したがって、必要に応じてSELECTクエリにそれらを保持できます。 UPDATEから安全に削除できます。
  • _(customer_id, object_id)_は、UNIQUE制約の候補のようです。データがこれに準拠している場合は、そのような制約を追加します。再帰CTEで実行された結合は、一意でないと意味がありません(ノードに2つの親が含まれる場合があります)。
  • その制約を追加すると、_(customer_id, parent_id)_は_FOREIGN KEY_制約の候補となり、REFERENCES(一意の)_(customer_id, object_id)_になります。あなたはおそらく、そのFK制約を追加したいではないので、説明により、新しい行を追加しており、一部の行は他の行を参照できますまだ追加されていません。
  • 大きなテーブルで実行する場合、クエリの効率には確かに問題があります。ほとんどすべてのテーブルがとにかく更新されるので、最初の実行ではありません。ただし、2回目は、新しい行(および1回目の実行で変更されなかった行)のみを更新の対象とする必要があります。現状のCTEは、大きな成果を生み出す必要があります。
    最終更新の_AND o.generation = -1_により、1回目の実行で更新された行が再度更新されなくなりますが、CTEは依然として高価な部分です。

以下は、これらの問題に対処するための試みです。できるだけ少ない行を考慮するように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

14
ypercubeᵀᴹ

@ ypercube既に は十分な説明を提供するので、追加する必要があるものを追跡します。

parent_idが存在しない場合は、生成列を-1に設定したままにする必要があります。

これは再帰的に適用されることになっていると思います。つまり、ツリーの残りの部分は常に不足しているノードの後に​​generation = -1があります。

ツリーのノードが(まだ)見つからない場合、generation = -1という行を見つける必要があります...
...はルートノードです
...またはgeneration > -1の親がいます。
そこから木を横断します。この選択の子ノードには、generation = -1も必要です。

ルートノードの場合、親のgenerationを1つインクリメントするか、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つの結合されたSELECTと論理的に同等です。どちらが速いかわかりません。テストする必要があります。
パフォーマンスにとってより重要なポイントは次のとおりです。

インデックス!

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

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

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

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

さらに、おそらく、@ ypercubeがすでに言及した(object_id, customer_id)に対するUNIQUE制約も必要です。または、何らかの理由で一意性を課すことができない場合(なぜですか)、代わりにプレーンインデックスを追加します。インデックス列の順序は重要です、ところで:

3