SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"

Periódicamente surge la tarea de buscar datos relacionados mediante un conjunto de claves, hasta que obtengamos el número total requerido de registros.

El ejemplo más "realista" es mostrar 20 problemas más antiguos, listado en la lista de empleados (por ejemplo, dentro del mismo departamento). Para varios "paneles" de gestión con breves resúmenes de áreas de trabajo, con bastante frecuencia se requiere un tema similar.

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"

En el artículo, consideraremos la implementación en PostgreSQL de una versión "ingenua" de resolver tal problema, un algoritmo "más inteligente" y muy complejo. "bucle" en SQL con una condición de salida de los datos encontrados, que puede ser útil tanto para el desarrollo general como para su uso en otros casos similares.

Tomemos un conjunto de datos de prueba de artículo anterior. Para que los registros de salida no “salten” de vez en cuando cuando los valores ordenados coincidan, ampliar el índice de materias agregando una clave principal. Al mismo tiempo, esto le dará inmediatamente unicidad y nos garantiza la unicidad del orden de clasificación:

CREATE INDEX ON task(owner_id, task_date, id);
-- а старый - удалим
DROP INDEX task_owner_id_task_date_idx;

Como se oye, así está escrito.

Primero, esbocemos la versión más simple de la solicitud, pasando los ID de los artistas. matriz como entrada:

SELECT
  *
FROM
  task
WHERE
  owner_id = ANY('{1,2,4,8,16,32,64,128,256,512}'::integer[])
ORDER BY
  task_date, id
LIMIT 20;

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"
[mira explicar.tensor.ru]

Un poco triste: pedimos solo 20 registros y Index Scan nos devolvió plazo 960, que luego también hubo que ordenar... E intentemos leer menos.

unnest + ARRAY

La primera consideración que nos ayudará -si necesitamos total 20 ordenados registros, basta con leer no más de 20 ordenados en el mismo orden para cada uno llave. Bien, índice adecuado (owner_id, task_date, id) que tenemos.

Utilicemos el mismo mecanismo de extraer y "convertir en columnas". entrada de tabla integral, como en último artículo. Y también aplicar la convolución a una matriz usando la función ARRAY():

WITH T AS (
  SELECT
    unnest(ARRAY(
      SELECT
        t
      FROM
        task t
      WHERE
        owner_id = unnest
      ORDER BY
        task_date, id
      LIMIT 20 -- ограничиваем тут...
    )) r
  FROM
    unnest('{1,2,4,8,16,32,64,128,256,512}'::integer[])
)
SELECT
  (r).*
FROM
  T
ORDER BY
  (r).task_date, (r).id
LIMIT 20; -- ... и тут - тоже

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"
[mira explicar.tensor.ru]

¡Oh, ya está mucho mejor! 40% más rápido y 4.5 veces menos datos Tuve que leer.

Materialización de registros tabulares vía CTEvoy a notar que en algunos casos un intento de trabajar inmediatamente con los campos del registro después de buscarlo en una subconsulta, sin "envolverlo" en un CTE, puede llevar a Plan de inicio "multiplicación" proporcional al número de estos mismos campos:

SELECT
  ((
    SELECT
      t
    FROM
      task t
    WHERE
      owner_id = 1
    ORDER BY
      task_date, id
    LIMIT 1
  ).*);

Result  (cost=4.77..4.78 rows=1 width=16) (actual time=0.063..0.063 rows=1 loops=1)
  Buffers: shared hit=16
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.031..0.032 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t  (cost=0.42..387.57 rows=500 width=48) (actual time=0.030..0.030 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4
  InitPlan 2 (returns $1)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.008..0.009 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t_1  (cost=0.42..387.57 rows=500 width=48) (actual time=0.008..0.008 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4
  InitPlan 3 (returns $2)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.008..0.008 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t_2  (cost=0.42..387.57 rows=500 width=48) (actual time=0.008..0.008 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4"
  InitPlan 4 (returns $3)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.009..0.009 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t_3  (cost=0.42..387.57 rows=500 width=48) (actual time=0.009..0.009 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4

El mismo registro fue “buscado” 4 veces… Hasta PostgreSQL 11, este comportamiento ocurre regularmente, y la solución es “envolverlo” en un CTE, que es un límite incondicional para el optimizador en estas versiones.

acumulador recursivo

En la versión anterior, en total, leemos plazo 200 por el bien de los 20 necesarios. Ya no 960, pero aún menos, ¿es posible?

Intentemos utilizar el conocimiento que necesitamos. xnumx total registros. Es decir, iteraremos la resta de datos solo hasta alcanzar la cantidad que necesitamos.

Paso 1: Lista de inicio

Obviamente, nuestra lista "objetivo" de 20 entradas debería comenzar con las "primeras" entradas de una de nuestras clavesowner_id. Por lo tanto, primero encontramos tales "muy primero" para cada una de las claves y ponerlo en la lista, ordenándolo en el orden que queramos - (task_date, id).

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"

Paso 2: busque los registros "siguientes"

Ahora, si tomamos la primera entrada de nuestra lista y comenzamos "paso" más abajo en el índice Al guardar la clave Owner_id, todos los registros encontrados son solo los siguientes en la selección resultante. Por supuesto, sólo hasta cruzar la clave aplicada segunda entrada en la lista.

Si resultó que "cruzamos" la segunda entrada, entonces la última entrada leída debe agregarse a la lista en lugar de la primera (con el mismoowner_id), después de lo cual la lista se ordena nuevamente.

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"

Es decir, siempre conseguimos que la lista no tenga más de una entrada para cada una de las claves (si se acabaron las entradas y no las hemos “cruzado”, entonces la primera entrada simplemente desaparecerá de la lista y no se añadirá nada). ), y ellos siempre ordenado en orden ascendente de la clave de la aplicación (task_date, id).

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"

Paso 3: Filtrar y expandir registros

En la parte de las filas de nuestra selección recursiva, algunos registros rv están duplicados: primero encontramos algo como "cruzar el borde de la segunda entrada de la lista", y luego lo sustituimos por el primero de la lista. Por lo tanto, la primera aparición debe filtrarse.

Terrible consulta final

WITH RECURSIVE T AS (
  -- #1 : заносим в список "первые" записи по каждому из ключей набора
  WITH wrap AS ( -- "материализуем" record'ы, чтобы обращение к полям не вызывало умножения InitPlan/SubPlan
    WITH T AS (
      SELECT
        (
          SELECT
            r
          FROM
            task r
          WHERE
            owner_id = unnest
          ORDER BY
            task_date, id
          LIMIT 1
        ) r
      FROM
        unnest('{1,2,4,8,16,32,64,128,256,512}'::integer[])
    )
    SELECT
      array_agg(r ORDER BY (r).task_date, (r).id) list -- сортируем список в нужном порядке
    FROM
      T
  )
  SELECT
    list
  , list[1] rv
  , FALSE not_cross
  , 0 size
  FROM
    wrap
UNION ALL
  -- #2 : вычитываем записи 1-го по порядку ключа, пока не перешагнем через запись 2-го
  SELECT
    CASE
      -- если ничего не найдено для ключа 1-й записи
      WHEN X._r IS NOT DISTINCT FROM NULL THEN
        T.list[2:] -- убираем ее из списка
      -- если мы НЕ пересекли прикладной ключ 2-й записи
      WHEN X.not_cross THEN
        T.list -- просто протягиваем тот же список без модификаций
      -- если в списке уже нет 2-й записи
      WHEN T.list[2] IS NULL THEN
        -- просто возвращаем пустой список
        '{}'
      -- пересортировываем словарь, убирая 1-ю запись и добавляя последнюю из найденных
      ELSE (
        SELECT
          coalesce(T.list[2] || array_agg(r ORDER BY (r).task_date, (r).id), '{}')
        FROM
          unnest(T.list[3:] || X._r) r
      )
    END
  , X._r
  , X.not_cross
  , T.size + X.not_cross::integer
  FROM
    T
  , LATERAL(
      WITH wrap AS ( -- "материализуем" record
        SELECT
          CASE
            -- если все-таки "перешагнули" через 2-ю запись
            WHEN NOT T.not_cross
              -- то нужная запись - первая из спписка
              THEN T.list[1]
            ELSE ( -- если не пересекли, то ключ остался как в предыдущей записи - отталкиваемся от нее
              SELECT
                _r
              FROM
                task _r
              WHERE
                owner_id = (rv).owner_id AND
                (task_date, id) > ((rv).task_date, (rv).id)
              ORDER BY
                task_date, id
              LIMIT 1
            )
          END _r
      )
      SELECT
        _r
      , CASE
          -- если 2-й записи уже нет в списке, но мы хоть что-то нашли
          WHEN list[2] IS NULL AND _r IS DISTINCT FROM NULL THEN
            TRUE
          ELSE -- ничего не нашли или "перешагнули"
            coalesce(((_r).task_date, (_r).id) < ((list[2]).task_date, (list[2]).id), FALSE)
        END not_cross
      FROM
        wrap
    ) X
  WHERE
    T.size < 20 AND -- ограничиваем тут количество
    T.list IS DISTINCT FROM '{}' -- или пока список не кончился
)
-- #3 : "разворачиваем" записи - порядок гарантирован по построению
SELECT
  (rv).*
FROM
  T
WHERE
  not_cross; -- берем только "непересекающие" записи

SQL HowTo: escribir un bucle while directamente en la consulta, o "Elemental de tres vías"
[mira explicar.tensor.ru]

Por lo tanto, nosotros intercambió 50% de lecturas de datos por 20% de tiempo de ejecución. Es decir, si tiene motivos para creer que la lectura puede ser larga (por ejemplo, los datos a menudo no están en el caché y tiene que ir al disco para buscarlos), entonces de esta manera puede depender de la lectura menos.

En cualquier caso, el tiempo de ejecución resultó ser mejor que en la primera opción "ingenua". Pero cuál de estas 3 opciones utilizar depende de usted.

Fuente: habr.com

Añadir un comentario