¿Cómo obtengo eficientemente "la fila correspondiente más reciente"?


53

Tengo un patrón de consulta que debe ser muy común, pero no sé cómo escribir una consulta eficiente para él. Quiero buscar las filas de una tabla que corresponden a "la fecha más reciente, no después" de las filas de otra tabla.

Tengo una mesa, por inventoryejemplo, que representa el inventario que tengo en un día determinado.

date       | good | quantity
------------------------------
2013-08-09 | egg  | 5
2013-08-09 | pear | 7
2013-08-02 | egg  | 1
2013-08-02 | pear | 2

y una tabla, por ejemplo, "precio", que contiene el precio de un bien en un día determinado

date       | good | price
--------------------------
2013-08-07 | egg  | 120
2013-08-06 | pear | 200
2013-08-01 | egg  | 110
2013-07-30 | pear | 220

¿Cómo puedo obtener de manera eficiente el precio "más reciente" para cada fila de la tabla de inventario, es decir

date       | pricing date | good | quantity | price
----------------------------------------------------
2013-08-09 | 2013-08-07   | egg  | 5        | 120
2013-08-09 | 2013-08-06   | pear | 7        | 200
2013-08-02 | 2013-08-01   | egg  | 1        | 110
2013-08-02 | 2013-07-30   | pear | 2        | 220

Sé una forma de hacer esto:

select inventory.date, max(price.date) as pricing_date, good
from inventory, price
where inventory.date >= price.date
and inventory.good = price.good
group by inventory.date, good

y luego vuelva a unir esta consulta al inventario. Para tablas grandes, incluso hacer la primera consulta (sin volver a unirse al inventario) es muy lento. Sin embargo, el mismo problema se resuelve rápidamente si simplemente uso mi lenguaje de programación para emitir una max(price.date) ... where price.date <= date_of_interest ... order by price.date desc limit 1consulta para cada uno date_of_interestde la tabla de inventario, por lo que sé que no hay impedimento computacional. Sin embargo, preferiría resolver todo el problema con una sola consulta SQL, ya que me permitiría realizar más procesamiento SQL en el resultado de la consulta.

¿Hay una forma estándar de hacer esto de manera eficiente? Parece que debe aparecer a menudo y que debería haber una forma de escribir una consulta rápida para ello.

Estoy usando Postgres, pero agradecería una respuesta genérica de SQL.


3
Votado para migrar a DBA.SE, ya que es una cuestión de eficiencia. Podríamos escribir la consulta de diferentes maneras, pero eso no lo hará mucho más rápido.
ypercubeᵀᴹ

55
¿Realmente necesita todos los productos para todos los días de una sola consulta? Parece un requisito poco probable? Más comúnmente, uno recuperaría los precios para una fecha específica o los precios para un bien específico (en una fecha específica). Esas consultas alternativas podrían beneficiarse mucho más fácilmente de los índices (apropiados). También necesitamos saber: cardinalidades (¿cuántas filas en cada tabla?), La definición completa de la tabla incl. tipos de datos, restricciones, índices, ... (uso \d tblen psql), su versión de Postgres y min. / max. cantidad de precios por bien.
Erwin Brandstetter

@ErwinBrandstetter ¿Me estás pidiendo que acepte una respuesta? No estoy realmente calificado para saber cuál es el mejor, aunque como el tuyo tiene más votos positivos, estoy feliz de aceptarlo.
Tom Ellis

Solo acepte si responde a su pregunta o funciona para usted. Incluso podría dejar un comentario sobre cómo procedió si eso podría ayudar a los casos relacionados. Si cree que su pregunta no tiene respuesta, infórmenos.
Erwin Brandstetter

1
Entonces tengo que disculparme, porque aunque he recibido lo que parecen ser excelentes respuestas, ya no estoy trabajando en el problema que provocó la pregunta, así que no estoy en condiciones de juzgar cuál es la mejor respuesta, o si alguna de ellas son realmente adecuados para mi caso de uso (como lo fue). Si hay alguna etiqueta de DBA.Stackexchange que debo seguir en este caso, hágamelo saber.
Tom Ellis

Respuestas:


42

Que depende mucho de las circunstancias y requisitos exactos. Considera mi comentario a la pregunta .

Solución simple

Con DISTINCT ONen Postgres:

SELECT DISTINCT ON (i.good, i.the_date)
       i.the_date, p.the_date AS pricing_date, i.good, p.price
FROM   inventory  i
LEFT   JOIN price p ON i.good = p.good AND i.the_date >= p.the_date
ORDER  BY i.good, i.the_date, p.the_date DESC;

Resultado ordenado.

O con NOT EXISTSSQL estándar (funciona con todos los RDBMS que conozco):

SELECT i.the_date, p.the_date AS pricing_date, i.good, i.quantity, p.price
FROM   inventory  i
LEFT   JOIN price p ON p.good = i.good AND p.the_date <= i.the_date
WHERE  NOT EXISTS (
   SELECT 1 FROM price p1
   WHERE  p1.good = p.good
   AND p1.the_date <= i.the_date
   AND p1.the_date >  p.the_date
   );

Mismo resultado, pero con un orden de clasificación arbitrario, a menos que agregue ORDER BY.
Dependiendo de la distribución de datos, requisitos e índices exactos, cualquiera de estos puede ser más rápido.
En general, DISTINCT ONes el vencedor y obtienes un resultado ordenado además de él. Pero para ciertos casos, otras técnicas de consulta son (mucho) más rápidas todavía. Vea abajo.

Las soluciones con subconsultas para calcular valores máximos / mínimos son generalmente más lentas. Las variantes con CTE son generalmente más lentas, todavía.

Las vistas simples (como propone otra respuesta) no ayudan en absoluto al rendimiento en Postgres.

SQL Fiddle.


Solución adecuada

Cuerdas y colación

En primer lugar, sufres de un diseño de tabla subóptimo. Puede parecer trivial, pero normalizar su esquema puede ser muy útil.

La clasificación por tipos de caracteres ( text, varchar, ...) tiene que ser hecho de acuerdo con la configuración regional - el COTEJO en particular. Lo más probable es que su base de datos use un conjunto local de reglas (como, en mi caso:) de_AT.UTF-8. Descubre con:

SHOW lc_collate;

Esto hace que la clasificación y las búsquedas de índice sean más lentas . Cuanto más largas sean sus cadenas (nombres de productos), peor. Si en realidad no le interesan las reglas de intercalación en su salida (o el orden de clasificación), esto puede ser más rápido si agrega COLLATE "C":

SELECT DISTINCT ON (i.good COLLATE "C", i.the_date)
       i.the_date, p.the_date AS pricing_date, i.good, p.price
FROM   inventory  i
LEFT   JOIN price p ON i.good = p.good AND i.the_date >= p.the_date
ORDER  BY i.good COLLATE "C", i.the_date, p.the_date DESC;

Tenga en cuenta cómo agregué la colación en dos lugares.
El doble de rápido en mi prueba con 20k filas cada una y nombres muy básicos ('good123').

Índice

Si se supone que su consulta debe usar un índice, las columnas con datos de caracteres deben usar una intercalación coincidente ( gooden el ejemplo):

CREATE INDEX inventory_good_date_desc_collate_c_idx
ON price(good COLLATE "C", the_date DESC);

Asegúrese de leer los últimos dos capítulos de esta respuesta relacionada en SO:

Incluso puede tener múltiples índices con diferentes clasificaciones en las mismas columnas, si también necesita productos ordenados de acuerdo con otra clasificación (o la predeterminada) en otras consultas.

Normalizar

Las cadenas redundantes (nombre del bien) también hinchan sus tablas e índices, lo que hace que todo sea aún más lento. Con un diseño de tabla adecuado, puede evitar la mayor parte del problema para empezar. Podría verse así:

CREATE TABLE good (
  good_id serial PRIMARY KEY
, good    text   NOT NULL
);

CREATE TABLE inventory (
  good_id  int  REFERENCES good (good_id)
, the_date date NOT NULL
, quantity int  NOT NULL
, PRIMARY KEY(good_id, the_date)
);

CREATE TABLE price (
  good_id  int     REFERENCES good (good_id)
, the_date date    NOT NULL
, price    numeric NOT NULL
, PRIMARY KEY(good_id, the_date));

Las claves principales proporcionan automáticamente (casi) todos los índices que necesitamos.
Según los detalles faltantes, un índice de pricevarias columnas con orden descendente en la segunda columna puede mejorar el rendimiento:

CREATE INDEX price_good_date_desc_idx ON price(good, the_date DESC);

Nuevamente, la clasificación debe coincidir con su consulta (ver arriba).

En Postgres 9.2 o posterior, los "índices de cobertura" para escaneos de solo índice podrían ayudar un poco más, especialmente si sus tablas contienen columnas adicionales, lo que hace que la tabla sea sustancialmente más grande que el índice de cobertura.

Estas consultas resultantes son mucho más rápidas:

NO EXISTE

SELECT i.the_date, p.the_date AS pricing_date, g.good, i.quantity, p.price
FROM   inventory  i
JOIN   good       g USING (good_id)
LEFT   JOIN price p ON p.good_id = i.good_id AND p.the_date <= i.the_date
AND    NOT EXISTS (
   SELECT 1 FROM price p1
   WHERE  p1.good_id = p.good_id
   AND    p1.the_date <= i.the_date
   AND    p1.the_date >  p.the_date
   );

DISTINTO EN

SELECT DISTINCT ON (i.the_date)
       i.the_date, p.the_date AS pricing_date, g.good, i.quantity, p.price
FROM   inventory  i
JOIN   good       g USING (good_id)
LEFT   JOIN price p ON p.good_id = i.good_id AND p.the_date <= i.the_date
ORDER  BY i.the_date, p.the_date DESC;

SQL Fiddle.


Soluciones más rápidas

Si eso todavía no es lo suficientemente rápido, puede haber soluciones más rápidas.

CTE recursiva / JOIN LATERAL/ subconsulta correlacionada

Especialmente para distribuciones de datos con muchos precios por bien :

Vista materializada

Si necesita ejecutar esto con frecuencia y rapidez, le sugiero que cree una vista materializada. Creo que es seguro asumir que los precios e inventarios de fechas pasadas rara vez cambian. Calcule el resultado una vez y almacene una instantánea como vista materializada.

Postgres 9.3+ tiene soporte automatizado para vistas materializadas. Puede implementar fácilmente una versión básica en versiones anteriores.


3
El price_good_date_desc_idxíndice que recomienda mejoró dramáticamente el rendimiento para una consulta mía similar. Mi plan de consulta pasó de un costo de 42374.01..42374.86abajo a 0.00..37.12!
cimmanon

@cimmanon: ¡Qué bien! ¿Cuál es su función principal de consulta? ¿NO EXISTE? DISTINTO EN? ¿AGRUPAR POR?
Erwin Brandstetter

Usando DISTINCT ON
cimmanon

6

Para su información, usé mssql 2008, por lo que Postgres no tendrá el índice "incluir". Sin embargo, el uso de la indexación básica que se muestra a continuación cambiará de combinaciones hash para combinar combinaciones en Postgres: http://explain.depesz.com/s/eF6 (sin índice) http://explain.depesz.com/s/j9x ( con índice en criterios de unión)

Propongo dividir su consulta en dos partes. Primero, una vista (no destinada a mejorar el rendimiento) que se puede usar en una variedad de otros contextos que representa la relación de las fechas de inventario y las fechas de fijación de precios.

create view mostrecent_pricing_dates_per_good as
select i.good,i.date i_date,max(p.date)p_date
  from inventory i
  join price p on i.good = p.good and i.date >= p.date
 group by i.good,i.date;

Entonces, su consulta puede volverse más simple y fácil de manipular para otros tipos si la consulta (como el uso de combinaciones izquierdas para encontrar inventario sin fechas de precios recientes):

select i.good
       ,i.date inventory_date
       ,i.quantity
       ,p.date pricing_date
       ,p.price       
  from inventory i
  join price p on i.good = p.good
  join mostrecent_pricing_dates_per_good x 
    on i.good = x.good 
   and p.date = x.p_date
   and i.date = x.i_date

Esto produce el siguiente plan de ejecución: http://sqlfiddle.com/#!3/24f23/1 sin indexación

... Todos los escaneos con un tipo completo. Observe que el costo de rendimiento de las coincidencias hash ocupa la mayor parte del costo total ... y sabemos que los escaneos y la clasificación de la tabla son lentos (en comparación con el objetivo: búsquedas de índice).

Ahora, agregue índices básicos para ayudar a los criterios utilizados en su unión (no pretendo que sean índices óptimos, pero ilustran el punto): http://sqlfiddle.com/#!3/5ec75/1 con indexación básica

Esto muestra una mejora. Las operaciones de bucle anidado (unión interna) ya no toman ningún costo total relevante para la consulta. El resto del costo ahora se distribuye entre las búsquedas de índice (un escaneo de inventario porque estamos tirando de cada fila de inventario). Pero aún podemos hacerlo mejor porque la consulta extrae cantidad y precio. Para obtener esos datos, después de evaluar los criterios de unión, se deben realizar búsquedas.

La iteración final usa "incluir" en los índices para facilitar que el plan se desplace y obtenga los datos adicionales solicitados directamente del índice. Entonces las búsquedas se han ido: http://sqlfiddle.com/#!3/5f143/1 ingrese la descripción de la imagen aquí

Ahora tenemos un plan de consulta donde el costo total de la consulta se distribuye de manera uniforme entre las operaciones de búsqueda de índice muy rápidas. Esto estará cerca de lo mejor posible. Seguramente otros expertos pueden mejorar esto aún más, pero la solución aclara un par de preocupaciones importantes:

  1. Crea estructuras de datos inteligibles en su base de datos que son más fáciles de componer y reutilizar en otras áreas de una aplicación.
  2. Todos los operadores de consulta más costosos han sido eliminados del plan de consulta mediante una indexación básica.

3
Esto está bien (para SQL-Server), pero la optimización para diferentes DBMS, aunque tiene similitudes, también tiene serias diferencias.
ypercubeᵀᴹ

@ypercube eso es cierto. Agregué algunas calificaciones sobre Postgres. Mi intención era que la mayor parte del proceso de pensamiento ilustrado aquí se aplicaría independientemente de las características específicas de DBMS.
cocogorilla

La respuesta es muy profunda, por lo que me llevará un tiempo probarla. Te dejaré saber cómo me llevo.
Tom Ellis

5

Si tiene PostgreSQL 9.3 (lanzado hoy), puede usar una LATERAL JOIN.

No tengo forma de probar esto, y nunca lo he usado antes, pero por lo que puedo decir de la documentación, la sintaxis sería algo así como:

SELECT  Inventory.Date,
        Inventory.Good,
        Inventory.Quantity,
        Price.Date,
        Price.Price
FROM    Inventory
        LATERAL
        (   SELECT  Date, Price
            FROM    Price
            WHERE   Price.Good = Inventory.Good
            AND     Price.Date <= Inventory.Date
            ORDER BY Price.Date DESC
            LIMIT 1
        ) p;

Esto es básicamente equivalente a la APLICACIÓN de SQL Server , y hay un ejemplo de esto en SQL-Fiddle para fines de demostración.


5

Como Erwin y otros han señalado, una consulta eficiente depende de muchas variables y PostgreSQL se esfuerza mucho por optimizar la ejecución de la consulta en función de esas variables. En general, desea escribir para mayor claridad primero y luego modificar para obtener un rendimiento posterior a medida que identifica los cuellos de botella.

Además, PostgreSQL tiene muchos trucos que puede usar para hacer que las cosas sean un poco más eficientes (índices parciales para uno), por lo que, dependiendo de su carga de lectura / escritura, es posible que pueda optimizar esto muy lejos al buscar una indexación cuidadosa.

Lo primero que debe intentar es hacer una vista y unirse a ella:

CREATE VIEW most_recent_rows AS
SELECT good, max(date) as max_date
FROM inventory
GROUP BY good;

Esto debería funcionar bien al hacer algo como:

SELECT price 
  FROM inventory i
  JOIN goods g ON i.goods = g.description
  JOIN most_recent_rows r ON i.goods = r.goods
 WHERE g.id = 123;

Entonces puedes unirte a eso. La consulta terminará uniendo la vista contra la tabla subyacente, pero suponiendo que tenga un índice único el (fecha, bueno en ese orden ), debería estar listo (ya que esto será una simple búsqueda de caché). Esto funcionará muy bien con algunas filas buscadas, pero será muy ineficiente si está tratando de digerir millones de precios de bienes.

La segunda cosa que puede hacer es agregar a la tabla de inventario una columna bool más reciente y

create unique index on inventory (good) where most_recent;

A continuación, desearía utilizar desencadenantes para establecer most_recent en falso cuando se inserta una nueva fila para un bien. Esto agrega más complejidad y mayores posibilidades de errores, pero es útil.

Nuevamente, mucho de esto depende de los índices apropiados que estén en su lugar. Para las consultas de fechas más recientes, probablemente debería tener un índice de fecha y, posiblemente, uno de varias columnas que comience con la fecha e incluya sus criterios de unión.

Actualice el comentario de Per Erwin a continuación, parece que entendí mal esto. Volviendo a leer la pregunta, no estoy seguro de qué se está haciendo. Quiero mencionar en la actualización cuál es el problema potencial que veo y por qué esto deja esto en claro.

El diseño de la base de datos ofrecido no tiene un uso real de IME con ERP y sistemas de contabilidad. Funcionaría en un modelo hipotético de precios perfectos donde todo lo vendido en un día determinado de un producto determinado tiene el mismo precio. Sin embargo, este no es siempre el caso. Ni siquiera es el caso de cosas como los cambios de divisas (aunque algunos modelos pretenden que sí). Si este es un ejemplo artificial, no está claro. Si es un ejemplo real, existen mayores problemas con el diseño a nivel de datos. Voy a suponer aquí que este es un ejemplo real.

No puede suponer que la fecha sola especifica el precio de un bien determinado. Los precios en cualquier negocio se pueden negociar por contraparte e incluso a veces por transacción. Por esta razón, realmente debe almacenar el precio en la tabla que realmente maneja el inventario dentro o fuera (la tabla de inventario). En tal caso, su tabla de fecha / bienes / precio simplemente especifica un precio base que puede estar sujeto a cambios en función de la negociación. En tal caso, este problema pasa de ser un problema de informes a uno que es transaccional y opera en una fila de cada tabla a la vez. Por ejemplo, puede buscar el precio predeterminado para un producto determinado en un día dado como:

 SELECT price 
   FROM prices p
   JOIN goods g ON p.good = g.good
  WHERE g.id = 123 AND p."date" >= '2013-03-01'
  ORDER BY p."date" ASC LIMIT 1;

Con un índice de precios (bueno, fecha) esto funcionará bien.

Si este es un ejemplo artificial, quizás algo más cercano a lo que está trabajando ayudaría.


El most_recentenfoque debería funcionar bien para el precio más reciente absolutamente . Sin embargo, parece que el OP necesita el precio más reciente en relación con cada fecha de inventario.
Erwin Brandstetter

Buen punto. Aunque vuelvo a leer, veo algunas deficiencias prácticas reales con los datos propuestos, pero no puedo decir si es solo un ejemplo artificial. Como ejemplo artificial, no puedo decir lo que falta. Tal vez una actualización para señalar esto también estaría en orden.
Chris Travers

@ChrisTravers: es un ejemplo artificial, pero no estoy en libertad de publicar el esquema real con el que estoy trabajando. Quizás podría decir un poco sobre las deficiencias prácticas que ha detectado.
Tom Ellis

No creo que deba ser exacto, pero me preocupa que el problema se pierda en la alegoría. Algo un poco más cercano sería útil. El problema es que con el precio, es probable que el precio en un día determinado sea un valor predeterminado y, en consecuencia, no lo usaría para informar solo como valor predeterminado para la entrada de transacciones, por lo que sus consultas interesantes generalmente son solo unas pocas filas en un hora.
Chris Travers

3

Otra forma sería usar la función de ventana lead()para obtener el rango de fechas para cada fila en el precio de la tabla y luego usarlo betweenal unir el inventario. De hecho, he usado esto en la vida real, pero principalmente porque esta fue mi primera idea de cómo resolver esto.

with cte as (
  select
    good,
    price,
    date,
    coalesce(lead(date) over(partition by good order by date) - 1
            ,Now()::date) as ndate
  from
    price
)

select * from inventory i join cte on
  (i.good = cte.good and i.date between cte.date and cte.ndate)

SqlFiddle


1

Use una unión del inventario al precio con condiciones de unión que limitan los registros del tabelp del precio a solo aquellos que están en o antes de la fecha de inventario, luego extraiga la fecha máxima y donde la fecha es la fecha más alta de ese subconjunto

Entonces, para su precio de inventario:

 Select i.date, p.Date pricingDate,
    i.good, quantity, price        
 from inventory I join price p 
    on p.good = i.good
        And p.Date = 
           (Select Max(Date from price
            where good = i.good
               and date <= i.Date)

Si el precio de un bien especificado cambió más de una vez en el mismo día, y realmente solo tiene fechas y no horas en estas columnas, es posible que deba aplicar más restricciones en las uniones para seleccionar solo uno de los registros de cambio de precio.


No parece acelerar las cosas, desafortunadamente.
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.