Skip to content

范围类型概述 ​

范围类型(Range Type)是 PostgreSQL 相对独有的能力:它把"一个区间"当作一个值来存储和比较。会议时段、价格带、版本号区间、IP 段这类数据用范围类型表达,比用两个独立字段(start / end)更安全、查询也更简洁。本节先建立整体认知,后续几节再逐个深入。

前置知识

阅读本节前,建议先了解:数组索引与性能

基础概念 ​

范围类型(Range Type) ​

范围类型用一对边界值表示一个连续的区间,例如"从 10:00 到 11:30"。它不是一个普通的复合结构,而是 PostgreSQL 的一等数据类型,拥有自己的操作符、函数与索引支持。

用它替代 start / end 两列的核心价值:

  1. 不会出现半截数据:不可能出现"填了 start 忘了填 end"。
  2. 语义明确:可以表达"包含起点、不包含终点"这类边界约定。
  3. 查询简洁:判断两个时段是否重叠只需一个 &&。
  4. 可以约束:用排除约束(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 日一整天",且相邻区间可以无缝衔接而不重叠。

sql
-- 相邻但不相交的两个区间
SELECT '[2026-09-01,2026-09-02)'::daterange && '[2026-09-02,2026-09-03)'::daterange AS overlap;
-- 结果:false

如果把两端都设成包含([]),相邻区间就会"重叠":

sql
SELECT '[2026-09-01,2026-09-02]'::daterange && '[2026-09-02,2026-09-03]'::daterange AS overlap;
-- 结果:true(两端都含 09-02,因此有交集)

内置范围类型 ​

PostgreSQL 内置了 6 种范围类型,覆盖常见的数值与时间场景。

范围类型子类型典型用途备注
int4rangeinteger整数区间、年龄段会自动规范化为 [)
int8rangebigint大整数区间、ID 段会自动规范化为 [)
numrangenumeric金额区间、价格带不做规范化
tsrangetimestamp本地时间区间无时区
tstzrangetimestamptz带时区的时间区间排期首选
daterangedate日期区间(按天)会自动规范化为 [)

空范围与无界范围 ​

除了普通区间,范围类型还支持两种"特殊值":

sql
-- 空范围:不包含任何值
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[] 变通。

多范围类型表示"多个不连续的区间",每种范围类型都有对应的多范围类型:

范围类型对应的多范围类型
int4rangeint4multirange
int8rangeint8multirange
numrangenummultirange
tsrangetsmultirange
tstzrangetstzmultirange
daterangedatemultirange
sql
-- 多个不连续区间
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)。

sql
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)。本节大量使用这些术语。

语法与用法 ​

范围类型的基本语法骨架:

text
-- 字面量:'[下界,上界)'
'[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', '[)')

建表使用范围类型:

sql
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 个条件,容易漏:

sql
-- 用 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;

问题二:区间包含判断。"这个时刻在营业时间内吗?"

sql
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 约束:

sql
-- 同一个房间的预约时段不允许重叠
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 索引,可用于 &&、@>、<@、<<、>> 等操作符。

sql
-- 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 步:建表并加防重叠约束 ​

sql
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 步:插入正常预约 ​

sql
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 步:验证防重叠约束生效 ​

sql
-- 尝试插入与 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。

sql
-- 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 步:查询某时刻哪些房间空闲 ​

sql
-- 找出 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 步:查询某时段内的所有预约 ​

sql
-- 与 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 拆解区间展示 ​

sql
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 步:清理 ​

sql
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 不含时区信息,容易在夏令时或跨区域时出错。

最佳实践 ​

  1. 时间区间一律用 tstzrange:除非业务明确只关心"本地墙上时间"。
  2. 边界统一用 [):构造时显式写 '[)',避免隐式插值造成的困惑。
  3. 关键场景加排除约束:EXCLUDE USING GIST (key WITH =, period WITH &&) 是防重叠的标准方案。
  4. 为范围列建 GiST 索引:重叠查询几乎都需要索引支撑。
  5. 用 lower / upper 做展示层拆解:不要指望前端直接解析范围字面量。
  6. 给范围列加 NOT NULL 与 CHECK (NOT isempty(...)):让数据始终有意义。
  7. 区间合并统计用 range_agg(PG14+):比手工循环简单得多。
  8. 不要把"时刻点"建模成范围:单点用 timestamptz 更合适,范围是给区间用的。
  9. 范围类型不适合大量离散值:元素多的场景用数组或关系表。

参考链接 ​