# 你还不需要另一个数据库

> 了解 Postgres 在键值、文档、图、时序、队列、搜索和向量负载上能走多远，以及什么时候该让另一个系统占据一席之地。

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-15"></span>

这是在一个有 200,000 行的 Postgres 表中查找会话：4 个页，数据库耗时只有几微秒。获取它所需的网络往返成本比查找本身还高；换成专用键值存储，情况也一样。

序言承诺过：产品需要的大多数东西，一个数据库就能满足。本章逐个检查这个承诺涵盖的模型：键值、文档、图、时序、队列、搜索和向量。对每一种模型，我们都会看看 Postgres 能做到多接近、要付出多少页的代价，以及什么信号说明该换另一个系统了。

## 每种模型各有一种形状，专门为它加速 {#each-model-is-one-shape-made-fast}

**数据模型（data model）**是数据库假定你的数据所具有的形状；它的存储引擎就是为让这种形状运行得快而构建的。

| 模型 | 优化目标 | Postgres 工具 |
| --- | --- | --- |
| 键值 | 按键获取和写入一个值 | 主键、不记日志表 |
| 文档 | 整体读写嵌套记录 | `jsonb`、GIN |
| 图 | 沿关系深入多跳 | 递归 CTE、`ltree` |
| 时序 | 只追加、按时间排序的范围扫描 | BRIN、分区 |
| 队列 | 把每个任务交给且只交给一个工作进程 | `for update skip locked` |
| 搜索 | 按相关性给文本排序 | `tsvector`、GIN |
| 向量 | 最近邻 | `pgvector` 扩展 |

<span id="tbl-15-1"></span>

**Table 15-1.** 每种模型各有一种形状，专门为它加速


每种模型都牺牲关系模型的通用性，换取在一种模式上的速度。Postgres 的可扩展性（新类型、操作符、索引方法）正是它把这种速度借回来的方式。

## 键值就是一次主键查找 {#key-value-is-a-primary-key-lookup}

键值存储把一个值放在一个键下面，再按键取回它。在 Postgres 里，这就是一张带主键的表；而主键查找几乎是数据库读取能做到的最便宜的操作：

```sql
create unlogged table kv_unlogged (
  key text primary key, value jsonb not null, expires_at timestamptz not null
);
explain (analyze, costs off, timing off, summary off)
select value from kv_unlogged where key = 'session:4242';
```

```text
 Index Scan using kv_unlogged_pkey on kv_unlogged (actual rows=1.00 loops=1)
   Buffers: shared hit=4
```

一张有 200,000 行的表只需读取 4 个页：几个 B-树页和一个堆页；数据库耗时只有几微秒。网络往返才是主要成本，任何联网存储都是如此。

### 崩溃后，不记日志表会被清空 {#an-unlogged-table-is-emptied-after-a-crash}

上面有意思的词是 `unlogged`。不记日志表（unlogged table）跳过预写式日志：同样插入 200,000 个会话，普通表写入了 41 MB 的 WAL，这张表则一条也没有。代价很严重：

- **崩溃后，不记日志表会被清空。** 不是回滚，而是截断。正常关机会保留它；崩溃或故障切换不会。
- **它不会被复制，**物理基础备份也会跳过它的数据。

因此，只把它用于缓存可以保存的东西：用户可以重新创建的会话、限流计数器、可重新计算的结果。凡是不能丢的数据，都绝不能放进去。它没有 TTL：分批删除过期行，读取时用 `expires_at > now()` 过滤，并为这张表设置积极的自动清理策略（[清理不可省](/10-vacuum/#basics-chapter-10)）。

<span id="four-pages-no-wal-and-the-empty-copy-that-replaces-a-crashed-table" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-15-four-pages-no-wal-and-the-empty-copy-that-replaces-a-crashed-table" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：4 个页、没有 WAL，以及崩溃后替代表的空副本
>
> > [!EXAMPLE]- 动手验证 · lab `14-models`
> >
> > ```sql
> > select lab.prove(
> >   'a primary key lookup in a 200,000-row table touches 4 pages',
> >   lab.nodes($$ select value from kv_unlogged where key = 'session:4242' $$) = '{Index Scan}'
> >   and lab.buffers($$ select value from kv_unlogged where key = 'session:4242' $$) = 4);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/14-models.sql)
>
> 两次插入的结果：
>
> ```text
>  Insert on kv_logged (actual rows=0.00 loops=1)
>    WAL: records=401315 fpi=1 bytes=41357183 buffers full=10690
>
>  Insert on kv_unlogged (actual rows=0.00 loops=1)
>    ->  Function Scan on generate_series g (actual rows=200000.00 loops=1)
> ```
>
> 没有 `WAL:` 行就表示为零（偶尔会有一两条系统目录维护记录，但绝不会记录这些行的数据）。Postgres 会在磁盘上为每张不记日志表保留一份空的“初始化分支文件（init fork）”，并在崩溃恢复期间把它复制过去覆盖数据；这就是截断。
>
> > [!EXAMPLE]- 动手验证 · lab `14-models`
> >
> > ```sql
> > select lab.prove(
> >   'inserting 200,000 sessions into a logged table writes about 41 MB of WAL',
> >   :logged_wal between 35e6 and 48e6);
> >
> > select lab.prove(
> >   'the same insert into an unlogged table writes no WAL for the rows (under 64 kB, against 41 MB)',
> >   :unlogged_wal < 65536);
> >
> > select lab.prove(
> >   'an unlogged table has an init fork: the empty copy that replaces it after a crash',
> >   (pg_stat_file(pg_relation_filepath('kv_unlogged') || '_init')).size = 0
> >   and not exists (select 1 from pg_ls_dir(
> >         regexp_replace(pg_relation_filepath('kv_logged'), '/[^/]+$', '')) as f
> >       where f = regexp_replace(pg_relation_filepath('kv_logged'), '^.*/', '') || '_init'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/14-models.sql)

### 数据库内部的缓存保护不了数据库本身 {#a-cache-inside-the-database-cant-shield-it}

当你需要在亚毫秒延迟下维持极高的操作速率、更丰富的数据结构（有序集合、发布/订阅），或者需要一道保护数据库的屏障时，就该使用专用缓存。最后这项工作无法在数据库内部完成：保护数据库的内部缓存并不会降低这个数据库的负载（[缓存：数据库不是缓存](/41-limits/#caching-a-database-is-not-a-cache)）。

## 大多数文档都该放进 jsonb 列 {#most-documents-belong-in-a-jsonb-column}

大多数“非结构化”数据载荷都该放进 `jsonb` 列，与它们所描述的行存放在一起，而不是另用独立的文档存储。文档数据库存储嵌套记录，每条记录都整体读写；`jsonb`（[使用 jsonb，把要查询的键放进独立列](/12-types/#use-jsonb-and-give-the-keys-you-query-their-own-columns)）让你在一列内部获得同样的能力：`->>` 可以深入取值，`@>` 测试包含关系，SQL/JSON 路径处理数组和嵌套结构。

GIN（广义倒排索引）会为每份文档的每个键和值建立索引，适合处理包含查询；[对于包含查询，jsonb_path_ops 胜过默认配置](/18-more-indexes/#for-containment-jsonb_path_ops-beats-the-default)会测量它的两种形式，并说明为什么它[对选择性差的查询没有帮助](/18-more-indexes/#gin-doesnt-help-unselective-queries)。

### 规划器看不到 jsonb 内部 {#the-planner-cant-see-inside-jsonb}

Postgres 会为列维护统计信息，规划器据此估算某个谓词匹配多少行。它不会维护 `jsonb` 值内部各键的统计信息，因此 `payload->>'status' = 'failed'` 会得到一个硬编码的 10,000 行估计，而实际有 500,420 行。这种偏差足以选错连接方式和内存预算。

建模上的教训是：如果规划器必须按某个 `jsonb` 字段过滤，就应该把它变成真正的列。如果它仍留在文档中，可以使用表达式统计信息（PostgreSQL 14+，`create statistics events_status_stats on ((payload->>'status')) from events`）或[表达式索引](/43-glossary/#expression-index)来修正估计；[函数会隐藏你的统计信息](/20-planner/#functions-hide-your-statistics)会演示这个问题和两种修复方式。

### 需要过滤、连接或排序的字段应该成为一列 {#a-field-you-filter-join-or-sort-on-wants-a-column}

真正多变的数据适合放在 `jsonb` 中：Webhook 请求体、用户定义的属性。某个字段在以下情况下就该成为一列：你要按它过滤、连接或排序；它需要约束（没有外键能指向 JSON 内部）；每一行都有它（如果把列名存起来，就要重复存两百万次）；或者它会独立更新（只改一个键也会重写整份文档）。存储型生成列可以在不改变写入方的情况下做到这一点，但添加它会重写整张表。

如果你面对的是海量、相互独立、永远不需要连接的文档写入，文档存储就很合适。如果你发现自己开始连接文档，那就是关系模型在要求回来。

## 实体-属性-值是个陷阱 {#entity-attribute-value-is-a-trap}

迟早会有人提出这样一张表：不必迁移，就能容纳任何设置：`settings_eav (project_id, attribute, value)`，每个项目的每项设置占一行。这就是**实体-属性-值（entity-attribute-value，EAV）**：模式从系统目录中移出，进入数据本身。它看起来很灵活，却只是用关系模型的零件造了一个键值存储，同时丢掉了这些零件原本提供的能力。

我们为 20,000 个项目各保存了六项设置，分别采用列、每个项目一份 `jsonb` 文档和 EAV 三种形式，然后提出每个设置页面最终都会遇到的问题：找出位于 `iad` 且启用了自动部署的项目。三种形式都准确匹配 1,333 个项目。

| 形式 | 含索引大小 | 读取页数 | 估计行数（实际 1,333） |
| --- | --- | --- | --- |
| 列 | 2,376 KB | 211 | 1,333 |
| `jsonb` | 8,504 KB | 548 | 1,414 |
| EAV | 14 MB | 1,833 | 约 35 |

<span id="tbl-15-2"></span>

**Table 15-2.** 实体-属性-值是个陷阱


EAV 占用的空间是列形式的六倍，读取的页数是九倍：其他形式为每个项目存一行，它却要存六行，而且每个条件都要做一次自连接。规划器估计会返回 35 行，实际返回了 1,333 行，因为它把 `attribute = 'region'` 和 `value = 'iad'` 当成相互独立的条件。

EAV 还会丢掉所有约束：`retention_days = 'ninety'` 可以写进去，属性名的拼写错误会变成一个新属性，而“每个项目都有区域”这条规则无法约束一行不存在的数据。`jsonb` 也会丢掉同样的约束，只是你可以手工用检查约束挽回一部分。

<span id="the-three-tables-the-measurements-and-what-each-one-accepts" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-15-the-three-tables-the-measurements-and-what-each-one-accepts" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：三张表、测量结果，以及每张表接受什么
>
> 列版本的定义如下；六项设置由项目 ID 推导而来，因此每个计数都准确：
>
> ```sql
> create table settings_cols (
>   project_id          bigint  primary key references projects (id),
>   region              text    not null
>                       check (region in ('iad', 'sfo', 'ams', 'fra', 'syd')),
>   retention_days      int     not null check (retention_days > 0),
>   auto_deploy         boolean not null default false,
>   deploy_branch       text    not null default 'main',
>   max_parallel_builds int     not null
>                       check (max_parallel_builds between 1 and 64),
>   notify_email        text
> );
> create index on settings_cols (region, auto_deploy);
> ```
>
> EAV 使用 `primary key (project_id, attribute)`，并在 `(attribute, value)` 上建立索引；它通过 `lateral (values ...)` 从列版本填充。文档形式则是 `to_jsonb(s) - 'project_id'`，并建立 `jsonb_path_ops` GIN 索引。三个查询如下：
>
> ```sql
> select project_id from settings_cols where region = 'iad' and auto_deploy;
>
> select project_id from settings_doc
> where settings @> '{"region": "iad", "auto_deploy": true}';
>
> select r.project_id
> from settings_eav r
> join settings_eav a using (project_id)
> where r.attribute = 'region'      and r.value = 'iad'
>   and a.attribute = 'auto_deploy' and a.value = 'true';
> ```
>
> 文档检查必须同时要求键存在且类型正确，因为检查约束遇到 null 会通过，而缺少键得到的就是 null：
>
> ```sql
> alter table settings_doc add constraint settings_retention_days check (
>   settings ? 'retention_days'
>   and jsonb_typeof(settings -> 'retention_days') = 'number'
> );
> ```
>
> > [!EXAMPLE]- 动手验证 · lab `14-models`
> >
> > ```sql
> > select lab.prove('EAV stores six rows per project: 120,000 rows',
> >   (select count(*) from settings_eav) = 120000);
> > select lab.prove('EAV takes about six times the space of the columns; jsonb sits in between',
> >   pg_total_relation_size('settings_eav') > 5 * pg_total_relation_size('settings_cols')
> >   and pg_total_relation_size('settings_doc') between pg_total_relation_size('settings_cols')
> >                                                  and pg_total_relation_size('settings_eav'));
> > select lab.prove('EAV reads nearly nine times the pages; the planner expects about 35 rows',
> >   :b_eav > 8 * :b_cols and :e_eav between 20 and 60);
> > select lab.prove('1,333 projects are in iad with auto-deploy on, in all three forms',
> >   (select count(*) from settings_cols where region = 'iad' and auto_deploy) = 1333
> >   and (select count(*) from settings_doc
> >        where settings @> '{"region": "iad", "auto_deploy": true}') = 1333
> >   and (select count(*) from settings_eav r join settings_eav a using (project_id)
> >        where r.attribute = 'region' and r.value = 'iad'
> >          and a.attribute = 'auto_deploy' and a.value = 'true') = 1333);
> > select lab.prove('the column rejects retention_days = ninety',
> >   lab.try($$ update settings_cols set retention_days = 'ninety' where project_id = 1 $$)
> >     like '22P02:%');
> > select lab.prove('EAV accepts it',
> >   lab.try($$ update settings_eav set value = 'ninety'
> >              where project_id = 1 and attribute = 'retention_days' $$) = 'ok');
> > select lab.prove('EAV accepts a misspelled attribute as a new setting',
> >   lab.try($$ insert into settings_eav values (1, 'retension_days', '30') $$) = 'ok');
> > select lab.prove('EAV lets a project lose its region; the column says no',
> >   lab.try($$ delete from settings_eav where project_id = 2 and attribute = 'region' $$) = 'ok'
> >   and lab.try($$ update settings_cols set region = null where project_id = 2 $$) like '23502:%');
> > select lab.prove('jsonb accepts "ninety" until a check says otherwise',
> >   lab.try($$ update settings_doc set settings = settings || '{"retention_days": "ninety"}'
> >              where project_id = 3 $$) = 'ok');
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/14-models.sql)

### EAV 已经没有胜出的场景了 {#theres-no-case-left-where-eav-wins}

EAV 的存在理由是避免迁移，但在 Postgres 中这个理由已经不成立：DDL 具有事务性，添加可为空的列只是一次系统目录变更，不会重写任何内容（[常量默认值免费；易变默认值会重写](/26-migrations/#constant-defaults-are-free-volatile-defaults-rewrite)）。如果设置由你的团队定义，就把它做成一列。属性不由你定义时，`jsonb` 是合适的选择：例如客户创建的字段、Webhook 的数据载荷。大多数真实表最终都会采用混合形式：把需要过滤和约束的内容做成列，再用一列 `jsonb` 存放长尾属性。

## 宽表：按列的读取方式拆分 {#wide-tables-split-by-how-columns-are-read}

一个空值只占一位（[NULL 只占一位](/04-storage/#nulls-cost-one-bit)），所以 80 个大多为空的列并不昂贵。真正消耗页的是**行内宽度（inline width）**：一个页容纳完整的行，因此只需要三列的查询，也会读取装满所有列的页。大值会移到行外的 [TOAST](/43-glossary/#toast) 中；中等大小的值会留在行内。增加一个 500 字节的 `description` 列后，`projects` 表会从 192 个页增长到 1,472 个页；于是列出项目名称时，读取的页数会超过原来的七倍，仅仅是为了一个根本不会显示的列。

> [!EXAMPLE]- 动手验证 · lab `14-models`
>
> ```sql
> select lab.prove('a 500-byte inline description takes the table from 192 pages to 1,472',
>   pg_relation_size('projects_narrow') / 8192 = 192
>   and pg_relation_size('projects_wide') / 8192 = 1472);
> select lab.prove('and the listing scan reads more than seven times the pages',
>   lab.buffers($$ select name from projects_wide where account_id = 9 $$)
>   > 7 * lab.buffers($$ select name from projects_narrow where account_id = 9 $$));
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/14-models.sql)

所以，应该按列的读取方式拆分。用于列表的列留在窄的主表中，详情列移到一张[一对一](/14-relationships/#a-one-to-one-split-costs-a-join-so-make-it-earn-one)表中，热点计数器则移到一张单独的窄表里（[把热点计数器移到窄表](/07-hot/#move-hot-counters-to-a-narrow-table)）。

## Postgres 用三种方式存储图 {#postgres-stores-a-graph-three-ways}

图由节点和边组成：团队嵌套在团队之中，服务依赖服务。Postgres 有三种标准形状。

### 邻接表让移动便宜，让遍历递归进行 {#adjacency-lists-make-moves-cheap-and-walks-recursive}

最简单的形式为每个节点存储 `parent_id`，再用**递归公用表表达式（CTE）**遍历它：一个起始查询和一个递归查询用 `union all` 合并起来，不断重复，直到不再出现新行。每一层都是一次连接，因此要为 `parent_id` 建索引（[外键要从两个方向检查，代价各不相同](/14-relationships/#a-foreign-key-is-checked-both-ways-at-different-prices)）。移动一棵子树只需一次更新。不是树的图可能有环，而 PostgreSQL 14 的 `cycle` 子句会在遍历再次访问某个节点时将其停止。

<span id="a-recursive-walk-down-the-team-tree-and-the-cycle-clause" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-15-a-recursive-walk-down-the-team-tree-and-the-cycle-clause" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：递归遍历团队树，以及 cycle 子句
>
> ```sql
> with recursive subtree as (
>   select id, name, 1 as depth, name::text as path
>   from teams where id = 1                                -- start here
>   union all
>   select t.id, t.name, s.depth + 1, s.path || ' > ' || t.name
>   from teams t join subtree s on t.parent_id = s.id      -- one level down
> )
> select id, depth, path from subtree order by path;
> ```
>
> ```text
>  id | depth |                     path
> ----+-------+-----------------------------------------------
>   1 |     1 | Engineering
>   2 |     2 | Engineering > Platform
>   4 |     3 | Engineering > Platform > Databases
>   7 |     4 | Engineering > Platform > Databases > Postgres
>  ...
> ```
>
> 让 Engineering 汇报给 Postgres，普通的递归遍历就永远不会结束。在 CTE 后面加入 `cycle id set is_cycle using visited`（`path` 列在上面的查询中已经被占用，因此路径列需要另取一个名字），它会先返回每个团队各一次，再额外返回一行标记为 `is_cycle` 的 Engineering，然后停止。
>
> > [!EXAMPLE]- 动手验证 · lab `14-models`
> >
> > ```sql
> > -- Engineering now reports to Postgres
> > update teams set parent_id = 7 where id = 1;
> >
> > select lab.prove(
> >   'with a cycle, the cycle clause (PostgreSQL 14+) stops at the revisited node and marks it',
> >   (with recursive subtree as (
> >      select id, parent_id, name from teams where id = 1
> >      union all
> >      select t.id, t.parent_id, t.name
> >      from teams t join subtree s on t.parent_id = s.id
> >    ) cycle id set is_cycle using visited
> >    select count(*) = 8 and count(*) filter (where is_cycle) = 1
> >    from subtree));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/14-models.sql)

### ltree 读取便宜，移动昂贵 {#ltree-trades-cheap-reads-for-costly-moves}

contrib 的 `ltree` 扩展存储每个节点的完整路径，例如 `eng.platform.databases`，并使用 [GiST](/43-glossary/#gist)（广义搜索树）索引。`where path <@ 'eng.platform'`（“是……的后代”）通过一次索引扫描就能返回整棵子树，不需要递归。代价转移到了写入：移动一棵子树，会重写其中的每条路径。

### 闭包表存储每一对祖先关系 {#closure-tables-store-every-ancestor-pair}

闭包表（closure table）存储每一对祖先与后代，以及它们之间的深度（7 个团队会产生 18 行）。“Platform 下面的所有内容”只需在 `ancestor_id` 上做一次索引查找，无论深度是多少，还能使用真正的外键；但每次插入和移动都必须维护这些关系对。

| 形状 | 读取子树 | 移动子树 | 最适合 |
| --- | --- | --- | --- |
| 邻接表 | 递归查询 | 一次更新 | 写入密集、层次较浅的树 |
| `ltree` 路径 | 一次索引扫描 | 重写子树 | 读取密集的层次结构 |
| 闭包表 | 一次索引查找 | 重写关系对 | 深树，以及需要双向查询的场景 |

<span id="tbl-15-3"></span>

**Table 15-3.** 闭包表存储每一对祖先关系


### 深而密的图，值得使用图数据库 {#a-graph-database-earns-its-place-on-deep-dense-graphs}

递归 CTE 应付树和几跳关系没有问题。面对大型、稠密数据上的图算法时，图数据库才值得占据一席之地：跨越数百万条边的最短路径、跨很多跳的模式匹配。在 SQL 中，每一跳都是一次连接，而指数增长的候选前沿会让你感受到它的代价（[图：每一跳仍然是一次连接](/41-limits/#graphs-every-hop-is-still-a-join)）。

## 时序适合 BRIN 和按月分区 {#time-series-fits-brin-and-monthly-partitions}

时序数据只追加并按时间排序，就像运行示例中的 `events` 一样，因此表的物理顺序与 `created_at` 一致。**BRIN（块范围索引）**利用了这一点：每个页范围只存储最小值和最大值。在 `created_at` 上，BRIN 只需 24 KB，而 B-树需要 43 MB（[BRIN 为每个页范围保留一个摘要](/18-more-indexes/#brin-keeps-one-summary-per-range-of-pages)）。

按月**分区**后，执行数据保留策略时只需丢弃一个分区，而不必执行一次巨大的 `delete`（[为了删除而分区](/39-partitioning/#basics-chapter-39)）。在极高的摄入速率和列式压缩场景中，时序系统会胜出（[时序：写入路径就是极限](/41-limits/#time-series-the-write-path-is-the-limit)）；而最知名的系统之一 TimescaleDB，本身就是一个 Postgres 扩展（[TimescaleDB 是带有主张的分区](/16-extensions/#timescaledb-is-partitioning-with-opinions)）。

## SKIP LOCKED 把每个任务交给且只交给一个工作进程 {#skip-locked-gives-each-job-to-exactly-one-worker}

队列就是一张表：`select ... for update skip locked` 让多个工作进程各自领取不同的任务，彼此之间无需等待；在与订单同一事务中入队的任务，也不可能脱离订单单独存在。[Postgres 队列与业务写入一起提交](/25-concurrency/#a-postgres-queue-commits-with-the-business-write)会构建一个完整的工作进程，包括工作进程崩溃后任务会发生什么。消息代理或日志适合扇出给多个消费者、重放，以及每秒数万条消息的吞吐量（[流：表不是日志](/41-limits/#streams-a-table-is-not-a-log)）。

## 搜索不需要从搜索引擎开始 {#search-starts-without-a-search-engine}

Postgres 会把文本转成由规范化单词组成的 `tsvector`，所以“failing”能匹配“failed”；GIN 索引让匹配变得快速，结果还能排序，完全用不着 `like '%deploy%'`。[不用搜索引擎，也能搜索](/23-search/#basics-chapter-23)会在真实语料上构建这一套，并划出搜索引擎开始值得使用的边界（[对大多数应用来说，Postgres 搜索就够了](/23-search/#for-most-applications-postgres-search-is-enough)、[搜索：Postgres 的排序不使用语料统计信息](/41-limits/#search-postgres-ranks-without-corpus-statistics)）。

## 让向量贴着它们描述的行存放 {#keep-vectors-next-to-the-rows-they-describe}

嵌入表示（embedding）会把文本或图像转换成浮点数数组，让相似的项彼此靠近。`pgvector` 扩展增加了 `vector` 类型和近似最近邻索引，因此相似度搜索可以在一条语句中连接你的数据并按租户过滤；对一个完全不了解你权限的独立向量存储来说，这是做不到的（[向量搜索能找到近邻点，与文字无关](/23-search/#a-vector-search-finds-near-points-whatever-the-words)）。

注意页数：一个 1,536 维向量大约有 6 KB，所以会存放在 TOAST 中；2,000 个这样的向量占用了 15 个堆页和 2,000 个 TOAST 页（[1,536 维向量会存放在 TOAST 中](/23-search/#a-1536-dimension-vector-lives-in-toast)）。这个扩展不属于 contrib，因此要确认你的托管服务允许安装它（[扩展来自允许列表](/34-managed/#extensions-come-from-an-allowlist)）；当向量达到数亿个时，专用系统或许就值得承担它的成本了（[向量：建立索引的列最多支持 2,000 个维度](/41-limits/#vectors-an-indexed-column-tops-out-at-2000-dimensions)）。

## 第二个数据库必须证明自己值得存在 {#a-second-database-has-to-earn-its-place}

当某种负载会占去主库的大部分资源，或者你需要 Postgres 不具备的某项能力时，第二个系统才值得存在，例如极高吞吐率下的内存级延迟，或对数十亿行进行列式扫描（[用测量结果证明需要第二个系统](/41-limits/#add-a-second-system-for-a-measured-reason)）。还要权衡它的代价：需要再备份、升级和监控一个系统，数据会发生漂移，而且两个系统之间没有事务。发给消息代理的消息可能宣布一笔随后回滚的订单；进程也可能在提交和发送之间崩溃，导致消息丢失，除非你增加一个事务发件箱（outbox），而它本身就是一张 Postgres 表（[把消息和订单放进同一个事务](/09-acid/#put-the-message-in-the-same-transaction-as-the-order)）。

> [!TIP] 从一个数据库开始
>
> 这个选择并不对称。以后把一种负载迁出 Postgres，有成熟的迁移方法可循；把五个数据库重新合并回一个数据库，则没有这样成熟的做法。

## 本章要点 {#the-short-version}

- 一次主键查找会触碰 4 个页。不记日志表不写 WAL，崩溃会清空它们。
- 规划器看不到 `jsonb` 内部的统计信息（估计 10,000 行，实际 500,420 行）。把你要过滤、连接或约束的字段提升为列。
- 永远不要建立实体-属性-值表：空间是六倍，页数是九倍，而且没有约束。
- 宽度会消耗页，空值不会：让列表查询使用的列保持窄。
- 层次结构可以用邻接表、`ltree` 路径或闭包表。时序数据使用 BRIN 和分区。队列使用 `for update skip locked`。
- 全文检索和 `pgvector` 都可以贴着你的数据存放；1,536 维向量会存放在 TOAST 中。
- 只有当你能说清 Postgres 缺少什么能力，以及哪项测量证明你确实需要它时，才添加独立系统。

## 延伸阅读 {#further-reading}

- [JSON Types](https://www.postgresql.org/docs/18/datatype-json.html) 和 [JSON Functions and Operators](https://www.postgresql.org/docs/18/functions-json.html)，PostgreSQL 文档。`jsonb` 存储、索引、操作符以及 SQL/JSON 路径语言。
- [WITH Queries](https://www.postgresql.org/docs/18/queries-with.html)，PostgreSQL 文档。递归 CTE、`search` 和 `cycle` 子句，以及物化。
- [ltree](https://www.postgresql.org/docs/18/ltree.html)，PostgreSQL 文档。标签路径、相关操作符和索引支持。
- [NOTIFY](https://www.postgresql.org/docs/18/sql-notify.html)，PostgreSQL 文档。投递语义和通知队列的限制。
- [Postgres LISTEN/NOTIFY does not scale](https://www.recall.ai/blog/postgres-listen-notify-does-not-scale)，Recall.ai。重度写并发下提交时锁的生产实践记录。
- [What Goes Around Comes Around... And Around...](https://db.cs.cmu.edu/papers/2024/whatgoesaround-sigmodrec2024.pdf)，Michael Stonebraker 和 Andrew Pavlo，SIGMOD Record 2024。关系模型为何仍在不断吸收其他模型。
