Độ sâu đệ quy của PostgreSQL


15

Tôi cần tính toán độ sâu của một hậu duệ từ tổ tiên của nó. Khi một bản ghi có object_id = parent_id = ancestor_id, nó được coi là một nút gốc (tổ tiên). Tôi đã cố gắng để có được một WITH RECURSIVEtruy vấn chạy với PostgreSQL 9.4 .

Tôi không kiểm soát dữ liệu hoặc các cột. Các lược đồ dữ liệu và bảng đến từ một nguồn bên ngoài. Bảng đang phát triển liên tục . Ngay bây giờ bằng khoảng 30k hồ sơ mỗi ngày. Bất kỳ nút nào trong cây có thể bị thiếu và chúng sẽ được kéo từ một nguồn bên ngoài tại một số điểm. Chúng thường được kéo theo created_at DESCthứ tự nhưng dữ liệu được kéo với các công việc nền không đồng bộ.

Chúng tôi ban đầu đã có một giải pháp mã cho vấn đề này, nhưng bây giờ có hàng 5M +, phải mất gần 30 phút để hoàn thành.

Định nghĩa bảng ví dụ và dữ liệu thử nghiệm:

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

Lưu ý rằng object_idkhông phải là duy nhất, nhưng sự kết hợp (customer_id, object_id)là duy nhất.
Chạy một truy vấn như thế này:

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;

Tôi muốn generationcột được đặt là độ sâu đã được tính toán. Khi một bản ghi mới được thêm vào, cột thế hệ được đặt là -1. Có một số trường hợp parent_idcó thể chưa được kéo. Nếu parent_idkhông tồn tại, nó sẽ để cột thế hệ được đặt thành -1.

Dữ liệu cuối cùng sẽ trông như sau:

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

Kết quả của truy vấn sẽ là cập nhật cột tạo đến độ sâu chính xác.

Tôi bắt đầu làm việc từ các câu trả lời cho câu hỏi liên quan này trên SO .


Vì vậy, bạn muốn updatebảng với kết quả của CTE đệ quy của bạn?
a_horse_with_no_name

Có, tôi muốn cột thế hệ được CẬP NHẬT với độ sâu của nó. Nếu không có cha mẹ (object.parent_id không khớp với bất kỳ đối tượng nào.object_id), thế hệ sẽ vẫn là -1.

Vậy ancestor_idlà đã được thiết lập, vì vậy bạn chỉ cần gán thế hệ từ CTE.depth?

Có, object_id, Parent_id và aneopor_id đã được đặt từ dữ liệu chúng tôi nhận được từ API. Tôi muốn đặt cột thế hệ thành bất kỳ độ sâu nào. Một lưu ý khác, object_id không phải là duy nhất, vì customer_id 1 có thể có object_id 1 và customer_id 2 có thể có object_id 1. Id chính trên bảng là duy nhất.

Đây có phải là bản cập nhật một lần hay bạn liên tục thêm vào bảng đang phát triển? Có vẻ như trường hợp sau. Làm cho một sự khác biệt lớn . Và chỉ có thể thiếu các nút gốc (chưa) hoặc bất kỳ nút nào trong cây?
Erwin Brandstetter

Câu trả lời:


14

Các truy vấn bạn có về cơ bản là chính xác. Lỗi duy nhất là ở phần thứ hai (đệ quy) của CTE nơi bạn có:

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

Nó nên là cách khác:

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

Bạn muốn tham gia các đối tượng với cha mẹ của họ (đã được tìm thấy).

Vì vậy, truy vấn tính toán độ sâu có thể được viết (không có gì khác thay đổi, chỉ định dạng):

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

Đối với bản cập nhật, bạn chỉ cần thay thế cuối cùng SELECT, bằng UPDATE, nối kết quả của cte, trở lại bảng:

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

Đã thử nghiệm trên SQLfiddle

Ý kiến ​​khác:

  • các ancestor_idparent_idkhông cần thiết phải trong danh sách lựa chọn (tổ tiên được rõ ràng, cha mẹ một chút khó khăn để tìm ra lý do tại sao), vì vậy bạn có thể giữ chúng trong các SELECTtruy vấn nếu bạn muốn nhưng bạn có thể loại bỏ một cách an toàn chúng ra khỏi UPDATE.
  • những (customer_id, object_id)có vẻ như một ứng cử viên cho một UNIQUEhạn chế. Nếu dữ liệu của bạn tuân thủ điều này, hãy thêm một ràng buộc như vậy. Các phép nối được thực hiện trong CTE đệ quy sẽ không có ý nghĩa nếu nó không phải là duy nhất (một nút có thể có 2 cha mẹ nếu không).
  • nếu bạn thêm ràng buộc đó, thì (customer_id, parent_id)đó sẽ là một ứng cử viên cho một FOREIGN KEYràng buộc đó REFERENCES(duy nhất) (customer_id, object_id). Rất có thể bạn không muốn thêm ràng buộc FK đó, vì theo mô tả của bạn, bạn đang thêm các hàng mới và một số hàng có thể tham chiếu các hàng khác chưa được thêm vào.
  • Chắc chắn có vấn đề với hiệu quả của truy vấn, nếu nó sẽ được thực hiện trong một bảng lớn. Không phải trong lần chạy đầu tiên, vì gần như toàn bộ bảng sẽ được cập nhật. Nhưng lần thứ hai, bạn sẽ chỉ muốn các hàng mới (và những hàng không được chạm vào lần chạy đầu tiên) sẽ được xem xét để cập nhật. CTE vì nó sẽ phải xây dựng một kết quả lớn.
    Bản AND o.generation = -1cập nhật cuối cùng sẽ đảm bảo rằng các hàng được cập nhật trong lần chạy đầu tiên sẽ không được cập nhật lại nhưng CTE vẫn là một phần đắt tiền.

Sau đây là một nỗ lực để giải quyết các vấn đề này: cải thiện CTE để xem xét càng ít hàng càng tốt và sử dụng (customer_id, obejct_id)thay vì (id)xác định các hàng (vì vậy idsẽ bị xóa hoàn toàn khỏi truy vấn. Nó có thể được sử dụng làm bản cập nhật đầu tiên hoặc tiếp theo:

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

Lưu ý cách CTE có 3 phần. Hai phần đầu là phần ổn định. Phần đầu tiên tìm thấy các nút gốc chưa được cập nhật trước đó và vẫn còn generation=-1để chúng phải là các nút mới được thêm vào. Phần thứ 2 tìm thấy con (với generation=-1) các nút cha mẹ đã được cập nhật trước đó.
Phần thứ 3, đệ quy, tìm thấy tất cả hậu duệ của hai phần đầu tiên, như trước đây.

Đã thử nghiệm trên SQLfiddle-2


3

@ypercube đã cung cấp giải thích phong phú, vì vậy tôi sẽ cắt theo đuổi những gì tôi phải thêm.

Nếu parent_idkhông tồn tại, nó sẽ để cột thế hệ được đặt thành -1.

Tôi giả sử điều này được cho là áp dụng đệ quy, tức là phần còn lại của cây luôngeneration = -1sau bất kỳ nút bị thiếu nào.

Nếu bất kỳ nút nào trong cây có thể bị thiếu (chưa), chúng ta cần tìm các hàng có generation = -1...
... là các nút gốc
... hoặc có cha mẹ với generation > -1.
Và đi qua cây từ đó. Các nút con của lựa chọn này cũng phải có generation = -1.

Lấy generationsố cha mẹ tăng thêm một hoặc giảm về 0 cho các nút gốc:

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;

Phần không đệ quy là một cách duy nhất SELECT, nhưng tương đương về mặt logic với hai liên minh của @ ypercube SELECT. Không chắc cái nào nhanh hơn, bạn sẽ phải kiểm tra.
Điểm quan trọng hơn nhiều đối với hiệu suất là:

Mục lục!

Nếu bạn liên tục thêm hàng vào một bảng lớn theo cách này, hãy thêm một chỉ mục một phần :

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

Điều này sẽ đạt được nhiều hơn cho hiệu suất so với tất cả các cải tiến khác được thảo luận cho đến nay - cho các bổ sung nhỏ lặp đi lặp lại cho một bảng lớn.

Tôi đã thêm điều kiện chỉ mục vào phần đệ quy của CTE (mặc dù là dự phòng logic) để giúp người lập kế hoạch truy vấn hiểu rằng chỉ mục một phần được áp dụng.

Ngoài ra, có lẽ bạn cũng nên có các UNIQUEràng buộc về (object_id, customer_id)@ypercube đã được đề cập. Hoặc, nếu bạn không thể áp đặt tính duy nhất vì một số lý do (tại sao?), Hãy thêm một chỉ mục đơn giản thay thế. Thứ tự của các cột chỉ mục quan trọng, btw:


1
Tôi sẽ thêm các chỉ mục và các ràng buộc được đề xuất bởi bạn và @ypercube. Nhìn qua dữ liệu, tôi không thấy bất kỳ lý do nào mà chúng không thể xảy ra (ngoài khóa ngoại vì đôi khi cha_id chưa được đặt). Tôi cũng sẽ đặt cột thế hệ là null và bộ mặc định là NULL thay vì -1. Sau đó, tôi sẽ không có nhiều bộ lọc "-1" và các chỉ mục một phần có thể là thế hệ WHERE, v.v.
Diggity

@Diggity: NULL chỉ hoạt động tốt nếu bạn thích nghi với phần còn lại, vâng.
Erwin Brandstetter

@Erwin tốt đẹp. Ban đầu tôi cũng nghĩ giống bạn. Một chỉ số ON objects (customer_id, parent_id, object_id) WHERE generation = -1;và có lẽ khác ON objects (customer_id, object_id) WHERE generation > -1;. Bản cập nhật cũng sẽ phải "chuyển đổi" tất cả các hàng được cập nhật từ chỉ mục này sang chỉ mục khác, vì vậy không chắc đây có phải là ý tưởng hay cho lần chạy đầu tiên của CẬP NHẬT hay không.
ypercubeᵀᴹ

Lập chỉ mục cho các truy vấn đệ quy có thể thực sự khó khăn.
ypercubeᵀᴹ
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.