Optimice la consulta GROUP BY para recuperar la última fila por usuario
Tengo la siguiente tabla de registro para mensajes de usuario (forma simplificada) en Postgres 9.2:
CREATE TABLE log (
log_date DATE,
user_id INTEGER,
payload INTEGER
);
Contiene hasta un registro por usuario y por día. Habrá aproximadamente 500.000 registros por día durante 300 días. la carga útil aumenta cada vez más para cada usuario (si eso importa).
Quiero recuperar de manera eficiente el último registro de cada usuario antes de una fecha específica. Mi consulta es:
SELECT user_id, max(log_date), max(payload)
FROM log
WHERE log_date <= :mydate
GROUP BY user_id
lo cual es extremadamente lento. También he probado:
SELECT DISTINCT ON(user_id), log_date, payload
FROM log
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC;
que tiene el mismo plan y es igualmente lento.
Hasta ahora tengo un índice único log(log_date)
, pero no ayuda mucho.
Y tengo una users
tabla con todos los usuarios incluidos. También quiero recuperar el resultado para algunos usuarios (aquellos con payload > :value
).
¿Hay algún otro índice que deba utilizar para acelerar esto o alguna otra forma de lograr lo que quiero?
Para obtener el mejor rendimiento de lectura, necesita un índice de varias columnas :
CREATE INDEX log_combo_idx
ON log (user_id, log_date DESC NULLS LAST);
Para que los escaneos solo de índice sean posibles, agregue la columna que de otro modo no sería necesaria payload
en un índice de cobertura con la INCLUDE
cláusula (Postgres 11 o posterior):
CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST) INCLUDE (payload);
Ver:
- ¿Cubrir índices en PostgreSQL ayuda a UNIR columnas?
Respaldo para versiones anteriores:
CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST, payload);
Por qué DESC NULLS LAST
?
- Índice no utilizado en consulta de rango de fechas
Para pocas filas por tabla user_id
o tablas pequeñas DISTINCT ON
suele ser lo más rápido y sencillo:
- ¿Seleccionar la primera fila en cada grupo GRUPO POR?
Para muchas filas por user_id
un escaneo de omisión de índice (o escaneo de índice suelto ) es (mucho) más eficiente. Esto no se implementó hasta Postgres 15 (el trabajo está en curso) . Pero hay formas de emularlo de manera eficiente.
Las expresiones de tabla comunes requieren Postgres 8.4+ .
LATERAL
requiere Postgres 9.3+ .
Las siguientes soluciones van más allá de lo que se cubre en Postgres Wiki .
1. No hay una tabla separada con usuarios únicos
Con una users
tabla separada, las soluciones del punto 2. a continuación suelen ser más simples y rápidas. Vaya directamente.
1a. CTE recursivo con LATERAL
unión
WITH RECURSIVE cte AS (
( -- parentheses required
SELECT user_id, log_date, payload
FROM log
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC NULLS LAST
LIMIT 1
)
UNION ALL
SELECT l.*
FROM cte c
CROSS JOIN LATERAL (
SELECT l.user_id, l.log_date, l.payload
FROM log l
WHERE l.user_id > c.user_id -- lateral reference
AND log_date <= :mydate -- repeat condition
ORDER BY l.user_id, l.log_date DESC NULLS LAST
LIMIT 1
) l
)
TABLE cte
ORDER BY user_id;
Es sencillo recuperar columnas arbitrarias y probablemente sea mejor en Postgres actual. Más explicación en el capítulo 2a. abajo.
1b. CTE recursivo con subconsulta correlacionada
WITH RECURSIVE cte AS (
( -- parentheses required
SELECT l AS my_row -- whole row
FROM log l
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC NULLS LAST
LIMIT 1
)
UNION ALL
SELECT (SELECT l -- whole row
FROM log l
WHERE l.user_id > (c.my_row).user_id
AND l.log_date <= :mydate -- repeat condition
ORDER BY l.user_id, l.log_date DESC NULLS LAST
LIMIT 1)
FROM cte c
WHERE (c.my_row).user_id IS NOT NULL -- note parentheses
)
SELECT (my_row).* -- decompose row
FROM cte
WHERE (my_row).user_id IS NOT NULL
ORDER BY (my_row).user_id;
Conveniente para recuperar una sola columna o toda la fila . El ejemplo utiliza todo el tipo de fila de la tabla. Otras variantes son posibles.
Para afirmar que se encontró una fila en la iteración anterior, pruebe una sola columna NO NULA (como la clave principal).
Más explicación para esta consulta en el capítulo 2b. abajo.
Relacionado:
- Consultar las últimas N filas relacionadas por fila
- AGRUPAR POR una columna, mientras ordena por otra en PostgreSQL
2. Con users
mesa separada
El diseño de la tabla apenas importa siempre que user_id
se garantice exactamente una fila por cada elemento relevante. Ejemplo:
CREATE TABLE users (
user_id serial PRIMARY KEY
, username text NOT NULL
);
Lo ideal es que la tabla esté físicamente ordenada en sincronía con la log
tabla. Ver:
- Optimice la consulta de Postgres en el rango de marca de tiempo
O es lo suficientemente pequeño (baja cardinalidad) que apenas importa. De lo contrario, ordenar filas en la consulta puede ayudar a optimizar aún más el rendimiento. Vea la incorporación de Gang Liang. Si el orden físico de la users
tabla coincide con el índice de log
, esto puede ser irrelevante.
2a. LATERAL
unirse
SELECT u.user_id, l.log_date, l.payload
FROM users u
CROSS JOIN LATERAL (
SELECT l.log_date, l.payload
FROM log l
WHERE l.user_id = u.user_id -- lateral reference
AND l.log_date <= :mydate
ORDER BY l.log_date DESC NULLS LAST
LIMIT 1
) l;
JOIN LATERAL
permite hacer referencia FROM
a elementos anteriores en el mismo nivel de consulta. Ver:
- ¿Cuál es la diferencia entre una LATERAL JOIN y una subconsulta en PostgreSQL?
Da como resultado una búsqueda de índice (solo) por usuario.
No devuelve ninguna fila para los usuarios que faltan en la users
tabla. Normalmente, una restricción de clave externa que imponga la integridad referencial descartaría esto.
Además, no hay fila para usuarios sin una entrada coincidente log
, de conformidad con la pregunta original. Para mantener a esos usuarios en el resultado, utilice LEFT JOIN LATERAL ... ON true
en lugar de CROSS JOIN LATERAL
:
- Llame a una función de retorno de conjunto con un argumento de matriz varias veces
Úselo LIMIT n
en lugar de LIMIT 1
para recuperar más de una fila (pero no todas) por usuario.
Efectivamente, todos estos hacen lo mismo:
JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...
Sin embargo, el último tiene menor prioridad. JOIN
Enlaces explícitos antes de la coma. Esa sutil diferencia puede ser importante con más tablas de unión. Ver:
- "referencia no válida a la entrada de la cláusula FROM para la tabla" en la consulta de Postgres
2b. Subconsulta correlacionada
Buena opción para recuperar una sola columna de una sola fila . Ejemplo de código:
- Optimizar la consulta máxima por grupo
Lo mismo es posible para varias columnas , pero necesitas más inteligencia:
CREATE TEMP TABLE combo (log_date date, payload int);
SELECT user_id, (combo1).* -- note parentheses
FROM (
SELECT u.user_id
, (SELECT (l.log_date, l.payload)::combo
FROM log l
WHERE l.user_id = u.user_id
AND l.log_date <= :mydate
ORDER BY l.log_date DESC NULLS LAST
LIMIT 1) AS combo1
FROM users u
) sub;
Como LEFT JOIN LATERAL
arriba, esta variante incluye a todos los usuarios, incluso sin entradas en log
. Obtienes NULL
for combo1
, que puedes filtrar fácilmente con una WHERE
cláusula en la consulta externa si es necesario.
Quisquilloso: en la consulta externa no se puede distinguir si la subconsulta no encontró una fila o si todos los valores de las columnas son NULL; el mismo resultado. Necesita una NOT NULL
columna en la subconsulta para evitar esta ambigüedad.
Una subconsulta correlacionada sólo puede devolver un único valor . Puede envolver varias columnas en un tipo compuesto. Pero para descomponerlo más tarde, Postgres exige un tipo compuesto bien conocido. Los registros anónimos sólo se pueden descomponer proporcionando una lista de definición de columnas.
Utilice un tipo registrado como el tipo de fila de una tabla existente. O registre un tipo compuesto explícitamente (y permanentemente) con CREATE TYPE
. O cree una tabla temporal (eliminada automáticamente al final de la sesión) para registrar su tipo de fila temporalmente. Sintaxis de transmisión:(log_date, payload)::combo
Finalmente, no queremos descomponernos combo1
en el mismo nivel de consulta. Debido a una debilidad en el planificador de consultas, esto evaluaría la subconsulta una vez para cada columna (aún así en Postgres 12). En su lugar, conviértala en una subconsulta y descompóngala en la consulta externa.
Relacionado:
- Obtener valores de la primera y última fila por grupo
Demostrando las 4 consultas con 100.000 entradas de registro y 1.000 usuarios:
db<>fiddle aquí - página 11
Antiguo sqlfiddle
Esta no es una respuesta independiente sino más bien un comentario a la respuesta de @Erwin . Para 2a, el ejemplo de unión lateral, la consulta se puede mejorar ordenando la users
tabla para explotar la localidad del índice en log
.
SELECT u.user_id, l.log_date, l.payload
FROM (SELECT user_id FROM users ORDER BY user_id) u,
LATERAL (SELECT log_date, payload
FROM log
WHERE user_id = u.user_id -- lateral reference
AND log_date <= :mydate
ORDER BY log_date DESC NULLS LAST
LIMIT 1) l;
La razón es que la búsqueda de índices es costosa si user_id
los valores son aleatorios. Al clasificar user_id
primero, la unión lateral posterior sería como un simple escaneo en el índice de log
. Aunque ambos planes de consulta parecen similares, el tiempo de ejecución diferirá mucho, especialmente en el caso de tablas grandes.
El coste de la clasificación es mínimo, especialmente si hay un índice en el user_id
campo.
Quizás ayudaría un índice diferente sobre la mesa. Prueba este: log(user_id, log_date)
. No estoy seguro de que Postgres haga un uso óptimo de distinct on
.
Entonces, me quedaría con ese índice y probaría esta versión:
select *
from log l
where not exists (select 1
from log l2
where l2.user_id = l.user_id and
l2.log_date <= :mydate and
l2.log_date > l.log_date
);
Esto debería reemplazar la clasificación/agrupación con búsquedas de índice. Puede que sea más rápido.