15 你还不需要另一个数据库
这是在一个有 200,000 行的 Postgres 表中查找会话:4 个页,数据库耗时只有几微秒。获取它所需的网络往返成本比查找本身还高;换成专用键值存储,情况也一样。
序言承诺过:产品需要的大多数东西,一个数据库就能满足。本章逐个检查这个承诺涵盖的模型:键值、文档、图、时序、队列、搜索和向量。对每一种模型,我们都会看看 Postgres 能做到多接近、要付出多少页的代价,以及什么信号说明该换另一个系统了。
每种模型各有一种形状,专门为它加速
**数据模型(data model)**是数据库假定你的数据所具有的形状;它的存储引擎就是为让这种形状运行得快而构建的。
| 模型 | 优化目标 | Postgres 工具 |
|---|---|---|
| 键值 | 按键获取和写入一个值 | 主键、不记日志表 |
| 文档 | 整体读写嵌套记录 | jsonb、GIN |
| 图 | 沿关系深入多跳 | 递归 CTE、ltree |
| 时序 | 只追加、按时间排序的范围扫描 | BRIN、分区 |
| 队列 | 把每个任务交给且只交给一个工作进程 | for update skip locked |
| 搜索 | 按相关性给文本排序 | tsvector、GIN |
| 向量 | 最近邻 | pgvector 扩展 |
每种模型都牺牲关系模型的通用性,换取在一种模式上的速度。Postgres 的可扩展性(新类型、操作符、索引方法)正是它把这种速度借回来的方式。
键值就是一次主键查找
键值存储把一个值放在一个键下面,再按键取回它。在 Postgres 里,这就是一张带主键的表;而主键查找几乎是数据库读取能做到的最便宜的操作:
一张有 200,000 行的表只需读取 4 个页:几个 B-树页和一个堆页;数据库耗时只有几微秒。网络往返才是主要成本,任何联网存储都是如此。
崩溃后,不记日志表会被清空
上面有意思的词是 unlogged。不记日志表(unlogged table)跳过预写式日志:同样插入 200,000 个会话,普通表写入了 41 MB 的 WAL,这张表则一条也没有。代价很严重:
- 崩溃后,不记日志表会被清空。 不是回滚,而是截断。正常关机会保留它;崩溃或故障切换不会。
- **它不会被复制,**物理基础备份也会跳过它的数据。
因此,只把它用于缓存可以保存的东西:用户可以重新创建的会话、限流计数器、可重新计算的结果。凡是不能丢的数据,都绝不能放进去。它没有 TTL:分批删除过期行,读取时用 expires_at > now() 过滤,并为这张表设置积极的自动清理策略(清理不可省)。
深入阅读:4 个页、没有 WAL,以及崩溃后替代表的空副本
数据库内部的缓存保护不了数据库本身
当你需要在亚毫秒延迟下维持极高的操作速率、更丰富的数据结构(有序集合、发布/订阅),或者需要一道保护数据库的屏障时,就该使用专用缓存。最后这项工作无法在数据库内部完成:保护数据库的内部缓存并不会降低这个数据库的负载(缓存:数据库不是缓存)。
大多数文档都该放进 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 KB | 211 | 1,333 |
jsonb | 8,504 KB | 548 | 1,414 |
| EAV | 14 MB | 1,833 | 约 35 |
EAV 占用的空间是列形式的六倍,读取的页数是九倍:其他形式为每个项目存一行,它却要存六行,而且每个条件都要做一次自连接。规划器估计会返回 35 行,实际返回了 1,333 行,因为它把 attribute = 'region' 和 value = 'iad' 当成相互独立的条件。
EAV 还会丢掉所有约束:retention_days = 'ninety' 可以写进去,属性名的拼写错误会变成一个新属性,而“每个项目都有区域”这条规则无法约束一行不存在的数据。jsonb 也会丢掉同样的约束,只是你可以手工用检查约束挽回一部分。
深入阅读:三张表、测量结果,以及每张表接受什么
列版本的定义如下;六项设置由项目 ID 推导而来,因此每个计数都准确:
EAV 使用 primary key (project_id, attribute),并在 (attribute, value) 上建立索引;它通过 lateral (values ...) 从列版本填充。文档形式则是 to_jsonb(s) - 'project_id',并建立 jsonb_path_ops GIN 索引。三个查询如下:
文档检查必须同时要求键存在且类型正确,因为检查约束遇到 null 会通过,而缺少键得到的就是 null:
动手验证 · lab 14-models
EAV 已经没有胜出的场景了
EAV 的存在理由是避免迁移,但在 Postgres 中这个理由已经不成立:DDL 具有事务性,添加可为空的列只是一次系统目录变更,不会重写任何内容(常量默认值免费;易变默认值会重写)。如果设置由你的团队定义,就把它做成一列。属性不由你定义时,jsonb 是合适的选择:例如客户创建的字段、Webhook 的数据载荷。大多数真实表最终都会采用混合形式:把需要过滤和约束的内容做成列,再用一列 jsonb 存放长尾属性。
宽表:按列的读取方式拆分
一个空值只占一位(NULL 只占一位),所以 80 个大多为空的列并不昂贵。真正消耗页的是行内宽度(inline width):一个页容纳完整的行,因此只需要三列的查询,也会读取装满所有列的页。大值会移到行外的 TOAST 中;中等大小的值会留在行内。增加一个 500 字节的 description 列后,projects 表会从 192 个页增长到 1,472 个页;于是列出项目名称时,读取的页数会超过原来的七倍,仅仅是为了一个根本不会显示的列。
动手验证 · lab 14-models
所以,应该按列的读取方式拆分。用于列表的列留在窄的主表中,详情列移到一张一对一表中,热点计数器则移到一张单独的窄表里(把热点计数器移到窄表)。
Postgres 用三种方式存储图
图由节点和边组成:团队嵌套在团队之中,服务依赖服务。Postgres 有三种标准形状。
邻接表让移动便宜,让遍历递归进行
最简单的形式为每个节点存储 parent_id,再用**递归公用表表达式(CTE)**遍历它:一个起始查询和一个递归查询用 union all 合并起来,不断重复,直到不再出现新行。每一层都是一次连接,因此要为 parent_id 建索引(外键要从两个方向检查,代价各不相同)。移动一棵子树只需一次更新。不是树的图可能有环,而 PostgreSQL 14 的 cycle 子句会在遍历再次访问某个节点时将其停止。
深入阅读:递归遍历团队树,以及 cycle 子句
让 Engineering 汇报给 Postgres,普通的递归遍历就永远不会结束。在 CTE 后面加入 cycle id set is_cycle using visited(path 列在上面的查询中已经被占用,因此路径列需要另取一个名字),它会先返回每个团队各一次,再额外返回一行标记为 is_cycle 的 Engineering,然后停止。
动手验证 · lab 14-models
ltree 读取便宜,移动昂贵
contrib 的 ltree 扩展存储每个节点的完整路径,例如 eng.platform.databases,并使用 GiST(广义搜索树)索引。where path <@ 'eng.platform'(“是……的后代”)通过一次索引扫描就能返回整棵子树,不需要递归。代价转移到了写入:移动一棵子树,会重写其中的每条路径。
闭包表存储每一对祖先关系
闭包表(closure table)存储每一对祖先与后代,以及它们之间的深度(7 个团队会产生 18 行)。“Platform 下面的所有内容”只需在 ancestor_id 上做一次索引查找,无论深度是多少,还能使用真正的外键;但每次插入和移动都必须维护这些关系对。
| 形状 | 读取子树 | 移动子树 | 最适合 |
|---|---|---|---|
| 邻接表 | 递归查询 | 一次更新 | 写入密集、层次较浅的树 |
ltree 路径 | 一次索引扫描 | 重写子树 | 读取密集的层次结构 |
| 闭包表 | 一次索引查找 | 重写关系对 | 深树,以及需要双向查询的场景 |
深而密的图,值得使用图数据库
递归 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 缺少什么能力,以及哪项测量证明你确实需要它时,才添加独立系统。
延伸阅读
- JSON Types 和 JSON Functions and Operators,PostgreSQL 文档。
jsonb存储、索引、操作符以及 SQL/JSON 路径语言。 - WITH Queries,PostgreSQL 文档。递归 CTE、
search和cycle子句,以及物化。 - ltree,PostgreSQL 文档。标签路径、相关操作符和索引支持。
- NOTIFY,PostgreSQL 文档。投递语义和通知队列的限制。
- Postgres LISTEN/NOTIFY does not scale,Recall.ai。重度写并发下提交时锁的生产实践记录。
- What Goes Around Comes Around… And Around…,Michael Stonebraker 和 Andrew Pavlo,SIGMOD Record 2024。关系模型为何仍在不断吸收其他模型。