编程 会议室预订出现重叠记录:用 PostgreSQL 范围类型把“不重叠”约束下沉到数据库层

2026-09-06 00:06:01

会议室预订出现重叠记录:用 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' 表示空区间。

内置类型覆盖常用标量:

  • int4rangeint
  • int8rangebigint
  • numrangenumeric
  • tsrange:无时区 timestamp
  • tstzrange:带时区 timestamptz
  • daterangedate

每种类型都有对应的 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));                         -- 是否空

离散范围有规范形式,连续范围没有

int4rangeint8rangedaterange 这类离散型范围带 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 没有范围类型,也没有对应的排除约束机制。要做同样的“同一资源、同一时段不重叠”,只能回到应用层先查后插加锁,或者用区间表之类的额外方案,复杂度会明显更高。

相关文档

推荐文章

程序员茄子在线接单