把多个值放进同一个字段这件事,我在 PostgreSQL 里纠结了很久。早期做内容产品的时候,一篇文章挂哪几个标签,最直觉的方案是建一张关联表,但等查询和写路径一多,我又忍不住想试试数组字段这条路。后来生产环境用得多了,才慢慢摸清楚数组类型的脾气:它的增删改查、运算符和 GIN 索引,跟普通标量字段完全不是一回事。这篇文章就把 PostgreSQL 数组的增删改查、常用运算符、索引优化这几个主题一次讲透,重点放在平时文档里查不到的边界条件和生产环境踩过的坑。适合正在数据建模评审、纠结“数组到底能不能用”的后端开发,也适合发现 GIN 索引没生效而正在排查的 DBA。
1. 数组字段做不了关系约束——先想清楚什么时候该用它
1.1 三种多值存储方案的取舍
面对“一个对象有多个标签/多个属性值”的需求,我见过的方案基本就三种:关联表、逗号分隔字符串、数组字段。三者的差异,用一张表就能说清楚。
| 方案 | 类型安全 | 外键约束 | 读取复杂度 | 查询能力 | 索引支持 | 适合场景 |
|---|---|---|---|---|---|---|
| 关联表 | 高 | 完整 | 要 JOIN | 最强 | 常规 B-tree 即可 | 需要参照完整性、频繁按元素关联 |
| 逗号分隔字符串 | 无 | 无 | 要拆分 | 弱 | 几乎不可用 | 基本不该用于正式业务 |
| 数组字段 | 高 | 无 | 一行直接读完 | 强(unnest 可展开) | GIN 很成熟 | 读多写少的标签、多值属性 |
数组字段最打动我的点是:类型安全。逗号分隔字符串存进数据库后,谁也不能保证里面是合法数字还是乱七八糟的文本;数组字段则明确是int[]、text[]还是别的类型,写入时数据库就会做类型检查。查询也多了一套原生运算符,@>、&&、<@这套语法比LIKE '%tag%'语义清晰得多。而且数组本身对顺序敏感,这对“按顺序展示标签”这类需求非常自然。
1.2 什么情况下我建议你别用数组
数组虽好,但有一类场景我坚决不用:需要关系约束的场景。比如用户的好友关系、课程和学生的多对多对应,这类数据必须用关联表加外键,否则很容易出现“引用了一个已被删除的实体”这种脏数据。关联表还能天然支持按人数聚合、跨对象统计,数组字段做这些事只能先unnest再聚合,数据量大时成本很高。
另外两类情况也要谨慎:元素数量可能无上限增长的场景(个人兴趣标签可能就十几个,但动态流里一个对象的点赞用户可能上千),以及高频增删改数组中某个元素的场景。数组更新本质是整行重写,GIN 索引维护成本不低,写放大问题在高频写场景下会非常明显。一句话:数组适合做“读多写少、数量有限、不需要参照完整性”的附属数据。
1.3 构建数组的三种常见姿势
把数组真正用起来之前,先记住三种构造方式,它们覆盖了九成以上需求。
-- 方式一:构造器 SELECT ARRAY[1, 2, 3] AS arr1; -- 方式二:字面量,常用于参数化查询或默认值 SELECT '{1,2,3}'::int[] AS arr2; -- 方式三:从字符串拆分,外部接口传参时非常常用 SELECT string_to_array('pg,postgres,gin', ',') AS arr3;另外,复合查询里也常配合array_agg把一组行聚成一个数组。例如按分类聚合标签:
SELECT category_id, array_agg(tag_name ORDER BY sort_no) AS tags FROM content_tags GROUP BY category_id;还有一个新手最容易踩的坑:ARRAY[]如果不带类型注解,数据库不知道它是什么类型,所以写空数组时要显式写上类型,比如ARRAY[]::text[]。
2. 追加与更新:array_append、下标赋值和“不存在才加”的难题
2.1 三种追加方式怎么选
往数组里加元素,PostgreSQL 给了至少三套写法:array_append、||操作符、array_cat。不少人一开始以为它们是同一件事,其实调用方式有细微差别。
-- 追加单个元素 SELECT array_append(ARRAY['a','b'], 'c'); SELECT ARRAY['a','b'] || 'c'; -- 拼接一个数组 SELECT array_cat(ARRAY['a','b'], ARRAY['c','d']); SELECT ARRAY['a','b'] || ARRAY['c','d'];我的习惯是:追加单元素用array_append,拼接多个元素用||加数组字面量。写 UPDATE 时也是同样思路:
UPDATE content_meta SET tags = array_append(tags, 'postgres') WHERE id = 1001;此处必须注意,如果tags本身是 NULL,array_append(NULL, 'postgres')的结果还是 NULL,不会自动变成含一个元素的数组。所以凡是允许字段为 NULL 的表,要么在写入时统一COALESCE,要么干脆给字段加NOT NULL DEFAULT '{}'::text[]。
2.2 按下标赋值:越界会“填空”
数组更新除了追加,还有“按位置改”。在 PostgreSQL 里可以直接对某个下标赋值:
UPDATE content_meta SET tags[2] = 'database' WHERE id = 1001;这里的坑藏在越界赋值里。比如一个数组本来只有 1 个元素,你直接给第 3 个位置赋值,数据库不会报错,而是把中间的位置填成 NULL:
DO $$ DECLARE arr text[] := ARRAY['a']; BEGIN arr[3] := 'c'; RAISE NOTICE '%', arr; END $$; -- NOTICE: {a,NULL,c}这是 PostgreSQL 数组“允许下标不连续”的特性带来的副作用。如果你的业务里“标签之间不允许出现空位”,越界赋值就是个隐藏炸弹。更稳的做法是先判断cardinality(tags)再决定是追加还是覆盖。
2.3 “不存在才追加”的原子写法
“给用户加一个标签,但如果它已经有了就别重复加”,这个需求看着简单,实际写起来很容易出错。我的做法是把判断放进 UPDATE 的 WHERE 条件里:
UPDATE content_meta SET tags = array_append(tags, 'postgres') WHERE id = 1001 AND NOT ('postgres' = ANY(tags));注意'postgres' = ANY(NULL)的结果是 NULL,所以如果 tags 列允许 NULL,这个 UPDATE 会静默跳过。可靠的写法是先把 NULL 兜掉:
UPDATE content_meta SET tags = array_append(COALESCE(tags, ARRAY[]::text[]), 'postgres') WHERE id = 1001 AND NOT ('postgres' = ANY(COALESCE(tags, ARRAY[]::text[])));这里顺带提醒一句:数组字段并没有数据库层面的唯一性约束,重复元素只能靠应用层或这种“条件更新”来控制。真要做到严格唯一,还是得回到关联表。
3. 删除的三种姿势:按值删、按位置删、按尾部删
3.1 array_remove 按值删,注意它会删干净
删除元素最常用的函数是array_remove,它会把数组中所有等于目标值的元素都删掉,而不是只删第一个。
SELECT array_remove(ARRAY['a','b','a','c'], 'a'); -- 结果:{b,c}这个“删干净”的行为很关键。如果你的业务场景是“用户误点了一次,应该只移除一个标签”,直接用array_remove会把历史重复数据一并清掉,十有八九不是你想要的效果。我建议在动手前先想清楚需求的语义:是按值去重后删除,还是只删一次?只删一次就得走按下标删的逻辑,见 3.2。
还有一个经常被忽略的用途:用array_remove清空数组里的 NULL 元素。
SELECT array_remove(ARRAY['a', NULL, 'b'], NULL); -- 结果:{a,b}这在数据清洗场景很实用。比如上游接口返回的标签数组里混着空值,入库前清理一下,能避免后面很多查询语义混乱的问题。
3.2 按位置删的切片拼接法
想只删除第一次出现的位置,或者按指定下标删除,PostgreSQL 没有现成的array_delete_at,但可以用“切片拼接”实现:把目标位置之前的切片和之后的切片拼起来。
UPDATE content_meta SET tags = tags[:1-1] || tags[1+1:] WHERE id = 1001;这个写法看着别扭,我通常封装成一个函数,业务代码里直接调用:
CREATE OR REPLACE FUNCTION remove_at(p_arr text[], p_idx int) RETURNS text[] AS $$ BEGIN IF p_idx IS NULL OR p_idx < array_lower(p_arr, 1) OR p_idx > array_upper(p_arr, 1) THEN RETURN p_arr; END IF; RETURN p_arr[:p_idx - 1] || p_arr[p_idx + 1:]; END; $$ LANGUAGE plpgsql IMMUTABLE;配合array_position找到第一个目标值的下标,就能实现“只删第一次出现”:
SELECT remove_at( array_position(ARRAY['a','b','a'], 'a'), ARRAY['a','b','a'] );实际使用中,不要忘了数组下标不一定从 1 开始,虽然绝大多数场景都是从 1 开始,但写通用逻辑时用array_lower和array_upper取值会稳妥得多。
3.3 trim_array 与“清空字段”的语义选择
如果是删除数组末尾的 N 个元素,可以用trim_array(版本较新的 PostgreSQL 都支持):
SELECT trim_array(ARRAY[1,2,3,4,5], 2); -- 结果:{1,2,3}比起切片写法,trim_array语义清楚,而且不会踩到边界值问题。最后再说“清空整个字段”这件事。清空有两种方式:SET tags = '{}'和SET tags = NULL。这两者在查询语义上是不同的。
SELECT '{}'::text[] @> ARRAY['a']; -- false SELECT NULL::text[] @> ARRAY['a']; -- NULL,过滤时表现为“不匹配”很多应用层会把“空数组”和“NULL”当成一回事,但在数据库里,WHERE tags @> ARRAY['a']会直接排除掉 tags 为 NULL 的行,却不会排除空数组行。为避免这种“看似相同实则不同”的坑,我建议建表时统一成NOT NULL DEFAULT '{}',把 NULL 从源头杜绝掉。
4. 查询运算符速查与索引生效的前提
4.1 五六个常用运算符
数组查询靠的是一组专用运算符,记住它们比记函数更有效率。
| 运算符 | 含义 | 示例 | 返回 |
|---|---|---|---|
@> | 左侧包含右侧所有元素 | ARRAY['a','b'] @> ARRAY['a'] | true |
<@ | 左侧所有元素都被右侧包含 | ARRAY['a'] <@ ARRAY['a','b'] | true |
&& | 两边有交集 | ARRAY['a'] && ARRAY['b','a'] | true |
= | 完全相等(顺序和重复都算) | ARRAY['a','b'] = ARRAY['b','a'] | false |
<> | 不完全相等 | ARRAY['a'] <> ARRAY['a','b'] | true |
|| | 拼接 | ARRAY['a'] || ARRAY['b'] | {a,b} |
实际查询中,“包含”和“有交集”是两种最常见的需求。比如筛选同时包含 sql 和 postgres 两个标签的文章:
SELECT id, title FROM content_meta WHERE tags @> ARRAY['sql', 'postgres'];筛选只要命中任一标签就行:
SELECT id, title FROM content_meta WHERE tags && ARRAY['sql', 'redis'];这两条 SQL 不只是语义不同,能否命中 GIN 索引的方式也不同。@>和&&都可以走 GIN,这也是数组字段最值钱的查询能力。
4.2 三个容易误解的语义细节
第一个误解是“包含”与“顺序无关、与重复也无关”。ARRAY['a','a','b'] @> ARRAY['a','b']返回 true,因为左边数组里确实存在 a 和 b 这两个值。如果业务上要求“元素完全一致,不能多也不能少”,那要用=而不是@>,因为=对顺序和重复都敏感。
第二个误解是空数组的包含关系。ARRAY['a'] @> '{}'为 true,反过来'{}' @> ARRAY['a']为 false。这符合集合论直觉,但在写 NOT 条件时要小心,别把空数组的语义想反了。
第三个误解是 NULL 元素的传播。数组里如果混入 NULL,@>这类运算的结果可能变成 NULL,进了 WHERE 之后表现为“不匹配”。所以数据清洗时优先用array_remove(arr, NULL)把 NULL 清掉,别让脏数据潜伏到查询逻辑里。
4.3 unnest 炸开数组查询
数组查询的另一半能力来自unnest,它把数组展开成行,让集合查询和聚合查询成为可能。
SELECT id, unnest(tags) AS tag FROM content_meta WHERE id = 1001;如果要保留元素在数组里的原始位置,用WITH ORDINALITY:
SELECT id, tag, ord FROM content_meta CROSS JOIN LATERAL unnest(tags) WITH ORDINALITY AS u(tag, ord) WHERE id = 1001;这里特别提醒:unnest在 SQL 标准里不承诺返回顺序。PostgreSQL 目前通常按数组顺序展开,但这不是文档保证的行为,一旦依赖它就可能被版本升级打脸。需要顺序的场合,最安全的方式是显式ORDER BY ord,或者直接在写入时就把顺序问题交给下游排序。
5. GIN 索引实战:建索引容易,让它真正生效难
5.1 建索引容易,但只有三类运算符走它
GIN 索引是数组查询提速的关键,初期建索引就是一句话:
CREATE INDEX ix_content_meta_tags ON content_meta USING GIN (tags);建完之后,只有@>(包含)、<@(被包含)、&&(交集)这三类运算符能真正吃到 GIN。其余操作,包括数组相等比较、长度过滤、下标定位,全都与 GIN 无缘。用EXPLAIN就能直观看到索引是否生效:
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM content_meta WHERE tags @> ARRAY['postgres'];如果看到Bitmap Index Scan on ix_content_meta_tags,说明索引被用上了。如果看到Seq Scan,则说明需要检查统计信息或测试一下强制关闭顺序扫描的场景。
5.2 哪些写法注定走不了索引
这类写法比“索引生效”更值得记住,因为它们看起来都“应该”能走索引,实际上却不行。
-- 走不了 GIN:左边是标量,右边是数组 SELECT id FROM content_meta WHERE 'postgres' = ANY(tags); -- 走不了 GIN:数组长度过滤 SELECT id FROM content_meta WHERE cardinality(tags) > 5; -- 走不了 GIN:按下标取元素 SELECT id FROM content_meta WHERE tags[1] = 'postgres';尤其是'postgres' = ANY(tags)这种写法,语义上和tags @> ARRAY['postgres']完全等价,但前者无法利用数组 GIN 索引。遇到这种查询,我建议直接在代码里统一改成@>写法,既简洁又能吃到索引。
cardinality(tags) > 5这种长度过滤属于常见需求,但它只能顺序扫描。如果必须频繁使用,我更倾向于在应用层维护一个计数列,或者把这类数据拆到关联表里,用 COUNT 查询。
5.3 统计信息与选择性,决定索引是否真的提速
很多 DBA 建完索引后发现查询还是顺序扫描,原因往往不是索引坏了,而是统计信息太旧或不充分。数组 GIN 索引的选择性估算依赖每个元素的出现频率,也就是所谓“最常见的元素”统计量。数据写入大量变化后,记得:
ANALYZE content_meta;如果某个元素标签出现得非常频繁,比如 1000 万行里有 600 万行都带postgres,那么用 GIN 索引扫描这个标签的效果几乎等于全表扫描,规划器很自然地会选择顺序扫描。这不是 bug,是成本估算在做正确的事。
想给规划器更多信息,可以针对数组列调整统计目标并重新 ANALYZE:
ALTER TABLE content_meta ALTER COLUMN tags SET STATISTICS 1000; ANALYZE content_meta;统计目标不是越大越好,我一般在 500 到 5000 之间试探,观察规划器的选择是否合理后再固定下来。另外,如果业务查询里普遍带一个固定的过滤条件,比如只看某状态的数据,也可以用部分索引缩小 GIN 的维护范围:
CREATE INDEX ix_meta_tags_active ON content_meta USING GIN (tags) WHERE status = 'active';查询时必须带上status = 'active'条件,这个部分索引才能被使用。生产环境中我用这个方案处理过一张热表,写放大明显缓解,查询速度没有损失。
6. 生产环境踩坑记录与可复用的设计建议
6.1 五个真实踩过的坑
第一个坑就是 NULL 与空数组混用。同一张表里,有的行 tags 是'{}',有的行是 NULL,查询语义就乱了。后来我把列改成NOT NULL DEFAULT '{}',并在写入接口统一做COALESCE,问题才算根治。
第二个坑是array_remove把所有重复值都删掉。有一次做“移除单个标签”功能,用户明明只点了一次取消,结果历史重复标签全没了。这段经历让我养成了遇到“删除”先想清楚语义再写代码的习惯。
第三个坑是依赖unnest的返回顺序。在某个版本升级之前一直没出问题,升级后某些查询返回顺序变了,排查了很久才发现是自己在无意识依赖非保证行为。从那以后凡是带顺序的需求一律用WITH ORDINALITY加显式排序。
第四个坑是string_to_array保留了空格。上游传来的字符串是'pg, redis, sql',直接拆分得到的是带前导空格的元素。清洗方案是:
SELECT regexp_split_to_array('pg, redis, sql', '\s*,\s*'); -- 结果:{pg,redis,sql}第五个坑是驱动程序对数组参数的处理。PreparedStatement 里直接传{a,b}这种字面量容易触发类型推断问题。更稳妥的方式是按驱动特性传原生数组,或者用字符串拼接后显式 cast。比如在应用层组装成'{a,b}'::text[]再传给查询,虽然不够优雅,但跨驱动行为最一致。
6.2 我目前推荐的设计组合
用了几轮下来,我现在的设计取了个折中:核心主数据用关联表,辅助多值属性用数组字段。以内容产品举例,文章的正式分类标签走关联表,由编辑维护,保证参照完整性;用户兴趣偏好则走数组字段,由推荐服务批量写入,读取时一行拿全,查询效率高。两者之间通过业务 ID 关联,不产生数据冗余。
这种组合的好处是:需要强一致和复杂关联的数据,依然在关系模型里;适合“读了就展示”的附属数据,则享受数组的读取效率和 GIN 索引。真到某一天数组字段膨胀得不可收拾,把unnest导出到一张新关联表做数据迁移,成本也可控。数组在 PostgreSQL 里从来不是“替代关系模型”的武器,它是在正确的位置上省掉一张表的工具。
最后分享一个实际操作中的体会:数组字段的维护逻辑,尽量封装成独立的 SQL 函数,而不是散落在业务代码里。追加去重、按下标删除、清理 NULL,这些函数一旦写好,所有应用层都共用同一套语义,数据库里的数组才不会慢慢长成没人敢碰的野马。