跳转到主要内容

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

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

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

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

每种模型各有一种形状,专门为它加速

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

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

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

键值就是一次主键查找

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

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';
 Index Scan using kv_unlogged_pkey on kv_unlogged (actual rows=1.00 loops=1)
   Buffers: shared hit=4

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

崩溃后,不记日志表会被清空

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

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

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

深入阅读:4 个页、没有 WAL,以及崩溃后替代表的空副本
动手验证 · lab 14-models
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);

完整实验脚本

两次插入的结果:

 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)”,并在崩溃恢复期间把它复制过去覆盖数据;这就是截断。

动手验证 · lab 14-models
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'));

完整实验脚本

数据库内部的缓存保护不了数据库本身

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

大多数文档都该放进 jsonb 列

大多数“非结构化”数据载荷都该放进 jsonb 列,与它们所描述的行存放在一起,而不是另用独立的文档存储。文档数据库存储嵌套记录,每条记录都整体读写;jsonb(使用 jsonb,把要查询的键放进独立列)让你在一列内部获得同样的能力:->> 可以深入取值,@> 测试包含关系,SQL/JSON 路径处理数组和嵌套结构。

GIN(广义倒排索引)会为每份文档的每个键和值建立索引,适合处理包含查询;对于包含查询,jsonb_path_ops 胜过默认配置会测量它的两种形式,并说明为什么它对选择性差的查询没有帮助。

规划器看不到 jsonb 内部

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

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

需要过滤、连接或排序的字段应该成为一列

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

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

实体-属性-值是个陷阱

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

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

形式含索引大小读取页数估计行数(实际 1,333)
列2,376 KB2111,333
jsonb8,504 KB5481,414
EAV14 MB1,833约 35
表 15-2 实体-属性-值是个陷阱

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

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

深入阅读:三张表、测量结果,以及每张表接受什么

列版本的定义如下;六项设置由项目 ID 推导而来,因此每个计数都准确:

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 索引。三个查询如下:

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:

alter table settings_doc add constraint settings_retention_days check (
  settings ? 'retention_days'
  and jsonb_typeof(settings -> 'retention_days') = 'number'
);
动手验证 · lab 14-models
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');

完整实验脚本

EAV 已经没有胜出的场景了

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

宽表:按列的读取方式拆分

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

动手验证 · lab 14-models
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 $$));

完整实验脚本

所以,应该按列的读取方式拆分。用于列表的列留在窄的主表中,详情列移到一张一对一表中,热点计数器则移到一张单独的窄表里(把热点计数器移到窄表)。

Postgres 用三种方式存储图

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

邻接表让移动便宜,让遍历递归进行

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

深入阅读:递归遍历团队树,以及 cycle 子句
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;
 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,然后停止。

动手验证 · lab 14-models
-- 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));

完整实验脚本

ltree 读取便宜,移动昂贵

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

闭包表存储每一对祖先关系

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

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

深而密的图,值得使用图数据库

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

时序适合 BRIN 和按月分区

时序数据只追加并按时间排序,就像运行示例中的 events 一样,因此表的物理顺序与 created_at 一致。**BRIN(块范围索引)**利用了这一点:每个页范围只存储最小值和最大值。在 created_at 上,BRIN 只需 24 KB,而 B-树需要 43 MB(BRIN 为每个页范围保留一个摘要)。

按月分区后,执行数据保留策略时只需丢弃一个分区,而不必执行一次巨大的 delete(为了删除而分区)。在极高的摄入速率和列式压缩场景中,时序系统会胜出(时序:写入路径就是极限);而最知名的系统之一 TimescaleDB,本身就是一个 Postgres 扩展(TimescaleDB 是带有主张的分区)。

SKIP LOCKED 把每个任务交给且只交给一个工作进程

队列就是一张表:select ... for update skip locked 让多个工作进程各自领取不同的任务,彼此之间无需等待;在与订单同一事务中入队的任务,也不可能脱离订单单独存在。Postgres 队列与业务写入一起提交会构建一个完整的工作进程,包括工作进程崩溃后任务会发生什么。消息代理或日志适合扇出给多个消费者、重放,以及每秒数万条消息的吞吐量(流:表不是日志)。

搜索不需要从搜索引擎开始

Postgres 会把文本转成由规范化单词组成的 tsvector,所以“failing”能匹配“failed”;GIN 索引让匹配变得快速,结果还能排序,完全用不着 like '%deploy%'。不用搜索引擎,也能搜索会在真实语料上构建这一套,并划出搜索引擎开始值得使用的边界(对大多数应用来说,Postgres 搜索就够了、搜索:Postgres 的排序不使用语料统计信息)。

让向量贴着它们描述的行存放

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

注意页数:一个 1,536 维向量大约有 6 KB,所以会存放在 TOAST 中;2,000 个这样的向量占用了 15 个堆页和 2,000 个 TOAST 页(1,536 维向量会存放在 TOAST 中)。这个扩展不属于 contrib,因此要确认你的托管服务允许安装它(扩展来自允许列表);当向量达到数亿个时,专用系统或许就值得承担它的成本了(向量:建立索引的列最多支持 2,000 个维度)。

第二个数据库必须证明自己值得存在

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

从一个数据库开始

这个选择并不对称。以后把一种负载迁出 Postgres,有成熟的迁移方法可循;把五个数据库重新合并回一个数据库,则没有这样成熟的做法。

本章要点

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

延伸阅读

表格阅读