【问题标题】:Perform this hours of operation query in PostgreSQL在 PostgreSQL 中执行此营业时间查询
【发布时间】:2020-07-29 10:05:17
【问题描述】:

我在 RoR 堆栈中,我必须编写一些实际的 SQL 来完成对所有“打开”记录的查询,这意味着当前时间在指定的操作小时内。在hours_of_operations 表中,两个integer 列opens_on 和closes_on 存储一个工作日,两个time 字段opens_at 和closes_at 存储一天中的相应时间。

我做了一个查询,将当前日期和时间与存储的值进行比较,但我想知道是否有办法转换为某种日期类型并让 PostgreSQL 完成其余的工作?

查询的重点是:

WHERE (
 (

 /* Opens in Future */
 (opens_on > 5 OR (opens_on = 5 AND opens_at::time > '2014-03-01 00:27:25.851655'))
 AND (
 (closes_on < opens_on AND closes_on > 5)
 OR ((closes_on = opens_on)
 AND (closes_at::time < opens_at::time AND closes_at::time > '2014-03-01 00:27:25.851655'))
 OR ((closes_on = 5)
 AND (closes_at::time > '2014-03-01 00:27:25.851655' AND closes_at::time < opens_at::time)))
 OR

 /* Opens in Past */
 (opens_on < 5 OR (opens_on = 5 AND opens_at::time < '2014-03-01 00:27:25.851655'))
 AND
 (closes_on > 5)
 OR
 ((closes_on = 5)
 AND (closes_at::time > '2014-03-01 00:27:25.851655'))
 OR (closes_on < opens_on)
 OR ((closes_on = opens_on)
 AND (closes_at::time < opens_at::time))
 )

 )

如此密集的复杂性的原因是一个小时的操作可能会在周末结束,例如,从周日中午开始到周一早上 6 点。由于我以 UTC 存储值,因此在许多情况下,用户的本地时间可能会以一种非常奇怪的方式换行。上面的查询确保您可以在一周中输入任意两次,并且我们会补偿换行。

【问题讨论】:

    标签: sql ruby-on-rails postgresql constraints range-types


    【解决方案1】:

    表格布局

    重新设计表格以将营业时间(营业时间)存储为一组tsrange (range of timestamp without time zone) 值。需要 Postgres 9.2 或更高版本。

    随机选择一周来安排您的营业时间。我喜欢这一周:
    1996-01-01(星期一)到1996-01-07(星期日)
    这是最近的闰年,1 月 1 日恰好是星期一。但对于这种情况,它可以是任何随机的一周。保持一致即可。

    先安装附加模块btree_gist:

    CREATE EXTENSION btree_gist;
    

    见:

    然后像这样创建表:

    CREATE TABLE hoo (
       hoo_id  serial PRIMARY KEY
     , shop_id int NOT NULL -- REFERENCES shop(shop_id)     -- reference to shop
     , hours   tsrange NOT NULL
     , CONSTRAINT hoo_no_overlap EXCLUDE USING gist (shop_id with =, hours WITH &&)
     , CONSTRAINT hoo_bounds_inclusive CHECK (lower_inc(hours) AND upper_inc(hours))
     , CONSTRAINT hoo_standard_week CHECK (hours <@ tsrange '[1996-01-01 0:0, 1996-01-08 0:0]')
    );
    

    one 列 hours 替换了您的所有列:

    <strike>opens_on, closes_on, opens_at, closes_at</strike>

    例如,从 星期三 18:30 到 星期四 05:00 UTC 的营业时间输入为:

    '[1996-01-03 18:30, 1996-01-04 05:00]'
    

    排除约束 hoo_no_overlap 可防止每个商店的条目重叠。它是通过 GiST 索引 实现的,它也恰好支持我们的查询。考虑下面讨论索引策略的“索引和性能”一章。

    检查约束 hoo_bounds_inclusive 为您的范围强制实施包容性边界,有两个值得注意的后果:

    • 始终包含恰好落在下边界或上边界的时间点。
    • 实际上不允许同一商店的相邻条目。使用包含边界,这些边界将“重叠”,并且排除约束将引发异常。相反,相邻条目必须合并为一行。除非它们环绕周日午夜,在这种情况下它们必须分成两行。下面的函数f_hoo_hours() 会处理这个问题。

    检查约束 hoo_standard_week 使用 "range is contained by" operator &lt;@ 强制执行暂存周的外部边界。

    使用 inclusive 界限,您必须观察一个极端情况,即时间在周日午夜结束:

    '1996-01-01 00:00+0' = '1996-01-08 00:00+0'
     Mon 00:00 = Sun 24:00 (= next Mon 00:00)
    

    您必须同时搜索两个时间戳。这是一个相关案例,其 exclusive 上限不会显示此缺点:

    函数f_hoo_time(timestamptz)

    要“规范化”任何给定的timestamp with time zone:

    CREATE OR REPLACE FUNCTION f_hoo_time(timestamptz)
      RETURNS timestamp
      LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
    $func$
    SELECT timestamp '1996-01-01' + ($1 AT TIME ZONE 'UTC' - date_trunc('week', $1 AT TIME ZONE 'UTC'))
    $func$;
    

    PARALLEL SAFE 仅适用于 Postgres 9.6 或更高版本。

    该函数接受timestamptz 并返回timestamp。它将 UTC 时间中相应周 ($1 - date_trunc('week', $1) 的经过间隔添加到我们暂存周的起点。 (date + interval 产生 timestamp。)

    函数f_hoo_hours(timestamptz, timestamptz)

    标准化范围并拆分那些穿过周一 00:00 的范围。该函数采用任何间隔(作为两个timestamptz)并产生一个或两个标准化tsrange 值。它涵盖任何合法输入并禁止其余的输入:

    CREATE OR REPLACE FUNCTION f_hoo_hours(_from timestamptz, _to timestamptz)
      RETURNS TABLE (hoo_hours tsrange)
      LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE COST 500 ROWS 1 AS
    $func$
    DECLARE
       ts_from timestamp := f_hoo_time(_from);
       ts_to   timestamp := f_hoo_time(_to);
    BEGIN
       -- sanity checks (optional)
       IF _to <= _from THEN
          RAISE EXCEPTION '%', '_to must be later than _from!';
       ELSIF _to > _from + interval '1 week' THEN
          RAISE EXCEPTION '%', 'Interval cannot span more than a week!';
       END IF;
    
       IF ts_from > ts_to THEN  -- split range at Mon 00:00
          RETURN QUERY
          VALUES (tsrange('1996-01-01', ts_to  , '[]'))
               , (tsrange(ts_from, '1996-01-08', '[]'));
       ELSE                     -- simple case: range in standard week
          hoo_hours := tsrange(ts_from, ts_to, '[]');
          RETURN NEXT;
       END IF;
    
       RETURN;
    END
    $func$;
    

    到INSERT一个单个输入行:

    INSERT INTO hoo(shop_id, hours)
    SELECT 123, f_hoo_hours('2016-01-11 00:00+04', '2016-01-11 08:00+04');
    

    对于任意个输入行数:

    INSERT INTO hoo(shop_id, hours)
    SELECT id, f_hoo_hours(f, t)
    FROM  (
       VALUES (7, timestamptz '2016-01-11 00:00+0', timestamptz '2016-01-11 08:00+0')
            , (8, '2016-01-11 00:00+1', '2016-01-11 08:00+1')
       ) t(id, f, t);
    

    如果一个范围需要在周一 00:00 UTC 拆分,则每个都可以插入两行。

    查询

    通过调整后的设计,您的整个大型、复杂、昂贵的查询可以替换为...这个:

    SELECT *
    FROM hoo
    WHERE hours @&gt; f_hoo_time(now());

    为了有点悬念,我在解决方案上放了一个扰流板。将鼠标移到上面。

    查询由所述 GiST 索引支持,速度很快,即使对于大表也是如此。

    dbfiddle here(还有更多示例)
    旧 sqlfiddle

    如果您想计算总营业时间(每家商店),这里有一个方法:

    指标和性能

    containment operator for range types 可以支持 GiST 或 SP-GiST 索引。两者都可以用来实现排除约束,但只有 GiST 支持multicolumn indexes:

    目前,只有 B-tree、GiST、GIN 和 BRIN 索引类型支持多列索引。

    还有order of index columns matters:

    多列 GiST 索引可以与以下查询条件一起使用 涉及索引列的任何子集。附加条件 列限制索引返回的条目,但条件 第一列是确定多少最重要的 的索引需要扫描。 GiST 索引将相对 如果它的第一列只有几个不同的值,则无效,即使 如果附加列中有许多不同的值。

    所以我们在这里有利益冲突。对于大表,shop_id 的不同值将比 hours 多得多。

    • 带有前导 shop_id 的 GiST 索引可以更快地编写和执行排除约束。
    • 但我们在查询中搜索hours。先有那个专栏会更好。
    • 如果我们需要在其他查询中查找 shop_id,那么普通的 btree 索引会更快。
    • 最重要的是,我发现 hours 上的 SP-GiST 索引对于查询来说是最快。

    基准测试

    在旧笔记本电脑上使用 Postgres 12 进行新测试。 我生成虚拟数据的脚本:

    INSERT INTO hoo(shop_id, hours)
    SELECT id
         , f_hoo_hours(((date '1996-01-01' + d) + interval  '4h' + interval '15 min' * trunc(32 * random()))            AT TIME ZONE 'UTC'
                     , ((date '1996-01-01' + d) + interval '12h' + interval '15 min' * trunc(64 * random() * random())) AT TIME ZONE 'UTC')
    FROM   generate_series(1, 30000) id
    JOIN   generate_series(0, 6) d ON random() > .33;
    

    产生约 141k 随机生成的行,约 30k 不同 shop_id,约 12k 不同 hours。表大小 8 MB。

    我删除并重新创建了排除约束:

    ALTER TABLE hoo
      DROP CONSTRAINT hoo_no_overlap
    , ADD CONSTRAINT hoo_no_overlap  EXCLUDE USING gist (shop_id WITH =, hours WITH &&);  -- 3.5 sec; index 8 MB
        
    ALTER TABLE hoo
      DROP CONSTRAINT hoo_no_overlap
    , ADD CONSTRAINT hoo_no_overlap  EXCLUDE USING gist (hours WITH &&, shop_id WITH =);  -- 13.6 sec; index 12 MB
    

    shop_id first 在此发行版中大约快 4 倍。

    此外,我还测试了两个读取性能:

    CREATE INDEX hoo_hours_gist_idx   on hoo USING gist (hours);
    CREATE INDEX hoo_hours_spgist_idx on hoo USING spgist (hours);  -- !!
    

    VACUUM FULL ANALYZE hoo; 之后,我运行了两个查询:

    • Q1:深夜,仅找到 35 行
    • Q2:下午,发现 4547 行。

    结果

    对每个都有一个仅索引扫描(当然,“无索引”除外):

    index                 idx size  Q1        Q2
    ------------------------------------------------
    no index                        38.5 ms   38.5 ms 
    gist (shop_id, hours)    8MB    17.5 ms   18.4 ms
    gist (hours, shop_id)   12MB     0.6 ms    3.4 ms
    gist (hours)            11MB     0.3 ms    3.1 ms
    spgist (hours)           9MB     0.7 ms    1.8 ms  -- !
    
    • SP-GiST 和 GiST 在查询结果很少(非常很少的情况下,GiST 甚至更快)。
    • SP-GiST 随着结果数量的增加可以更好地扩展,而且更小。

    如果您读的比写的多(典型用例),请按照一开始的建议保留排除约束,并创建一个额外的 SP-GiST 索引以优化读取性能。

    【讨论】:

    • 是的,这太不可思议了,我唯一的问题是:有没有办法让这种设计模式与环绕式一起工作(商店有一个小时的营业时间,周日开门,周一关门)?
    • @BrianWheeler:考虑一下解决这个问题的附录。
    • @ErwinBrandstetter 如果本地开放时间转换为小于 1996-01-01 00:00 UTC,则应在开放时间中增加一周,以便日期从 12 月 31 日星期日更改为 1 月星期日第七?或者是否需要更改下限以允许本地时间最晚于 UTC 的“星期一午夜”的最小可能 UTC 值
    • @FuzzyTree:很好的问题,它指出了f_hoo_time() 中的一个错误:date_trunc() 依赖于当前时区设置,因此时区必须是SET 明确地指向UTC。在此过程中,我在很大程度上改进了解决方案,并添加了一个功能来照顾……一切。
    • @ErwinBrandstetter 你是对的,tstzrange vs tsrange 不是问题。我实施了这些更改,现在它运行良好。我将不得不测试索引以查看哪些索引对我的应用程序表现更好。再次感谢 - 出色的工作!
    猜你喜欢
    • 1970-01-01
    • 2015-12-17
    • 2016-08-05
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-10-15
    • 1970-01-01
    相关资源
    最近更新 更多