范围操作符
范围类型的价值一半在存储,另一半在操作符。&& 能判断重叠、@> 能判断包含、-|- 能判断相邻,这三个操作符几乎覆盖了所有区间类业务需求。本节系统梳理全部范围操作符,并说明哪些能走 GiST 索引。
前置知识
阅读本节前,建议先了解:日期时间范围(TSRANGE / TSTZRANGE / DATERANGE)
基础概念
操作符总表
| 操作符 | 含义 | 示例 | 结果 |
|---|---|---|---|
= | 相等 | '[1,3)'::int4range = '[1,3)'::int4range | true |
<> | 不等 | '[1,3)'::int4range <> '[2,5)'::int4range | true |
< | 严格小于(下界比较) | '[1,3)'::int4range < '[2,5)'::int4range | true |
<= | 小于等于 | '[1,3)'::int4range <= '[1,3)'::int4range | true |
> | 严格大于 | '[2,5)'::int4range > '[1,3)'::int4range | true |
>= | 大于等于 | '[2,5)'::int4range >= '[2,5)'::int4range | true |
@> | 包含元素或范围 | '[1,5)'::int4range @> 3 | true |
<@ | 被包含 | 3 <@ '[1,5)'::int4range | true |
&& | 重叠(有交集) | '[1,5)'::int4range && '[4,8)'::int4range | true |
<< | 严格位于左侧 | '[1,3)'::int4range << '[4,8)'::int4range | true |
>> | 严格位于右侧 | '[4,8)'::int4range >> '[1,3)'::int4range | true |
&< | 不延伸到右侧之外 | '[1,3)'::int4range &< '[3,8)'::int4range | true |
&> | 不延伸到左侧之外 | '[3,8)'::int4range &> '[1,3)'::int4range | true |
| `- | -` | 相邻(刚好接触) | `'[1,3)'::int4range - |
+ | 并集(可能失败) | '[1,3)'::int4range + '[2,8)'::int4range | [1,8) |
* | 交集 | '[1,5)'::int4range * '[4,8)'::int4range | [4,5) |
- | 差集 | '[1,8)'::int4range - '[4,5)'::int4range | [1,4) |
三类操作符的定位
比较类(= < <= > >= <>):用于 ORDER BY、DISTINCT、唯一约束。比较是按"下界优先、上界其次"进行的。
位置关系类(@> <@ && << >> &< &> -|-):用于查询与约束,是范围类型的核心价值。这类操作符在符号形式上模仿了集合论:@> 读作 "contains",&& 读作 "overlaps"。
集合运算类(+ * -):把范围当作点集做并、交、差。
集合论视角(把范围看作点的集合):
[1,5) @> 3 3 ∈ [1,5) 元素属于集合
A @> B B ⊆ A B 是 A 的子集
A && B A ∩ B ≠ ∅ 两个集合有交集
A + B A ∪ B 并集(要求结果仍是单区间)
A * B A ∩ B 交集(结果打包成范围)
A - B A \ B 差集&& 是所有区间业务的基础
-- 判断两个时段是否冲突(最常见的一行 SQL)
SELECT '[2026-03-01 09:00, 2026-03-01 11:00)'::tsrange
&& '[2026-03-01 10:00, 2026-03-01 12:00)'::tsrange AS conflict; -- true用两个独立字段(start_at / end_at)写同样的逻辑需要四个比较条件:
-- 等价写法(无范围类型时)
SELECT NOT (end_at <= '2026-03-01 10:00' OR start_at >= '2026-03-01 12:00') AS conflict;一个 && 顶四个比较,且不会因为漏掉某个边界条件而写错。
术语提示
包含(Containment)、重叠(Overlap)、相邻(Adjacent)、并集(Union)、交集(Intersection)、差集(Difference)、严格位于左侧(Strictly Left Of)。本节全部操作符都围绕这几组关系展开。
语法与用法
操作符骨架
-- 元素判定
range_or_value @> range_or_value
range_or_value <@ range_or_value
-- 集合关系
range && range -- 重叠
range << range / range >> range -- 严格左右
range &< range / range &> range -- 不越界
range -|- range -- 相邻
-- 集合运算
range + range -- 并集,若结果不连续则报错
range * range -- 交集,返回 range(可能为 empty)
range - range -- 差集,返回 range(可能为 empty)
-- 比较
range = range / range < range / range <= range / ...示例表
DROP TABLE IF EXISTS events;
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
hall TEXT NOT NULL,
span TSTZRANGE NOT NULL, -- 占用时段
capacity INT NOT NULL
);
CREATE INDEX idx_events_span ON events USING GIST (span);
INSERT INTO events (name, hall, span, capacity) VALUES
('晨会', 'A厅', '[2026-04-01 09:00+08, 2026-04-01 10:00+08)', 30),
('产品评审', 'A厅', '[2026-04-01 10:00+08, 2026-04-01 12:00+08)', 20),
('技术分享', 'B厅', '[2026-04-01 10:30+08, 2026-04-01 12:30+08)', 80),
('客户拜访', 'B厅', '[2026-04-01 14:00+08, 2026-04-01 15:30+08)', 10),
('全员大会', 'C厅', '[2026-04-01 13:00+08, 2026-04-01 16:00+08)', 200);
-- INSERT 0 5详细说明
包含:@> 与 <@
-- 1. 时间点是否落在时段内
SELECT count(*) FROM events
WHERE span @> '2026-04-01 10:00+08'::timestamptz; -- 2(产品评审的上界 12:00 不含,晨会的 10:00 含) count
-------
2-- 2. 范围是否包含另一个范围
SELECT '[2026-04-01 09:00,2026-04-01 13:00)'::tstzrange
@> '[2026-04-01 10:00,2026-04-01 12:00)'::tstzrange AS contain; -- true
-- 3. 边界相等时:包含要求被包含者的两端都在内部
SELECT '[2026-04-01 09:00,2026-04-01 13:00)'::tstzrange
@> '[2026-04-01 09:00,2026-04-01 13:00)'::tstzrange AS self_contain; -- true(自身包含自身)
-- 4. 空范围永远不被包含(除了空范围自身)
SELECT '[2026-04-01 09:00,2026-04-01 13:00)'::tstzrange
@> 'empty'::tstzrange AS contain_empty; -- false被包含 <@ 只是 @> 的参数换序:
SELECT '[2026-04-01 10:00,2026-04-01 11:00)'::tstzrange
<@ '[2026-04-01 09:00,2026-04-01 13:00)'::tstzrange AS contained; -- true常见错误
'[1,5)'::int4range @> '[5,9)'::int4range 返回 false,因为 [1,5) 的上界 5 不含,而 [5,9) 的下界 5 含,5 这个点不在 [1,5) 里。判断"是否只是搭上边界"要用 &&(这里也返回 false,因为不重叠)或 -|-(返回 true)。
重叠:&&
-- 找出与指定时段冲突的活动
SELECT name, hall, span
FROM events
WHERE span && '[2026-04-01 10:00+08, 2026-04-01 11:00+08)'::tstzrange
ORDER BY lower(span); name | hall | span
------------+------+-----------------------------------------------
产品评审 | A厅 | ["2026-04-01 10:00:00+08","2026-04-01 12:00:00+08")
技术分享 | B厅 | ["2026-04-01 10:30:00+08","2026-04-01 12:30:00+08")晨会([09:00,10:00))没有出现在结果里——它与查询区间只在 10:00 这一点相接,不共享任何瞬间,&& 返回 false。这正是 [) 写法的价值:晨会 10:00 结束、产品评审 10:00 开始,两者相邻而不冲突,同一会议室可以无缝衔接使用。
如果把查询区间改成 [09:30, 10:30),晨会 就会命中:
SELECT name FROM events
WHERE span && '[2026-04-01 09:30+08, 2026-04-01 10:30+08)'::tstzrange
ORDER BY lower(span); name
------------
晨会
产品评审技术分享 从 10:30 开始,与查询区间上界 10:30 相接,同样不算重叠。
相邻:-|-
SELECT '[2026-04-01 09:00,2026-04-01 10:00)'::tstzrange
-|- '[2026-04-01 10:00,2026-04-01 12:00)'::tstzrange AS adjacent; -- true-|- 与 NOT (a && b) 完全不同:
| 表达式 | 含义 | 两个相距 10 年的区间 |
|---|---|---|
| `a - | - b` | 仅仅"刚好接触" |
NOT (a && b) | 只要不重叠就为真 | true |
a << b OR b << a | 一个完全在另一个左边/右边 | true |
-|- 用于"连续时段检查":校验排班表是否每班无缝衔接、没有空档也没有重叠。
严格左右:<< >> 与 &< &>
SELECT '[1,3)'::int4range << '[4,8)'::int4range AS strictly_left, -- true(1,3 整体在 4 之前)
'[1,3)'::int4range << '[3,8)'::int4range AS touching_left, -- false(3 不严格小于 3)
'[1,3)'::int4range &< '[3,8)'::int4range AS not_right_of; -- true(未延伸到 3 右侧)四者的精确定义:
| 操作符 | 含义 | 边界相等时 |
|---|---|---|
<< | 左操作数的上界 严格小于右操作数的下界 | false |
>> | 左操作数的下界 严格大于右操作数的上界 | false |
&< | 左操作数不向右越过右操作数的左端 | true |
&> | 左操作数不向左越过右操作数的右端 | true |
记忆技巧:<< / >> 是严格的,&< / &> 的 & 表示"允许接触"。
集合运算:+ * -
-- 并集:只有结果仍是单个连续区间时才成功
SELECT '[1,3)'::int4range + '[3,8)'::int4range AS u1, -- [1,8) 相邻可合并
'[1,3)'::int4range + '[5,8)'::int4range AS u2; -- ERROR: result of range union would not be contiguousERROR: result of range union would not be contiguous-- 交集:总是成功,无交集时返回 empty
SELECT '[1,5)'::int4range * '[4,8)'::int4range AS i1, -- [4,5)
'[1,2)'::int4range * '[4,8)'::int4range AS i2; -- empty i1 | i2
---------+-------
[4,5) | empty-- 差集:从左边移除右边的部分,返回仍是单区间
SELECT '[1,8)'::int4range - '[4,5)'::int4range AS d1, -- [1,4) 只保留左侧残留
'[1,8)'::int4range - '[1,2)'::int4range AS d2, -- [2,8) 只保留右侧残留
'[1,8)'::int4range - '[3,5)'::int4range AS d3; -- ERROR: result of range difference would not be contiguousERROR: result of range difference would not be contiguous差集把 [1,8) 从中间挖掉一块后会变成两段 [1,3) 和 [5,8),无法用单区间表示,因此报错。需要保留多段结果时必须用多范围类型:
-- PostgreSQL 14+:多范围支持不连续结果
SELECT multirange(int4range(1,8)) - multirange(int4range(3,5)) AS dm;
-- {[1,3),[5,8)}在 WHERE 中组合使用
-- 场景一:某会议室在某段时间空着(没有任何活动与之重叠)
SELECT h.hall
FROM (VALUES ('A厅'), ('B厅'), ('C厅')) AS h(hall)
WHERE NOT EXISTS (
SELECT 1 FROM events e
WHERE e.hall = h.hall
AND e.span && '[2026-04-01 15:00+08, 2026-04-01 16:00+08)'::tstzrange
); hall
------
A厅-- 场景二:找出每个会议室内部的冲突对(自查数据质量)
SELECT a.hall, a.name AS event_a, b.name AS event_b
FROM events a
JOIN events b ON a.hall = b.hall AND a.id < b.id
WHERE a.span && b.span; hall | event_a | event_b
------+----------+----------
(0 rows)-- 场景三:找到跨越午休时段的安排
SELECT name, span
FROM events
WHERE span && '[2026-04-01 12:00+08, 2026-04-01 13:00+08)'::tstzrange; name | span
------------+-----------------------------------------------
技术分享 | ["2026-04-01 10:30:00+08","2026-04-01 12:30:00+08")只有 技术分享 命中。产品评审 的上界 12:00 与午休下界 12:00 相接,晨会 类的边界情形同理——这意味着只用 && 无法查出"刚好在午休前一秒结束"的安排。若业务上把 12:00 整点结束也算"占用到午休开始",应改为闭区间查询:
-- 用 [] 把 12:00 这个瞬间也纳入左侧
SELECT name FROM events
WHERE span && '[2026-04-01 12:00+08, 2026-04-01 13:00+08]'::tstzrange; name
------------
产品评审
技术分享操作符与索引
| 操作符 | GiST | SP-GiST | B-tree |
|---|---|---|---|
= <> < <= > >= | ✔ | ✔ | ✔(范围有原生 B-tree 操作符类) |
@> <@ && << >> &< &> `- | -` | ✔ | ✔ |
+ * - | ✘ | ✘ | ✘ |
CREATE INDEX idx_events_span_gist ON events USING GIST (span);
-- 验证:&& 查询走 Bitmap Index Scan
EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF)
SELECT * FROM events WHERE span && '[2026-04-01 10:00+08, 2026-04-01 11:00+08)'::tstzrange; QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on events
Recheck Cond: (span && '["2026-04-01 10:00:00+08","2026-04-01 11:00:00+08")'::tstzrange)
-> Bitmap Index Scan on idx_events_span_gist
Index Cond: (span && '["2026-04-01 10:00:00+08","2026-04-01 11:00:00+08")'::tstzrange)操作符与排除约束
排除约束的第二个操作符直接就是范围操作符:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE hall_reservations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
hall TEXT NOT NULL,
span TSTZRANGE NOT NULL,
CONSTRAINT no_hall_overlap
EXCLUDE USING GIST (hall WITH =, span WITH &&) -- 同厅堂 && 重叠 → 冲突
);常用的排除约束搭配:
| 业务要求 | 排除约束写法 |
|---|---|
| 同一资源时段不重叠 | EXCLUDE USING GIST (resource_id WITH =, span WITH &&) |
| 数值区间不重叠 | EXCLUDE USING GIST (band WITH &&) |
| 同资源同类型不重复 | EXCLUDE USING GIST (resource_id WITH =, kind WITH =) |
版本提示
范围操作符(含 -|-)自 PostgreSQL 9.2 引入范围类型时即已提供。而多范围类型(Multirange)及其操作符(例如能返回不连续结果的差集)是 PostgreSQL 14 新增的,PostgreSQL 13 及更早版本无法表示由"多段不连续区间"组成的范围。
实战示例
用 events 表演示一套完整的会场冲突排查流程。
第 1 步:准备数据
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS events;
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
hall TEXT NOT NULL,
span TSTZRANGE NOT NULL,
capacity INT NOT NULL,
CONSTRAINT chk_span_not_empty CHECK (NOT isempty(span)),
CONSTRAINT no_hall_overlap
EXCLUDE USING GIST (hall WITH =, span WITH &&)
);
CREATE INDEX idx_events_span ON events USING GIST (span);第 2 步:插入合法数据(相邻不冲突)
INSERT INTO events (name, hall, span, capacity) VALUES
('晨会', 'A厅', '[2026-04-01 09:00+08, 2026-04-01 10:00+08)', 30),
('产品评审', 'A厅', '[2026-04-01 10:00+08, 2026-04-01 12:00+08)', 20),
('技术分享', 'B厅', '[2026-04-01 10:30+08, 2026-04-01 12:30+08)', 80),
('客户拜访', 'B厅', '[2026-04-01 14:00+08, 2026-04-01 15:30+08)', 10),
('全员大会', 'C厅', '[2026-04-01 13:00+08, 2026-04-01 16:00+08)', 200);
-- INSERT 0 5第 3 步:找出某时刻正在进行的活动
SELECT name, hall
FROM events
WHERE span @> '2026-04-01 10:30+08'::timestamptz
ORDER BY hall; name | hall
------------+------
产品评审 | A厅
技术分享 | B厅第 4 步:查询重叠关系并计算交叠时长
SELECT a.name AS event_a,
b.name AS event_b,
a.hall,
upper(a.span * b.span) - lower(a.span * b.span) AS overlap_duration
FROM events a
JOIN events b ON a.hall = b.hall AND a.id < b.id
WHERE a.span && b.span
ORDER BY a.hall, a.name; event_a | event_b | hall | overlap_duration
---------+---------+------+------------------
(0 rows)同厅堂内没有重叠(被排除约束保证),因此跨厅堂查询:
SELECT a.name AS event_a, b.name AS event_b,
a.hall AS hall_a, b.hall AS hall_b,
upper(a.span * b.span) - lower(a.span * b.span) AS overlap_duration
FROM events a
JOIN events b ON a.id < b.id
WHERE a.hall <> b.hall AND a.span && b.span
ORDER BY overlap_duration DESC; event_a | event_b | hall_a | hall_b | overlap_duration
-----------+-----------+--------+--------+------------------
产品评审 | 技术分享 | A厅 | B厅 | 01:30:00
全员大会 | 客户拜访 | C厅 | B厅 | 01:30:00第 5 步:检查某厅堂的时段连续性
-- 按时间排序,检查相邻两场之间是否刚好衔接(无空档)
SELECT name,
span,
lead(span) OVER (ORDER BY lower(span)) AS next_span,
span -|- lead(span) OVER (ORDER BY lower(span)) AS is_adjacent
FROM events
WHERE hall = 'A厅'
ORDER BY lower(span); name | span | next_span | is_adjacent
------------+---------------------------------------------------+---------------------------------------------------+-------------
晨会 | ["2026-04-01 09:00:00+08","2026-04-01 10:00:00+08") | ["2026-04-01 10:00:00+08","2026-04-01 12:00:00+08") | t
产品评审 | ["2026-04-01 10:00:00+08","2026-04-01 12:00:00+08") | (null) | (null)用窗口函数把相邻两行直接拿出来比对。若某行 is_adjacent 为 f,说明两场之间有空档;若为 t,说明排期无缝衔接。
更直接的写法是用连接 + 显式相邻判断:
SELECT a.name AS prev_event, b.name AS next_event,
a.span -|- b.span AS adjacent
FROM events a
JOIN events b ON a.hall = b.hall AND upper(a.span) = lower(b.span)
WHERE a.hall = 'A厅'; prev_event | next_event | adjacent
------------+------------+----------
晨会 | 产品评审 | t第 6 步:用并集合并可衔接的排期
-- 把 A厅 上午的连续安排合并成一个完整占用段
SELECT range_merge(span) AS merged
FROM events
WHERE hall = 'A厅' AND span << '[2026-04-01 12:00+08,)'::tstzrange; merged
-----------------------------------------------------
["2026-04-01 09:00:00+08","2026-04-01 12:00:00+08")多行合并时用 range_agg(PostgreSQL 14+):
SELECT hall, range_agg(span) AS all_spans
FROM events
GROUP BY hall
ORDER BY hall; hall | all_spans
------+-------------------------------------------------------------------
A厅 | {["2026-04-01 09:00:00+08","2026-04-01 12:00:00+08")}
B厅 | {["2026-04-01 10:30:00+08","2026-04-01 12:30:00+08"),["2026-04-01 14:00:00+08","2026-04-01 15:30:00+08")}
C厅 | {["2026-04-01 13:00:00+08","2026-04-01 16:00:00+08")}第 7 步:验证排除约束拦截冲突
INSERT INTO events (name, hall, span, capacity) VALUES
('临时加塞', 'A厅', '[2026-04-01 10:30+08, 2026-04-01 11:00+08)', 5);ERROR: conflicting key value violates exclusion constraint "no_hall_overlap"
DETAIL: Key (hall, span)=(A厅, ["2026-04-01 10:30:00+08","2026-04-01 11:00:00+08")) conflicts with existing key (hall, span)=(A厅, ["2026-04-01 10:00:00+08","2026-04-01 12:00:00+08")).第 8 步:清理
DROP TABLE IF EXISTS events;注意事项
@>与<@方向极易记反。 记忆法:张开嘴的那个(@>)"吃"下右边的东西。A @> B表示 A 包含 B。&&与-|-语义完全不同。&&是"有共同点",-|-是"只共享边界点"。判定冲突用&&,判定衔接用-|-。+与-可能报错。 结果不再是单区间时返回ERROR: result of range union/difference would not be contiguous,需要多范围或应用层处理。- 空范围参与运算要小心。
'empty'::int4range @> 'empty'::int4range为真,但'[1,5)' @> 'empty'为假。 *无交集时返回empty而不是 NULL。 判断"有没有交集"用&&比(a * b) IS NULL更直接(后者永远不成立)。- 比较操作符(
<>)不等于"位置在前在后"。'[1,3)' < '[3,8)'与'[1,3)' << '[3,8)'可能给出不同结果,前者比下界,后者比整体位置。 - B-tree 索引不能加速
&&。 范围列上的范围查询必须用 GiST 或 SP-GiST。 - GiST 索引查询需要 Recheck。 索引给出的是"可能匹配"的候选集,精确判断由
Recheck Cond完成,这属于 GiST 的正常工作方式。 - 排除约束必须用 GiST,不能用 SP-GiST 或 B-tree。 且配合非范围列(如
TEXT)时需要btree_gist扩展。 - GiST 索引下
&&的实际耗时随匹配行数增长。 若某时段横跨大量记录,索引帮助有限,考虑按时间分区。 - 多范围的操作符与范围不完全对称。 多范围之间可用
@><@&&+*-,但多范围与单个范围的混用需显式转换。 =比较包含边界开闭属性。'[1,5)'::int4range = '(0,6)'::int4range吗?前者规范化为[1,5),后者仍需规范化,规范化后相等才为真。
最佳实践
- 冲突检测统一用
&&。 不要写四个比较条件,出错概率高且无法走索引。 - 时段语义统一
[)。 全库一致的开闭约定能消除"边界时刻算谁"的争论。 - "相邻且无缝"用
-|-,不要用NOT (a && b)。 后者会把无关区间也算进来。 - 需要防重叠就用排除约束,不要只靠应用层。 并发场景下应用层的"先查再插"必然有竞态。
- 范围列一律建 GiST 索引。 即使有排除约束(会自动建索引),普通查询的索引也可能需要独立创建以满足多列查询。
*求交后立刻解析出时长。upper(a*b) - lower(a*b)直接给出重叠时长,比在应用层算更方便。- 差集期望多段结果时改用多范围。 PostgreSQL 14+ 用
multirange()包装,避免-报错。 ORDER BY lower(span)而非ORDER BY span。 按业务起始时间排序更符合直觉。- 大表按时间范围分区。
span上建 GiST 索引在数据量上千万后收益递减,结合PARTITION BY RANGE按年/月切分效果更好。 - 用
EXPLAIN (ANALYZE, BUFFERS)验证操作符是否走索引。 写好的&&查询若退化成 Seq Scan,通常是索引没建或统计信息过期。
下一节
继续学习:范围函数
参考链接
- PostgreSQL 官方文档:Range Functions and Operators
- PostgreSQL 官方文档:Range Types
- PostgreSQL 官方文档:Multirange Types
- PostgreSQL 官方文档:GiST Indexes
- PostgreSQL 官方文档:Constraints