存储数以百万计的行的非数字化数据还是某些SQL魔术?


8

我的DBA经验远不止于简单存储+检索CMS样式数据,还有很多,所以这可能是一个愚蠢的问题,我不知道!

我遇到一个问题,我需要查询或计算特定时间段内特定群体人数和特定天数的假期价格。例如:

一月份任何时候可供4人入住2晚的酒店客房多少钱?

我有像这样存储的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日有1个可用空间,HAVING子句将返回3,因此我们没有得到456号酒店的结果。

到目前为止,一切都很好。

但是,有没有办法找出一月份有可用空间的所有四个夜晚?我们可以重复查询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家酒店= 500m个结果!

但我们将有一个非常简单的查询,以查找2人在1月最便宜的4晚住宿:

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;

有没有一种方法可以在初始表上执行此查询,而不必生成500m行查找表!例如在临时表或其他内部查询魔术中生成27种可能的结果?

目前,所有数据都保存在Postgres数据库中-如果出于此目的,我们可以将数据移至其他更合适的位置?不确定这种查询是否适合NoSQL样式数据库的映射/减少模式...

Answers:


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

注意与PK相反的顺序。您可能需要两个索引,对于以下查询,第二个索引至关重要。详细说明:

直接查询而无需 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_ctday_diff带有单个检查。

怎么样?

  • 在子查询中,仅考虑时间范围内的日期(“一月”表示时间范围内包括最后一天)。

  • 窗口函数的框架跨越当前行以及接下来的num_nights - 14 - 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函数并仅传递参数。或具有动态SQL 的PL / pgSQL函数,并EXECUTE允许修改frame子句。

另类

date_range可以在单个行中存储连续范围的范围类型可能是另一种选择-在您的情况下很复杂,每天价格或空间可能会有所变化。

有关:


@GuyBowden:更好是好的敌人。考虑很大程度上重写的答案。
Erwin Brandstetter'9

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小提琴


非常优雅的解决方案!启用多列索引可能很快(spaces, day),甚至启用覆盖索引(spaces, day, hotel_id, price)
Erwin Brandstetter 2014年

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月开始但一直持续到2月的住宿。如果不需要,可以在日期测试中添加一个额外的子句(或调整输入的结束日期值)。


1

不管HotelID是什么,都可以使用带有计算列的汇总表,如下所示:

汇总表Rev3

该表中没有主键或外键,因为它仅用于快速计算值的多个组合。如果您需要或想要多个计算值,请为每个月值与每个人员和价格PP值结合使用新视图名称创建一个新视图:

伪代码示例

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

最后,将视图加入到HotelID中。为此,即使没有在视图中使用HotelID进行计算,您也需要将所有HotelID的列表存储在SummingTable中(我在上表中进行过存储)。像这样:

更多伪代码

SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.