
PostgreSQL 中那些 MySQL 没有的数据类型怎么玩
很多人从 MySQL 切到 PostgreSQL,第一反应是:语法差不多,SELECT、JOIN、索引也差不多,应该很快就能上手。然后某天他打开 PostgreSQL 的类型文档,看见了数组、范围、多范围、复合类型、域、inet、tsvector 和一串几何类型,感觉像误入了数据库的后厨。
这些东西到底有什么用?难道只是 PostgreSQL 把「一列只能放一个值」这条规矩也给折腾没了?
先把标题里的话说严谨一点:下面有些类型是 MySQL 没有对应物,有些是 PostgreSQL 把同一个问题做得更深入,还有些只是 MySQL 也有、但使用方式完全不同。 JSON、ENUM 和空间类型不能算 PostgreSQL 独占。比较它们的目的,也不是给数据库排座次,而是看看一个类型系统能替应用代码承担多少约束和查询工作。
本文以 PostgreSQL 18 为准。完整清单可以参考 PostgreSQL 18 数据类型文档;版本升级后,别把这篇文章当成正在运行实例的最终说明书。
一、先建立一张地图
PostgreSQL 18 的常用内置类型,大致可以按下面几类理解:
| 类别 | 代表类型 | 它替你表达了什么 |
|---|---|---|
| 数值与时间 | numeric、interval、timestamptz | 精确金额、时间长度、带时区的时间点 |
| 半结构化 | jsonb、xml | 可查询的文档和 XML 数据 |
| 容器 | 数组、复合类型 | 一列中的有序集合,或一个结构化值 |
| 约束与集合 | enum、domain、Range、Multirange | 允许的值、可复用约束、连续区间 |
| 网络与标识 | inet、cidr、macaddr、uuid | IP、网段、硬件地址、全局标识 |
| 检索与计算 | tsvector、tsquery、bit | 全文检索文档、检索表达式、位掩码 |
| 几何 | point、box、circle、polygon | 平面上的几何对象 |
这张表里最重要的词不是「稀奇」,而是语义。如果一列存的是 IP,就让数据库知道它是 IP;如果一列表示一段营业时间,就别把两个时间戳塞进 JSON 再让每个应用各自解释。
二、jsonb:不是把数据库变成 MongoDB
MySQL 也有 JSON,所以它不属于「MySQL 没有」的类型。但 PostgreSQL 的 jsonb 很值得单独说:它不是原样保存 JSON 文本,而是解析后以二进制形式保存,适合索引和查询;代价是会丢掉空白、对象键顺序和重复键。
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}'
);
INSERT INTO orders (customer_id, attributes)
VALUES (42, '{"channel":"mobile", "coupon":"new-user"}');
SELECT id
FROM orders
WHERE attributes @> '{"channel":"mobile"}';@> 是包含判断:左边的 jsonb 是否包含右边给出的结构。需要按键查询时,可以建立 GIN 索引:
CREATE INDEX orders_attributes_gin
ON orders USING gin (attributes);我的建议是:稳定、需要关联、排序或单独约束的字段,仍然建成普通列;变化频繁但结构仍有意义的附加属性,再放进 jsonb。 把整张业务表压成一个 JSON,短期少写几行 ALTER TABLE,长期却会失去类型检查、列级统计和清晰的约束边界。JSON 是逃生门,不是地基。
三、数组:一列存多个值,但不是让表格变成 Excel
PostgreSQL 原生支持数组,而且数组可以是多维的:
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
matrix integer[][]
);
INSERT INTO articles (title, tags)
VALUES ('PostgreSQL 类型漫游', ARRAY['postgresql', 'database']);
SELECT title
FROM articles
WHERE 'postgresql' = ANY (tags);ANY、ALL、unnest、数组切片和 array_position 让它不只是「一串逗号分隔的字符串」。如果需要判断数组是否包含某些元素,也可以使用 @>,再配合 GIN 索引。
但数组有一条很硬的边界:数组适合属性集合,不适合关系。 用户的多个地址、订单的多个商品、需要独立更新和关联的标签,应该是子表;文章的少量标签、设备支持的协议列表、固定维度的坐标,才是数组的舒适区。数组不是把一对多关系偷偷塞进一列的许可证。
四、enum、domain 与复合类型:把规则放回类型系统
enum:稳定且确实有限的集合
MySQL 也有 ENUM,而 PostgreSQL 的枚举通常通过独立类型定义:
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'cancelled');
CREATE TABLE payments (
order_id bigint PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending'
);枚举值有顺序,默认按声明顺序比较。它适合状态很稳定、值很少的概念。状态经常新增、需要后台配置或携带描述时,我更倾向于使用字典表;枚举不是免费的配置中心,修改类型也需要认真安排迁移。
domain:给既有类型套一个可复用约束
domain 不是新存储格式,而是「某种基础类型 + 默认值或检查约束」:
CREATE DOMAIN positive_money AS numeric(12, 2)
CHECK (VALUE >= 0);
CREATE TABLE products (
price positive_money NOT NULL
);如果多个表都要表达「非负金额」「合法的中国手机号」或「非空短文本」,域可以让约束集中定义。它比每张表复制一份 CHECK 更不容易漂移。
复合类型:一行记录也可以成为一个值
CREATE TYPE address AS (
city text,
detail text,
postal_code text
);
CREATE TABLE users (
id bigint PRIMARY KEY,
home_address address
);复合类型可以作为列类型、函数参数和返回值。它在数据库函数、视图和领域模型里很方便,但对 ORM、部分客户端驱动和迁移工具不如普通列友好。能用普通列讲清楚的结构,不必为了复合类型而复合。
五、Range 和 Multirange:时间段终于不必拆成两个孤儿字段
这是 PostgreSQL 最有辨识度的一组能力。Range 表示一个区间,例如 [2026-08-22 10:00, 2026-08-22 11:00);Multirange 则表示多个不相交区间的集合。
内置类型包括:
| Range | Multirange | 常见含义 |
|---|---|---|
int4range | int4multirange | 整数编号区间 |
int8range | int8multirange | 大整数区间 |
numrange | nummultirange | 数值区间 |
tsrange | tsmultirange | 不带时区的时间区间 |
tstzrange | tstzmultirange | 带时区的时间区间 |
daterange | datemultirange | 日期区间 |
日期预约可以这样写:
CREATE TABLE bookings (
room_id bigint NOT NULL,
during tstzrange NOT NULL,
EXCLUDE USING gist (
room_id WITH =,
during WITH &&
)
);
INSERT INTO bookings
VALUES (7, '[2026-08-22 10:00+08, 2026-08-22 11:00+08)');&& 表示两个区间有重叠,EXCLUDE 约束则保证同一个房间不能出现重叠预约。这里的关键不是少写了两个字段,而是把「区间不能重叠」变成数据库可以拒绝的事实。
Range 的上下界可以是包含、排除或无限边界。离散类型如日期还有规范化行为:[2026-08-22, 2026-08-23) 表示一天,右边界通常用排除形式,做连续时间计算时尤其不容易出现「23:59:59 到底算不算」的争论。
如果一个员工每周有多段可用时间,Multirange 可以把这些段作为一个值保存;如果需要对每一段分别查询、排序或记录来源,仍然应该建明细表。类型能表达集合,不代表明细表就失业了。
六、网络地址:别再把 IP 当字符串
inet 表示主机地址或带掩码的网络地址,cidr 表示网络;它们支持 IPv4 和 IPv6,并且能做包含判断:
CREATE TABLE access_logs (
client_ip inet NOT NULL,
allowed_network cidr
);
SELECT client_ip
FROM access_logs
WHERE client_ip <<= '10.0.0.0/8'::cidr;<<= 表示左侧地址或网络被右侧网络包含。inet 和 cidr 还提供掩码长度、地址族、网络地址等运算。这样做的好处很朴素:数据库知道 10.0.0.2 是地址,不会把 10.0.0.100 错排在 10.0.0.20 前面,也不需要应用层手写 CIDR 判断。
macaddr 和 macaddr8 则用于 MAC 地址。它们的使用频率没有 inet 高,但在网络设备、资产管理和 DHCP 记录中比 varchar(17) 更诚实。
七、uuid:标识符不必都是自增整数
UUID 也不是 PostgreSQL 独有类型,但 PostgreSQL 18 已经内置了 uuidv7(),可以生成带时间排序特征的 UUID:
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
happened_at timestamptz NOT NULL DEFAULT now(),
payload jsonb NOT NULL DEFAULT '{}'
);UUID 的优势是可以在应用侧生成、跨系统合并时不容易冲突,也不直接暴露「这是第几条数据」。传统随机 UUID v4 写入 B-tree 时可能带来索引局部性问题;UUIDv7 把时间信息放进高位,通常更适合按生成时间逐渐增长的场景,但它仍然不是严格的业务时间戳,也不应拿来替代 created_at。
如果只是单库单表、内部管理系统、自增整数没有隐私和扩展问题,我不会为了显得分布式就把所有主键换成 UUID。类型选择解决的是边界问题,不是架构信仰。
八、tsvector 和 tsquery:全文检索不是 LIKE '%关键词%'
PostgreSQL 内置了全文检索类型。tsvector 是规范化后的文档词项,tsquery 是检索表达式:
CREATE TABLE notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
document tsvector GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED
);
CREATE INDEX notes_document_gin
ON notes USING gin (document);
SELECT id, title
FROM notes
WHERE document @@ plainto_tsquery('simple', 'vacuum');@@ 表示文档匹配查询。PostgreSQL 可以记录词项位置、做词干化、权重排序,并用 GIN 索引加速检索。中文分词则是另一件事:内置配置并不等于中文搜索方案,通常需要合适的分词扩展或外部搜索引擎。
这组类型适合站内搜索、后台文档和日志检索。搜索需求如果已经包括复杂分词、拼写纠错、聚合和海量全文,别因为数据库「也能搜」就拒绝专业搜索系统;数据库内置能力的价值,是让中小规模全文查询少依赖一个额外服务。
九、几何类型:小型平面计算的便宜工具
PostgreSQL 内置 point、line、lseg、box、path、polygon 和 circle:
CREATE TABLE stores (
id bigint PRIMARY KEY,
location point NOT NULL
);
SELECT id
FROM stores
WHERE location <@ circle '<(0,0), 10>';这里的 <@ 表示点位于圆内。它们适合简单的二维平面计算、绘图标注和非常轻量的空间关系。经纬度、投影、距离单位、坐标系一旦复杂起来,应考虑 PostGIS;不要把地球表面粗暴地当成一张无限大的方格纸。
顺便说一句:MySQL 也有空间类型,所以它们不属于 PostgreSQL 独占。但 PostgreSQL 的扩展接口和 GiST 索引让这条路非常自然,Range 的排他约束也正是这种可扩展索引系统的一个漂亮产物。
十、还有一些不那么常用、但确实有用的类型
bit 与 bit varying
bit(n) 是固定长度位串,bit varying(n) 是可变长度位串,适合权限位图、协议字段和需要逐位运算的场景:
SELECT B'1010' & B'1100'; -- 1000权限复杂到需要频繁解释每一位时,位图可能比一串布尔列紧凑;但它的可读性很差,权限名称仍然应该在代码或字典表中有对应关系。
money
money 表示带本地化格式的货币金额,能做一些方便的格式化和运算,但它受 lc_monetary 影响,跨环境输出和多币种业务都容易踩坑。金融金额通常使用带明确精度和舍入规则的 numeric(p, s),而不是看起来很顺手的 money。
xml
xml 能够检查 XML 文档的格式,并提供 XML 相关函数。它适合确实要保存 XML 的接口交换、配置和遗留系统数据;新项目若没有 XML 生态约束,不必为了「类型齐全」而选它。
pg_lsn 与系统类型
pg_lsn 表示 PostgreSQL WAL(预写式日志,Write-Ahead Log)中的日志序列号,适合复制、备份和恢复工具记录进度。pg_snapshot、OID 等类型也能在系统目录和诊断工具里见到,但它们不是普通业务表的万能主键。
另外还有伪类型,例如 record、anyelement 和 void。它们主要用于声明函数参数和返回值,不能像普通列类型一样保存业务数据。看到一个类型出现在文档里,不代表应该把它放进 CREATE TABLE。
十一、从 MySQL 迁移时,最容易想错的几件事
serial 不是魔法类型
PostgreSQL 的 serial、bigserial 是创建序列、默认值和整数列的一组便利语法,并不是真正意义上的独立数据类型。新表更推荐使用 SQL 标准的 identity column:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);它的意图比隐式创建序列更清楚,也更适合迁移和权限审查。
timestamp 不等于「带时区」
PostgreSQL 的 timestamp without time zone 不保存时区;timestamptz 保存的是一个绝对时间点,输出时按当前会话时区显示。它不是把原始时区名字永远存下来。
SET TIME ZONE 'Asia/Shanghai';
SELECT '2026-08-22 10:00+08'::timestamptz;记录事件发生的时刻通常用 timestamptz;记录「每天 9 点营业」这种与地点和规则相关的墙上时间,则要单独设计时区和重复规则。时间类型选错,往往比少建一个索引更难补救。
不要把 PostgreSQL 特性当 ORM 特性
数组、复合类型、Range 和 Multirange 在 SQL 里很好看,但 ORM、迁移工具、BI 软件和客户端驱动可能只支持其中一部分。真正采用之前,至少验证四件事:
- 驱动能否正确读写该类型,而不是返回一串未经解析的文本;
- ORM 是否支持迁移、查询条件和变更检测;
- 备份恢复、跨语言服务和 CDC(Change Data Capture,变更数据捕获)是否能处理它;
- 团队是否愿意长期维护这条不那么主流的路径。
数据库不是孤岛。一个类型在 PostgreSQL 里很优雅,出了数据库边界之后可能就要靠你写适配器。
十二、我会怎么选
我会先把不变量放进类型系统:金额用 numeric,时间点用 timestamptz,IP 用 inet,全局标识用 uuid,有限状态用 enum 或字典表,重复约束用 domain,不重叠区间用 Range 加 EXCLUDE。这些选择不是为了显摆 PostgreSQL,而是因为错误数据一旦写入,后面每个服务都要重复防守。
然后才考虑容器:少量有序属性用数组,结构不稳定的附加字段用 jsonb,数据库函数之间传递结构用复合类型,全文检索用 tsvector。如果一个类型让迁移、驱动和分析链路都变得痛苦,那就算它再漂亮,也应该退回普通列和关联表。
说到底,PostgreSQL 的厉害之处不是「类型比 MySQL 多」,而是它愿意把更多领域概念变成可组合、可索引、可约束的数据库对象。好的类型让错误在入口处失败;坏的类型只是把复杂度推迟到所有调用者身上。
我喜欢这些类型,但不会见到新玩具就往表里塞。先问数据到底是什么,再选最能诚实描述它的类型。数据库知道得越多,应用需要猜的就越少;但数据库承担得越多,迁移和生态的成本也越真实。这个取舍,才是 PostgreSQL 类型系统真正好玩的地方。
