范围类型概述
范围类型(Range Type)是 PostgreSQL 相对独有的能力:它把"一个区间"当作一个值来存储和比较。会议时段、价格带、版本号区间、IP 段这类数据用范围类型表达,比用两个独立字段(start / end)更安全、查询也更简洁。本节先建立整体认知,后续几节再逐个深入。
前置知识
阅读本节前,建议先了解:数组索引与性能
基础概念
范围类型(Range Type)
范围类型用一对边界值表示一个连续的区间,例如"从 10:00 到 11:30"。它不是一个普通的复合结构,而是 PostgreSQL 的一等数据类型,拥有自己的操作符、函数与索引支持。
用它替代 start / end 两列的核心价值:
- 不会出现半截数据:不可能出现"填了
start忘了填end"。 - 语义明确:可以表达"包含起点、不包含终点"这类边界约定。
- 查询简洁:判断两个时段是否重叠只需一个
&&。 - 可以约束:用排除约束(Exclusion Constraint)在数据库层面禁止重叠区间。
开闭区间符号
范围类型的边界有两种状态:包含(Inclusive) 与 排除(Exclusive)。方括号代表包含,圆括号代表排除。
| 写法 | 数学表示 | 含义 |
|---|---|---|
[a,b] | a ≤ x ≤ b | 两端都包含 |
(a,b) | a < x < b | 两端都排除 |
[a,b) | a ≤ x < b | 左包含、右排除(最常用) |
(a,b] | a < x ≤ b | 左排除、右包含 |
其中 [a,b) 是时间区间建模的黄金标准:[2026-09-01, 2026-09-02) 明确表示"9 月 1 日一整天",且相邻区间可以无缝衔接而不重叠。
-- 相邻但不相交的两个区间
SELECT '[2026-09-01,2026-09-02)'::daterange && '[2026-09-02,2026-09-03)'::daterange AS overlap;
-- 结果:false如果把两端都设成包含([]),相邻区间就会"重叠":
SELECT '[2026-09-01,2026-09-02]'::daterange && '[2026-09-02,2026-09-03]'::daterange AS overlap;
-- 结果:true(两端都含 09-02,因此有交集)内置范围类型
PostgreSQL 内置了 6 种范围类型,覆盖常见的数值与时间场景。
| 范围类型 | 子类型 | 典型用途 | 备注 |
|---|---|---|---|
int4range | integer | 整数区间、年龄段 | 会自动规范化为 [) |
int8range | bigint | 大整数区间、ID 段 | 会自动规范化为 [) |
numrange | numeric | 金额区间、价格带 | 不做规范化 |
tsrange | timestamp | 本地时间区间 | 无时区 |
tstzrange | timestamptz | 带时区的时间区间 | 排期首选 |
daterange | date | 日期区间(按天) | 会自动规范化为 [) |
空范围与无界范围
除了普通区间,范围类型还支持两种"特殊值":
-- 空范围:不包含任何值
SELECT 'empty'::int4range AS empty_range;
-- 无界范围:用省略边界表示无穷
SELECT '(,)'::int4range AS unbounded, -- 全部整数
'[3,)'::int4range AS from_3, -- 3 及以上
'(,10)'::int4range AS up_to_10; -- 10 以下无界范围在建模中很实用:"价格 100 以上"就是 [100,)。
多范围类型(Multirange,PG14+)
版本提示
多范围类型(Multirange)是 PostgreSQL 14 新增特性。在 14 之前的版本中,只能存一个连续区间,或者用数组 int4range[] 变通。
多范围类型表示"多个不连续的区间",每种范围类型都有对应的多范围类型:
| 范围类型 | 对应的多范围类型 |
|---|---|
int4range | int4multirange |
int8range | int8multirange |
numrange | nummultirange |
tsrange | tsmultirange |
tstzrange | tstzmultirange |
daterange | datemultirange |
-- 多个不连续区间
SELECT '{[1,3),[5,8)}'::int4multirange AS mr,
cardinality('{[1,3),[5,8)}'::int4multirange) AS parts;
-- 结果:
-- mr | parts
-- -------------+-------
-- {[1,3),[5,8)} | 2规范化(Canonicalization)
离散类型的范围(int4range / int8range / daterange)会自动把边界调整为标准的 [) 形式,这个过程叫规范化(Canonicalization)。
SELECT '[1,5]'::int4range AS a, -- [1,6)
'(1,5)'::int4range AS b; -- [2,5)
-- 结果:
-- a | b
-- ------+------
-- [1,6) | [2,5)原因很直观:对整数而言,[1,5](含 5)等价于 [1,6)(不含 6),后者才是标准形式。numrange 是连续的,无法这样转换,因此不做规范化。
术语提示
范围类型(Range Type)、多范围类型(Multirange)、子类型(Subtype)、开闭区间(Inclusive / Exclusive Bound)、规范化(Canonicalization)、排除约束(Exclusion Constraint)。本节大量使用这些术语。
语法与用法
范围类型的基本语法骨架:
-- 字面量:'[下界,上界)'
'[1,10)'::int4range
'[2026-09-01,2026-09-30)'::daterange
'[2026-09-01 09:00,2026-09-01 11:00)'::tstzrange
-- 无界
'(,100)'::numrange
-- 空
'empty'::int4range
-- 函数构造
int4range(1, 10) -- 默认 [)
int4range(1, 10, '[]') -- 显式边界
daterange('2026-09-01', '2026-09-30', '[)')
tstzrange('2026-09-01 09:00+08', '2026-09-01 11:00+08', '[)')建表使用范围类型:
DROP TABLE IF EXISTS bookings;
CREATE TABLE bookings (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id TEXT NOT NULL,
booker TEXT NOT NULL,
period TSTZRANGE NOT NULL, -- 预约时段
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO bookings (room_id, booker, period) VALUES
('A-101', 'alice', '[2026-09-01 09:00+08, 2026-09-01 11:00+08)'),
('A-101', 'bob', '[2026-09-01 13:00+08, 2026-09-01 15:00+08)'),
('A-102', 'carol', '[2026-09-01 10:00+08, 2026-09-01 12:00+08)');
SELECT id, room_id, booker, period FROM bookings ORDER BY id;
-- 结果:
-- id | room_id | booker | period
-- ----+---------+--------+-----------------------------------------
-- 1 | A-101 | alice | ["2026-09-01 09:00:00+08","2026-09-01 11:00:00+08")
-- 2 | A-101 | bob | ["2026-09-01 13:00:00+08","2026-09-01 15:00:00+08")
-- 3 | A-102 | carol | ["2026-09-01 10:00:00+08","2026-09-01 12:00:00+08")详细说明
范围类型能解决什么问题
问题一:区间重叠检测。用两个独立字段判断重叠需要写 4 个条件,容易漏:
-- 用 start / end 两列判断重叠(繁琐)
SELECT * FROM schedule
WHERE NOT (end_at <= '2026-09-01 10:00' OR start_at >= '2026-09-01 12:00');
-- 用范围类型(一行搞定)
SELECT * FROM bookings
WHERE period && '[2026-09-01 10:00+08, 2026-09-01 12:00+08)'::tstzrange;问题二:区间包含判断。"这个时刻在营业时间内吗?"
SELECT '[2026-09-01 10:30+08, 2026-09-01 10:31+08)'::tstzrange
<@ '[2026-09-01 09:00+08, 2026-09-01 18:00+08)'::tstzrange AS in_business_hours;
-- 结果:true问题三:防止数据本身重叠。用 EXCLUDE USING gist 约束:
-- 同一个房间的预约时段不允许重叠
ALTER TABLE bookings
ADD CONSTRAINT no_overlap_room
EXCLUDE USING GIST (room_id WITH =, period WITH &&);这条约束会在数据库层拒绝任何与既有预约重叠的插入,即使应用层有并发 Bug 也不会写出脏数据。
典型业务场景
| 场景 | 推荐类型 | 关键操作符/函数 |
|---|---|---|
| 会议室/酒店/会议室预约 | tstzrange | &&、排除约束 |
| 营业时间 / 排班表 | tstzrange、daterange | @>、<@ |
| 商品价格带 | numrange、int4range | @>、<< |
| 版本号兼容区间 | int4range | @>、- |
| 促销活动有效期 | tstzrange | &&、@> |
| 年龄段分组 | int4range | @>、<< |
| IP 段归属(补充) | 见 网络类型 | << |
范围与两个独立字段的对比
| 维度 | 范围类型 | start + end 两列 |
|---|---|---|
| 数据完整性 | 天然成对,无半截数据 | 需 CHECK 保证 start < end |
| 重叠查询 | 单个 && | 4 个条件组合 |
| 防止重叠 | EXCLUDE 约束原生支持 | 需触发器或应用层保证 |
| 索引 | GiST / SP-GiST 直接支持 | 需复合 B-tree(仅单一维度) |
| 边界语义 | 明确的开闭区间 | 隐式约定,容易混乱 |
| 绝对值查询 | 需 lower() / upper() 拆解 | 直接可查 |
| 输出格式 | 带引号的区间字面量 | 直观的列 |
索引支持
范围类型支持 GiST 与 SP-GiST 索引,可用于 &&、@>、<@、<<、>> 等操作符。
-- GiST 索引:最通用
CREATE INDEX idx_bookings_period ON bookings USING GIST (period);
-- SP-GiST 索引:适合数据分布稀疏、区间查询为主的场景
CREATE INDEX idx_bookings_period_spgist ON bookings USING SP-GiST (period);生成类索引(Generated Column)+ GiST 也可以组合使用,见后续章节。
范围类型与数组、JSONB 的取舍
| 需求 | 推荐 |
|---|---|
| 连续区间 + 重叠判断 | 范围类型 |
| 多个不连续区间 | 多范围类型(PG14+) |
| 离散的多个取值(如标签) | 数组 |
| 结构不固定的嵌套数据 | JSONB |
| 需要按区间统计、合并 | 范围类型 + range_agg |
实战示例
用"会议室预约系统"串起范围类型的核心用法。
第 1 步:建表并加防重叠约束
DROP TABLE IF EXISTS room_bookings;
CREATE TABLE room_bookings (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id TEXT NOT NULL,
booker TEXT NOT NULL,
period TSTZRANGE NOT NULL,
CONSTRAINT chk_period_not_empty CHECK (NOT isempty(period)),
-- 同一房间的时段不允许重叠
CONSTRAINT no_overlap_room
EXCLUDE USING GIST (room_id WITH =, period WITH &&)
);
CREATE INDEX idx_room_bookings_period ON room_bookings USING GIST (period);第 2 步:插入正常预约
INSERT INTO room_bookings (room_id, booker, period) VALUES
('A-101', 'alice', '[2026-09-01 09:00+08, 2026-09-01 11:00+08)'),
('A-101', 'bob', '[2026-09-01 11:00+08, 2026-09-01 12:00+08)'),
('A-102', 'carol', '[2026-09-01 10:00+08, 2026-09-01 12:00+08)');
-- 相邻时段(11:00 结束,11:00 开始)不算重叠,插入成功
SELECT id, room_id, booker, period FROM room_bookings ORDER BY id;
-- 结果:
-- id | room_id | booker | period
-- ----+---------+--------+-----------------------------------------
-- 1 | A-101 | alice | ["2026-09-01 09:00:00+08","2026-09-01 11:00:00+08")
-- 2 | A-101 | bob | ["2026-09-01 11:00:00+08","2026-09-01 12:00:00+08")
-- 3 | A-102 | carol | ["2026-09-01 10:00:00+08","2026-09-01 12:00:00+08")第 3 步:验证防重叠约束生效
-- 尝试插入与 alice 重叠的时段
INSERT INTO room_bookings (room_id, booker, period)
VALUES ('A-101', 'dave', '[2026-09-01 10:30+08, 2026-09-01 11:30+08)');
-- 结果:
-- ERROR: conflicting key value violates exclusion constraint "no_overlap_room"
-- DETAIL: Key (room_id, period)=(A-101, ["2026-09-01 10:30:00+08","2026-09-01 11:30:00+08")) conflicts with existing key (room_id, period)=(A-101, ["2026-09-01 09:00:00+08","2026-09-01 11:00:00+08")).注意:不同房间的同一时段可以并存,因为约束的第一维是 room_id。
-- A-102 的同一时段可以插入(不同房间)
INSERT INTO room_bookings (room_id, booker, period)
VALUES ('A-102', 'dave', '[2026-09-01 10:30+08, 2026-09-01 11:30+08)')
RETURNING id, room_id, booker;
-- 结果:
-- id | room_id | booker
-- ----+---------+--------
-- 4 | A-102 | dave第 4 步:查询某时刻哪些房间空闲
-- 找出 2026-09-01 10:30 时刻已占用的房间
SELECT DISTINCT room_id
FROM room_bookings
WHERE period @> '2026-09-01 10:30+08'::timestamptz
ORDER BY room_id;
-- 结果:
-- room_id
-- ---------
-- A-101
-- A-102第 5 步:查询某时段内的所有预约
-- 与 09:00~12:00 有交集的全部预约
SELECT room_id, booker, period
FROM room_bookings
WHERE period && '[2026-09-01 09:00+08, 2026-09-01 12:00+08)'::tstzrange
ORDER BY room_id, lower(period);
-- 结果:
-- room_id | booker | period
-- ---------+--------+-----------------------------------------
-- A-101 | alice | ["2026-09-01 09:00:00+08","2026-09-01 11:00:00+08")
-- A-101 | bob | ["2026-09-01 11:00:00+08","2026-09-01 12:00:00+08")
-- A-102 | carol | ["2026-09-01 10:00:00+08","2026-09-01 12:00:00+08")
-- A-102 | dave | ["2026-09-01 10:30:00+08","2026-09-01 11:30:00+08")第 6 步:用 lower / upper 拆解区间展示
SELECT room_id, booker,
lower(period) AS start_at,
upper(period) AS end_at,
upper(period) - lower(period) AS duration
FROM room_bookings
ORDER BY id;
-- 结果:
-- room_id | booker | start_at | end_at | duration
-- ---------+--------+----------------------+----------------------+----------
-- A-101 | alice | 2026-09-01 09:00:00+08 | 2026-09-01 11:00:00+08 | 02:00:00
-- A-101 | bob | 2026-09-01 11:00:00+08 | 2026-09-01 12:00:00+08 | 01:00:00
-- A-102 | carol | 2026-09-01 10:00:00+08 | 2026-09-01 12:00:00+08 | 02:00:00
-- A-102 | dave | 2026-09-01 10:30:00+08 | 2026-09-01 11:30:00+08 | 01:00:00注意 upper(period) - lower(period) 的结果是 interval 类型,可以直接参与运算和格式化。
第 7 步:清理
DROP TABLE room_bookings;注意事项
[a,b)是时间区间的最佳实践:相邻区间不会重叠,也不会留下间隙。- 离散类型会被规范化:
'[1,5]'::int4range会被存成[1,6),输出与输入不同是正常现象。 numrange不做规范化:'[1.0,5.0]'::numrange保持原样,因为实数区间无法这样转换。empty是合法值:任何范围类型都有"空"状态,业务上需要用CHECK (NOT isempty(...))排除。NULL与empty不同:NULL表示"未知",empty表示"已知为空"。- 无界范围不能用
lower/upper直接取值:它们返回NULL,需要先用lower_inf/upper_inf判断。 - 排除约束需要 GiST 或 SP-GiST:B-tree 不支持
&&,创建EXCLUDE时不能选错。 - 排除约束有性能代价:每次写入都要检查索引,写密集表要评估。
- 多范围类型需要 PG14+:低版本只能用范围数组变通。
- 范围类型不能直接建外键:需要引用其他表时仍要拆成普通列。
- 跨时区场景务必用
tstzrange:tsrange不含时区信息,容易在夏令时或跨区域时出错。
最佳实践
- 时间区间一律用
tstzrange:除非业务明确只关心"本地墙上时间"。 - 边界统一用
[):构造时显式写'[)',避免隐式插值造成的困惑。 - 关键场景加排除约束:
EXCLUDE USING GIST (key WITH =, period WITH &&)是防重叠的标准方案。 - 为范围列建 GiST 索引:重叠查询几乎都需要索引支撑。
- 用
lower/upper做展示层拆解:不要指望前端直接解析范围字面量。 - 给范围列加
NOT NULL与CHECK (NOT isempty(...)):让数据始终有意义。 - 区间合并统计用
range_agg(PG14+):比手工循环简单得多。 - 不要把"时刻点"建模成范围:单点用
timestamptz更合适,范围是给区间用的。 - 范围类型不适合大量离散值:元素多的场景用数组或关系表。
下一节
参考链接
- PostgreSQL 官方文档:Range Types
- PostgreSQL 官方文档:Range Functions and Operators
- PostgreSQL 官方文档:Multirange Types
- PostgreSQL 官方文档:Constraints
- PostgreSQL 官方文档:GiST Indexes