Profundidade descendente recursiva do PostgreSQL

15

Preciso calcular a profundidade de um descendente a partir de seu ancestral. Quando um registro possui object_id = parent_id = ancestor_id, ele é considerado um nó raiz (o ancestral). Eu tenho tentado obter uma WITH RECURSIVEconsulta em execução com o PostgreSQL 9.4 .

Eu não controlo os dados ou as colunas. O esquema de dados e tabela é proveniente de uma fonte externa. A tabela está crescendo continuamente . No momento, cerca de 30 mil registros por dia. Qualquer nó da árvore pode estar ausente e será extraído de uma fonte externa em algum momento. Eles geralmente são puxados em created_at DESCordem, mas os dados são puxados com trabalhos em segundo plano assíncronos.

Inicialmente, tínhamos uma solução de código para esse problema, mas agora com mais de 5 milhões de linhas, leva quase 30 minutos para ser concluído.

Definição de tabela de exemplo e dados de teste:

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

Observe que object_idnão é exclusivo, mas a combinação (customer_id, object_id)é única.
Executando uma consulta como esta:

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;

Gostaria que a generationcoluna fosse definida como a profundidade que foi calculada. Quando um novo registro é adicionado, a coluna de geração é definida como -1. Existem alguns casos em que um parent_idpode não ter sido extraído ainda. Se oparent_id não existir, deve deixar a coluna de geração definida como -1.

Os dados finais devem ter a seguinte aparência:

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

O resultado da consulta deve ser atualizar a coluna de geração para a profundidade correta.

Comecei a trabalhar a partir das respostas a esta pergunta relacionada no SO .

Diggity
fonte
Então você quer updatea mesa com o resultado da sua CTE recursiva?
a_horse_with_no_name
Sim, eu gostaria que a coluna de geração fosse ATUALIZADA com a profundidade. Se não houver pai (objects.parent_id não corresponde a nenhum objects.object_id), a geração permanecerá como -1.
Então, o ancestor_idjá está definido, então você só precisa atribuir a geração a partir do CTE.depth?
Sim, object_id, parent_id e ancestor_id já estão definidos a partir dos dados que obtemos da API. Eu gostaria de definir a coluna de geração para qualquer profundidade. Outra observação: o object_id não é exclusivo, pois customer_id 1 pode ter object_id 1 e customer_id 2 pode ter object_id 1. O ID principal da tabela é exclusivo.
Esta é uma atualização única ou você está adicionando continuamente a uma tabela crescente? Parece o último caso. Faz uma grande diferença. E somente os nós raiz podem estar ausentes (ainda) ou qualquer nó na árvore?
Erwin Brandstetter

Respostas:

14

A consulta que você tem está basicamente correta. O único erro está na segunda parte (recursiva) do CTE, onde você tem:

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

Deveria ser o contrário:

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

Você deseja unir os objetos aos pais (que já foram encontrados).

Portanto, a consulta que calcula a profundidade pode ser escrita (nada mais mudou, apenas a formatação):

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

Para a atualização, basta substituir o último SELECT, com o UPDATE, juntando o resultado do cte, de volta à tabela:

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

Testado no SQLfiddle

Comentários adicionais:

  • o ancestor_ide o parent_idnão precisam estar na lista de seleção (o ancestral é óbvio, o pai é um pouco complicado para descobrir o porquê), para que você possa mantê-los noSELECT consulta, se quiser, mas pode removê-los com segurança do UPDATE.
  • o (customer_id, object_id)parece ser um candidato a umUNIQUE restrição. Se seus dados estiverem em conformidade com isso, adicione essa restrição. As junções realizadas no CTE recursivo não faria sentido se não fosse exclusivo (um nó poderia ter dois pais caso contrário).
  • se você adicionar essa restrição, (customer_id, parent_id)ela seria candidata a uma FOREIGN KEYrestrição que REFERENCES(única) (customer_id, object_id). Você provavelmente não deseja adicionar essa restrição FK, já que, por sua descrição, você está adicionando novas linhas e algumas linhas podem fazer referência a outras que ainda não foram adicionadas.
  • Certamente há problemas com a eficiência da consulta, se ela for executada em uma grande tabela. Não na primeira execução, pois quase toda a tabela será atualizada de qualquer maneira. Mas na segunda vez, você desejará que apenas as novas linhas (e as que não foram tocadas pela primeira execução) sejam consideradas para atualização. O CTE, como é, terá que gerar um grande resultado.
    A AND o.generation = -1atualização final garantirá que as linhas que foram atualizadas na 1ª execução não serão atualizadas novamente, mas o CTE ainda é uma parte cara.

A seguir, é apresentada uma tentativa de resolver esses problemas: melhore o CTE para considerar o menor número possível de linhas e use em (customer_id, obejct_id)vez de (id)identificar linhas (isso idé completamente removido da consulta. Ele pode ser usado como a 1ª atualização ou uma subseqüente:

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

Observe como o CTE possui 3 partes. Os dois primeiros são as partes estáveis. A 1ª parte encontra os nós raiz que não foram atualizados antes e ainda os têm, generation=-1portanto devem ser nós adicionados recentemente. A segunda parte localiza os filhos (com generation=-1) dos nós pai que foram atualizados anteriormente.
A terceira parte recursiva encontra todos os descendentes das duas primeiras partes, como antes.

Testado no SQLfiddle-2

ypercubeᵀᴹ
fonte
3

O @ypercube já fornece uma explicação ampla, por isso vou direto ao assunto o que tenho que adicionar.

Se parent_idnão existir, deve deixar a coluna de geração definida como -1.

Suponho que isso deva se aplicar recursivamente, ou seja, o restante da árvore sempre tem generation = -1um nó ausente.

Se algum nó da árvore estiver ausente (ainda), precisamos encontrar linhas com generation = -1isso ...
... são nós raiz
... ou ter um pai com generation > -1.
E atravesse a árvore de lá. Os nós filhos dessa seleção também devem ter generation = -1.

Pegue o generationdo pai incrementado em um ou volte para 0 para nós raiz:

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;

A parte não recursiva é única SELECTdessa maneira, mas logicamente equivalente às duas uniões do @ ypercube SELECT. Não tem certeza do que é mais rápido, você terá que testar.
O ponto muito mais importante para o desempenho é:

Índice!

Se você adicionar repetidamente linhas a uma tabela grande dessa maneira, adicione um índice parcial :

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

Isso alcançará mais desempenho do que todas as outras melhorias discutidas até agora - para pequenas e repetidas adições a uma grande tabela.

Adicionei a condição de índice à parte recursiva do CTE (mesmo que logicamente redundante) para ajudar o planejador de consultas a entender que o índice parcial é aplicável.

Além disso, você provavelmente também deve ter a UNIQUErestrição de (object_id, customer_id)que o @ypercube já mencionado. Ou, se você não puder impor exclusividade por algum motivo (por quê?), Adicione um índice simples. A ordem das colunas do índice é importante, entre:

Erwin Brandstetter
fonte
1
Vou adicionar os índices e restrições sugeridos por você e @ypercube. Examinando os dados, não vejo nenhuma razão para que eles não pudessem acontecer (além da chave estrangeira, às vezes o parent_id ainda não está definido). Também definirei a coluna de geração como anulável e o padrão definido como NULL em vez de -1. Então não terei muitos filtros "-1" e os índices parciais podem ser ONDE a geração é NULL, etc.
Diggity
@ Diggity: NULL deve funcionar muito bem se você adaptar o resto, sim.
Erwin Brandstetter
@ Erwin nice. Eu originalmente pensava semelhante a você. Um índice ON objects (customer_id, parent_id, object_id) WHERE generation = -1;e talvez outro ON objects (customer_id, object_id) WHERE generation > -1;. A atualização também precisará "alternar" todas as linhas atualizadas de um índice para outro, portanto, não tenha certeza se essa é uma boa idéia para a execução inicial do UPDATE.
usar o seguinte comando
A indexação para consultas recursivas pode ser muito difícil.
precisa saber é o seguinte