PostgreSQL 数据类型深入

深入 PostgreSQL 的高级数据类型。涵盖数组类型的构造、下标与 GIN 索引、范围类型与排他约束、复合类型的定义与展开、枚举类型的排序与演进、hstore 键值存储,以及各类型在索引、约束与查询写法上的最佳实践和常见陷阱。

除了整数、文本、时间戳这些基础类型,PostgreSQL 还内置了一组表达能力极强的高级类型:数组能在一列里存多值,范围类型能自然表达区间与重叠,复合类型让一行可以嵌套另一行,枚举约束取值集合,hstore 提供轻量键值存储。这些类型如果用对,可以显著简化数据模型、减少关联表;如果用错,则会带来索引失效、查询难写、迁移困难等一系列问题。本文逐一拆解它们的语义、索引支持与适用边界。

核心认知:高级类型不是「炫技」,而是用更贴近领域的方式表达数据。判断标准只有一个——它能否被索引、被约束、被清晰查询。


一、数组类型

1.1 定义与构造

CREATE TABLE articles (
    id    serial PRIMARY KEY,
    title text,
    tags  text[]          -- 文本数组
);

-- 字面量构造
INSERT INTO articles (title, tags)
VALUES ('PostgreSQL 入门', ARRAY['postgres', 'database', 'sql']);

-- 等价的花括号写法
INSERT INTO articles (title, tags)
VALUES ('索引原理', '{"postgres","index","btree"}');

-- 从查询构造
INSERT INTO articles (title, tags)
SELECT '汇总', array_agg(t) FROM unnest(ARRAY['a','b','c']) t;

1.2 下标与切片

PostgreSQL 数组下标从 1 开始(不是 0):

SELECT tags[1] FROM articles;            -- 第一个元素
SELECT tags[1:2] FROM articles;          -- 切片,返回子数组
SELECT array_length(tags, 1) FROM articles;  -- 长度
SELECT cardinality(tags) FROM articles;      -- 元素总数

1.3 数组查询操作符

操作符含义示例
@>包含tags @> ARRAY['sql']
<@被包含ARRAY['sql'] <@ tags
&&有交集tags && ARRAY['sql','nosql']
=相等tags = ARRAY['a','b']
||拼接tags || ARRAY['new']
-- 查找包含 sql 标签的文章
SELECT title FROM articles WHERE tags @> ARRAY['sql'];

-- 查找含任一标签的文章
SELECT title FROM articles WHERE tags && ARRAY['index', 'btree'];

-- 展开数组为多行
SELECT id, unnest(tags) AS tag FROM articles;

-- 在数组中查找某值的位置
SELECT array_position(tags, 'sql') FROM articles;

1.4 数组索引:GIN 是关键

普通 B-tree 索引对数组只能做整体比较,无法加速 @>。必须用 GIN:

CREATE INDEX idx_articles_tags ON articles USING gin (tags);

-- 现在 @> 与 && 都能走索引
EXPLAIN (ANALYZE)
SELECT title FROM articles WHERE tags @> ARRAY['sql'];

1.5 数组 vs 关联表

维度数组列关联表
查询单值GIN 索引可加速天然索引
元素约束无外键有外键
元素元数据无法附加可加列
顺序保留需排序列
更新单个元素需重写整行精确更新
适用标签、小集合、只读多值有元数据、需引用完整性

经验法则:元素是简单标量、集合小、几乎不单独更新,用数组;元素是实体、需要外键或元数据,用关联表。

1.6 数组的坑

-- 坑 1:数组是可变长度,无法声明「最多 5 个」
-- 需要约束时用 CHECK
ALTER TABLE articles ADD CONSTRAINT tags_max5 CHECK (cardinality(tags) <= 5);

-- 坑 2:NULL 元素与 NULL 数组不同
SELECT ARRAY[1, NULL, 3];        -- 含 NULL 元素
SELECT NULL::int[];              -- 整个数组为 NULL

-- 坑 3:多维数组是矩形的,不能锯齿
SELECT ARRAY[[1,2],[3,4]];       -- 合法,2x2
-- SELECT ARRAY[[1,2],[3]];      -- 非法,维度不一致

二、范围类型

2.1 内置范围类型

int4range   整数区间
int8range   大整数区间
numrange    数值区间
tsrange     无时区时间戳区间
tstzrange   带时区时间戳区间
daterange   日期区间

2.2 构造与语义

CREATE TABLE room_bookings (
    id     serial PRIMARY KEY,
    room   text,
    period tstzrange,
    EXCLUDE USING gist (room WITH =, period WITH &&)   -- 同房间时间不可重叠
);

-- 左闭右开区间
INSERT INTO room_bookings (room, period)
VALUES ('A101', '[2026-10-01 09:00, 2026-10-01 11:00)');

-- 尝试插入重叠区间 → 被排他约束拒绝
INSERT INTO room_bookings (room, period)
VALUES ('A101', '[2026-10-01 10:00, 2026-10-01 12:00)');
-- ERROR: conflicting key value violates exclusion constraint

2.3 范围操作符

操作符含义
&&重叠
@>包含元素或子范围
<@被包含
`--`
<<严格在左侧
>>严格在右侧
-- 查找某时刻被占用的房间
SELECT room FROM room_bookings
WHERE period @> '2026-10-01 10:30'::timestamptz;

-- 查找与给定区间重叠的预订
SELECT room FROM room_bookings
WHERE period && '[2026-10-01 10:00, 2026-10-01 11:00)'::tstzrange;

2.4 排他约束是范围的杀手锏

EXCLUDE 约束让「同一资源在时间上不重叠」这类业务规则由数据库强制保证,而不必依赖应用层检查:

-- 复合排他:同房间 + 同类型,时间不可重叠
ALTER TABLE room_bookings ADD CONSTRAINT no_overlap
    EXCLUDE USING gist (room WITH =, period WITH &&);

2.5 范围索引

-- 排他约束会自动创建 GiST 索引
-- 若只需查询加速,也可单独建
CREATE INDEX idx_bookings_period ON room_bookings USING gist (period);

三、复合类型

3.1 定义与使用

CREATE TYPE address AS (
    street  text,
    city    text,
    zip     text,
    country text DEFAULT 'CN'
);

CREATE TABLE customers (
    id      serial PRIMARY KEY,
    name    text,
    addr    address
);

INSERT INTO customers (name, addr)
VALUES ('Acme', ROW('中山路 1 号', '北京', '100000', 'CN')::address);

3.2 访问字段

-- 用点号访问字段,必须加括号
SELECT (addr).city, (addr).zip FROM customers;

-- 展开为多列
SELECT id, name, (addr).* FROM customers;

-- 按字段过滤
SELECT name FROM customers WHERE (addr).city = '北京';

注意:(addr).city 的括号是必须的。写成 addr.city 会被解析为「表 addr 的列 city」,报错。

3.3 复合类型的适用场景

- 强类型的地理坐标、金额、时间区间
- 函数返回多值(避免建临时表)
- 与 PL/pgSQL 配合封装业务对象

3.4 复合类型的限制

-- 复合类型字段无法单独建索引(只能整体或表达式索引)
CREATE INDEX idx_customers_city ON customers (((addr).city));

-- 复合类型无表级约束(外键等)
-- 复合类型不适合频繁单字段更新

3.5 表行类型也是复合类型

-- 任意表的行类型都可以当作复合类型使用
SELECT row_to_json(c) FROM customers c;
SELECT (c).name FROM customers c;

四、枚举类型

4.1 定义与使用

CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'delivered', 'cancelled');

CREATE TABLE orders (
    id     serial PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'pending'
);

INSERT INTO orders (status) VALUES ('paid');
-- INSERT INTO orders (status) VALUES ('unknown');   -- 报错,非法值

4.2 排序语义

枚举的排序按定义顺序,不是字母序:

SELECT * FROM orders ORDER BY status;
-- pending < paid < shipped < delivered < cancelled

这既是优点(业务顺序即定义顺序),也是陷阱(改变定义顺序需重建类型)。

4.3 枚举的演进

-- 添加新值(可指定位置)
ALTER TYPE order_status ADD VALUE 'refunded' BEFORE 'cancelled';

-- 注意:ALTER TYPE ADD VALUE 在旧版本不能在事务内使用
-- 且不能删除枚举值

4.4 枚举 vs 检查约束 vs 查找表

方案取值约束可加元数据修改成本存储
enum强否高(不能删值)4 字节
CHECK中否中原类型
查找表 + 外键强是低外键

建议:取值集合稳定且不需要元数据时用 enum;需要显示名、排序权重、启用开关等元数据时,用查找表。

4.5 枚举的坑

-- 坑:枚举值删除不了,只能重建类型
-- 坑:枚举与文本比较需显式转换
SELECT * FROM orders WHERE status = 'paid';          -- 合法,字面量自动转
SELECT * FROM orders WHERE status::text = 'paid';    -- 也可,但会失去索引

五、hstore 键值存储

5.1 启用与使用

CREATE EXTENSION hstore;

CREATE TABLE products (
    id       serial PRIMARY KEY,
    name     text,
    attrs    hstore
);

INSERT INTO products (name, attrs)
VALUES ('笔记本', 'brand=>ThinkPad, ram=>16GB, cpu=>i7');

5.2 查询操作

-- 取键
SELECT attrs -> 'brand' FROM products;

-- 判断是否含键
SELECT name FROM products WHERE attrs ? 'ram';

-- 判断键值对
SELECT name FROM products WHERE attrs @> 'brand=>ThinkPad';

-- 展开为行
SELECT id, (each(attrs)).key, (each(attrs)).value FROM products;

-- 键列表
SELECT akeys(attrs) FROM products;

5.3 hstore 索引

-- GIN 索引支持 ?、@> 等操作符
CREATE INDEX idx_products_attrs ON products USING gin (attrs);

-- 单键查询也可用表达式索引
CREATE INDEX idx_products_brand ON products ((attrs -> 'brand'));

5.4 hstore vs jsonb

维度hstorejsonb
值类型仅文本任意 JSON
嵌套不支持支持
索引GIN(无路径操作符类)GIN(jsonb_path_ops)
大小更小稍大
维护状态稳定但发展缓慢主力方向

结论:新项目一律优先 jsonb;只有当值全是简单字符串、对体积极度敏感、且已有 hstore 存量时才用 hstore。

5.5 hstore 的坑

-- 坑:hstore 的值只能是文本,数字需显式转换
SELECT (attrs -> 'ram')::text FROM products;   -- 返回 '16GB'

-- 坑:键不存在时返回 NULL,不会报错
SELECT attrs -> 'nonexistent' FROM products;   -- NULL

六、选型与性能对比

6.1 各类型的索引支持

类型默认索引推荐索引支持操作符
数组B-tree(整体)GIN@>、&&
范围B-tree(整体)GiST&&、@>
复合表达式索引B-tree 表达式字段等值
枚举B-treeB-tree=、排序
hstoreB-tree(整体)GIN?、@>

6.2 存储开销

-- 查看列的平均宽度
SELECT attname, avg_width
FROM pg_stats
WHERE tablename = 'articles' AND attname = 'tags';

数组、hstore 都会带来变长存储与 TOAST 溢写。当单行超过约 2KB,PostgreSQL 会压缩并可能外存到 TOAST 表,读取时需额外 IO。

6.3 查询写法对照

-- 数组包含
SELECT * FROM articles WHERE tags @> ARRAY['sql'];

-- 范围重叠
SELECT * FROM room_bookings WHERE period && '[2026-10-01, 2026-10-02)'::tstzrange;

-- 复合字段
SELECT * FROM customers WHERE (addr).city = '北京';

-- 枚举过滤
SELECT * FROM orders WHERE status = 'paid';

-- hstore 键存在
SELECT * FROM products WHERE attrs ? 'ram';

6.4 迁移与兼容

-- 数组转关联表(用 unnest 展开)
CREATE TABLE article_tags AS
SELECT id AS article_id, unnest(tags) AS tag FROM articles;

-- hstore 转 jsonb
ALTER TABLE products
    ALTER COLUMN attrs TYPE jsonb USING hstore_to_jsonb(attrs);

常见问题(FAQ)

数组和 jsonb 数组的选型

如果元素是同类标量、需要 GIN 索引与 @> 查询,用原生数组,它更紧凑、类型更强。如果元素是异构对象、需要嵌套与路径查询,用 jsonb。原生数组能做的 jsonb 都能做,但反过来不成立。

范围类型能否存空区间

可以。empty 是一个特殊范围值,表示不含任何元素的区间,'empty'::int4range。它与任何范围都不重叠,常用于表示「无有效期」这类语义。

枚举能否删除某个值

不能。PostgreSQL 不支持 ALTER TYPE ... DROP VALUE。如果确实需要删除,只能新建类型、转换列、删除旧类型,这在生产上代价不小。因此枚举应只用于真正稳定的取值集合。

复合类型字段能否建索引

可以,但必须用表达式索引,如 CREATE INDEX ON t (((col).field))。复合类型本身只能整体索引,单独字段索引需要提取表达式。

hstore 和 jsonb 的查询性能对比

对于简单的「键存在」查询,hstore 的 GIN 索引通常略快且更小,因为它结构更简单。但对于嵌套、数组、数值比较等复杂查询,jsonb 完胜且功能更全。除非有明确理由,新项目选 jsonb。


相关阅读

延伸阅读


完整示例(一键复制)

-- ========== 1. 数组类型 ==========
CREATE TABLE articles (
    id    serial PRIMARY KEY,
    title text,
    tags  text[]
);
CREATE INDEX idx_articles_tags ON articles USING gin (tags);

INSERT INTO articles (title, tags) VALUES
    ('PostgreSQL 入门', ARRAY['postgres', 'database', 'sql']),
    ('索引原理', '{"postgres","index","btree"}');

SELECT title FROM articles WHERE tags @> ARRAY['sql'];
SELECT title FROM articles WHERE tags && ARRAY['index', 'btree'];
SELECT id, unnest(tags) AS tag FROM articles;

-- ========== 2. 范围类型 + 排他约束 ==========
CREATE TABLE room_bookings (
    id     serial PRIMARY KEY,
    room   text,
    period tstzrange,
    EXCLUDE USING gist (room WITH =, period WITH &&)
);

INSERT INTO room_bookings (room, period)
VALUES ('A101', '[2026-10-01 09:00, 2026-10-01 11:00)');

SELECT room FROM room_bookings
WHERE period @> '2026-10-01 10:30'::timestamptz;

-- ========== 3. 复合类型 ==========
CREATE TYPE address AS (
    street  text,
    city    text,
    zip     text,
    country text DEFAULT 'CN'
);

CREATE TABLE customers (
    id   serial PRIMARY KEY,
    name text,
    addr address
);

INSERT INTO customers (name, addr)
VALUES ('Acme', ROW('中山路 1 号', '北京', '100000', 'CN')::address);

SELECT name, (addr).city, (addr).zip FROM customers;
CREATE INDEX idx_customers_city ON customers (((addr).city));

-- ========== 4. 枚举类型 ==========
CREATE TYPE order_status AS ENUM
    ('pending', 'paid', 'shipped', 'delivered', 'cancelled');

CREATE TABLE orders (
    id     serial PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'pending'
);

INSERT INTO orders (status) VALUES ('paid');
SELECT * FROM orders ORDER BY status;
ALTER TYPE order_status ADD VALUE 'refunded' BEFORE 'cancelled';

-- ========== 5. hstore ==========
CREATE EXTENSION IF NOT EXISTS hstore;

CREATE TABLE products (
    id    serial PRIMARY KEY,
    name  text,
    attrs hstore
);
CREATE INDEX idx_products_attrs ON products USING gin (attrs);

INSERT INTO products (name, attrs)
VALUES ('笔记本', 'brand=>ThinkPad, ram=>16GB, cpu=>i7');

SELECT name FROM products WHERE attrs ? 'ram';
SELECT name FROM products WHERE attrs @> 'brand=>ThinkPad';
SELECT id, (each(attrs)).key, (each(attrs)).value FROM products;

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 时序数据工作负载
  2. PostgreSQL 大版本升级
  3. PostgreSQL PostGIS 地理空间