Хранить миллионы строк деномализованных данных или какую-то магию SQL?

8

Мой опыт работы с БД не намного больше, чем простое хранение + извлечение данных в стиле CMS - так что это может быть глупый вопрос, я не знаю!

У меня есть проблема, из-за которой мне нужно искать или рассчитывать отпускные цены для определенного размера группы и определенного количества дней в течение определенного периода времени. Например:

Сколько стоит номер в гостинице для 2 человек на 4 ночи в любое время в январе?

У меня есть данные о ценах и наличии, скажем, 5000 отелей, которые хранятся примерно так:

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

С помощью этой таблицы я могу сделать запрос следующим образом:

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;

Результаты

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

HAVINGПункт здесь убеждается , что есть запись для каждого дня между моей датой , которая имеет пространство доступно. то есть. В отеле 456 было 1 место, доступное 2 января, предложение HAVING вернуло бы 3, поэтому мы не получаем результат для отеля 456.

Все идет нормально.

Тем не менее, есть ли способ узнать все 4 ночи в январе, где есть свободное место? Мы могли бы повторить запрос 27 раз - увеличивая даты каждый раз, что кажется немного неловким. Или другой путь может заключаться в том, чтобы хранить все возможные комбинации в таблице поиска следующим образом:

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

И так далее. Нам нужно ограничить максимальное количество ночей и максимальное количество людей, которых мы будем искать - например, максимальное количество ночей = 28, максимальное количество людей = 10 (ограничено количеством мест, доступных для этого установленного периода, начинающегося с этой даты).

Для одного отеля это может дать нам 28 * 10 * 365 = 102000 результатов в год. 5000 отелей = 500м результатов!

Но у нас был бы очень простой запрос, чтобы найти самый дешевый 4 ночи в январе для 2 человек:

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;

Есть ли способ выполнить этот запрос к исходной таблице, не создавая таблицу поиска строк длиной 500 м !? например, сгенерировать 27 возможных результатов во временной таблице или какую-то другую магию внутренних запросов?

В настоящий момент все данные хранятся в БД Postgres - если для этого потребуется, мы можем переместить данные в другое, более подходящее место? Не уверен, что этот тип запроса соответствует шаблонам карты / сокращения для БД в стиле NoSQL ...

Гай Боуден
источник

Ответы:

6

Вы можете многое сделать с помощью оконных функций . Представляем два решения : одно с материализацией, а другое без материализованного представления.

Прецедент

Опираясь на этот стол:

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
);

Количество дней в году hotel_idдолжно быть уникальным (здесь применяется PK), в противном случае недопустимо остальное.

Многоколонный индекс для базовой таблицы:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);

Обратите внимание на обратный порядок по сравнению с ПК. Вероятно, вам понадобятся оба индекса, для следующего запроса второй индекс необходим. Детальное объяснение:

Прямой запрос без 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;

Также см . Вариант @ ypercube сlag() , который можно заменить day_ctи day_diffодной проверкой.

Как?

  • В подзапросе учитывайте только дни в пределах вашего периода времени («в январе» означает, что последний день включен в период времени).

  • Рамка для оконных функций охватывает текущую строку плюс следующие num_nights - 1( 4 - 1 = 3) строки (дни). Рассчитайте разницу в днях , количество строк и минимум пробелов, чтобы убедиться, что диапазон достаточно длинный , без зазоров и всегда имеет достаточно пробелов .

    • К сожалению, предложение frame оконных функций не принимает динамические значения, поэтому не может быть параметризовано для подготовленного оператора.ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING`
  • Я тщательно составил все оконные функции в подзапросе, чтобы повторно использовать одно и то же окно, используя один шаг сортировки.

  • Полученная цена sum_priceуже умножается на количество запрошенных пробелов.

С MATERIALIZED VIEW

Чтобы избежать проверки множества строк без шансов на успех, сохраните только нужные столбцы плюс три избыточных вычисленных значения из базовой таблицы. Убедитесь, что MV обновлен. Если вы не знакомы с концепцией, сначала прочтите руководство .

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 сохраняет первый день каждого непрерывного диапазона для двух целей:

    • пометить набор строк как элементы общего диапазона
    • показать начало диапазона для возможных других целей.
  • range_lenколичество дней в интервале без пропусков.
    max_spacesэто максимум открытых пространств в диапазоне.

    • Оба столбца используются для немедленного исключения невозможных строк из запроса.
  • Я привел оба к smallint(максимум 32768 должно быть достаточно для обоих), чтобы оптимизировать хранилище: только 52 байта на строку (включая заголовок кортежа кучи и идентификатор элемента). Подробности:

Индекс многоколонок для MV:

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

Запрос на основе 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;

Это быстрее, чем запрос к таблице, поскольку сразу можно удалить больше строк. Опять же, индекс имеет важное значение. Поскольку разделы здесь без промежутков , проверки day_ctдостаточно.

SQL Fiddle демонстрирует оба .

Повторное использование

Если вы будете часто его использовать, я бы создал функцию SQL и передавал только параметры. Или функция PL / pgSQL с динамическим SQL и EXECUTEпозволяющая адаптировать предложение кадра.

альтернатива

Типы диапазонов с date_rangeвозможностью сохранения непрерывных диапазонов в одной строке могут быть альтернативой - сложными в вашем случае с потенциальными колебаниями цен или пробелов в день.

Связанные с:

Эрвин Брандштеттер
источник
@GuyBowden: лучше - враг добра. Рассмотрим в значительной степени переписанный ответ.
Эрвин Брандштеттер
3

Еще один способ, используя LAG()функцию:

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 ;

Тест в: SQL-Fiddle

ypercubeᵀᴹ
источник
Очень элегантное решение! Возможно, очень быстро с включенным многоколоночным индексом (spaces, day), может быть, даже с индексом покрытия (spaces, day, hotel_id, price).
Эрвин Брандштеттер
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;

должен получить результат, который вы ищете, без необходимости в дополнительных структурах, хотя в зависимости от размера входных данных, структуры вашего индекса и яркости планировщика запросов внутренний запрос может привести к спулингу на диск. Вы можете найти это достаточно эффективным, хотя. Предостережение: мой опыт работы с MS SQL Server и возможностями его планировщика запросов, поэтому приведенному выше синтаксису могут потребоваться твики, если только в именах функций (ypercube изменил синтаксис, так что теперь он предположительно совместим с postgres, см. Историю ответов для варианта TSQL) .

Вышеуказанные находки начнутся в январе, но продолжатся в феврале. Добавление дополнительного условия к тесту даты (или корректировка значения конечной даты) легко справится с этим, если это нежелательно.

Дэвид Спиллетт
источник
1

Независимо от HotelID, вы можете использовать таблицу суммирования с вычисляемым столбцом, например:

SummingTable Rev3

В этой таблице нет первичных или внешних ключей, так как она используется только для быстрого расчета нескольких комбинаций значений. Если вам нужно или требуется более одного рассчитанного значения, создайте новое представление с новым именем представления для каждого из значений месяца в сочетании с каждым из значений PP People и Price:

ПРИМЕР КОДА 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

Наконец, присоедините вид к идентификатору отеля. Для этого вам нужно будет сохранить список всех идентификаторов отелей в SummingTable (я это делал в приведенной выше таблице), даже если HotelID не используется для расчета в представлении. Вот так:

БОЛЬШЕ КОДА ПСЕВДО

SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID
eyoung100
источник