¿Almacenar millones de filas de datos denominados o algo de magia SQL?


8

Mi experiencia DBA no va mucho más allá del simple almacenamiento + recuperación de datos de estilo CMS, por lo que esta puede ser una pregunta tonta, ¡no lo sé!

Tengo un problema por el cual necesito buscar o calcular los precios de vacaciones para un determinado tamaño de grupo y un cierto número de días dentro de un cierto período de tiempo. P.ej:

¿Cuánto cuesta una habitación de hotel para 2 personas por 4 noches en cualquier momento de enero?

Tengo datos de precios y disponibilidad de, digamos, 5000 hoteles almacenados de esta manera:

Hotel ID | Date | Spaces | Price PP
-----------------------------------
     123 | Jan1 | 5      | 100
     123 | Jan2 | 7      | 100
     123 | Jan3 | 5      | 100
     123 | Jan4 | 3      | 100
     123 | Jan5 | 5      | 100
     123 | Jan6 | 7      | 110
     456 | Jan1 | 5      | 120
     456 | Jan2 | 1      | 120
     456 | Jan3 | 4      | 130
     456 | Jan4 | 3      | 110
     456 | Jan5 | 5      | 100
     456 | Jan6 | 7      |  90

Con esta tabla, puedo hacer una consulta así:

SELECT hotel_id, sum(price_pp)
FROM hotel_data
WHERE
    date >= Jan1 and date <= Jan4
    and spaces >= 2
GROUP BY hotel_id
HAVING count(*) = 4;

resultados

hotel_id | sum
----------------
     123 | 400

La HAVINGcláusula aquí se asegura de que haya una entrada para cada día entre mis fechas deseadas que tenga los espacios disponibles. es decir. El hotel 456 tenía 1 espacio disponible el 2 de enero, la cláusula HAVING devolvería 3, por lo que no obtenemos un resultado para el hotel 456.

Hasta aquí todo bien.

Sin embargo, ¿hay alguna manera de averiguar todos los períodos de 4 noches en enero donde hay espacio disponible? Podríamos repetir la consulta 27 veces, incrementando las fechas cada vez, lo que parece un poco incómodo. O otra forma podría ser almacenar todas las combinaciones posibles en una tabla de búsqueda de esta manera:

Hotel ID | total price pp | num_people | num_nights | start_date
----------------------------------------------------------------
     123 |            400 | 2          | 4          | Jan1
     123 |            400 | 2          | 4          | Jan2
     123 |            400 | 2          | 4          | Jan3
     123 |            400 | 3          | 4          | Jan1
     123 |            400 | 3          | 4          | Jan2
     123 |            400 | 3          | 4          | Jan3

Y así. Tendríamos que limitar el número máximo de noches y el número máximo de personas que buscaríamos, por ejemplo, noches máximas = 28, personas máximas = 10 (limitado al número de espacios disponibles para ese período establecido que comienza en esa fecha).

Para un hotel, esto podría darnos 28 * 10 * 365 = 102000 resultados por año. 5000 hoteles = 500m de resultados!

Pero tendríamos una consulta muy simple para encontrar la estadía más barata de 4 noches en enero para 2 personas:

SELECT
hotel_id, start_date, price
from hotel_lookup
where num_people=2
and num_nights=4
and start_date >= Jan1
and start_date <= Jan27
order by price
limit 1;

¿Hay alguna manera de realizar esta consulta en la tabla inicial sin tener que generar la tabla de búsqueda de filas de 500 m? por ejemplo, ¿generar los 27 resultados posibles en una tabla temporal o alguna otra magia de consulta interna?

Por el momento, todos los datos se guardan en una base de datos de Postgres. Si es necesario para este propósito, ¿podemos mover los datos a otra cosa más adecuada? No estoy seguro de si este tipo de consulta se ajusta al mapa / reduce los patrones para las bases de datos de estilo NoSQL ...

Respuestas:


6

Puedes hacer mucho con las funciones de ventana . Presentando dos soluciones : una con y otra sin vista materializada.

Caso de prueba

Sobre la base de esta tabla:

CREATE TABLE hotel_data (
   hotel_id int
 , day      date  -- using "day", not "date"
 , spaces   int
 , price    int
 , PRIMARY KEY (hotel_id, day)  -- provides essential index automatically
);

Los días por hotel_iddeben ser únicos (aplicados por PK aquí), o el resto no es válido.

Índice de varias columnas para la tabla base:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);

Tenga en cuenta el orden inverso en comparación con el PK. Probablemente necesitará ambos índices, para la siguiente consulta, el segundo índice es esencial. Explicación detallada:

Consulta directa sin MATERIALIZED VIEW

SELECT hotel_id, day, sum_price
FROM  (
   SELECT hotel_id, day, price, spaces
        , sum(price)      OVER w * 2   AS sum_price
        , min(spaces)     OVER w       AS min_spaces
        , last_value(day) OVER w - day AS day_diff
        , count(*)        OVER w       AS day_ct
   FROM   hotel_data
   WHERE  day BETWEEN '2014-01-01'::date AND '2014-01-31'::date
   AND    spaces >= 2
   WINDOW w AS (PARTITION BY hotel_id ORDER BY day
                ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING) -- adapt to nights - 1
   ) sub
WHERE  day_ct = 4
AND    day_diff = 3  -- make sure there is not gap
AND    min_spaces >= 2
ORDER  BY sum_price, hotel_id, day;
-- LIMIT 1 to get only 1 winner;

También vea la variante de @ ypercube conlag() , que puede reemplazar day_cty day_diffcon una sola comprobación.

¿Cómo?

  • En la subconsulta, solo considere los días dentro de su marco de tiempo ("en enero" significa que el último día está incluido en el marco de tiempo).

  • El marco para las funciones de la ventana abarca la fila actual más las siguientes num_nights - 1( 4 - 1 = 3) filas (días). Calcule la diferencia en días , el recuento de filas y el mínimo de espacios para asegurarse de que el rango sea lo suficientemente largo , sin espacios y siempre tenga suficientes espacios .

    • Desafortunadamente, la cláusula frame de las funciones de ventana no acepta valores dinámicos, por lo que no se puede parametrizar para una declaración preparada.ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING`
  • Redacté cuidadosamente todas las funciones de la ventana en la subconsulta para reutilizar la misma ventana, usando un solo paso de clasificación.

  • El precio resultante sum_priceya se multiplica por el número de espacios solicitados.

Con MATERIALIZED VIEW

Para evitar inspeccionar muchas filas sin posibilidad de éxito, guarde solo las columnas que necesita más tres valores calculados redundantes de la tabla base. Asegúrese de que el MV esté actualizado. Si no está familiarizado con el concepto, lea primero el manual .

CREATE MATERIALIZED VIEW mv_hotel AS
SELECT hotel_id, day
     , first_value(day) OVER (w ORDER BY day) AS range_start
     , price, spaces
     ,(count(*)    OVER w)::int2 AS range_len
     ,(max(spaces) OVER w)::int2 AS max_spaces

FROM  (
   SELECT *
        , day - row_number() OVER (PARTITION BY hotel_id ORDER BY day)::int AS grp
   FROM   hotel_data
   ) sub1
WINDOW w AS (PARTITION BY hotel_id, grp);
  • range_start almacena el primer día de cada rango continuo para dos propósitos:

    • para marcar un conjunto de filas como miembros de un rango común
    • para mostrar el inicio del rango para otros posibles propósitos.
  • range_lenes el número de días en el rango sin espacios.
    max_spaceses el máximo de espacios abiertos en el rango.

    • Ambas columnas se utilizan para excluir filas imposibles de la consulta de inmediato.
  • Eché ambos a smallint(máx. 32768 debería ser suficiente para ambos) para optimizar el almacenamiento: solo 52 bytes por fila (incluido el encabezado de tupla de montón e identificador de elemento). Detalles:

Índice de varias columnas para MV:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (range_len, max_spaces, day);

Consulta basada en MV

SELECT hotel_id, day, sum_price
FROM  (
   SELECT hotel_id, day, price, spaces
        , sum(price)      OVER w * 2   AS sum_price
        , min(spaces)     OVER w       AS min_spaces
        , count(*)        OVER w       AS day_ct
   FROM   mv_hotel
   WHERE  day BETWEEN '2014-01-01'::date AND '2014-01-31'::date
   AND    range_len >= 4   -- exclude impossible rows
   AND    max_spaces >= 2  -- exclude impossible rows
   WINDOW w AS (PARTITION BY hotel_id, range_start ORDER BY day
                ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING) -- adapt to $nights - 1
   ) sub
WHERE  day_ct = 4
AND    min_spaces >= 2
ORDER  BY sum_price, hotel_id, day;
-- LIMIT 1 to get only 1 winner;

Esto es más rápido que la consulta en la tabla porque se pueden eliminar más filas inmediatamente. Nuevamente, el índice es esencial. Como las particiones no tienen espacios aquí, la comprobación day_ctes suficiente.

SQL Fiddle que demuestra ambos .

Uso repetido

Si lo usa mucho, crearía una función SQL y solo pasaría parámetros. O una función PL / pgSQL con SQL dinámico y EXECUTEpara permitir la adaptación de la cláusula frame.

Alternativa

Los tipos de rango date_rangepara almacenar rangos continuos en una sola fila podrían ser una alternativa, complicada en su caso con posibles variaciones en los precios o espacios por día.

Relacionado:


@GuyBowden: Mejor es el enemigo del bien. Considere la respuesta en gran parte reescrita.
Erwin Brandstetter

3

Otra forma, usando la LAG()función:

WITH x AS
  ( SELECT hotel_id, day, 
           LAG(day, 3) OVER (PARTITION BY hotel_id 
                             ORDER BY day)
              AS day_start,
           2 * SUM(price) OVER (PARTITION BY hotel_id 
                                ORDER BY day
                                ROWS BETWEEN 3 PRECEDING 
                                         AND CURRENT ROW)
              AS sum_price
    FROM hotel_data
    WHERE spaces >= 2
   -- AND day >= '2014-01-01'::date      -- date restrictions 
   -- AND day <  '2014-02-01'::date      -- can be added here
  )
SELECT hotel_id, day_start, sum_price
FROM x
WHERE day_start = day - 3 ;

Prueba en: SQL-Fiddle


Solución muy elegante! Probablemente muy rápido con un índice de varias columnas activado (spaces, day), tal vez incluso un índice de cobertura activado (spaces, day, hotel_id, price).
Erwin Brandstetter

3
SELECT hotel, totprice
FROM   (
       SELECT r.hotel, SUM(r.pricepp)*@spacesd_needed AS totprice
       FROM   availability AS a
       JOIN   availability AS r 
              ON r.date BETWEEN a.date AND a.date + (@days_needed-1) 
              AND a.hotel = r.hotel
              AND r.spaces >= @spaces_needed
       WHERE  a.date BETWEEN '2014-01-01' AND '2014-01-31'
       GROUP BY a.date, a.hotel
       HAVING COUNT(*) >= @days_needed
       ) AS matches
ORDER BY totprice ASC
LIMIT 1;

debería obtener el resultado que está buscando sin necesidad de estructuras adicionales, aunque dependiendo del tamaño de los datos de entrada, su estructura de índice y cuán brillante sea el planificador de consultas, la consulta interna puede dar como resultado un carrete en el disco. Sin embargo, puede encontrarlo suficientemente eficiente. Advertencia: mi experiencia es con MS SQL Server y las capacidades de su planificador de consultas, por lo que la sintaxis anterior puede necesitar tweeks si solo en los nombres de las funciones (ypercube ha ajustado la sintaxis por lo que presumiblemente es compatible con postgres ahora, consulte el historial de respuestas para la variante TSQL) .

Lo anterior encontrará estadías que comienzan en enero pero continúan hasta febrero. Agregar una cláusula adicional a la prueba de la fecha (o ajustar el valor de la fecha de finalización) se solucionará fácilmente si no es deseable.


1

Independientemente de HotelID, puede usar una tabla de suma, con una columna calculada, de esta manera:

SummingTable Rev3

No hay claves primarias o externas en esta tabla, ya que solo se usa para calcular rápidamente múltiples combinaciones de valores. Si necesita o desea más de un valor calculado, cree una nueva vista con un nuevo nombre de vista para cada uno de los valores del mes en combinación con cada uno de los valores de personas y precios PP:

EJEMPLO DE CÓDIGO PSEUDO

CREATE VIEW NightPeriods2People3DaysPricePP400 AS (
SELECT (DaysInverse - DaysOfMonth) AS NumOfDays, (NumberOfPeople * PricePP * NumOfDays) AS SummedColumn 
FROM SummingTable
WHERE NumberOfPeople = 2) AND (DaysInverse = 4) AND (DaysOfMonth = 1) AND (PricePP = 400)
)

SummedColumn = 2400

Por último, únase a la vista del HotelID. Para ello, deberá almacenar una lista de todos los ID de hotel en SummingTable (lo hice en la tabla anterior), aunque el ID de hotel no se use para calcular en la vista. Al igual que:

MÁS CÓDIGO PSEUDO

SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID
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.