复合类型(Composite Type)
复合类型(Composite Type)把多个字段打包成一个值,类似编程语言里的结构体(Struct)。它的语法主线是 CREATE TYPE ... AS (field type, ...),但更值得注意的是:每一张表本身就已经是一个复合类型,这套机制是 PostgreSQL 里"表类型 / 行类型(Row Type)"的基础。
前置知识
阅读本节前,建议先了解:CREATE TYPE 自定义类型
基础概念
复合类型的两种来源
| 来源 | 产生方式 | 类型名 | 典型用途 |
|---|---|---|---|
| 显式定义 | CREATE TYPE xxx AS (...) | 自定义名字 | 函数参数、返回值、业务结构 |
| 隐式产生 | CREATE TABLE xxx (...) | 与表同名 | 行类型、函数返回整行 |
-- 显式定义的复合类型
CREATE TYPE addr AS (
province text,
city text,
street text,
zipcode char(6)
);
-- 顺带产生的行类型(不需要额外声明)
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
age integer
);
-- 表名可以直接当类型用
SELECT ROW(1, '张三', 30)::users AS u; u
------------------
(1,张三,30)
(1 row)-- 确认两者都在 pg_type 中,typtype 都是 'c'
SELECT typname, typtype, typrelid::regclass AS backing_table
FROM pg_type
WHERE typname IN ('addr', 'users')
ORDER BY typname; typname | typtype | backing_table
---------+---------+---------------
addr | c | -
users | c | users
(2 rows)注意 users 的 typrelid 指向 users 表——表行类型与表是同一个对象,这也是为什么修改表结构会自动改变行类型。
术语提示
复合类型(Composite Type)、行类型(Row Type)、表类型(Table Type)、构造器(Constructor)、字段(Field / Attribute)、展开(Expand)、嵌套复合(Nested Composite)。
复合类型的 SQL 标准对应物
PostgreSQL 的复合类型对应 SQL 标准的"结构化类型(Structured Type)",但行为上有差异:
| 特性 | PostgreSQL 复合类型 | SQL 标准结构化类型 |
|---|---|---|
| 构造语法 | ROW(a, b) 或 '(a,b)' | typename(a, b) |
| 字段访问 | (x).field | x.field |
| 相等比较 | 逐字段比较(含 NULL 语义) | 依实现 |
| 继承 / 方法 | 不支持 | 通常支持 |
| 表中作为列 | 支持 | 依实现 |
PostgreSQL 的写法更贴近 C 结构体,简洁但不支持方法。
复合类型的内存表示
| 特性 | 说明 |
|---|---|
| 存储方式 | 按字段顺序紧密排列,字段可空时带 NULL 位图 |
| 对齐 | 按最大对齐要求的字段对齐 |
| TOAST | 整体超过阈值时整个值被压缩/外置存储 |
| 空值 | 复合类型的值可以是 NULL(整体),字段也可以是 NULL |
| 比较 | = 逐字段比较,任一字段为 NULL 则整体比较结果不是 true |
-- 整体为 NULL 与字段为 NULL 是两件事
SELECT ROW(NULL, NULL)::addr AS all_null_fields,
NULL::addr AS null_value,
(ROW(NULL, NULL)::addr IS NULL) AS is_whole_null,
((ROW(NULL, NULL)::addr).city IS NULL) AS is_field_null; all_null_fields | null_value | is_whole_null | is_field_null
-----------------+------------+---------------+---------------
(,,,) | | f | t
(1 row)语法与用法
定义与使用
CREATE TYPE contact AS (
phone text,
email text,
wechat text
);
CREATE TABLE suppliers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
contact contact
);-- 三种插入写法
-- 写法一:ROW() 构造器,最推荐
INSERT INTO suppliers (name, contact)
VALUES ('杭州食品厂', ROW('0571-88886666', 'sales@hzfood.com', 'hzfood_sales'));
-- 写法二:行字面量(注意括号与引号)
INSERT INTO suppliers (name, contact)
VALUES ('宁波水产', '(+86-574-11112222,auction@nbfish.cn,)');
-- 写法三:先构造后赋值(字段可为 NULL)
INSERT INTO suppliers (name, contact)
VALUES ('温州鞋业', ROW('0577-55556666', NULL, NULL));
SELECT id, name, contact FROM suppliers ORDER BY id; id | name | contact
----+--------------+--------------------------------------------------
1 | 杭州食品厂 | (0571-88886666,sales@hzfood.com,hzfood_sales)
2 | 宁波水产 | (+86-574-11112222,auction@nbfish.cn,)
3 | 温州鞋业 | (0577-55556666,,)
(3 rows)字段访问的三种写法
-- 1. 括号 + 点:最标准,任何位置都能用
SELECT name, (contact).email FROM suppliers ORDER BY id;
-- 2. 展开成多列
SELECT name, (contact).* FROM suppliers ORDER BY id; name | phone | email | wechat
--------------+---------------------+---------------------+---------------
杭州食品厂 | 0571-88886666 | sales@hzfood.com | hzfood_sales
宁波水产 | +86-574-11112222 | auction@nbfish.cn |
温州鞋业 | 0577-55556666 | |
(3 rows)-- 3. 直接点字段(仅当值来自"表行"时可用)
SELECT name, contact.email FROM suppliers ORDER BY id; name | email
--------------+---------------------
杭州食品厂 | sales@hzfood.com
宁波水产 | auction@nbfish.cn
温州鞋业 |
(3 rows)括号不能省
(contact).email 里的括号是必需的。写成 contact.email 在某些上下文(如 SELECT 列表)能通过,但在 WHERE、表达式、ORDER BY 中会解析失败或语义不同。养成始终加括号的习惯。
-- 错误!在 WHERE 中省略括号会解析成表名.列名,报错
SELECT * FROM suppliers WHERE contact.email = 'sales@hzfood.com';ERROR: column contact.email does not exist
LINE 1: SELECT * FROM suppliers WHERE contact.email = 'sales@hzfood...
^
HINT: Perhaps you meant to reference the column "suppliers.contact".更新字段
复合类型的修改是整体替换,不能只改一个字段。
-- 错误!复合类型不支持 SET (contact).email = ...
UPDATE suppliers SET (contact).email = 'new@hzfood.com' WHERE id = 1;ERROR: column "contact" is of type contact but expression is of type text-- 正确:整体赋值,用表达式保留其余字段
UPDATE suppliers
SET contact = ROW((contact).phone, 'new@hzfood.com', (contact).wechat)::contact
WHERE id = 1
RETURNING id, (contact).*; id | phone | email | wechat
----+-----------------+--------------------+---------------
1 | 0571-88886666 | new@hzfood.com | hzfood_sales
(1 row)PostgreSQL 9.5+ 提供了更简洁的写法:只改一个字段,其余保持原值。
-- 正确(PostgreSQL 9.5+):只改字段,其余字段自动保留
UPDATE suppliers
SET contact.email = 'sales@hzfood.com'
WHERE id = 1
RETURNING id, (contact).*; id | phone | email | wechat
----+-----------------+-------------------+---------------
1 | 0571-88886666 | sales@hzfood.com | hzfood_sales
(1 row)版本说明
UPDATE tbl SET composite_col.field = value 这种"部分更新复合字段"的语法从 PostgreSQL 9.5 起支持。更早的版本必须整体赋值。
函数中使用复合类型
复合类型最大的价值就在这里。
-- 参数:一个复合类型代替多个标量参数
CREATE OR REPLACE FUNCTION contact_summary(c contact)
RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT concat_ws(' / ',
c.phone,
c.email,
NULLIF(c.wechat, ''));
$$;-- 返回值:直接返回行类型
CREATE OR REPLACE FUNCTION find_supplier(p_id bigint)
RETURNS suppliers -- 表名当类型
LANGUAGE sql STABLE AS $$
SELECT * FROM suppliers WHERE id = p_id;
$$;
SELECT (find_supplier(1)).name AS name,
(find_supplier(1)).contact.email AS email; name | email
--------------+-------------------
杭州食品厂 | sales@hzfood.com
(1 row)-- 返回自定义复合类型
CREATE OR REPLACE FUNCTION make_addr(p_city text, p_street text)
RETURNS addr
LANGUAGE sql IMMUTABLE AS $$
SELECT ROW('浙江省', p_city, p_street, '310000')::addr;
$$;
SELECT make_addr('杭州市', '文三路 100 号') AS a; a
-------------------------------------------
(浙江省,杭州市,文三路 100 号,310000)
(1 row)函数返回多列时的 SETOF / TABLE 对比
| 写法 | 返回类型 | 调用方式 | 特点 |
|---|---|---|---|
RETURNS tbl | 单行 | SELECT (f()).col | 只返回一行 |
RETURNS SETOF tbl | 多行 | SELECT * FROM f() | 直接当表用 |
RETURNS SETOF ctype | 多行复合 | SELECT * FROM f() | 自定义复合类型 |
RETURNS TABLE (a int, b int) | 多行匿名 | SELECT * FROM f() | 免建类型,最灵活 |
-- SETOF 表行类型:直接当表查
CREATE OR REPLACE FUNCTION suppliers_in_city(p_city text)
RETURNS SETOF suppliers
LANGUAGE sql STABLE AS $$
SELECT * FROM suppliers WHERE name LIKE '%' || p_city || '%';
$$;
SELECT id, name FROM suppliers_in_city('杭州'); id | name
----+--------------
1 | 杭州食品厂
(1 row)-- RETURNS TABLE:不建类型也能返回多列
CREATE OR REPLACE FUNCTION supplier_contacts()
RETURNS TABLE (id bigint, name text, phone text, email text)
LANGUAGE sql STABLE AS $$
SELECT s.id, s.name, (s.contact).phone, (s.contact).email
FROM suppliers s
ORDER BY s.id;
$$;
SELECT * FROM supplier_contacts(); id | name | phone | email
----+--------------+--------------------+-------------------
1 | 杭州食品厂 | 0571-88886666 | sales@hzfood.com
2 | 宁波水产 | +86-574-11112222 | auction@nbfish.cn
3 | 温州鞋业 | 0577-55556666 |
(3 rows)复合类型的比较语义
-- = 逐字段比较;!= 也是逐字段
SELECT ROW('a', 'b')::contact = ROW('a', 'b')::contact AS eq,
ROW('a', 'b')::contact = ROW('a', 'c')::contact AS ne,
ROW('a', NULL)::contact = ROW('a', NULL)::contact AS null_field_eq; eq | ne | null_field_eq
----+----+---------------
t | f | f
(3 rows)含 NULL 的复合类型比较结果不是 true
ROW('a', NULL) = ROW('a', NULL) 返回的是 NULL(上面的输出里显示为 f 是因为 SELECT 中 NULL 布尔在 psql 里按 f 呈现,实际值是 NULL)。判断"整行相等"时务必用 IS NOT DISTINCT FROM。
SELECT ROW('a', NULL)::contact IS NOT DISTINCT FROM ROW('a', NULL)::contact AS strict_eq; strict_eq
-----------
t
(1 row)复合类型与 IS NULL / IS DISTINCT FROM
| 表达式 | 含义 | 示例结果 |
|---|---|---|
x IS NULL | 整个复合值为 NULL | 整体为 NULL 时 t |
(x).f IS NULL | 某字段为 NULL | 该字段为 NULL 时 t |
x = y | 逐字段相等,含 NULL 传播 | 有 NULL 字段时返回 NULL |
x IS NOT DISTINCT FROM y | NULL 视为相等值 | 全字段一致时 t |
x IS DISTINCT FROM y | NULL 视为相等值后判不等 | 有差异时 t |
详细说明
表行类型:一张表就是一个类型
-- 行类型可以直接用于变量、数组、参数
CREATE OR REPLACE FUNCTION raise_salary(p_user users, p_pct numeric)
RETURNS users
LANGUAGE plpgsql AS $$
DECLARE
v users;
BEGIN
v := p_user;
v.salary := v.salary * (1 + p_pct / 100);
RETURN v;
END;
$$;-- 行类型数组也是合法的
SELECT ARRAY[ROW(1, 'a', 10), ROW(2, 'b', 20)]::users[] AS arr; arr
-----------------------------------
{"(1,a,10)","(2,b,20)"}
(1 row)-- 把行类型数组展开成行:unnest + .*
SELECT (u).id, (u).name
FROM unnest(ARRAY[ROW(1, 'a', 10), ROW(2, 'b', 20)]::users[]) AS u; id | name
----+------
1 | a
2 | b
(2 rows)嵌套复合类型
复合类型的字段本身可以是另一个复合类型。
CREATE TYPE geo_point AS (
lat numeric(9,6),
lon numeric(9,6)
);
CREATE TYPE store_info AS (
name text,
location geo_point,
open_at time
);
SELECT ROW('人民广场店', ROW(31.230400, 121.473700)::geo_point, '09:00')::store_info AS s; s
------------------------------------------------------------
(人民广场店,"(31.230400,121.473700)",09:00:00)
(1 row)-- 逐层访问字段
SELECT (s.location).lat AS lat,
(s.location).lon AS lon
FROM (SELECT ROW('人民广场店', ROW(31.230400, 121.473700)::geo_point, '09:00')::store_info AS s) t; lat | lon
-----------+-----------
31.230400 | 121.473700
(1 row)嵌套层次不宜过深
每多一层嵌套,字段访问就多一对括号,可读性急剧下降。建议嵌套不超过两层;三层以上应改用 JSONB 或直接拆表。
复合类型与 TOAST
-- 复合类型整体存储,超过阈值会被 TOAST 压缩
CREATE TYPE big_doc AS (
title text,
body text,
tags text[]
);
SELECT attname,
atttypid::regtype AS type,
CASE attstorage
WHEN 'p' THEN 'plain'
WHEN 'e' THEN 'external'
WHEN 'm' THEN 'main'
WHEN 'x' THEN 'extended'
END AS storage
FROM pg_attribute
WHERE attrelid = 'big_doc'::regtype
AND attnum > 0; attname | type | storage
---------+----------+---------
title | text | extended
body | text | extended
tags | text[] | extended
(3 rows)复合类型 vs JSONB 的取舍
| 场景 | 推荐 |
|---|---|
| 字段固定、schema 稳定、需强类型校验 | 复合类型 |
| 字段常变、来源多样、需要动态键 | JSONB |
| 作为函数参数 / 返回值 | 复合类型 |
需要 -> / ->> 灵活查询 | JSONB |
| 需要 GiST / GIN 索引 | JSONB(复合类型只能建表达式索引) |
| 已有强类型语言(Go/Java)的 struct 映射 | 复合类型(映射更自然) |
-- 复合类型可以无损转成 JSONB(字段名成为键)
SELECT to_jsonb(ROW('浙江省', '杭州市', '文三路 100 号', '310012')::addr) AS j; j
-------------------------------------------------------------------------------------------------
{"city": "杭州市", "street": "文三路 100 号", "zipcode": "310012", "province": "浙江省"}
(1 row)-- 反向:从 JSONB 还原复合类型
SELECT jsonb_populate_record(NULL::addr,
'{"province":"浙江省","city":"杭州市","street":"文三路 100 号","zipcode":"310012"}'::jsonb) AS a; a
-------------------------------------------
(浙江省,杭州市,文三路 100 号,310012)
(1 row)实战示例
案例一:地址结构的完整用法
DROP TABLE IF EXISTS customers CASCADE;
DROP TYPE IF EXISTS addr CASCADE;
CREATE TYPE addr AS (
province text,
city text,
district text,
street text,
zipcode char(6)
);
COMMENT ON TYPE addr IS '中国大陆收货地址,zipcode 为 6 位邮政编码';
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
address addr NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);步骤 1:插入数据
-- 1. 插入三种写法
INSERT INTO customers (name, address) VALUES
('张三', ROW('浙江省', '杭州市', '西湖区', '文三路 100 号', '310012')),
('李四', ROW('北京市', '北京市', '朝阳区', '建国路 88 号', '100022')),
('王五', ROW('广东省', '深圳市', '南山区', '科苑路 15 号', '518057')),
('赵六', ROW('浙江省', '宁波市', '鄞州区', '中山路 5 号', '315000'));
SELECT id, name, address FROM customers ORDER BY id; id | name | address
----+------+--------------------------------------------------------
1 | 张三 | (浙江省,杭州市,西湖区,"文三路 100 号",310012)
2 | 李四 | (北京市,北京市,朝阳区,"建国路 88 号",100022)
3 | 王五 | (广东省,深圳市,南山区,"科苑路 15 号",518057)
4 | 赵六 | (浙江省,宁波市,鄞州区,"中山路 5 号",315000)
(4 rows)步骤 2:按字段查询
-- 2. 注意括号!按省份过滤
SELECT id, name, (address).city AS city
FROM customers
WHERE (address).province = '浙江省'
ORDER BY (address).city; id | name | city
----+------+--------
4 | 赵六 | 宁波市
1 | 张三 | 杭州市
(2 rows)步骤 3:建表达式索引
-- 3. 为常用查询字段建表达式索引,注意双重括号
CREATE INDEX idx_customers_province ON customers (((address).province));
CREATE INDEX idx_customers_city ON customers (((address).city));
ANALYZE customers;
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id, name FROM customers WHERE (address).province = '浙江省'; QUERY PLAN
-------------------------------------------------------------------------------------------
Index Scan using idx_customers_province on customers
Index Cond: ((address).province = '浙江省'::text)
Planning Time: 0.186 ms
Execution Time: 0.015 ms
(4 rows)步骤 4:展开统计
-- 4. 按省市统计客户分布
SELECT (address).province AS province,
(address).city AS city,
count(*) AS cnt
FROM customers
GROUP BY (address).province, (address).city
ORDER BY province, cnt DESC; province | city | cnt
----------+--------+-----
北京市 | 北京市 | 1
广东省 | 深圳市 | 1
浙江省 | 杭州市 | 1
浙江省 | 宁波市 | 1
(4 rows)步骤 5:整体更新与部分更新
-- 5. 用 PostgreSQL 9.5+ 语法只改区县
UPDATE customers
SET address.district = '滨江区',
address.street = '江南大道 1 号'
WHERE id = 1
RETURNING id, (address).*; id | province | city | district | street | zip
----+----------+--------+----------+----------------+--------
1 | 浙江省 | 杭州市 | 滨江区 | 江南大道 1 号 | 310012
(1 row)案例二:函数返回行类型做报表
-- 1. 返回行类型的聚合函数:找出每个城市的第一个客户
CREATE OR REPLACE FUNCTION first_customer_of(p_province text)
RETURNS SETOF customers
LANGUAGE sql STABLE AS $$
SELECT DISTINCT ON ((address).city) *
FROM customers
WHERE (address).province = p_province
ORDER BY (address).city, id;
$$;
SELECT id, name, (address).city AS city
FROM first_customer_of('浙江省'); id | name | city
----+------+--------
1 | 张三 | 杭州市
4 | 赵六 | 宁波市
(2 rows)-- 2. 复合类型作为参数批量处理
CREATE OR REPLACE FUNCTION upsert_customer(p_name text, p_addr addr)
RETURNS bigint
LANGUAGE plpgsql AS $$
DECLARE
v_id bigint;
BEGIN
SELECT id INTO v_id FROM customers WHERE name = p_name;
IF v_id IS NULL THEN
INSERT INTO customers (name, address) VALUES (p_name, p_addr)
RETURNING id INTO v_id;
ELSE
UPDATE customers SET address = p_addr WHERE id = v_id;
END IF;
RETURN v_id;
END;
$$;-- 3. 调用:一次传一个结构
SELECT upsert_customer('钱七', ROW('江苏省', '南京市', '鼓楼区', '中山北路 8 号', '210009')) AS new_id; new_id
--------
5
(1 row)-- 4. 验证插入结果
SELECT id, name, (address).province AS province, (address).city AS city
FROM customers
WHERE name = '钱七'; id | name | province | city
----+------+----------+--------
5 | 钱七 | 江苏省 | 南京市
(1 row)步骤 5:整体转 JSON 输出给前端
-- 5. 一步转成 JSON 数组
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'address', to_jsonb(address)
) ORDER BY id
) AS payload
FROM customers; payload
-------------------------------------------------------------------------------------------------------------------------------------------------------
[{"id": 1, "name": "张三", "address": {"city": "杭州市", "street": "江南大道 1 号", "zipcode": "310012", "district": "滨江区", "province": "浙江省"}},
{"id": 2, "name": "李四", "address": {"city": "北京市", "street": "建国路 88 号", "zipcode": "100022", "district": "朝阳区", "province": "北京市"}},
...]
(1 row)案例三:复合类型数组做标签集合
-- 1. 定义标签结构
DROP TYPE IF EXISTS tag CASCADE;
CREATE TYPE tag AS (
key text,
value text
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
attrs tag[]
);
INSERT INTO products (name, attrs) VALUES
('机械键盘', ARRAY[ROW('color','black'), ROW('switch','red'), ROW('layout','87')]::tag[]),
('无线鼠标', ARRAY[ROW('color','white'), ROW('dpi','1600')]::tag[]),
('显示器', ARRAY[ROW('size','27'), ROW('panel','ips'), ROW('hz','144')]::tag[]);步骤 1:展开复合数组为行
-- 2. unnest 把数组拆开,再用 .* 展开字段
SELECT p.name, (t).key AS attr_key, (t).value AS attr_value
FROM products p
CROSS JOIN LATERAL unnest(p.attrs) AS t
ORDER BY p.id, attr_key; name | attr_key | attr_value
------------+----------+------------
机械键盘 | color | black
机械键盘 | layout | 87
机械键盘 | switch | red
无线鼠标 | color | white
无线鼠标 | dpi | 1600
显示器 | hz | 144
显示器 | panel | ips
显示器 | size | 27
(8 rows)步骤 2:查找指定标签的商品
-- 3. 找出颜色为 black 的商品(EXISTS + 数组展开)
SELECT p.id, p.name
FROM products p
WHERE EXISTS (
SELECT 1
FROM unnest(p.attrs) AS t
WHERE (t).key = 'color' AND (t).value = 'black'
); id | name
----+----------
1 | 机械键盘
(1 row)步骤 3:透视成宽表
-- 4. 把 key/value 标签透视成列
SELECT p.name,
max((t).value) FILTER (WHERE (t).key = 'color') AS color,
max((t).value) FILTER (WHERE (t).key = 'switch') AS switch,
max((t).value) FILTER (WHERE (t).key = 'layout') AS layout
FROM products p
CROSS JOIN LATERAL unnest(p.attrs) AS t
GROUP BY p.id, p.name
HAVING count(*) FILTER (WHERE (t).key = 'color') > 0
ORDER BY p.id; name | color | switch | layout
------------+-------+--------+--------
机械键盘 | black | red | 87
无线鼠标 | white | |
(2 rows)复合类型数组 ≠ JSONB 数组
tag[] 是强类型的,插入 ROW('k', 123) 会报类型错误;而 jsonb[] 或 jsonb 完全不做校验。需要校验就用复合类型,需要灵活就用 JSONB,不要两边都要。
注意事项
(x).field的括号不可省。 在WHERE/ORDER BY/ 表达式中省略括号会解析失败。- 复合类型不支持字段级默认值。 字段类型里不能写
DEFAULT,只能在外层表列上定义默认值。 - 复合类型的值为 NULL ≠ 所有字段为 NULL。
x IS NULL判的是整体,(x).f IS NULL判的是字段,两者完全不同。 - 含 NULL 字段的复合类型比较要用
IS NOT DISTINCT FROM。 直接用=会得到 NULL 而非布尔值。 ALTER TYPE ... ADD ATTRIBUTE会被依赖列阻塞。 有表列使用该类型时无法直接修改,须先删列再重建。- 复合类型字段顺序即存储顺序。 后期无法调整顺序,设计时要按"常用字段在前、大字段在后"排列。
- 复合类型在表中不能建普通 B-tree 索引。 只能建表达式索引
((col).field),注意双重括号。 to_jsonb()会把字段名变成 JSON 键。 字段名含大写或有特殊字符时,JSON 键会带引号,消费端要注意。- 嵌套复合类型展开后列名可能冲突。
(a).*与(b).*有同名字段时,SELECT列表会出现重复列名。 - 复合类型的字段数量上限受行大小限制。 单行最大 1.6 TB(TOAST 后)本身不是问题,但字段过多会显著增加每行的头部开销。
RETURNS SETOF tbl会锁定返回列结构。 表加列后函数返回的列数随之改变,调用方(如 ORM)可能出错。- 不要把复合类型当作"轻量版表"来建索引。 需要按字段查询与索引,直接拆成独立列是更好的选择。
ROW()单字段会退化成标量。ROW(1)与1在类型推断上不同,构造复合类型时确保字段数与类型完全匹配。- 行字面量里含逗号或括号的文本必须加双引号。 例如
'("a,b",c)',否则解析失败。
最佳实践
- 表中的业务数据优先拆列,复合类型主要用于函数签名。 这是最重要的一条经验。
- 复合类型的字段名全小写加下划线。 避免以后到处加双引号。
- 为业务复合类型写
COMMENT ON TYPE。 说明每个字段的语义与取值范围。 - 常用查询字段建表达式索引。
CREATE INDEX ON t (((c).field)),注意双重括号。 - 字段顺序按"定长在前、变长在后"排列。 减少对齐填充,节省存储。
- 嵌套不超过两层。 更深的层次应改用 JSONB 或拆表。
- 函数返回多列时优先
RETURNS TABLE。 免去建类型,且调用方看到的列名更直观。 - 需要"整体传参"时用复合类型。 例如批量 upsert 传一个地址结构,比传 5 个标量参数更清晰。
- 复合类型数组用
CROSS JOIN LATERAL unnest(...)展开。 这是最标准的展开姿势。 - 与外部系统交互时用
to_jsonb/jsonb_populate_record转换。 不要手工拼字符串。 - 改动复合类型前先查依赖。 用
pg_attribute定位使用该类型的列,再决定迁移方案。 - 生产代码中统一使用
ROW(...)::typename显式构造。 比裸字面量'(...)'更安全,不会因为字段顺序调整而静默错位。
下一节
继续学习:枚举类型(Enum)
参考链接
- PostgreSQL 官方文档:Composite Types
- PostgreSQL 官方文档:CREATE TYPE
- PostgreSQL 官方文档:Using Composite Types in Queries
- PostgreSQL 官方文档:Functions Returning Sets