Kedalaman Keturunan Rekursif PostgreSQL

15

Saya perlu menghitung kedalaman keturunan dari leluhurnya. Ketika sebuah catatan memiliki object_id = parent_id = ancestor_id, itu dianggap sebagai simpul akar (leluhur). Saya telah mencoba WITH RECURSIVEmenjalankan kueri dengan PostgreSQL 9.4 .

Saya tidak mengontrol data atau kolom. Skema data dan tabel berasal dari sumber eksternal. Meja tumbuh terus menerus . Saat ini sekitar 30 ribu catatan per hari. Setiap simpul di pohon dapat hilang dan mereka akan ditarik dari sumber eksternal di beberapa titik. Mereka biasanya ditarik dalam created_at DESCurutan tetapi data ditarik dengan pekerjaan latar belakang asinkron.

Kami awalnya memiliki solusi kode untuk masalah ini, tetapi sekarang memiliki 5M + baris, dibutuhkan hampir 30 menit untuk menyelesaikannya.

Definisi tabel contoh dan data uji:

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

Perhatikan itu object_idtidak unik, tetapi kombinasinya (customer_id, object_id)unik.
Menjalankan kueri seperti ini:

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;

Saya ingin generationkolom ditetapkan sebagai kedalaman yang dihitung. Ketika catatan baru ditambahkan, kolom generasi diatur sebagai -1. Ada beberapa kasus di mana a parent_idmungkin belum ditarik. Jika parent_idtidak ada, seharusnya membiarkan kolom generasi diatur ke -1.

Data akhir akan terlihat seperti:

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

Hasil kueri harus memperbarui kolom generasi ke kedalaman yang benar.

Saya mulai bekerja dari jawaban untuk pertanyaan terkait ini di SO .

Diggity
sumber
Jadi Anda ingin ke updatemeja dengan hasil CTE rekursif Anda?
a_horse_with_no_name
Ya, saya ingin agar kolom generasi DIPERBARUI sesuai kedalamannya. Jika tidak ada orang tua (objek.parent_id tidak cocok dengan objek.object_id) generasi akan tetap -1.
Jadi ancestor_idsudah diatur, jadi Anda hanya perlu menetapkan generasi dari CTE.depth?
Ya, object_id, parent_id, dan leluhur_id sudah ditetapkan dari data yang kami dapatkan dari API. Saya ingin mengatur kolom generasi ke kedalaman berapa pun. Satu catatan lain, object_id tidak unik, karena customer_id 1 dapat memiliki object_id 1, dan customer_id 2 bisa memiliki object_id 1. Id utama pada tabel adalah unik.
Apakah ini pembaruan satu kali atau Anda terus menambahkan ke tabel tumbuh? Sepertinya kasus terakhir. Membuat perbedaan besar . Dan bisakah hanya root node yang hilang (belum) atau ada simpul di pohon?
Erwin Brandstetter

Jawaban:

14

Permintaan yang Anda miliki pada dasarnya benar. Satu-satunya kesalahan adalah di bagian kedua (rekursif) dari CTE di mana Anda memiliki:

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

Itu harus sebaliknya:

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

Anda ingin bergabung dengan benda-benda dengan orang tua mereka (yang telah ditemukan).

Jadi kueri yang menghitung kedalaman dapat ditulis (tidak ada yang berubah, hanya memformat):

-- 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 ;

Untuk pembaruan, Anda cukup mengganti yang terakhir SELECT, dengan UPDATE, bergabung dengan hasil dari cte, kembali ke tabel:

-- 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

Diuji pada SQLfiddle

Komentar tambahan:

  • yang ancestor_iddan parent_idtidak perlu berada di daftar pilih (leluhur yang jelas, orang tua sedikit sulit untuk mencari tahu mengapa), sehingga Anda dapat menjaga mereka di SELECTquery jika Anda inginkan, tetapi Anda dapat dengan aman menghapus mereka dari UPDATE.
  • yang (customer_id, object_id)tampaknya seperti kandidat untuk UNIQUEkendala. Jika data Anda mematuhi ini, tambahkan kendala seperti itu. Gabungan yang dilakukan dalam CTE rekursif tidak akan masuk akal jika tidak unik (sebuah simpul bisa memiliki 2 orang tua sebaliknya).
  • jika Anda menambahkan kendala itu, (customer_id, parent_id)akan menjadi kandidat untuk FOREIGN KEYkendala yang REFERENCES(unik) (customer_id, object_id). Anda kemungkinan besar tidak ingin menambahkan batasan FK itu, karena dengan uraian Anda, Anda menambahkan baris baru dan beberapa baris dapat mereferensikan yang lain yang belum ditambahkan.
  • Tentunya ada masalah dengan efisiensi kueri, jika itu akan dilakukan dalam tabel besar. Tidak di jalankan pertama, karena hampir seluruh tabel akan diperbarui pula. Tetapi yang kedua, Anda hanya ingin baris baru (dan yang tidak tersentuh oleh run pertama) dipertimbangkan untuk pembaruan. CTE seperti itu harus membangun hasil yang besar.
    The AND o.generation = -1di update akhir akan memastikan bahwa baris yang diperbarui pada tanggal 1 run tidak akan diperbarui lagi tapi CTE masih merupakan bagian yang mahal.

Berikut ini adalah upaya untuk mengatasi masalah ini: tingkatkan CTE dengan mempertimbangkan beberapa baris sebanyak mungkin dan gunakan (customer_id, obejct_id)alih-alih (id)mengidentifikasi baris (sehingga idsepenuhnya dihapus dari kueri. Dapat digunakan sebagai pembaruan pertama atau berikutnya:

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

Perhatikan bagaimana CTE memiliki 3 bagian. Dua yang pertama adalah bagian yang stabil. Bagian 1 menemukan node root yang belum diperbarui sebelumnya dan masih generation=-1jadi mereka harus ditambahkan node baru. Bagian ke-2 menemukan anak-anak (dengan generation=-1) dari simpul orangtua yang sebelumnya telah diperbarui.
Bagian ke-3, rekursif, menemukan semua keturunan dari dua bagian pertama, seperti sebelumnya.

Diuji pada SQLfiddle-2

ypercubeᵀᴹ
sumber
3

@ ypercube sudah memberikan penjelasan yang cukup, jadi saya akan memotong apa yang harus saya tambahkan.

Jika parent_idtidak ada, seharusnya membiarkan kolom generasi diatur ke -1.

Saya menganggap ini seharusnya diterapkan secara rekursif, yaitu sisa pohon selalu memiliki generation = -1setelah simpul yang hilang.

Jika ada simpul di pohon dapat hilang (belum) kita perlu mencari baris dengan generation = -1itu ...
... adalah simpul root
... atau memiliki orangtua dengan generation > -1.
Dan lintasi pohon itu dari sana. Node anak dari pilihan ini juga harus dimiliki generation = -1.

Ambil salah satu generationdari induk yang bertambah satu atau turun kembali ke 0 untuk simpul root:

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;

Bagian non-rekursif adalah tunggal SELECTdengan cara ini, tetapi secara logis setara dengan dua penyatuan @ ypercube SELECT. Tidak yakin mana yang lebih cepat, Anda harus menguji.
Poin yang jauh lebih penting untuk kinerja adalah:

Indeks!

Jika Anda berulang kali menambahkan baris ke tabel besar dengan cara ini, tambahkan sebagian indeks :

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

Ini akan mencapai lebih banyak untuk kinerja daripada semua perbaikan lain yang dibahas sejauh ini - untuk penambahan kecil berulang ke tabel besar.

Saya menambahkan kondisi indeks ke bagian rekursif CTE (meskipun secara logis berlebihan) untuk membantu perencana kueri memahami bahwa indeks parsial berlaku.

Selain itu Anda mungkin juga harus memiliki UNIQUEkendala pada (object_id, customer_id)@ ypercube yang telah disebutkan. Atau, jika Anda tidak dapat memaksakan keunikan karena alasan tertentu (mengapa?) Tambahkan indeks biasa saja. Urutan kolom indeks penting, antara:

Erwin Brandstetter
sumber
1
Saya akan menambahkan indeks dan batasan yang disarankan oleh Anda dan @ypercube. Melihat melalui data, saya tidak melihat alasan bahwa mereka tidak dapat terjadi (selain kunci asing karena terkadang parent_id belum disetel). Saya juga akan mengatur kolom generasi menjadi nullable dan default ditetapkan sebagai NULL bukannya -1. Maka saya tidak akan memiliki banyak filter "-1" dan indeks parsial dapat menjadi DI MANA generasi NULL, dll.
Diggity
@Diggity: NULL akan bekerja dengan baik jika Anda mengadaptasi sisanya, ya.
Erwin Brandstetter
@ Erwin bagus. Saya awalnya berpikir sama seperti Anda. Indeks ON objects (customer_id, parent_id, object_id) WHERE generation = -1;dan mungkin yang lain ON objects (customer_id, object_id) WHERE generation > -1;. Pembaruan juga harus "mengalihkan" semua baris yang diperbarui dari satu indeks ke indeks lain, jadi tidak yakin apakah ini adalah ide yang baik untuk menjalankan awal UPDATE.
ypercubeᵀᴹ
Pengindeksan untuk kueri rekursif bisa sangat sulit.
ypercubeᵀᴹ