数据库基础体系 · 第 25/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

PostgreSQL JSONB 与扩展生态:全文检索、分区、FDW 和扩展边界

在 PostgreSQL 中,jsonb、全文检索、分区表和 FDW(Foreign Data Wrapper,外部数据包装器)经常出现在同一类系统里:

  • 业务对象的字段并不完全固定,需要使用 jsonb 保存扩展属性;
  • 需要对标题、正文以及 JSON 中的字段进行全文检索;
  • 数据量增长后按时间或租户进行分区;
  • 一部分历史数据、异构数据或其他 PostgreSQL 实例需要通过 FDW 访问;
  • 需要使用 pg_trgmunaccentpgvector 等扩展补充能力。

这些功能并不是一个“JSONB 搜索开关”。它们分别位于 PostgreSQL 的数据类型、索引、查询规划、分区路由、事务管理和扩展加载边界上。理解它们之间的关系,首先要区分三个问题:

  1. 数据以什么类型保存;
  2. 查询条件如何表示以及如何建立索引;
  3. 数据实际位于哪个关系、分区或远程服务器上。

一、JSONB 是数据类型,不是全文检索引擎

1. jsonjsonb

PostgreSQL 同时提供 jsonjsonb

  • json 保存输入文本的 JSON 表示;
  • jsonb 将 JSON 解析为二进制结构,便于比较、运算和建立索引。

jsonb 不保证保留对象键的输入顺序,也不会保留重复键。对于类似下面的输入:

{"status": "draft", "status": "published"}

解析为 jsonb 后,重复键不会作为两个独立字段保留,通常只有最后一个值有效。因此,如果输入文本本身的格式、空白、键顺序或重复键具有业务意义,应考虑保存原始文本,而不能只保存 jsonb

jsonb 的优势是可以直接进行结构化操作:

SELECT
    payload->>'title' AS title,
    payload->'author'->>'name' AS author_name
FROM documents
WHERE payload @> '{"status": "published"}';

这里有三个不同的操作:

  • -> 返回 JSON 值;
  • ->> 返回文本值;
  • @> 判断左侧 JSONB 是否包含右侧 JSONB 结构。

例如:

SELECT
    '{"a": 1, "b": {"c": 2}}'::jsonb @> '{"b": {"c": 2}}'::jsonb
       AS contains_result;

结果为:

 contains_result
-----------------
 t

@> 是结构包含,不是文本包含。下面两个条件含义不同:

-- JSON 结构中存在 tags 数组,并且包含 "database"
payload @> '{"tags": ["database"]}'::jsonb

-- 从 JSON 中提取 tags 后,再判断数组中是否包含文本
payload->'tags' ? 'database'

后者要求 tags 是 JSON 对象或数组中适合 ? 操作的值;它并不等价于全文检索。

2. SQL NULL、JSON null 和缺失字段

JSONB 查询中最容易混淆的是三种状态:

  1. SQL NULL
  2. JSON 值 null
  3. JSON 路径不存在。

例如:

SELECT
    '{"x": null}'::jsonb->'x' AS json_null,
    '{}'::jsonb->'x' AS missing_value;

二者在显示上都可能表现为 NULL,但语义不同。要判断 JSON 对象是否明确包含某个键,应使用:

payload ? 'x'

而不是仅判断:

payload->'x' IS NULL

因为后者无法区分“键存在且值为 JSON null”和“键不存在”。

如果要把文本字段用于搜索,通常使用 coalesce 处理缺失字段:

coalesce(payload->>'title', '')

这样可以避免字符串拼接中的 SQL NULL 传播。


二、JSONB 的索引:结构查询与文本搜索是两条路径

1. GIN 与 JSONB 操作符类

JSONB 最常用的是 GIN(Generalized Inverted Index,广义倒排索引)。它不是为每行保存一个单一排序键,而是把一个 JSON 文档拆成多个可检索项,从而支持“哪些文档包含这个键、值或路径”的反向查找。

默认的 jsonb_ops 操作符类支持范围较广,例如:

  • @>:包含;
  • ?:是否存在键或数组元素;
  • ?|?&:是否存在任意或全部指定项;
  • 部分 JSONPath 查询。

示例:

CREATE TABLE documents (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload  jsonb NOT NULL
);

CREATE INDEX documents_payload_gin_idx
ON documents
USING gin (payload);

如果主要查询结构包含,可以考虑 jsonb_path_ops

CREATE INDEX documents_payload_path_gin_idx
ON documents
USING gin (payload jsonb_path_ops);

两者的取舍不是“一个新、一个旧”:

  • jsonb_ops 索引表达能力更广,适合多种 JSONB 操作;
  • jsonb_path_ops 通常针对包含路径生成更紧凑的索引项,适合大量 @> 查询;
  • 选择 jsonb_path_ops 后,不能假设所有 jsonb_ops 支持的键存在性查询都仍然可以使用该索引。

应以实际查询和 EXPLAIN 验证,而不是仅凭索引名称判断:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM documents
WHERE payload @> '{"status": "published"}'::jsonb;

如果查询条件是:

WHERE payload->>'status' = 'published'

优化器不一定能把它转化为适合上述整列 GIN 的条件。对于稳定的标量路径,表达式索引往往更直接:

CREATE INDEX documents_status_idx
ON documents ((payload->>'status'));

这两个索引解决的是不同问题:

  • 整列 GIN 适合 JSON 结构包含和多种结构操作;
  • B-tree 表达式索引适合某个路径提取后的等值、排序或范围查询。

2. JSONB 不是“包含字符串”的索引

下面的查询是文本模式匹配:

WHERE payload->>'title' ILIKE '%postgres%'

普通 B-tree 索引无法有效支持前导 %。JSONB GIN 也不会自动把任意字符串拆成全文词项。此时可以选择:

  • 将字段提取后交给全文检索;
  • 使用 pg_trgm 支持相似度和 %...% 模式;
  • 对结构化字段使用等值或范围索引。

例如,使用三元组扩展:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX documents_title_trgm_idx
ON documents
USING gin ((payload->>'title') gin_trgm_ops);

这个索引服务于子字符串匹配和相似度匹配,不等同于 PostgreSQL 全文检索。pg_trgm 将文本拆成三元组;全文检索则先进行分词、归一化并生成词位。


三、PostgreSQL 全文检索的实际数据流

1. tsvectortsquery

PostgreSQL 全文检索的核心是两个类型:

  • tsvector:文档经过分词和归一化后的词项集合,可以包含词位位置和权重;
  • tsquery:查询条件的逻辑表达式。

例如:

SELECT to_tsvector(
    'english',
    'PostgreSQL provides powerful database features'
) AS document_vector;

结果的具体词形由文本搜索配置决定。english 配置可能进行词干化,使 featuresfeature 等词形进入可匹配的归一化结果。

查询:

SELECT to_tsquery('english', 'database & feature');

再使用匹配运算符:

SELECT to_tsvector(
           'english',
           'PostgreSQL provides powerful database features'
       )
       @@ to_tsquery('english', 'database & feature')
       AS matched;

@@ 的左侧通常是文档向量,右侧是查询向量,但 PostgreSQL 也提供适配的重载。实际工程中应显式使用相同配置,避免索引生成和查询解析采用不同的词法规则。

常见查询构造函数有不同语义:

  • to_tsquery:输入是 PostgreSQL 全文查询语法;
  • plainto_tsquery:把普通文本解析为 AND 关系的词项;
  • phraseto_tsquery:考虑词项相邻关系;
  • websearch_to_tsquery:接受接近搜索引擎风格的输入,并尽量避免因普通用户输入语法错误而失败。

例如,用户输入不能直接无条件拼接进 to_tsquery

-- 用户输入 "postgres database" 时通常可以工作
to_tsquery('simple', user_input)

但用户输入 postgres & ( 之类不完整表达式时可能报错。面向搜索框的输入通常更适合:

websearch_to_tsquery('simple', user_input)

这不意味着它可以消除所有业务级输入验证;查询长度、权限过滤和资源消耗仍需控制。

2. 从 JSONB 生成全文检索向量

全文检索不会自动读取 JSONB 内部所有字符串。必须明确指定哪些字段参与搜索:

CREATE TABLE articles (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload  jsonb NOT NULL
);

可以建立表达式 GIN 索引:

CREATE INDEX articles_search_idx
ON articles
USING gin (
    to_tsvector(
        'simple',
        coalesce(payload->>'title', '') || ' ' ||
        coalesce(payload->>'body', '')
    )
);

查询时必须写出等价表达式:

SELECT id, payload->>'title' AS title
FROM articles
WHERE to_tsvector(
          'simple',
          coalesce(payload->>'title', '') || ' ' ||
          coalesce(payload->>'body', '')
      )
      @@ websearch_to_tsquery('simple', 'jsonb index');

表达式索引的关键条件是:查询表达式要与索引表达式在语义和函数配置上保持一致。若索引使用 simple,查询却使用 english,即使文本相同,词项也可能不同,索引无法按预期服务查询。

另一种更容易复用和调试的方式是保存 tsvector 列:

CREATE TABLE searchable_articles (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload       jsonb NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        to_tsvector(
            'simple',
            coalesce(payload->>'title', '') || ' ' ||
            coalesce(payload->>'body', '')
        )
    ) STORED
);

CREATE INDEX searchable_articles_search_idx
ON searchable_articles
USING gin (search_vector);

插入数据:

INSERT INTO searchable_articles (payload)
VALUES (
    '{
       "title": "PostgreSQL JSONB index",
       "body": "GIN can accelerate structural JSONB queries and full text search"
     }'::jsonb
);

执行:

SELECT id, payload->>'title'
FROM searchable_articles
WHERE search_vector
      @@ websearch_to_tsquery('simple', 'JSONB GIN');

search_vector 是由源数据计算出的存储列。应用不能直接写入它;修改 payload 后,数据库会在写入行时重新计算。代价是每次相关 JSONB 字段变更都需要重新生成向量,并且更新索引。

3. 权重、排名与高亮

标题和正文的重要性通常不同,可以使用权重:

CREATE TABLE weighted_articles (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload       jsonb NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(
            to_tsvector('simple', coalesce(payload->>'title', '')),
            'A'
        )
        ||
        setweight(
            to_tsvector('simple', coalesce(payload->>'body', '')),
            'D'
        )
    ) STORED
);

其中:

  • A 是较高权重;
  • D 是较低权重;
  • || 合并两个 tsvector

查询和排名:

WITH q AS (
    SELECT websearch_to_tsquery('simple', 'GIN JSONB') AS query
)
SELECT
    id,
    ts_rank_cd(search_vector, q.query) AS rank,
    payload->>'title' AS title
FROM weighted_articles, q
WHERE search_vector @@ q.query
ORDER BY rank DESC;

ts_rank_cd 是一种考虑词项覆盖密度的排名函数。排名不是“是否匹配”的一部分,而是匹配后的排序指标。GIN 索引主要帮助完成 @@ 过滤,不能直接等价为按相关性排序的索引。

如果需要高亮,可使用 ts_headline,但它会重新处理文本,应该在已经过滤出的较小结果集上调用,而不是对全表执行。


四、JSONB、全文检索和约束:灵活不等于无约束

JSONB 常用于承载可变属性,但关系表仍然应保存查询关键且稳定的字段。例如:

CREATE TABLE orders (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id    bigint NOT NULL,
    created_at   timestamptz NOT NULL,
    order_status text NOT NULL,
    attributes   jsonb NOT NULL DEFAULT '{}'::jsonb,
    CHECK (jsonb_typeof(attributes) = 'object'),
    CHECK (order_status IN ('pending', 'paid', 'cancelled'))
);

这里:

  • order_status 是核心业务状态,适合使用普通列和 CHECK
  • attributes 是扩展属性,适合使用 JSONB;
  • jsonb_typeof 确保顶层结构是对象,而不是数组或标量。

对于反复使用的约束,可以使用域(domain)。域是带有约束的基础类型,而不是新的存储结构:

CREATE DOMAIN nonempty_text AS text
CHECK (length(btrim(VALUE)) > 0);

然后:

CREATE TABLE customers (
    id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name nonempty_text NOT NULL
);

域适合复用“非空文本”“合法代码”等标量约束;它不能替代 JSON Schema,也不会自动递归验证 JSONB 的任意内部结构。JSONB 内部约束必须通过 CHECK、触发器、应用验证或专门扩展实现,并明确验证发生在哪个边界。


五、分区表:先决定数据路由,再谈 JSONB 索引

1. 分区的组件

声明式分区至少包含:

  • 分区父表:定义逻辑表结构和分区策略;
  • 分区键:决定新行进入哪个分区;
  • 分区:实际保存数据的子表;
  • 分区约束:说明每个分区允许的键值范围或集合;
  • 分区裁剪:规划器根据查询条件排除不可能命中的分区;
  • 分区路由:插入或更新时把行送入目标分区。

下面按时间范围创建文档表:

CREATE TABLE partitioned_articles (
    id            bigint NOT NULL,
    tenant_id     bigint NOT NULL,
    created_at    timestamptz NOT NULL,
    payload       jsonb NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(
            to_tsvector('simple', coalesce(payload->>'title', '')),
            'A'
        )
        ||
        setweight(
            to_tsvector('simple', coalesce(payload->>'body', '')),
            'D'
        )
    ) STORED,
    PRIMARY KEY (created_at, id)
) PARTITION BY RANGE (created_at);

创建分区:

CREATE TABLE partitioned_articles_2025_01
PARTITION OF partitioned_articles
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE partitioned_articles_2025_02
PARTITION OF partitioned_articles
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

范围分区使用半开区间:

[2025-01-01, 2025-02-01)

因此,2025-02-01 00:00:00 不属于一月分区,而属于二月分区。

2. 分区裁剪与全文检索的关系

查询包含分区键条件时,规划器可以裁剪分区:

EXPLAIN
SELECT id
FROM partitioned_articles
WHERE created_at >= '2025-02-01'
  AND created_at <  '2025-03-01'
  AND search_vector @@ websearch_to_tsquery('simple', 'postgresql');

理想情况下,执行计划只访问二月分区。全文检索条件负责筛选分区内部的行,时间条件负责减少需要访问的分区数量。

如果只写:

WHERE search_vector @@ websearch_to_tsquery('simple', 'postgresql')

数据库无法从全文检索词项推导出时间范围,通常需要检查所有相关分区。此时每个分区上的 GIN 索引仍可能有效,但分区数量越多,规划和执行管理成本越高。

JSONB 条件也是类似的:

SELECT id
FROM partitioned_articles
WHERE created_at >= '2025-02-01'
  AND created_at <  '2025-03-01'
  AND payload @> '{"status": "published"}'::jsonb;

时间条件负责分区裁剪,JSONB GIN 索引负责分区内部的结构查找。二者不是同一个层次的优化。

3. 分区索引的实际边界

在分区父表上创建索引:

CREATE INDEX partitioned_articles_search_idx
ON partitioned_articles
USING gin (search_vector);

这会创建分区级索引结构,使查询可以在各个分区内使用对应索引。新建分区时也应确认索引已建立,已有分区则要检查索引是否完整。

分区表的唯一约束和主键有一个重要限制:要保证跨分区全局唯一,约束通常必须包含分区键。上例使用:

PRIMARY KEY (created_at, id)

而不是单独的:

PRIMARY KEY (id)

原因是不同分区分别维护索引,数据库不能仅依靠各分区本地索引证明 id 在所有分区中全局唯一。如果业务要求 id 全局唯一,可以:

  • 把 ID 设计为全局生成的值,并在应用或其他机制中保证;
  • 把分区键纳入唯一约束;
  • 使用集中式唯一性设计,而不是误以为分区主键自动提供跨分区验证。

4. 分区路由、更新和故障路径

插入时,PostgreSQL 根据分区键选择目标分区:

INSERT INTO partitioned_articles (id, tenant_id, created_at, payload)
VALUES (
    1,
    10,
    '2025-02-10 12:00:00+00',
    '{"title": "Partitioned JSONB", "body": "routing"}'
);

这行进入二月分区。如果没有匹配分区,插入会失败:

ERROR: no partition of relation "partitioned_articles" found for row

可以增加默认分区承接未匹配数据,但默认分区会掩盖分区规划遗漏,且后续增加新分区时可能需要先清理或验证默认分区中的冲突数据。

更新分区键可能导致行从一个分区移动到另一个分区。这个过程不是简单地修改当前物理表中的一列,可能表现为从旧分区删除、向新分区插入;如果目标分区不存在、权限不足或存在约束冲突,整个语句会失败。生产中修改时间分区键应特别注意锁、触发器、外键和索引开销。


六、FDW:远程关系访问,不是分布式数据库透明层

1. FDW 的组成

FDW 是 PostgreSQL 的外部数据访问框架。一次典型配置包含:

  1. 扩展,例如 postgres_fdw
  2. 外部服务器(foreign server),描述远程连接;
  3. 用户映射(user mapping),描述本地用户如何认证远程服务;
  4. 外部表(foreign table),描述远程对象的本地关系定义;
  5. FDW 执行器,把扫描、过滤或修改转换为远程操作。

在本地数据库创建:

CREATE EXTENSION IF NOT EXISTS postgres_fdw;

CREATE SERVER remote_pg
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (
    host 'remote-db.example',
    port '5432',
    dbname 'content'
);

为本地用户配置远程认证:

CREATE USER MAPPING FOR CURRENT_USER
SERVER remote_pg
OPTIONS (
    user 'content_reader',
    password 'replace-with-secret'
);

导入远程 schema 中的表:

CREATE SCHEMA remote_content;

IMPORT FOREIGN SCHEMA public
LIMIT TO (articles)
FROM SERVER remote_pg
INTO remote_content;

之后可以查询:

SELECT id, payload->>'title'
FROM remote_content.articles
WHERE payload @> '{"status": "published"}'::jsonb;

前提是远程表确实有 payload JSONB 列,并且远程数据库支持相同的 JSONB 运算语义。

2. 查询数据流和下推

本地查询 FDW 外部表时,规划器会尝试把一部分操作下推到远端。例如:

SELECT id
FROM remote_content.articles
WHERE tenant_id = 10
  AND payload @> '{"status": "published"}'::jsonb;

可能的数据流是:

本地 SQL
  -> 本地规划器判断可下推条件
  -> 远程 PostgreSQL 执行 WHERE
  -> 仅返回满足条件的行
  -> 本地继续执行未下推部分

但“写了 WHERE”不等于“WHERE 一定在远端执行”。应使用:

EXPLAIN (VERBOSE, COSTS)
SELECT id
FROM remote_content.articles
WHERE tenant_id = 10
  AND payload @> '{"status": "published"}'::jsonb;

检查计划中的外部扫描条件和远程 SQL 信息。

postgres_fdw 对常见过滤、列裁剪、部分连接、聚合和排序具有下推能力,但是否下推取决于:

  • 操作是否能在远端安全地执行;
  • 两端类型、函数、排序规则和语义是否兼容;
  • 连接条件是否满足下推要求;
  • 本地和远端的成本估算;
  • 查询结构是否阻止了规划器重写。

尤其要注意全文检索:

WHERE search_vector @@ websearch_to_tsquery('simple', 'postgresql')

若外部表拥有远程 tsvector 列和相容配置,可能在远程执行;若本地先从 JSONB 提取字段、调用本地函数或混合了本地表,全文检索可能部分或全部留在本地。不要根据 SQL 文本推断网络传输量,必须查看执行计划,并结合远程日志验证。

3. FDW 事务和故障边界

一次本地事务访问远程 PostgreSQL 时,postgres_fdw 会在远端建立相应事务上下文。远程扫描、远程写入和提交并不等于本地表的普通本地存储访问。

需要区分:

  • 本地事务回滚通常会要求远程事务回滚;
  • 网络中断、远程数据库崩溃或本地进程异常会造成连接和事务状态不确定;
  • 多个远程服务器之间不自动构成完整的分布式事务;
  • 远程提交与本地其他资源的提交不能简单视为一个全局原子操作。

因此,FDW 适合访问和联邦查询,但不能默认提供跨多个数据库的完整两阶段提交、全局锁或全局唯一约束。跨库写入需要显式设计幂等、补偿、重试和一致性验证。

一个常见错误是把外部表当作“本地缓存”:

INSERT INTO remote_content.articles ...

这可能产生远程写入和网络等待;若语句执行到一半连接断开,本地客户端看到的错误不一定能直接说明远程是否已经接受了请求。对非幂等操作,应使用业务唯一键和结果确认机制,不能无条件重试。


七、扩展边界:CREATE EXTENSION 改变了什么,没有改变什么

1. 扩展可以注册数据库对象

PostgreSQL 扩展可以通过控制文件、SQL 脚本和可选的服务器端代码注册或提供:

  • 数据类型;
  • 函数和操作符;
  • 操作符类;
  • 索引访问方法;
  • 触发器;
  • FDW;
  • 统计信息或规划器相关能力;
  • 需要预加载的后台或共享库能力。

使用扩展:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

这通常会把扩展定义的对象安装到当前数据库,而不是自动安装到同一实例上的所有数据库。另一个数据库仍可能需要单独执行 CREATE EXTENSION

扩展安装还受以下边界影响:

  • 服务器上必须已安装扩展文件;
  • 当前用户必须有相应权限;
  • 某些扩展需要修改 shared_preload_libraries 并重启;
  • 扩展的版本升级由扩展自身的升级脚本支持;
  • 删除扩展可能删除其管理的对象,生产操作前必须确认依赖关系。

因此,“服务器安装了扩展”和“当前数据库已经可以使用扩展”是两个不同条件。

2. 内置能力与扩展能力

JSONB 和 PostgreSQL 全文检索的核心类型、操作符和 GIN 支持属于 PostgreSQL 核心能力,不要求额外安装扩展。

但以下能力通常来自扩展:

  • pg_trgm:三元组索引、相似度和部分模式匹配;
  • unaccent:去除重音符号的文本搜索字典或函数;
  • postgres_fdw:访问其他 PostgreSQL 数据库;
  • pgvector:向量类型、向量距离运算和对应索引。

扩展不会把 PostgreSQL 变成另一个执行引擎。比如安装 pg_trgm 不会让 JSONB 自动支持全文分词;安装 pgvector 也不会让全文检索自动理解向量相似度。它们通过类型、函数、操作符和索引扩展能力,最终仍受 PostgreSQL 的 MVCC、事务、锁、规划器和资源管理约束。

3. 与 pgvector 的边界

如果文档同时保存全文检索向量和语义向量,可以使用类似结构:

CREATE TABLE article_embeddings (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload       jsonb NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        to_tsvector(
            'simple',
            coalesce(payload->>'title', '') || ' ' ||
            coalesce(payload->>'body', '')
        )
    ) STORED
    -- embedding vector(...) 由 pgvector 扩展提供
);

全文检索回答的是:

文档是否包含经过词法分析后匹配的词项?

向量检索回答的是:

文档向量在指定距离函数下是否接近查询向量?

混合查询可以先用结构条件和全文检索缩小候选集,再计算向量距离,也可以反过来。具体执行计划取决于过滤选择性、向量索引类型、排序要求和扩展版本。不能因为两者都使用索引,就假设数据库会自动得到全局最优的混合检索计划。


八、一个组合示例:分区 JSONB 文档表与全文检索

下面把前面的机制组合起来:

CREATE TABLE knowledge_docs (
    tenant_id     bigint NOT NULL,
    doc_id        bigint NOT NULL,
    created_at    timestamptz NOT NULL,
    payload       jsonb NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(
            to_tsvector('simple', coalesce(payload->>'title', '')),
            'A'
        )
        ||
        setweight(
            to_tsvector('simple', coalesce(payload->>'content', '')),
            'D'
        )
    ) STORED,
    PRIMARY KEY (created_at, tenant_id, doc_id),
    CHECK (jsonb_typeof(payload) = 'object')
) PARTITION BY RANGE (created_at);

创建分区和索引:

CREATE TABLE knowledge_docs_2025_01
PARTITION OF knowledge_docs
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE knowledge_docs_2025_02
PARTITION OF knowledge_docs
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

CREATE INDEX knowledge_docs_search_idx
ON knowledge_docs
USING gin (search_vector);

CREATE INDEX knowledge_docs_payload_idx
ON knowledge_docs
USING gin (payload jsonb_path_ops);

写入:

INSERT INTO knowledge_docs
    (tenant_id, doc_id, created_at, payload)
VALUES
    (
        7,
        1001,
        '2025-02-12 09:30:00+00',
        '{
           "title": "PostgreSQL JSONB",
           "content": "GIN supports structural queries and text search pipelines",
           "status": "published",
           "tags": ["postgresql", "database"]
         }'::jsonb
    );

组合查询:

SELECT
    tenant_id,
    doc_id,
    payload->>'title' AS title,
    ts_rank_cd(
        search_vector,
        websearch_to_tsquery('simple', 'GIN JSONB')
    ) AS rank
FROM knowledge_docs
WHERE created_at >= '2025-02-01'
  AND created_at <  '2025-03-01'
  AND payload @> '{"status": "published"}'::jsonb
  AND search_vector @@ websearch_to_tsquery('simple', 'GIN JSONB')
ORDER BY rank DESC;

这个查询按以下顺序理解:

  1. created_at 给规划器提供分区裁剪条件;
  2. payload @> 进行 JSONB 结构过滤;
  3. search_vector @@ ... 进行全文匹配;
  4. ts_rank_cd 对已经匹配的结果进行相关性排序。

实际执行时,优化器不保证严格按上述文字顺序执行。上述顺序是语义分解,不是执行计划承诺。最终应通过:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
...

确认是否发生了分区裁剪、哪些索引被使用,以及是否存在大量回表或排序开销。


九、常见失败表现与诊断路径

1. 搜索结果为空,但 JSONB 中明明有文本

可能原因包括:

  • search_vector 只提取了 title,查询词在 content
  • 索引使用 english,查询使用 simple
  • JSONB 字段实际是数字、数组或对象,不是文本;
  • 文本配置的停用词或词干化规则改变了词项;
  • 更新 JSONB 的路径没有真正改变预期字段。

检查生成结果:

SELECT
    payload->>'title',
    payload->>'content',
    search_vector
FROM searchable_articles
LIMIT 10;

再单独检查查询解析结果:

SELECT websearch_to_tsquery('simple', 'GIN JSONB');

这一步可以确认问题是在源文本、配置还是查询构造。

2. 有 GIN 索引但仍然扫描很多数据

GIN 只回答它支持的操作符问题,不会保证每个查询都使用索引。常见原因:

  • 查询使用了不匹配的表达式;
  • 使用了不适合该操作符类的操作;
  • 条件选择性太低,顺序扫描成本更低;
  • 分区键条件缺失,必须访问大量分区;
  • 统计信息过旧;
  • JSONB 文档很大,索引和回表成本较高。

应先看:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...

不要仅凭 CREATE INDEX 是否成功判断查询已经被优化。

3. JSONB 更新成本异常高

JSONB 是一行中的一个值。更新其中一个路径,数据库仍需要写入新的行版本;如果值较大,还可能涉及 TOAST 存储和相关索引更新。对拥有大型 JSONB、GIN 索引和全文向量的表,频繁更新任意字段可能同时带来:

  • 新旧行版本;
  • JSONB 存储写入;
  • GIN 索引维护;
  • tsvector 重新计算;
  • WAL 增长;
  • 后台清理压力。

如果某些字段频繁变化、某些字段用于高频查询,应考虑将它们提升为独立列,而不是把所有内容永久封装在同一个 JSONB 值中。

4. 分区表插入失败

错误:

no partition of relation found for row

优先检查:

SELECT min(created_at), max(created_at)
FROM incoming_source;

然后核对分区边界是否覆盖这些值,尤其注意:

  • 范围分区的上界不包含;
  • 时区转换可能使应用时间落入不同日期;
  • 是否需要默认分区;
  • 新月份分区是否已提前创建。

5. FDW 查询很慢

诊断应分成两段:

  1. 本地是否把过滤、列裁剪或聚合下推;
  2. 远程数据库是否使用了预期的 JSONB 或全文索引。

本地查看:

EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT ...
FROM remote_content.articles
WHERE ...;

远程则应在远程数据库中对等价 SQL 执行 EXPLAIN。如果大量远程行被传回本地后才过滤,瓶颈通常在下推失败、类型不兼容、查询包装方式或连接条件,而不是 JSONB 本身。


十、如何划分职责

可以用下面的边界判断设计:

  • 稳定且需要约束、排序、连接的字段:普通列、CHECK、域和普通索引;
  • 结构变化频繁但仍需结构查询的属性:JSONB;
  • 键和值的存在、包含和 JSONPath 条件:JSONB 操作符与合适的 GIN 或表达式索引;
  • 自然语言词项匹配tsvectortsquery 和全文检索 GIN;
  • 任意子串、模糊拼写和相似文本pg_trgm
  • 按时间、租户或哈希隔离数据访问范围:声明式分区;
  • 访问另一个数据库或外部系统:FDW;
  • 语义相似度检索:向量扩展及其距离索引。

最重要的不是把这些功能全部安装上,而是让每个条件落在与其语义匹配的层次:

分区键       -> 决定访问哪些物理分区
结构条件     -> 决定 JSONB 文档是否包含某种结构
全文条件     -> 决定词项是否匹配
向量条件     -> 决定距离是否足够接近
FDW          -> 决定数据从哪个系统读取以及哪些操作远程执行

当这些边界清晰时,JSONB 可以提供灵活的数据模型,全文检索可以提供可解释的词项匹配,分区可以缩小物理访问范围,FDW 可以连接外部关系,而扩展则在 PostgreSQL 已有事务和规划框架内增加新的类型、操作符和访问能力。反之,如果把 JSONB 当全文索引、把分区当全局唯一性机制、把 FDW 当分布式事务系统,问题通常会在数据量、并发或故障条件下才暴露出来。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。