Регресивна глибина поглинання PostgreSQL


15

Мені потрібно обчислити глибину нащадка від його предка. Коли запис є object_id = parent_id = ancestor_id, він вважається кореневим вузлом (предком). Я намагався WITH RECURSIVEзапустити запит із PostgreSQL 9.4 .

Я не контролюю дані чи стовпці. Схема даних та таблиць надходить із зовнішнього джерела. Стіл постійно зростає . Зараз приблизно 30 000 записів на день. Будь-який вузол на дереві може бути відсутнім, і вони будуть витягнуті із зовнішнього джерела в якийсь момент. Зазвичай вони витягуються в created_at DESCпорядку, але дані витягуються з асинхронними фоновими завданнями.

Спочатку ми мали рішення коду для цієї проблеми, але тепер, маючи 5М + рядків, потрібно 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

Так, я хотів би, щоб колонка покоління була ОНОВЛЕНА до тієї глибини. Якщо немає батьківського (object.parent_id не відповідає жодному object.object_id), покоління залишиться як -1.

Отже, ancestor_idопція вже встановлена, тому вам потрібно лише призначити покоління з CTE.depth?

Так, object_id, parent_id та pretstor_id вже встановлені з даних, які ми отримуємо з API. Я хотів би встановити колонку покоління на будь-яку глибину. Ще одна примітка: object_id не є унікальним, оскільки customer_id 1 може мати object_id 1, а customer_id 2 може мати object_id 1. Основний ідентифікатор таблиці є унікальним.

Це разове оновлення чи ви постійно додаєте до зростаючої таблиці? Схоже, останній випадок. Робить велику різницю. І чи можуть бракувати (ще) лише кореневі вузли чи будь-який вузол на дереві?
Ервін Брандстеттер

Відповіді:


14

У вас запит в основному правильний. Єдина помилка - у другій (рекурсивній) частині CTE, де у вас є:

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_idare не потрібні, щоб бути у списку вибору (предка очевидно, батько трохи складний, щоб з'ясувати, чому), тому ви можете тримати їх у SELECTзапиті, якщо хочете, але ви можете безпечно їх видалити з UPDATE.
  • (customer_id, object_id)здається кандидатом на UNIQUEобмеження. Якщо ваші дані відповідають цьому, додайте таке обмеження. Приєднання, виконані в рекурсивному CTE, не мали б сенсу, якби воно не було унікальним (у вузлі інакше могли бути 2 батьки).
  • якщо додати це обмеження, то (customer_id, parent_id)буде кандидатом на FOREIGN KEYобмеження, яке REFERENCES(унікальне) (customer_id, object_id). Ви, ймовірно, не хочете додавати це обмеження FK, оскільки, описуючи, ви додаєте нові рядки, а деякі рядки можуть посилатися на інші, які ще не були додані.
  • З ефективністю запиту, безумовно, є проблеми, якщо він буде виконуватися у великій таблиці. Не в першому запуску, оскільки майже вся таблиця все одно буде оновлена. Але вдруге ви хочете, щоб лише оновлені рядки (і ті, які не торкнулися 1-го запуску) були розглянуті для оновлення. CTE, як є, повинен буде створити великий результат. В остаточному відновленні буде переконатися , що рядки , які були оновлені в 1 - м періоді не буде оновлюватися раз , але КТР по - , як і раніше дорогий частиною.
    AND o.generation = -1

Далі йде спроба вирішити ці проблеми: покращити CTE, щоб розглянути якомога менше рядків і використовувати (customer_id, obejct_id)замість того, (id)щоб ідентифікувати рядки (так idповністю видаляється з запиту. Він може бути використаний як 1-е оновлення або наступне:

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 частини. Перші два - це стійкі частини. У першій частині знаходять кореневі вузли, які раніше не оновлювались і все ще є, generation=-1тому вони повинні бути знову доданими вузлами. У другій частині знаходяться діти (з generation=-1) батьківських вузлів, які раніше були оновлені.
Третя, рекурсивна частина, знаходить усіх нащадків перших двох частин, як і раніше.

Тестовано на SQLfiddle-2


3

@ypercube вже дає достатньо пояснень, тож я виріжу на погоню, що мені додати.

Якщо parent_idцього не існує, він повинен залишити генераційний стовпчик на -1.

Я припускаю, що це має застосовуватися рекурсивно, тобто решта дерева завжди є generation = -1після будь-якого відсутнього вузла.

Якщо будь-який вузол на дереві може бути відсутнім (все ж), нам потрібно знайти рядки з generation = -1цим ...
... кореневими вузлами
... або мати батьків generation > -1.
І обходьте дерево звідти. Дочірні вузли цього вибору повинні також бути generation = -1.

Візьміть generationбатьківський приріст на один або поверніться до 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 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. Або, якщо ви не можете нав'язати унікальність з якихось причин (чому?), Додайте натомість простий індекс. Порядок індексних стовпців має значення, btw:


1
Я додам індекси та обмеження, запропоновані вами та @ypercube. Переглядаючи дані, я не бачу жодної причини, щоб вони не могли статися (крім іноземного ключа, оскільки іноді parent_id ще не встановлений). Я також встановлю стовпчик генерації нульовим, а за замовчуванням - NULL замість -1. Тоді у мене не буде багато «-1» фільтрів і часткові індекси можуть бути WHERE покоління IS NULL і т.д.
Diggity

@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ᵀᴹ
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.