你是不是也遇到过这样的场景:两张表 JOIN 的时候,明明看着都是存手机号的字段,PG 却直接甩给你一句operator does not exist: text = bigint;或者是金额字段用 FLOAT 存了两年,突然某一天对账对不上,差了那么几分钱;再或者表里有个 JSON 字段,查询起来慢得像全表扫描,怎么加索引都没用。这些问题的根源,九成以上都出在同一个环节——数据类型选错了,或者根本没搞懂 PG 的数据类型解析规则。
PG 的数据类型体系是它最强大的能力之一,也是新手和老手拉开差距最多的地方。这篇内容不是教科书式的类型罗列,也不是文档翻译,而是我多年在业务里反复踩坑之后沉淀下来的完整解析:数据类型的家族图谱怎么划分、每种类型的适用场景和翻车边界在哪、隐式转换和类型解析到底怎么走、类型选型如何影响索引和查询性能。如果你正在为字段选型、关联查询报错、JSON 存储方案或者数据精度问题头疼,这篇文章值得你花时间读完。
1. PG 类型体系全貌与设计思想
1.1 为什么说 PG 是“强类型中的强类型”
有些数据库允许你在建表时随便写个类型,实际存储和计算全靠隐式转换兜底,怎么都能跑通。但 PG 不一样,它在类型系统上走得非常激进:每个值都有明确的类型,每个操作符和函数都对参数类型有严格要求,类型不匹配时报错绝不妥协。这听起来很死板,但恰恰是这种“死板”保证了数据不会在不知不觉中被截断或变形。
举个例子:SELECT '1' + 1在 PG 里会直接报错,因为'1'是 text 类型,text 没有和 integer 相加的操作符。而在某些数据库里,这种写法会被自动转成数字然后算出 2,看起来方便,实际上掩盖了大量的类型设计隐患。PG 的哲学是:类型不匹配就在写入和计算之前暴露出来,而不是在数据被悄悄改坏之后才后悔。
这种强类型体系带来的直接好处有三个:
- 存储是确定的。同样存一个数字,INT2、INT4、INT8 占用空间不同,精度范围不同,写入时就约束住了。
- 运算行为是可预期的。两个 DATE 相减得到天数,两个 TIMESTAMP 相减得到 interval,两个 VARCHAR 相加就是字符串拼接,规则明确。
- 索引是可以优化的。基数、排序规则、比较逻辑都由类型决定,类型选对,B-tree 索引才能发挥最大效用。
1.2 类型目录 pg_type:先学会查元数据
PG 本身把类型的信息全部记录在系统表pg_type中,这个表就是你理解整个类型系统的“根字典”。我建议每个 DBA 都要学会在这个表上做查询,而不是死记硬背文档。你只需要一条 SQL 就能看到某个类型的关键信息:
SELECT typname, typlen, typcategory, typispreferred FROM pg_type WHERE typname IN ('int4', 'numeric', 'text', 'json', 'jsonb');typlen表示该类型的固定长度(-1 表示变长类型),typcategory表示类型分类,typispreferred表示该分类下是否为首选类型。这个表不仅能帮你理解现有类型,还能让你排查自定义类型、枚举类型、域类型是否真的注册成功了。我遇到过几次自定义类型没生效的情况,后来都是靠查pg_type定位到是搜索路径导致找不到类型,而不是类型真的没建。
1.3 类型的分类体系:Base Type 与 Composite Type
把类型按“底层实现”划分,PG 的类型主要分为几类:基础类型(internal)、复合类型(composite)、域类型(domain)、枚举类型(enum)、伪类型(pseudo)等。平时建表用到的 INT、TEXT、JSONB 都是基础类型;行记录本身也是一个复合类型;域类型则是基于已有类型加约束包装出来的。
理解这些分类有个实际用途:有些函数和操作符只接受特定种类的类型。例如row_to_json()要求输入是复合类型,你给它一个基础类型就报错。再比如CREATE DOMAIN创建的域类型和基础类型在存储上一致,但在函数重载解析时会被当作不同的类型,这会影响后面的类型转换规则。
2. 数值类型解析:整数、小数与浮点的边界
2.1 整数类型:INT2、INT4、INT8 怎么选
PG 提供三种整数类型:SMALLINT(INT2)、INTEGER(INT4)、BIGINT(INT8),分别占用 2、4、8 字节。很多人选型时只看范围是否满足,这当然没错,但我的经验是,还要把“未来的增长”和“索引占用”一起算上。
| 类型 | 字节数 | 范围 | 典型场景 |
|---|---|---|---|
| INT2 | 2 | -32768 到 32767 | 状态码、枚举值存储 |
| INT4 | 4 | -21亿 到 21亿 | 业务主键、计数器 |
| INT8 | 8 | 极大 | 雪花ID、流水号 |
一个很常见的翻车现场:业务初期把主键定义为 INT4,跑了三年之后发现 ID 逼近 21 亿,这时候再做类型升级(ALTER TYPE 重建主键)非常痛苦,表越大越伤。所以主键类字段我一般直接选 INT8,尤其是分布式的趋势下,INT8 几乎成了默认选择。
INT2 的适用场景没那么窄。如果你的字段本质上只是几个固定值,而且数据量非常庞大——比如一张日志表里有上千亿行,状态字段用 INT2 比 INT4 能省不少空间,B-tree 索引也更紧凑。数据量真的大的时候,空间就是成本,也是性能。
2.2 NUMERIC 与 DECIMAL:精确计算的神器与性能陷阱
NUMERIC(别名 DECIMAL)是 PG 的定点精确类型,它能把小数完整精确地存储下来,不会出现浮点误差。金额计算、税率计算、账务核对这些场景,必须用 NUMERIC,这是铁律。
但 NUMERIC 有一个容易忽视的性能问题:它的计算不是 CPU 原生支持的,PG 内部通过 numerics 库以十进制方式逐位计算,运算速度比浮点数慢一个量级。如果你在几亿行的表上对 NUMERIC 字段做频繁的 SUM、AVG,执行时间会明显上升。我的建议是:
- 业务计算和存储用 NUMERIC,账务数据绝对不能动。
- 大规模数值分析、科学计算用 FLOAT8。
- 需要对 NUMERIC 做索引时,先把精度定死,例如
NUMERIC(12,2),不要用无精度的 NUMERIC,否则索引效率不高,且约束不可控。
这里还有个细节:NUMERIC(12,2)的小数位是固定值,但整数位最大是 10 位。很多同学不知道 12 指的是总精度,导致存大金额时突然报错numeric field overflow。换算一下,NUMERIC(12,2)能表示的范围最大是 9999999999.99,超过这个就会炸,设计表时一定要先算业务上限。
2.3 FLOAT4 与 FLOAT8:理解二进制浮点的固有误差
FLOAT4(REAL)和 FLOAT8(DOUBLE PRECISION)是二进制浮点数,遵循 IEEE 754 标准,天生存在表示误差。0.1 + 0.2在 FLOAT8 下结果不是精确的 0.3,这在所有数据库里都一样,PG 当然也不例外。
我自己曾经在统计报表里用过 FLOAT8 存某个比率,结果两个系统算出来的汇总数字总是差最后一个尾数,排查了大半天才想起来是浮点累积误差。从那以后,我对所有“需要对账”的指标口径都改成 NUMERIC。浮点类型只应该用于不需要精确比较的场景,比如温度、坐标、置信度、KPI 趋势值。
另外要注意:FLOAT4 的精度只有大概 6 位十进制有效数字,FLOAT8 是 15 到 17 位。你以为存的是地理坐标精度,实际上可能已经被截断了。经纬度场景建议直接用专门的point类型或者 NUMERIC,而不是 FLOAT4。
3. 文本类型与日期时间的“暗坑”
3.1 TEXT、VARCHAR(n) 与 CHAR(n):别再迷信 VARCHAR 了
PG 的 TEXT 和 VARCHAR 在存储上没有本质区别,它们都是变长类型,性能几乎一致。很多从 MySQL 迁移过来的人习惯用VARCHAR(255),在 PG 里这完全没必要。除非你有强制的长度限制需求,否则直接用 TEXT 反而省心。
你可能会问,TEXT 没有长度限制,会不会导致脏数据?不会,因为长度校验本来就应该在应用层做,数据库只负责存储和查询。真的想限制长度,可以加 CHECK 约束:
ALTER TABLE user_profile ADD CONSTRAINT chk_nickname_len CHECK (char_length(nickname) BETWEEN 2 AND 32);这样的好处是:约束逻辑独立于类型,后面想调整长度不用改表结构,只改约束。如果你用了VARCHAR(32),有一天想改成 64,就必须ALTER TABLE ALTER COLUMN,在大表上会重写整个表。
CHAR(n) 是定长类型,会在尾部补空格,它几乎没有任何使用优势,还容易造成“明明字符内容一样,比较时却因为空格对不上”的困扰。我见过一个老系统用 CHAR(10) 存手机号,结果所有查询都要先 TRIM,纯属给自己找麻烦。在 PG 里,不要用 CHAR(n),除非你在对接某种古老的文本协议。
3.2 日期时间类型:DATE、TIME、TIMESTAMP、TIMESTAMPTZ
日期时间是 PG 里最值得花时间理清的类型组。常用类型有 DATE、TIME、TIMESTAMP、TIMESTAMPTZ、INTERVAL。其中最容易搞混的就是 TIMESTAMP 和 TIMESTAMPTZ。
TIMESTAMP存储的是一个不包含时区信息的时刻值,比如2024-06-01 10:00:00,它本身不表示世界上的某个具体时间点。TIMESTAMPTZ存储的是“时刻”,内部统一按 UTC 保存,展示时按当前会话的时区进行转换。
这就是一个常见的误区:很多人以为 TIMESTAMPTZ 里存的是带时区的字符串,不是的。它内部是 UTC,你换一个SET TIMEZONE TO 'America/New_York',同一个字段 SELECT 出来就变成当地时间了。这其实是好事,它保证了“同一时刻”在全球不同时区的表现一致。
我在业务上有一条硬性建议:业务表中的“创建时间”“更新时间”一律使用 TIMESTAMPTZ,不要用 TIMESTAMP,更不要用 VARCHAR 存时间字符串。用 TIMESTAMPTZ 的好处是,无论应用部署在哪个时区,写进去的都是同一个时刻;前端展示时可以按用户的时区自由切换。以前用 VARCHAR 存字符串的系统,由于格式不统一,在排序、区间筛选、时区转换上全都难受。
3.3 INTERVAL:时间运算的正确姿势
INTERVAL 是 PG 特有的时间间隔类型,两个 TIMESTAMPTZ 相减会得到一个 INTERVAL。它支持年、月、日、时、分、秒等不同单位组合,非常灵活。但要小心“月和日”不是固定长度的概念:1 个月可能是 28 天也可能是 31 天,加 1 个月不等于加 30 天。
在实际业务里,如果你要计算“上个月”“上季度”这种自然时间跨度,用date_trunc()配合 INTERVAL 更可靠:
SELECT date_trunc('month', now()) - INTERVAL '1 month';而如果你要做“24 小时后的过期时间”这种固定秒数计算,直接用时间戳加减86400 * INTERVAL '1 second'或者now() + INTERVAL '24 hours'都行。关键是先理解这个时间跨度是否是自然定义的,别把“固定秒数”和“自然月份”混为一谈。
3.4 时区函数与 AT TIME ZONE 的用法
AT TIME ZONE是 PG 里处理时区转换的利器,但语义有一点绕。timestamp AT TIME ZONE zone会返回 timestamptz,timestamptz AT TIME ZONE zone会返回 timestamp。换句话说,作用在不同类型的输入上,结果方向是相反的。
我提供一个容易记忆的方式:AT TIME ZONE后面跟的时区名,是一个“解释上下文”。你有一个 timestamp 需要指定它是在哪个时区,就转成 timestamptz;你有一个 timestamptz 需要展示成哪个时区的当地时间,就转成 timestamp。写查询时先在草稿上标清楚输入输出类型,能省很多调试时间。
4. 高级类型:JSON、数组、范围与自定义类型
4.1 JSON 与 JSONB:两个同名但性格截然不同的类型
JSON 和 JSONB 是 PG 最常被误解的一对:JSON 只存储原始文本,不做解析,保留原始格式和重复键;JSONB 存储的是解析后的二进制格式,键会自动排序,重复键保留最后一个,查询时不需要重新解析。总之,绝大多数业务场景都应该用 JSONB。
- JSONB 支持 GIN 索引,可以高效查询内部字段。
- JSONB 支持直接更新内部字段,而 JSON 是不支持的(要改就必须把整个字符串读出来改再写回去)。
- JSONB 占用空间通常略大一点,因为需要存键名和解析后的结构,但这个成本换来的查询弹性非常值。
使用 JSONB 的时候,有几个日常容易踩的坑。第一,JSONB 的键排序可能导致你 dump 出来的内容“看起来不一样”,这不影响逻辑,但需要注意对比工具会误报差异。第二,JSONB 在做聚合时没有顺序概念,如果你依赖文档中的键顺序,就别用它。第三,重复键问题:SELECT '{"a": 1, "a": 2}'::jsonb的结果是{"a": 2},PNG 直接丢掉了前一个 a。
在 JSONB 上建索引的常见方式是 GIN:
CREATE INDEX idx_user_attrs ON user_docs USING GIN ((doc -> 'tags'));这里用表达式索引,索引的是某个字段的值。如果你想对整个 doc 做存在性查询(@>操作符),直接建USING GIN (doc)就行。不过要记住,GIN 索引对包含@>、?这类操作符的提速非常明显,但对->提取字段值本身没有加速效果。
4.2 数组类型:不只是“一行多值”
PG 支持从一维到多维的数组类型,比如INT[]、TEXT[][]。数组最直接的用途是存储一个实体的一组属性,比如标签、ID 集合。
数组配合操作符@>和 GIN 索引,可以实现非常高效的“包含查询”:
CREATE INDEX idx_tags_gin ON articles USING GIN (tags); SELECT * FROM articles WHERE tags @> ARRAY['postgresql'];不过要克制使用数组的场景。数组虽然方便,但它破坏了关系模型的第一范式和查询灵活性:你很难按数组里某个元素做 JOIN、聚合或 FK 关联。我通常只在两类场景下用数组:一是纯展示型数据的附属属性,二是避免额外建关联表的简单小列表。凡是数组元素需要参与复杂业务逻辑的,还是老老实实拆成子表。
4.3 范围类型:时间与数值区间的利器
范围类型(range type)是 PG 比较有特色的类型,包括 INT4RANGE、NUMRANGE、TSRANGE、TSTZRANGE、DATERANGE 等。它用来表示一个数学区间,特别适合排课冲突检测、酒店房间预订、价格区间优惠等业务。
比如你要查询“某一段预订记录是否和给定的时间段冲突”,传统的做法是写一堆WHERE start < $end AND end > $start,在 PG 里可以直接用&&(重叠)操作符:
SELECT * FROM reservations WHERE room_id = 12 AND booking_range && tstzrange($1, $2, '[)');这里[)表示包含下界、不包含上界,是时间区间最常用的定义方式。范围类型还能配合 GiST 或 SP-GiST 索引加速重叠查询,效果非常显著。如果你做的业务和“区间重叠”强相关,强烈建议把范围类型纳入方案。
4.4 枚举类型、域类型和自定义类型
枚举用于固定的一组值,比如订单状态:pending、paid、shipped、cancelled,用枚举的好处是数据库层面约束了合法值,比VARCHAR加应用校验更稳。但要小心,枚举一旦投入生产,添加新值需要ALTER TYPE ... ADD VALUE,这在 PG 旧版本上不能在事务内执行,升级枚举容易锁表或者带来部署流程上的麻烦。我的建议是:状态枚举少用数据库枚举,优先用VARCHAR + CHECK或者 INT2 加应用字典;只有状态集极稳定、极短时才用枚举。
域类型(DOMAIN)是基于已有类型加上约束之后的类型别名。比如你定义了一个positive_int域:只能是正整数,然后多个表复用它。这样比每次写 CHECK 约束更省心,也容易统一修改。不过需要注意,域类型在函数重载解析里有特殊性,可能导致一些隐式转换变得复杂,所以使用时要有一个“遇到奇怪报错先看看是不是域类型在作祟”的排查意识。
5. 类型转换与解析规则:为什么 PG 会报“类型不匹配”
5.1 隐式转换:看起来自动,背后有严格规则
PG 有一套完整的类型转换规则,决定了在没有显式指定类型的情况下,操作符和函数如何被解析。理解这套规则,能让你对“为什么不匹配”有一个清晰的诊断方向。
简单说,PG 在解析SELECT * FROM t WHERE int_col = '123'时,会看操作符=两侧的类型。如果左侧是 int4,右侧是 unknown 类型的字符串常量,PG 会尝试把右侧转换为 int4。这就是隐式转换,它帮你省了很多 CAST。但如果你写的是int_col = text_col,PG 就找不到内置的 int4 = text 操作符,于是报错:operator does not exist: integer = text。
这种报错看起来烦人,实际上是安全设计。如果 PG 随意把 text 转成 int 再比较,一旦 text 里的内容不是合法数字,就要么悄悄转失败要么需要抛异常。所以 PG 选择:不鼓励跨类型的隐式比较,你要比就显式转换。
5.2 CAST、:: 与函数调用的细节
PG 中做类型转换的写法主要有三种:CAST(x AS type)、x::type、type(x)。它们大体等价,但在可读性上有差异。::是 PG 特有的写法,简洁但不够直观,适合写即时查询;CAST是 SQL 标准写法,适合存到正式报表或给团队其他人维护的 SQL 里。
我经常在排查数据问题时用“先看类型再下结论”的方法:
SELECT pg_typeof(column_name) FROM table_name LIMIT 1;这个函数非常有用。它能直接告诉你某列、某表达式的实际类型,很多“为什么相等条件查不到数据”的问题,最后都发现是类型不一致导致比较失败。新版客户端工具里,你也可以通过查看查询结果的行类型标签来确认。
5.3 UNION、JOIN 与 INSERT 场景的类型兼容
多个结果集合并时,PG 对类型一致性也有严格要求。UNION的每一列类型必须匹配或能隐式转换,不能一边是 integer 一边是 text。一般遇到这种情况,我建议直接在 SQL 里CAST成统一类型:
SELECT id::text AS id, 'user' AS source_type FROM users UNION ALL SELECT account_id::text, 'order' FROM orders;JOIN 的关联字段也一样:两边类型不同时,尽早统一。特别是字符集和排序规则不同的 text/varchar,看起来都是字符串,但排序规则可能不同,JOIN 时也会有细微的类型错误。养成写查询前先检查连接字段类型的习惯,能省去大量排错时间。
5.4 类型解析的优先级与首选类型
PG 内部为每种类型分类设置了“首选类型”。比如字符串分类里 TEXT 是首选,数字分类里 INT8 是首选。当出现类型不明确的场景,比如SELECT coalesce(col1, col2)两个字段类型不一致时,PG 会优先选择该分类中的首选类型。这就解释了为什么有些时候 COALESCE 的结果类型不是你预期的那一个。
遇到这种类型歧义问题,最直接的办法是显式CAST。不要试图依赖隐式转换的“智能性”来猜 PG 的行为,规则太复杂,猜不中就要花半天时间。显式转换虽然啰嗦,但是可预测的。
6. 故障排查实录与性能印象
6.1 常见类型错误速查表
| 错误信息 | 常见原因 | 解决思路 |
|---|---|---|
operator does not exist: integer = text | 关联字段类型不同 | 显式 CAST 对齐类型 |
column "x" is of type numeric but expression is of type text | INSERT 时类型未转换 | 检查目标列类型并改入参类型 |
date/time field value out of range | 非法日期字符串 | 先查数据格式,用 TO_TIMESTAMP 防御性解析 |
could not identify an equality operator for type json | 对 JSON 类型使用相等比较 | 转成 JSONB 或使用 jsonb 操作符 |
invalid input syntax for type numeric: "abc" | 脏数据进入数值列 | 使用正则或应用层过滤后写入 |
numeric field overflow | 值超过 NUMERIC 精度 | 扩大总精度或改用 INT8 |
cannot cast type bigint to jsonb | 类型不兼容 | 先转 text 再转 jsonb |
6.2 类型选型对索引和性能的血泪经验
数据类型直接影响索引的构建和查询优化器对查询计划的估算。举个例子,UUID 主键和 INT8 主键在同样的 B-tree 索引下,UUID 的随机性导致索引页频繁分裂,写入性能会明显变差。如果你的业务 ID 是 UUID,但表记录量很大且写入频繁,可以考虑换成有序 ID 或者带时间前缀的 UUID,或者至少意识到随机主键的成本。
字符串类型的选择影响更是直接:如果你需要做前缀模糊查询,text_pattern_ops操作符类能利用 LIKE 做前缀搜索的索引,而标准 B-tree 索引默认的 C 排序规则对 LIKE 的'%abc'后匹配没有加速作用。类型和索引操作符类要一起考虑。
JSONB 的 GIN 索引有一个容易坑到人的点:它的索引对@>操作符的左侧类型有要求,整个表达式必须是 JSONB,不能让json类型的列直接用@>查询,必须::jsonb转换后才能命中索引。我见过某团队在这个问题上纠结一天,最后确认是类型没对齐导致索引根本没被使用。
6.3 实战排查记录:一个 JOIN 类型不匹配的完整案例
有一次我接管一个旧系统,有两张表,一张orders的customer_id是 INT8,另一张customers的id是 VARCHAR。两边的数据实际都是“12345”这种数字字符串,看起来完全能对上,但 JOIN 就是不返回结果,因为VARCHAR的'12345'和 INT8 的12345不相等。
排查过程我用pg_typeof()把两列的真实类型先打出来,确定类型后,再看数据有没有隐形空格、前导零。最终修复方案是:
UPDATE customers SET id = TRIM(id); -- 统一成 INT8 ALTER TABLE customers ALTER COLUMN id TYPE bigint USING id::bigint;这个案例告诉我们:看到“查不出数据”,第一时间不要怀疑数据内容,先怀疑类型是否一致,再用pg_typeof和\d table_name确认列定义。类型对齐后,很多疑难杂症不治自愈。
6.4 类型升级的运维注意事项
在从旧版本迁移或者调整类型时,例如 INT4 升级到 INT8,或者 VARCHAR 改成 TEXT,PG 会重写整张表并可能持有 ACCESS EXCLUSIVE 锁。大表上的ALTER COLUMN TYPE会导致长时间锁表,业务无法写入。
我建议的处理方式有两种:
- 使用
pg_repack工具做在线重建,减少锁时间。 - 或者采用“先新建新类型列,通过触发器或应用双写,再切换”的方法。
如果你的 PG 版本较新,ALTER TABLE ... ALTER COLUMN TYPE在部分场景下会利用 INHERIT 优化,不用重写表,但这依赖于类型转换是否有表达式。总之,类型升级不能贸然在生产环境直接执行,先在测试库上评估影响和执行时间再动手。
7. 写在最后的一些实操经验
我个人的体会是,PG 的类型系统看似啰嗦,但它把很多数据库推到应用层才暴露的问题提前拦截在了数据库这一层。使用它的时候,别总想着找绕过报错的办法,而是应该顺着它的规则去理解业务数据本身。
最后分享一个实用小技巧:写新表之前,把每个字段拿出来问三个问题——这个字段的业务含义决定了它的逻辑类型吗?它的取值范围和精度上限是多少?它未来会不会参与 JOIN、计算、索引或者类型转换?三个问题过一遍,字段类型基本就不会选错。用这种“先灵魂拷问再建表”的做法,我在几个项目里都把后期返工率和线上数据质量问题压得很低。
如果你已经有一堆建好的表,不妨挑几个核心表,用pg_typeof和\d检查一下列的类型是否和业务预期一致,该修的类型趁早修,越晚越被动。