表格布局
重新设计表格以将营业时间(营业时间)存储为一组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 <@ 强制执行暂存周的外部边界。
使用 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 @> 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 索引以优化读取性能。