Antipatrones de PostgreSQL: usemos el ÚNETE pesado con un diccionario
Continuamos la serie de artículos dedicados al estudio de formas poco conocidas de mejorar el rendimiento de consultas PostgreSQL "aparentemente simples":
Pero a menudo sin él, la solicitud resulta mucho más productiva que con él. Así que hoy intentaremos deshacerse de JOIN que consume muchos recursos - usando un diccionario.
A partir de PostgreSQL 12, algunas de las situaciones que se describen a continuación pueden reproducirse de forma ligeramente diferente debido a CTE de no materialización por defecto. Este comportamiento se puede revertir especificando la clave MATERIALIZED.
Muchos "hechos" en un vocabulario limitado
Tomemos una tarea de aplicación muy real: necesitamos mostrar una lista mensajes entrantes o tareas activas con remitentes:
25.01 | Иванов И.И. | Подготовить описание нового алгоритма.
22.01 | Иванов И.И. | Написать статью на Хабр: жизнь без JOIN.
20.01 | Петров П.П. | Помочь оптимизировать запрос.
18.01 | Иванов И.И. | Написать статью на Хабр: JOIN с учетом распределения данных.
16.01 | Петров П.П. | Помочь оптимизировать запрос.
En el mundo abstracto, los autores de las tareas deberían distribuirse uniformemente entre todos los empleados de nuestra organización, pero en realidad Las tareas provienen, por regla general, de un número bastante limitado de personas. - “desde la dirección” hasta la jerarquía o “desde los subcontratistas” de los departamentos vecinos (analistas, diseñadores, marketing, ...).
Aceptemos que en nuestra organización de 1000 personas, solo 20 autores (normalmente incluso menos) establecen tareas para cada intérprete específico y Usemos este conocimiento de la materia.para acelerar la consulta "tradicional".
Generador de guiones
-- сотрудники
CREATE TABLE person AS
SELECT
id
, repeat(chr(ascii('a') + (id % 26)), (id % 32) + 1) "name"
, '2000-01-01'::date - (random() * 1e4)::integer birth_date
FROM
generate_series(1, 1000) id;
ALTER TABLE person ADD PRIMARY KEY(id);
-- задачи с указанным распределением
CREATE TABLE task AS
WITH aid AS (
SELECT
id
, array_agg((random() * 999)::integer + 1) aids
FROM
generate_series(1, 1000) id
, generate_series(1, 20)
GROUP BY
1
)
SELECT
*
FROM
(
SELECT
id
, '2020-01-01'::date - (random() * 1e3)::integer task_date
, (random() * 999)::integer + 1 owner_id
FROM
generate_series(1, 100000) id
) T
, LATERAL(
SELECT
aids[(random() * (array_length(aids, 1) - 1))::integer + 1] author_id
FROM
aid
WHERE
id = T.owner_id
LIMIT 1
) a;
ALTER TABLE task ADD PRIMARY KEY(id);
CREATE INDEX ON task(owner_id, task_date);
CREATE INDEX ON task(author_id);
Mostremos las últimas 100 tareas para un ejecutor específico:
SELECT
task.*
, person.name
FROM
task
LEFT JOIN
person
ON person.id = task.author_id
WHERE
owner_id = 777
ORDER BY
task_date DESC
LIMIT 100;
Resulta que 1/3 del tiempo total y 3/4 de lecturas Se crearon páginas de datos solo para buscar al autor 100 veces, para cada tarea de salida. Pero sabemos que entre estos cientos solo 20 diferentes - ¿Es posible utilizar este conocimiento?
diccionario-hstore
usemos tipo de tienda para generar un valor-clave de "diccionario":
CREATE EXTENSION hstore
Sólo necesitamos poner el ID del autor y su nombre en el diccionario para luego poder extraer usando esta clave:
-- формируем целевую выборку
WITH T AS (
SELECT
*
FROM
task
WHERE
owner_id = 777
ORDER BY
task_date DESC
LIMIT 100
)
-- формируем словарь для уникальных значений
, dict AS (
SELECT
hstore( -- hstore(keys::text[], values::text[])
array_agg(id)::text[]
, array_agg(name)::text[]
)
FROM
person
WHERE
id = ANY(ARRAY(
SELECT DISTINCT
author_id
FROM
T
))
)
-- получаем связанные значения словаря
SELECT
*
, (TABLE dict) -> author_id::text -- hstore -> key
FROM
T;
Gastado en obtener información sobre personas. 2 veces menos tiempo y 7 veces menos datos leídos! Además del “vocabulario”, lo que también nos ayudó a lograr estos resultados fue recuperación masiva de registros de la mesa en una sola pasada usando = ANY(ARRAY(...)).
Entradas de tabla: serialización y deserialización
Pero ¿qué pasa si necesitamos guardar no sólo un campo de texto, sino una entrada completa en el diccionario? En este caso, la capacidad de PostgreSQL nos ayudará tratar una entrada de tabla como un valor único:
...
, dict AS (
SELECT
hstore(
array_agg(id)::text[]
, array_agg(p)::text[] -- магия #1
)
FROM
person p
WHERE
...
)
SELECT
*
, (((TABLE dict) -> author_id::text)::person).* -- магия #2
FROM
T;
Veamos lo que estaba pasando aquí:
tomamos p como alias para la entrada completa de la tabla de personas y reunió una gran variedad de ellos.
este Se reformuló el conjunto de grabaciones. a una matriz de cadenas de texto (persona[]::text[]) para colocarlo en el diccionario hstore como una matriz de valores.
Cuando recibimos un registro relacionado, extraído del diccionario por clave como una cadena de texto.
Necesitamos texto convertirse en un valor de tipo tabla persona (para cada tabla se crea automáticamente un tipo con el mismo nombre).
“Expandir” el registro escrito en columnas usando (...).*.
diccionario json
Pero un truco como el que aplicamos anteriormente no funcionará si no existe un tipo de tabla correspondiente para realizar el "casting". Surgirá exactamente la misma situación, y si intentamos utilizar una fila CTE, no una tabla "real".
...
, p AS ( -- это уже CTE
SELECT
*
FROM
person
WHERE
...
)
, dict AS (
SELECT
json_object( -- теперь это уже json
array_agg(id)::text[]
, array_agg(row_to_json(p))::text[] -- и внутри json для каждой строки
)
FROM
p
)
SELECT
*
FROM
T
, LATERAL(
SELECT
*
FROM
json_to_record(
((TABLE dict) ->> author_id::text)::json -- извлекли из словаря как json
) AS j(name text, birth_date date) -- заполнили нужную нам структуру
) j;
Cabe señalar que al describir la estructura de destino, no podemos enumerar todos los campos de la cadena de origen, sino solo aquellos que realmente necesitamos. Si tenemos una tabla "nativa", entonces es mejor usar la función json_populate_record.
Todavía accedemos al diccionario una vez, pero Los costos de serialización json-[de] son bastante altosPor lo tanto, es razonable utilizar este método sólo en algunos casos, cuando la exploración CTE "honesta" resulta peor.
Prueba de rendimiento
Entonces, tenemos dos formas de serializar datos en un diccionario: hstore/json_objeto. Además, los propios arrays de claves y valores también se pueden generar de dos formas, con conversión interna o externa a texto: array_agg(i::texto) / array_agg(i)::texto[].
Comprobemos la eficacia de los diferentes tipos de serialización utilizando un ejemplo puramente sintético: serializar diferentes números de claves:
WITH dict AS (
SELECT
hstore(
array_agg(i::text)
, array_agg(i::text)
)
FROM
generate_series(1, ...) i
)
TABLE dict;
Guión de evaluación: serialización
WITH T AS (
SELECT
*
, (
SELECT
regexp_replace(ea[array_length(ea, 1)], '^Execution Time: (d+.d+) ms$', '1')::real et
FROM
(
SELECT
array_agg(el) ea
FROM
dblink('port= ' || current_setting('port') || ' dbname=' || current_database(), $$
explain analyze
WITH dict AS (
SELECT
hstore(
array_agg(i::text)
, array_agg(i::text)
)
FROM
generate_series(1, $$ || (1 << v) || $$) i
)
TABLE dict
$$) T(el text)
) T
) et
FROM
generate_series(0, 19) v
, LATERAL generate_series(1, 7) i
ORDER BY
1, 2
)
SELECT
v
, avg(et)::numeric(32,3)
FROM
T
GROUP BY
1
ORDER BY
1;
En PostgreSQL 11, hasta aproximadamente un tamaño de diccionario de 2^12 claves la serialización a json lleva menos tiempo. En este caso, lo más efectivo es la combinación de json_object y conversión de tipo "interno". array_agg(i::text).
Ahora intentemos leer el valor de cada clave 8 veces; después de todo, si no accede al diccionario, ¿por qué es necesario?
Guión de evaluación: lectura de un diccionario
WITH T AS (
SELECT
*
, (
SELECT
regexp_replace(ea[array_length(ea, 1)], '^Execution Time: (d+.d+) ms$', '1')::real et
FROM
(
SELECT
array_agg(el) ea
FROM
dblink('port= ' || current_setting('port') || ' dbname=' || current_database(), $$
explain analyze
WITH dict AS (
SELECT
json_object(
array_agg(i::text)
, array_agg(i::text)
)
FROM
generate_series(1, $$ || (1 << v) || $$) i
)
SELECT
(TABLE dict) -> (i % ($$ || (1 << v) || $$) + 1)::text
FROM
generate_series(1, $$ || (1 << (v + 3)) || $$) i
$$) T(el text)
) T
) et
FROM
generate_series(0, 19) v
, LATERAL generate_series(1, 7) i
ORDER BY
1, 2
)
SELECT
v
, avg(et)::numeric(32,3)
FROM
T
GROUP BY
1
ORDER BY
1;
Y... ya aproximadamente con 2 ^ 6 claves, la lectura de un diccionario json comienza a perderse varias veces leyendo desde hstore, para jsonb sucede lo mismo en 2^9.
Conclusiones finales:
si necesitas hacerlo ÚNETE con múltiples registros repetidos — es mejor usar el “diccionario” de la tabla
si se espera su diccionario pequeño y no leerás mucho de él - puedes usar json[b]
en todos los demás casos hstore + array_agg(i::texto) será más efectivo