存储数百万行非标准化数据或一些 SQL 魔法?

Guy*_*den 8 postgresql window-functions datetime denormalization

我的 DBA 经验只是简单的存储 + CMS 样式数据的检索 - 所以这可能是一个愚蠢的问题,我不知道!

我有一个问题,我需要查找或计算特定组大小和特定时间段内特定天数的假期价格。例如:

1 月任何时候 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
Run Code Online (Sandbox Code Playgroud)

有了这个表,我可以做一个这样的查询:

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;
Run Code Online (Sandbox Code Playgroud)

结果

hotel_id | sum
----------------
     123 | 400
Run Code Online (Sandbox Code Playgroud)

HAVING这里的条款确保在我想要的日期之间的每一天都有一个条目,并且有可用的空间。IE。酒店 456 在 1 月 2 日有 1 个可用空间,HAVING 子句将返回 3,因此我们没有得到酒店 456 的结果。

到现在为止还挺好。

但是,有没有办法找出 1 月份有空位的所有 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
Run Code Online (Sandbox Code Playgroud)

等等。我们必须限制最大晚数,以及我们要搜索的最大人数 - 例如最大晚数 = 28,最大人数 = 10(仅限于从该日期开始的该设定时间段的可用空间数)。

对于一家酒店,这每年可以为我们带来 28*10*365=102000 的结果。5000 家酒店 = 5 亿结果!

但是我们有一个非常简单的查询来查找 1 月 2 人的最便宜的 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;
Run Code Online (Sandbox Code Playgroud)

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

目前所有数据都保存在 Postgres 数据库中 - 如果需要为此目的,我们可以将数据移到其他更合适的地方吗?不确定这种类型的查询是否适合 NoSQL 风格数据库的映射/减少模式......

Erw*_*ter 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
);
Run Code Online (Sandbox Code Playgroud)

Days perhotel_id必须是唯一的(此处由 PK 强制执行),否则其余部分无效。

基表的多列索引:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);
Run Code Online (Sandbox Code Playgroud)

请注意与 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;
Run Code Online (Sandbox Code Playgroud)

另请参阅@ypercube 的带有 的变体lag(),它可以用一个检查替换day_ctday_diff

如何?

  • 在子查询中,只考虑时间范围内的天数(“一月”意味着最后一天包含在时间范围内)。

  • 窗口函数的框架跨越当前行加上接下来的num_nights - 1( 4 - 1 = 3) 行(天)。计算日差行数和最小的空间,以确保该范围足够长无间隙,总是有足够的空间

    • 不幸的是,窗口函数的框架子句不接受动态值,因此不能为准备好的语句参数化。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);
Run Code Online (Sandbox Code Playgroud)
  • range_start 存储每个连续范围的第一天有两个目的:

    • 将一组行标记为公共范围的成员
    • 为可能的其他目的显示范围的开始。
  • range_len是无间隙范围内的天数。
    max_spaces是范围内开放空间的最大值。

    • 两列都用于立即从查询中排除不可能的行。
  • 我将两者都转换为smallint(最大 32768 对两者来说都足够了)以优化存储:每行只有 52 个字节(包括堆元组标题和项目标识符)。细节:

MV 的多列索引:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (range_len, max_spaces, day);
Run Code Online (Sandbox Code Playgroud)

基于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;
Run Code Online (Sandbox Code Playgroud)

这比在表上查询要快,因为可以立即消除更多行。再次,索引是必不可少的。由于这里的分区是无间隙的,检查day_ct就足够了。

SQL Fiddle演示.

重复使用

如果你经常使用它,我会创建一个 SQL 函数并且只传递参数。或者具有动态 SQL的PL/pgSQL函数并EXECUTE允许调整框架子句。

选择

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

有关的: