会议室预订出现重叠记录:用 PostgreSQL 范围类型把“不重叠”约束下沉到数据库层
现象是:会议室预订接口偶发返回成功,数据库里却出现同房间重叠的两行。表结构最初是典型的两列设计:
CREATE TABLE reservation (
id serial PRIMARY KEY,
room int NOT NULL,
start_ts timestamptz NOT NULL,
end_ts timestamptz NOT NULL
);
应用层校验重叠通常是“先查再插”:
SELECT 1 FROM reservation
WHERE room = $1
AND start_ts < $2
AND end_ts > $3;
查不到记录就插入。问题在于查询和插入之间不是原子的。两个并发请求同时查同一空档,都看不到对方尚未提交的数据,然后都插入成功。普通 UNIQUE 约束也拦不住这种重叠,因为唯一约束只能判断相等,判断不了“相交”。
PostgreSQL 的解法是把“一段时间”建模成单个值,再用数据库约束直接拒绝重叠写入。完整机制见官方文档:Range Types。
范围类型把区间建模成单个值
改造后的表可以这样建:
CREATE TABLE reservation (
room int,
during tsrange
);
INSERT INTO reservation
VALUES (1108, '[2010-01-01 14:30, 2010-01-01 15:30)');
[ 表示包含边界,( 表示不包含。两参构造默认是 [);三参可以显式传 '()'、'(]'、'[)'、'[]'。某侧传 NULL 表示无界,写文本 'empty' 表示空区间。
内置类型覆盖常用标量:
int4range:intint8range:bigintnumrange:numerictsrange:无时区timestamptstzrange:带时区timestamptzdaterange:date
每种类型都有对应的 multirange,比如 int4multirange。一个 multirange 值可以包含多段不连续区间,适合表达“某个人多段空闲档期”这类数据。
范围已经自带一套集合式操作:
SELECT int4range(10, 20) @> 3; -- 包含
SELECT numrange(11.1, 22.2) && numrange(20.0, 30.0); -- 重叠
SELECT upper(int8range(15, 25)); -- 上界
SELECT int4range(10, 20) * int4range(15, 25); -- 交集
SELECT isempty(numrange(1, 5)); -- 是否空
离散范围有规范形式,连续范围没有
int4range、int8range、daterange 这类离散型范围带 canonical 函数,存储和比较前会被规范成 [) 形式。例如 int8range(1, 14, '(]') 实际包含 2 到 14,规范显示为 [2, 15)。
同一值集可以写出不同格式,比如整数范围 [4, 8] 和 (3, 9) 都表示 4 到 8。如果自定义离散范围没有 canonical 函数,这两种等价写法可能被判成不相等。
连续型范围不需要 canonical,但需要 subtype_diff 才能让 GiST 索引高效;没有也能用,但效率会明显下降。
CREATE TYPE floatrange AS RANGE (
subtype = float8,
subtype_diff = float8mi
);
CREATE FUNCTION time_subtype_diff(x time, y time) RETURNS float8 AS
'SELECT EXTRACT(EPOCH FROM (x - y))'
LANGUAGE sql STRICT IMMUTABLE;
CREATE TYPE timerange AS RANGE (
subtype = time,
subtype_diff = time_subtype_diff
);
范围列还可以走 GiST 或 SP-GiST 索引:
CREATE INDEX reservation_idx ON reservation USING GIST (during);
GiST 能加速 = && <@ @> << >> -|- &< &> 这类范围操作。
排除约束:把“不重叠”变成数据库约束
范围类型的核心价值不只是查询方便,而是能建排他性约束。UNIQUE 对范围不适用,要用 EXCLUDE ... USING GIST。
最简形式,约束整张表任意两条记录不能重叠:
CREATE TABLE reservation (
during tsrange,
EXCLUDE USING GIST (during WITH &&)
);
插入重叠时段时会被数据库直接拒绝:
INSERT INTO reservation
VALUES ('[2024-01-01 10:00, 2024-01-01 11:00)');
INSERT INTO reservation
VALUES ('[2024-01-01 10:30, 2024-01-01 11:30)');
-- ERROR: conflicting key value violates exclusion constraint "reservation_during_excl"
真实会议室场景要约束的是“同一房间内不重叠,不同房间互不影响”。此时需要把 room 也放进排除约束,并启用 btree_gist 扩展:
CREATE EXTENSION btree_gist;
CREATE TABLE room_reservation (
room text,
during tsrange,
EXCLUDE USING GIST (room WITH =, during WITH &&)
);
同房间重叠插入会被拒,不同房间放行:
INSERT INTO room_reservation
VALUES ('A', '[2024-01-01 09:00, 2024-01-01 10:00)');
INSERT INTO room_reservation
VALUES ('A', '[2024-01-01 09:30, 2024-01-01 10:30)');
-- ERROR: conflicting key value violates exclusion constraint
INSERT INTO room_reservation
VALUES ('123B', '[2024-01-01 09:30, 2024-01-01 10:30)');
-- OK
应用层还保留哪些职责
排除约束本质是一种索引约束,写路径由数据库统一拦截,原先“先查后插”的锁逻辑可以撤掉。但应用层仍然要处理两件事:
- 并发很高时,约束冲突会作为写入错误抛出来,接口需要捕获后给出友好响应,比如提示用户换时段或重试。
- 前置校验如果保留,只适合做快速失败,减少无谓写入;它不是防线,最终防线是排除约束。
MySQL 没有范围类型,也没有对应的排除约束机制。要做同样的“同一资源、同一时段不重叠”,只能回到应用层先查后插加锁,或者用区间表之类的额外方案,复杂度会明显更高。