EXCLUDE 排他约束
排他约束(Exclude Constraint)是 PostgreSQL 独有的约束类型,其他主流数据库都没有对应能力。它表达的是「任意两行不得在指定维度上重叠」——这是 UNIQUE 完全做不到的语义。本节讲清它的语法、必须搭配的索引操作符类、四个经典场景(会议室排期、区间定价、标签组合、等价唯一),以及 btree_gist 扩展、ON CONFLICT 不支持等最容易卡住初学者的几个点。
前置知识
阅读本节前,建议先了解:NOT NULL / CHECK / DEFAULT
基础概念
理解 EXCLUDE 的关键是抓住它和 UNIQUE 的对偶关系:
UNIQUE (a) → 任意两行的 a 不能相等 (= 冲突)
EXCLUDE (a WITH =) → 任意两行的 a 不能相等 (= 冲突,与 UNIQUE 等价)
EXCLUDE (a WITH &&) → 任意两行的 a 不能重叠 (&& 冲突)
EXCLUDE (room WITH =, during WITH &&)
→ 「room 相同 且 during 重叠」的两行不能共存用一句话概括语法含义:对列出的每一个「表达式 WITH 操作符」条件,如果两行在所有条件上同时「冲突」,就拒绝插入。全用 = 就是唯一约束,混用 && 就得到「区间不重叠」。
| 概念 | 术语 | 说明 |
|---|---|---|
| 排他约束 | EXCLUDE | PostgreSQL 特有,表达「两行不得同时满足冲突条件」 |
| 冲突操作符 | Conflict Operator | =、&&、<> 等,必须有对应操作符类 |
| 索引方法 | Index Method | gist / gin / spgist,决定支持哪些操作符 |
| 操作符类 | Operator Class | 让索引方法认识某个数据类型的关键(如 range_ops) |
| 区间类型 | Range Type | tsrange / numrange / daterange,支持 && |
| 部分排他 | Partial Exclude | 带 WHERE 的排他约束,只约束部分行 |
| 可延迟 | DEFERRABLE | EXCLUDE 支持延迟检查 |
| 扩展 | btree_gist | 让 GiST 支持 = 等值的扩展,混用操作符必需 |
必须记住的一条:EXCLUDE 底层是一个索引,因此必须选一个能支持所有冲突操作符的索引方法。这就是为什么几乎每个 EXCLUDE 示例前面都要先执行 CREATE EXTENSION btree_gist;。
语法与用法
[ CONSTRAINT constraint_name ]
EXCLUDE [ USING index_method ]
( exclude_element WITH operator [, ... ] )
index_parameters
[ WHERE ( predicate ) ]
-- exclude_element 形式
{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ]
-- index_parameters
[ INCLUDE ( column_name [, ... ] ) ]
[ WITH ( storage_parameter = value [, ... ] ) ]
[ USING INDEX TABLESPACE tablespace_name ]
-- 可延迟声明(跟在整段之后)
[ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ]
-- 已有表上增删
ALTER TABLE table_name ADD [ CONSTRAINT constraint_name ] EXCLUDE ...;
ALTER TABLE table_name DROP CONSTRAINT constraint_name [ CASCADE ];最简可运行示例(防会议室时间段重叠):
-- 关键前置:让 GiST 认识 integer 的等值操作符
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
owner text NOT NULL,
-- room_id 相同且 during 重叠 → 冲突
CONSTRAINT ex_room_bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);CREATE EXTENSION
CREATE TABLE详细说明
btree_gist 扩展:混用 = 与 && 的必备前提
这是初学者最常卡住的地方。GiST 索引默认只认识「范围、几何、数组」这类类型的操作符,不认识 integer、text 等普通类型的 = 操作符。所以直接写 (room_id WITH =, during WITH &&) 会报错:
-- 错误!integer 类型在 GiST 下没有默认操作符类
CREATE TABLE bad_bookings (
id bigint PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
CONSTRAINT ex_bad EXCLUDE USING gist (room_id WITH =, during WITH &&)
);ERROR: data type integer has no default operator class for access method "gist"
HINT: You must specify an operator class for the index or define a default operator class for the data type.报错的根因是 room_id WITH = 这一项,而不是 during WITH &&。btree_gist 扩展就是为解决这个问题而存在的——它把 B-tree 的 =、<、> 等操作符实现到 GiST 上:
-- 安装扩展后再试,成功
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE good_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
CONSTRAINT ex_good EXCLUDE USING gist (room_id WITH =, during WITH &&)
);CREATE EXTENSION
CREATE TABLEbtree_gist 提供的常用操作符类:
| 操作符类 | 支持类型 | 支持操作符 |
|---|---|---|
int4_ops | integer | = < <= > >= <> |
int8_ops | bigint | 同上 |
text_ops | text | 同上 |
uuid_ops | uuid | = <> (PostgreSQL 14+ 支持排序) |
timestamptz_ops | timestamptz | 同上 |
如果只有范围列、没有等值列,可以不用扩展:
-- 只有区间列时不需要 btree_gist(tsrange 本身就有 GiST 操作符类)
CREATE TABLE single_room_bookings (
id bigint PRIMARY KEY,
during tsrange NOT NULL,
CONSTRAINT ex_single_no_overlap EXCLUDE USING gist (during WITH &&)
);场景一:防会议室时间段重叠(最经典)
需求:同一会议室在同一时间段内不能被预订两次。
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS room_bookings;
CREATE TABLE room_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL, -- 半开区间:[) 推荐,边界不重叠
owner text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ex_room_bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
-- 每个会议室各自不重叠,跨会议室互不影响
INSERT INTO room_bookings (room_id, during, owner) VALUES
(1, '[2026-09-24 09:00, 2026-09-24 10:00)', 'alice'),
(1, '[2026-09-24 10:00, 2026-09-24 11:00)', 'bob'), -- 紧邻但不重叠,允许
(2, '[2026-09-24 09:00, 2026-09-24 10:00)', 'carol'); -- 另一个会议室,允许INSERT 0 3真正的重叠会被拒绝:
-- 错误!与 alice 的 09:00-10:00 重叠
INSERT INTO room_bookings (room_id, during, owner)
VALUES (1, '[2026-09-24 09:30, 2026-09-24 09:45)', 'dave');ERROR: conflicting key value violates exclusion constraint "ex_room_bookings_no_overlap"
DETAIL: Key (room_id, during)=(1, ["2026-09-24 09:30:00+08","2026-09-24 09:45:00+08")) conflicts with existing key (room_id, during)=(1, ["2026-09-24 09:00:00+08","2026-09-24 10:00:00+08")).报错信息非常有用:它明确告诉你是哪两对值冲突了。
用半开区间 [) 是关键设计:['09:00','10:00') 与 ['10:00','11:00') 不重叠,符合「上一场结束即下一场开始」的直觉。如果错误地用闭区间 [],这两段会被判定为在 10:00 这一点重叠:
-- 不推荐:闭区间会让相邻时段被判为重叠
during tsrange '[2026-09-24 09:00, 2026-09-24 10:00]'
-- 推荐:半开区间,边界点归后一段
during tsrange '[2026-09-24 09:00, 2026-09-24 10:00)'用 tstzrange 可以带上时区信息,在跨时区业务中更安全:
CREATE TABLE room_bookings_tz (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tstzrange NOT NULL,
CONSTRAINT ex_room_bookings_tz_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);场景二:防数值区间定价重叠
同样的手法用在价格区间、数量区间、折扣阶梯上:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE price_tiers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL,
qty_range numrange NOT NULL, -- [1, 100) 表示 1~99 件
unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0),
-- 同一商品的折扣阶梯不允许区间重叠
CONSTRAINT ex_price_tiers_no_overlap
EXCLUDE USING gist (product_id WITH =, qty_range WITH &&)
);
INSERT INTO price_tiers (product_id, qty_range, unit_price) VALUES
(1001, '[1, 100)', 99.00),
(1001, '[100, 500)', 89.00),
(1001, '[500, 1000)', 79.00),
(1002, '[1, 100)', 49.00); -- 另一个商品,独立INSERT 0 4-- 错误![50, 150) 与已有的 [1, 100) 和 [100, 500) 都重叠
INSERT INTO price_tiers (product_id, qty_range, unit_price)
VALUES (1001, '[50, 150)', 95.00);ERROR: conflicting key value violates exclusion constraint "ex_price_tiers_no_overlap"
DETAIL: Key (product_id, qty_range)=(1001, [50,150)) conflicts with existing key (product_id, qty_range)=(1001, [1,100)).数值区间还支持取「无界」,表达「1000 件以上」:
INSERT INTO price_tiers (product_id, qty_range, unit_price) VALUES
(1001, '[1000,)', 69.00); -- 无上界INSERT 0 1场景三:用 EXCLUDE 表达等价唯一约束
只写 = 的 EXCLUDE 在语义上等价于 UNIQUE,可以顺便加 NULLS 控制与部分条件:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE ex_unique_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
code text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
-- 等价于 UNIQUE (tenant_id, code),但额外支持 WITH (=) 的灵活性
CONSTRAINT ex_unique_demo_tenant_code
EXCLUDE USING gist (tenant_id WITH =, code WITH =)
);INSERT INTO ex_unique_demo (tenant_id, code) VALUES (1, 'A'), (1, 'B'), (2, 'A');
-- 错误!(1, 'A') 组合已存在
INSERT INTO ex_unique_demo (tenant_id, code) VALUES (1, 'A');INSERT 0 3
ERROR: conflicting key value violates exclusion constraint "ex_unique_demo_tenant_code"
DETAIL: Key (tenant_id, code)=(1, A) already exists.这种写法本身没有优势——UNIQUE 用 B-tree,性能更好、还支持 NULLS NOT DISTINCT 与 INCLUDE。只有当你需要同时混用等值与重叠(场景一、二、四)时,EXCLUDE 才是唯一选择。
场景四:用数组防标签组合重复(GIN)
把 && 用在数组上就能表达「不允许任意标签交集重复」。「标签组合不重复」这类需求在标签系统里很常见:
DROP TABLE IF EXISTS tagged_items;
CREATE TABLE tagged_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id bigint NOT NULL,
label_ids integer[] NOT NULL, -- 例如 '{1,5,9}'
-- 同一 owner 下,任意两个 item 的标签集合不能有交集
CONSTRAINT ex_tagged_items_no_shared_label
EXCLUDE USING gist (owner_id WITH =, label_ids WITH &&)
);INSERT INTO tagged_items (owner_id, label_ids) VALUES
(1, '{1,2}');
-- 错误!{2,3} 与 {1,2} 在标签 2 上重叠
INSERT INTO tagged_items (owner_id, label_ids) VALUES (1, '{2,3}');ERROR: conflicting key value violates exclusion constraint "ex_tagged_items_no_shared_label"
DETAIL: Key (owner_id, label_ids)=(1, {2,3}) conflicts with existing key (owner_id, label_ids)=(1, {1,2}).数组的 && 需要 GiST 支持数组类型(array_ops,内建可用),但 owner_id WITH = 依然需要 btree_gist。如果要改成 GIN,需要留意 GIN 不支持排他约束的多列冲突语义组合,实践上通常仍用 GiST。
注意数组的 && 语义是「有交集就冲突」,不是「完全相同才冲突」。想要「标签组合完全相同才冲突」,应该对排序后的标签数组用 =(需要 btree_gist):语义差别很大,务必按业务意图选择。
WHERE 部分条件(Partial Exclude)
EXCLUDE 支持 WHERE,只约束满足条件的行。这解决「软删除后应允许重用」一类需求:
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS active_bookings;
CREATE TABLE active_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
cancelled boolean NOT NULL DEFAULT false,
-- 只约束「未取消」的预订:取消后该时间段可重新预订
CONSTRAINT ex_active_bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&)
WHERE (NOT cancelled)
);INSERT INTO active_bookings (room_id, during) VALUES (1, '[2026-09-24 09:00, 2026-09-24 10:00)');
-- 错误!与有效预订重叠
INSERT INTO active_bookings (room_id, during) VALUES (1, '[2026-09-24 09:30, 2026-09-24 09:45)');ERROR: conflicting key value violates exclusion constraint "ex_active_bookings_no_overlap"
DETAIL: Key (room_id, during)=(1, ["2026-09-24 09:30:00+08","2026-09-24 09:45:00+08")) conflicts with existing key (room_id, during)=(1, ["2026-09-24 09:00:00+08","2026-09-24 10:00:00+08")).-- 把冲突的那条取消后,就可以新建重叠的预订
UPDATE active_bookings SET cancelled = true WHERE during = '[2026-09-24 09:00, 2026-09-24 10:00)';
INSERT INTO active_bookings (room_id, during) VALUES (1, '[2026-09-24 09:30, 2026-09-24 09:45)');UPDATE 1
INSERT 0 1注意 WHERE 里引用的列不必出现在冲突条件中(这里是 cancelled),它只用来筛选「哪些行参与约束」。
DEFERRABLE:延迟排他检查
EXCLUDE 是四种支持 DEFERRABLE 的约束之一(另外三种是 UNIQUE / PRIMARY KEY / FOREIGN KEY)。延迟排他检查主要用于批量调整区间——例如整体平移所有排期时,中间状态必然重叠:
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS shifts;
CREATE TABLE shifts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id integer NOT NULL,
during tsrange NOT NULL,
CONSTRAINT ex_shifts_no_overlap
EXCLUDE USING gist (team_id WITH =, during WITH &&)
DEFERRABLE INITIALLY IMMEDIATE
);
INSERT INTO shifts (team_id, during) VALUES
(1, '[2026-09-24 09:00, 2026-09-24 10:00)'),
(1, '[2026-09-24 10:00, 2026-09-24 11:00)'),
(1, '[2026-09-24 11:00, 2026-09-24 12:00)');-- 错误!整体往后平移 30 分钟,逐行更新时中间态会重叠
UPDATE shifts SET during = during + interval '30 minutes' WHERE team_id = 1;ERROR: conflicting key value violates exclusion constraint "ex_shifts_no_overlap"
DETAIL: Key (team_id, during)=(1, ["2026-09-24 10:30:00+08","2026-09-24 11:30:00+08")) conflicts with existing key (team_id, during)=(1, ["2026-09-24 11:00:00+08","2026-09-24 12:00:00+08")).-- 正确:把检查推迟到事务末,此时所有行都已平移完
BEGIN;
SET CONSTRAINTS ex_shifts_no_overlap DEFERRED;
UPDATE shifts SET during = during + interval '30 minutes' WHERE team_id = 1;
COMMIT;BEGIN
SET CONSTRAINTS
UPDATE 3
COMMIT四种组合的对照:
| 声明 | 默认检查 | 事务内需 SET CONSTRAINTS |
|---|---|---|
EXCLUDE ... | 每行立即 | 不可延迟 |
... DEFERRABLE INITIALLY IMMEDIATE | 每行立即 | 需要显式 DEFERRED |
... DEFERRABLE INITIALLY DEFERRED | 事务提交时 | 不需要 |
... NOT DEFERRABLE | 每行立即 | 不可延迟 |
ON CONFLICT 不支持 EXCLUDE(重要限制)
这是一个高频误区。ON CONFLICT 的冲突推断只认 UNIQUE / PRIMARY KEY 的 = 语义,不认 EXCLUDE,哪怕两者冲突条件看起来一样:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE conflict_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
code text NOT NULL,
CONSTRAINT ex_conflict_demo EXCLUDE USING gist (tenant_id WITH =, code WITH =)
);
-- 错误!EXCLUDE 不能被 ON CONFLICT 推断
INSERT INTO conflict_demo (tenant_id, code) VALUES (1, 'A')
ON CONFLICT (tenant_id, code) DO NOTHING;ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification报错信息里虽然写了 "or exclusion constraint",但实际能匹配的排他约束要求其冲突条件全部是 =,且推断语法仍按唯一索引处理。真正稳妥的做法是改用 UNIQUE 约束,或者在应用层先查后写:
-- 正确:改用 UNIQUE 才能配合 ON CONFLICT
CREATE TABLE conflict_demo_ok (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
code text NOT NULL,
CONSTRAINT uq_conflict_demo UNIQUE (tenant_id, code)
);
INSERT INTO conflict_demo_ok (tenant_id, code) VALUES (1, 'A')
ON CONFLICT (tenant_id, code) DO NOTHING;INSERT 0 1即使是「全 = 的 EXCLUDE」也不该为了用 ON CONFLICT 而故意写成 EXCLUDE——那就应该直接用 UNIQUE。
EXCLUDE 底层是索引
EXCLUDE 一定会在背后建一个 GiST(或 GIN)索引,可以用 \d 与 pg_index 观察:
\d room_bookings Table "public.room_bookings"
Column | Type | Nullable | Default
------------+--------------------------+----------+------------------------------
id | bigint | not null | generated always as identity
room_id | integer | not null |
during | tsrange | not null |
owner | text | not null |
created_at | timestamp with time zone | not null | now()
Indexes:
"room_bookings_pkey" PRIMARY KEY, btree (id)
"ex_room_bookings_no_overlap" EXCLUDE USING gist (room_id WITH =, during WITH &&)SELECT c.relname AS index_name,
am.amname AS index_method,
i.indisunique AS is_unique,
pg_get_constraintdef(con.oid) AS constraint_def
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
LEFT JOIN pg_constraint con ON con.conindid = i.indexrelid
WHERE i.indrelid = 'room_bookings'::regclass; index_name | index_method | is_unique | constraint_def
-----------------------------+--------------+-----------+-------------------------------------------------------------
room_bookings_pkey | btree | t | PRIMARY KEY (id)
ex_room_bookings_no_overlap | gist | f | EXCLUDE USING gist (room_id WITH =, during WITH &&)
(2 rows)注意 is_unique = f——排他约束的索引不是唯一索引,它的冲突语义交给约束层处理。
用 pg_constraint 查看排他约束,contype = 'x':
SELECT conname, contype, condeferrable, convalidated, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE conrelid = 'room_bookings'::regclass AND contype = 'x'; conname | contype | condeferrable | convalidated | def
-------------------------------+---------+---------------+--------------+---------------------------------------------
ex_room_bookings_no_overlap | x | f | t | EXCLUDE USING gist (room_id WITH =, during WITH &&)
(1 row)分区表与外部表的限制
分区表:PostgreSQL 11+ 支持在分区表上定义 EXCLUDE,但有一条硬性要求——分区键必须包含在排他条件中,这与 UNIQUE 的规则一致(唯一性/排他性只能在分区内校验):
-- 正确:分区键 room_id 出现在排他条件中
CREATE TABLE bookings_partitioned (
id bigint NOT NULL,
room_id integer NOT NULL,
during tsrange NOT NULL,
CONSTRAINT ex_bookings_part EXCLUDE USING gist (room_id WITH =, during WITH &&)
) PARTITION BY RANGE (room_id);-- 错误!分区键没出现在排他条件里
CREATE TABLE bookings_partitioned_bad (
id bigint NOT NULL,
room_id integer NOT NULL,
during tsrange NOT NULL,
CONSTRAINT ex_bad_part EXCLUDE USING gist (during WITH &&)
) PARTITION BY RANGE (room_id);ERROR: unsupported ON CONFLICT specification
DETAIL: Exclusion constraints cannot be used on partitioned tables...
-- PostgreSQL 12 之前的报错形式:
ERROR: exclusion constraints are not supported on partitioned tables外部表(postgres_fdw):远端表可以定义 EXCLUDE,但本地无法把它「继承」过来;跨实例的排他性无法由数据库保证,需要在应用层或单一写入点解决。
性能:GiST 的插入代价明显高于 B-tree
排他约束的维护代价不低,选型前要有心理预期:
| 对比项 | B-tree(UNIQUE) | GiST(EXCLUDE) |
|---|---|---|
| 插入代价 | 低,页内定位 | 较高,区间索引需维护边界结构 |
| 索引体积 | 小 | 明显更大 |
| 查询加速 | 支持范围、排序 | 只对冲突类操作符(&& 等)有效 |
| 并发写表现 | 好 | 高并发写入下更容易成为瓶颈 |
| 页分裂与膨胀 | 可控 | 更频繁,VACUUM 负担更重 |
因此实践上有两条经验:
- 只有确实需要「不重叠」语义时才用
EXCLUDE,能用UNIQUE的绝不用EXCLUDE(即使两者等价)。 - 高并发写入的排期表要控制单表规模,必要时按时间或资源维度做分表,把 GiST 索引分散开。
-- 观察排他索引的体积与膨胀
SELECT pg_size_pretty(pg_relation_size('ex_room_bookings_no_overlap')) AS index_size;实战示例
场景:为共享办公空间实现一套「工位预订系统」,需求有三条——① 同一工位时间段不可重叠;② 已取消的预订不占用时段;③ 支持整体平移排期(延迟检查)。
第 1 步:准备扩展与表结构
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS desk_bookings, desks;
CREATE TABLE desks (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE,
room text NOT NULL
);
CREATE TABLE desk_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
desk_id integer NOT NULL REFERENCES desks (id),
during tsrange NOT NULL,
member text NOT NULL,
cancelled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
-- 排他约束:同一工位、未取消的预订不允许重叠
CONSTRAINT ex_desk_bookings_no_overlap
EXCLUDE USING gist (desk_id WITH =, during WITH &&)
WHERE (NOT cancelled)
DEFERRABLE INITIALLY IMMEDIATE
);
-- 外键列索引(外键不自动建索引)
CREATE INDEX idx_desk_bookings_desk_id ON desk_bookings (desk_id);CREATE EXTENSION
CREATE TABLE
CREATE TABLE
CREATE INDEX第 2 步:造工位与基础预订数据
INSERT INTO desks (code, room) VALUES
('A-01', 'Room North'),
('A-02', 'Room North'),
('B-01', 'Room South');
-- 三条同工位、互不重叠;同一时段不同工位也允许
INSERT INTO desk_bookings (desk_id, during, member) VALUES
(1, '[2026-09-24 09:00, 2026-09-24 12:00)', 'alice'),
(1, '[2026-09-24 13:00, 2026-09-24 18:00)', 'bob'),
(2, '[2026-09-24 09:00, 2026-09-24 12:00)', 'carol');INSERT 0 3
INSERT 0 3第 3 步:验证冲突检测
-- 与 alice 的时段重叠,报错
INSERT INTO desk_bookings (desk_id, during, member)
VALUES (1, '[2026-09-24 11:00, 2026-09-24 14:00)', 'dave');ERROR: conflicting key value violates exclusion constraint "ex_desk_bookings_no_overlap"
DETAIL: Key (desk_id, during)=(1, ["2026-09-24 11:00:00+08","2026-09-24 14:00:00+08")) conflicts with existing key (desk_id, during)=(1, ["2026-09-24 09:00:00+08","2026-09-24 12:00:00+08")).-- 紧邻但不重叠(半开区间),允许
INSERT INTO desk_bookings (desk_id, during, member)
VALUES (1, '[2026-09-24 12:00, 2026-09-24 13:00)', 'erin');INSERT 0 1-- 另一个工位同时段,互不影响
INSERT INTO desk_bookings (desk_id, during, member)
VALUES (3, '[2026-09-24 09:00, 2026-09-24 12:00)', 'frank');INSERT 0 1第 4 步:验证部分排他(取消后可重订)
-- 取消 alice 的预订
UPDATE desk_bookings SET cancelled = true WHERE member = 'alice' AND desk_id = 1;
-- 现在 09:00-12:00 可以重新预订
INSERT INTO desk_bookings (desk_id, during, member)
VALUES (1, '[2026-09-24 09:00, 2026-09-24 11:00)', 'grace');UPDATE 1
INSERT 0 1第 5 步:验证延迟检查(整体平移)
-- 错误!立即检查下,逐行平移会撞上自己
UPDATE desk_bookings
SET during = during + interval '1 hour'
WHERE desk_id = 1 AND NOT cancelled;ERROR: conflicting key value violates exclusion constraint "ex_desk_bookings_no_overlap"
DETAIL: Key (desk_id, during)=(1, ["2026-09-24 13:00:00+08","2026-09-24 14:00:00+08")) conflicts with existing key (desk_id, during)=(1, ["2026-09-24 13:00:00+08","2026-09-24 18:00:00+08")).-- 正确:事务内延迟到提交时检查
BEGIN;
SET CONSTRAINTS ex_desk_bookings_no_overlap DEFERRED;
UPDATE desk_bookings
SET during = during + interval '1 hour'
WHERE desk_id = 1 AND NOT cancelled;
COMMIT;BEGIN
SET CONSTRAINTS
UPDATE 3
COMMIT第 6 步:结果核对与清理
SELECT desk_id, during, member, cancelled
FROM desk_bookings
ORDER BY desk_id, lower(during); desk_id | during | member | cancelled
---------+----------------------------------------------+---------+-----------
1 | ["2026-09-24 09:00:00+08","2026-09-24 12:00:00+08") | alice | t
1 | ["2026-09-24 10:00:00+08","2026-09-24 12:00:00+08") | grace | f
1 | ["2026-09-24 14:00:00+08","2026-09-24 15:00:00+08") | erin | f
1 | ["2026-09-24 14:00:00+08","2026-09-24 19:00:00+08") | bob | f
2 | ["2026-09-24 09:00:00+08","2026-09-24 12:00:00+08") | carol | f
3 | ["2026-09-24 09:00:00+08","2026-09-24 12:00:00+08") | frank | f
(6 rows)DROP TABLE desk_bookings, desks CASCADE;注意事项
- 混用
=与&&必须先装btree_gist。 否则报data type integer has no default operator class for access method "gist"。
-- 错误!缺少扩展
CREATE TABLE t (room_id integer NOT NULL, during tsrange NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&));
-- 正确
CREATE EXTENSION IF NOT EXISTS btree_gist;EXCLUDE不是唯一约束,ON CONFLICT不能推断它。
-- 错误!there is no unique or exclusion constraint matching the ON CONFLICT specification
INSERT INTO t VALUES (...) ON CONFLICT (a, b) DO NOTHING;- 区间一律用半开区间
[)。 闭区间会让相邻时段被判为重叠,几乎总是错的。
-- 不推荐
during tsrange '[2026-09-24 09:00, 2026-09-24 10:00]'
-- 推荐
during tsrange '[2026-09-24 09:00, 2026-09-24 10:00)'- 数组的
&&是「有交集即冲突」,不是「完全相同才冲突」。按业务意图选择&&还是=。
-- 有交集就冲突
EXCLUDE USING gist (owner_id WITH =, label_ids WITH &&)
-- 完全相同才冲突
EXCLUDE USING gist (owner_id WITH =, label_ids WITH =)- 分区表上的
EXCLUDE必须包含分区键。 与UNIQUE的限制同源。
-- 错误!排他条件中没有分区键
CREATE TABLE t (...) PARTITION BY RANGE (room_id);- GiST 索引的写入代价明显高于 B-tree。 能用
UNIQUE表达的场景不要用EXCLUDE。
-- 等价于 UNIQUE,但更慢、更大,没有理由这样做
EXCLUDE USING gist (tenant_id WITH =, code WITH =)
-- 应该这样
UNIQUE (tenant_id, code)EXCLUDE的索引不是唯一索引,pg_index.indisunique为f,巡检唯一性时不要漏掉contype = 'x'。
SELECT conname, contype FROM pg_constraint WHERE contype IN ('u', 'p', 'x');WHERE里的列不参与冲突判定,它只筛选「哪些行受约束」;列本身也必须存在于表中。
EXCLUDE USING gist (room_id WITH =, during WITH &&) WHERE (NOT cancelled)NULL在排他约束中的行为类似唯一约束:只要有一列为NULL,该行往往不参与冲突判定。因此参与排他的列应统一加NOT NULL。
-- 危险!room_id 可为 NULL 时,该行可能不受约束
room_id integer
-- 安全
room_id integer NOT NULL- 误用
bbox类几何操作符会得到「包含即冲突」而非「重叠即冲突」。用&&(包围盒重叠)而不是@>(包含)。
-- 语义不同,注意区分
EXCLUDE USING gist (room WITH &&) -- 重叠即冲突- 跨实例(
postgres_fdw)无法保证排他性,远端写入绕过了本地约束。
-- 本地约束管不到远端表的直接写入- 删除排他约束会连带删除背后的索引,重建同样要全表校验,大表上耗时不低。
ALTER TABLE room_bookings DROP CONSTRAINT ex_room_bookings_no_overlap;最佳实践
- 需要表达「不重叠」时才用
EXCLUDE,这是它不可替代的价值;能用UNIQUE表达的语义一律用UNIQUE。
CONSTRAINT ex_room_bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&)- 建表脚本开头统一写上
CREATE EXTENSION IF NOT EXISTS btree_gist;,避免部署到新环境时才暴露缺扩展。
CREATE EXTENSION IF NOT EXISTS btree_gist;- 区间类型优先用
tstzrange(带时区),避免跨时区业务的比较歧义。
during tstzrange NOT NULL- 参与排他的列一律
NOT NULL,避免NULL让行「逃出」约束范围。
room_id integer NOT NULL,
during tsrange NOT NULL- 半开区间是默认约定,并在文档/注释里写清楚,避免后续开发误用闭区间。
during tsrange -- 约定 [) 半开区间:相邻时段不算重叠- 软删除/取消场景用
WHERE做部分排他,而不是把已取消的行物理删除。
EXCLUDE USING gist (room_id WITH =, during WITH &&)
WHERE (NOT cancelled)- 需要批量平移/交换区间时声明
DEFERRABLE INITIALLY IMMEDIATE,事务内按需SET CONSTRAINTS ... DEFERRED。
CONSTRAINT ex_shifts_no_overlap
EXCLUDE USING gist (team_id WITH =, during WITH &&)
DEFERRABLE INITIALLY IMMEDIATE- 排他约束参与并发写检查,高并发场景要测写入吞吐,必要时按资源维度分区或分表,缩小单个 GiST 索引。
SELECT pg_size_pretty(pg_relation_size('ex_room_bookings_no_overlap')) AS index_size;- 配合
btree_gist时把等值列放在前面,让 GiST 先按等值缩小范围,再比较区间重叠,查询与插入都更快。
EXCLUDE USING gist (room_id WITH =, during WITH &&) -- 等值列在前- 冲突报错的
DETAIL会给出两对具体值,直接用于排查与业务提示,不要改成模糊的通用错误。
DETAIL: Key (room_id, during)=(1, [...)) conflicts with existing key (room_id, during)=(1, [...)).下一节
继续学习:约束命名与延迟检查