物理结构图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
primary.idx
├─ entry 0 -> granule 0
├─ entry 1 -> granule 1
└─ ...

column.mrk4 (per column)
├─ mark 0 -> (compressed_offset, decompressed_offset) of granule 0
├─ mark 1 -> (compressed_offset, decompressed_offset) of granule 1
└─ ...

data.bin
├─ compressed block A
│ ├─ granule 0
│ └─ granule 1
├─ compressed block B
│ └─ granule 2
└─ ...

primary.idx / primary.cidx(主键稀疏索引)

主键索引文件,用于按 granule 粒度 进行范围裁剪(range pruning)。

索引项(entry)生成规则

  • 每当形成一个新的 granule,就会在 primary 索引中生成一个 entry

  • granule 的边界由以下两个条件之一触发(OR 关系):

    • 行数达到 index_granularity(默认 8192 行)
    • ranule 内累计的 解压后数据大小 达到 index_granularity_bytes(通常约 10MB)

    重要说明

    • granule 的行数是 上限约束,并非固定值
    • 当 10MB 条件先触发时,一个 granule 的行数可能 明显小于 8192
    • primary 索引是 稀疏索引,只定位到 granule,而非单行

column.mrk / column.mrk4(列级定位信息)

对于 每一列,都有一个对应的 mark 文件,用于描述 每个 granule 在该列数据文件中的物理位置。

mark 的数量关系
每个 data part 中:primary.idx 中有多少个 entry,每个列的 .mrk / .mrk4 文件中就有多少个 mark。二者在 granule 维度上 一一对应

每个 mark 的内容
每个 mark 是一对偏移量:
compressed_offset :granule 所在压缩块在 .bin 文件中的起始字节偏移
decompressed_offset:压缩块解压到内存后,该 granule 在解压后内存块中的起始字节偏移

补充说明
decompressed_offset 是 内存语义,不是磁盘语义。当一个压缩块只包含一个 granule 时,该值通常为 0;当一个压缩块包含多个 granule 时:第一个 granule 的 decompressed_offset = 0;后续 granule 的 decompressed_offset 为其在解压后内存块中的字节偏移

data.bin(列数据文件)

物理结构

data.bin 由多个 压缩块(compressed blocks) 顺序组成

每个压缩块包含:
1 个或多个完整 granule;任何一个 granule 都不会跨越压缩块边界

关系约束
一个 granule:必然完全位于某一个压缩块中;一个压缩块:可以包含多个 granule(取决于 granule 大小与压缩策略)

其他文件

  1. columns.txt:描述本 part 中:列名、列类型、列顺序

  2. columns_substreams.txt:
    描述每个列的 子流(substream)布局。特别是:

    • Nullable
    • Array
    • LowCardinality
    • Map

    决定:一个逻辑列拆成几个 .bin / .mrk

  3. checksums.txt:记录本 part 中 每个文件的校验和与大小

  4. count.txt:记录该 part 中的 行数,相当于 part 级的 “统计元数据”

  5. serialization.json:描述 列的序列化 / 反序列化方式

  6. default_compression_codec.txt:记录该 part 使用的默认压缩算法(如 LZ4、ZSTD)

  7. metadata_version.txt:表示该 part 的 metadata 版本

B-tree 和 LSM-tree 是数据密集型应用中用于组织和存储数据最广泛的两种数据结构。然而,两者各有优劣。本文旨在通过定量分析的方法对这两种数据结构进行对比。

衡量指标

通常情况下,衡量数据结构性能有三个关键指标:写放大(Write Amplification)、读放大(Read Amplification)和空间放大(Space Amplification)。本节旨在对这些指标进行描述。

对于机械硬盘(HDD)而言,磁盘寻道成本极高,导致随机读写的性能远不如顺序读写。本文假设使用的是闪存存储(如 SSD),因此我们可以忽略磁盘寻道的成本。

写放大(Write Amplification)

写放大(Write Amplification)是指写入存储设备的实际数据量与写入数据库的数据量之比。

例如,如果你向数据库写入了 10 MB 数据,但观察到磁盘实际产生了 30 MB 的写入量(或观测到磁盘写入带宽是数据库写入带宽的 3 倍),那么写放大就是 3

读放大(Read Amplification)

读放大(Read Amplification)是指每次查询所需的磁盘读取次数。

例如,如果为了响应一个查询需要读取 5 个页面,那么读放大就是 5。

请注意,写放大与读放大的单位是不同的。写放大衡量的是实际写入数据量与应用程序预期写入量之间的倍数关系;而读放大计算的是执行一次查询所需的磁盘读取次数。

读放大针对点查询(Point Query)和范围查询(Range Query)有不同的定义。对于范围查询,范围的长度(即需要获取的行数)是一个重要因素。

缓存是影响读放大的关键因素。例如,对于冷缓存(Cold-cache)情况下的 B 树,一次点查询需要 O(log_B N)次磁盘读取;而在热缓存(Warm-cache)情况下,B 树的内部节点已被缓存,因此每次查询最多只需要一次磁盘读取。

N:数据库中 记录(keys / tuples)的总数量
B:一个磁盘页(page / block)能容纳的 key 数量

空间放大(Space Amplification)

空间放大(Space Amplification)是指存储设备上占用的实际数据量与数据库中存储的逻辑数据量之比。

例如,如果您向数据库存入 10 MB 的数据,而该数据库在磁盘上占用了 100 MB,那么空间放大倍数就是 10。

通常来说,一种数据结构最多只能在读、写和空间放大这三个指标中优化其中的两个。这意味着一种数据结构很难在所有三个指标上都优于另一种。例如,B 树的读放大比 LSM 树小,而 LSM 树的写放大则比 B 树小

分析

B 树是二叉搜索树(Binary Search Tree)的一种推广,其节点可以拥有两个以上的子节点。B 树中有两类节点:内部节点(Internal nodes)和叶子节点(Leaf nodes)。叶子节点包含实际的数据记录且没有子节点;而内部节点可以在预定义的范围内拥有不同数量的子节点。内部节点可以进行合并或分裂。图 1 展示了一个 B 树的示例。

图 1。根节点位于树的顶部,在本例中恰好包含一个枢轴键(Pivot keys)(20),这表示键值为 k 且满足 k <= 20 的记录存储在第一个子节点中,而键值满足 k > 20 的记录存储在第二个子节点中。第一个子节点包含两个枢轴键(11 和 15),这表示键值满足 k <= 11 的记录存储在第一个(孙子)节点中,满足 11 < k <= 15 的记录存储在第二个节点中,而满足 k > 15 的记录则存储在第三个节点中。最左侧的叶子节点包含三个数值(3、5 和 7)。

“B 树”一词可以指代一种特定的设计,也可以指代一类通用的设计。从狭义上讲,B 树在其内部节点中存储键(Keys),但并不一定在叶子节点的记录中重复存储这些键。B+ 树(B+ tree)是 B 树最著名的变体之一。B+ 树的核心理念是:内部节点仅包含键,且在底部增加了一个包含实际数值的额外层级,该层级的叶子节点相互连接。

与其他搜索树一样,LSM 树(LSM-tree)也包含键值对(Key-value pairs)。它将数据维护在两个或多个独立的组件中(有时被称为 SSTables),每个组件都针对其对应的底层存储介质进行了优化。低层组件中的数据会以批处理的方式高效地与高层组件中的数据进行合并。图 2 展示了一个 LSM 树的示例。

图 2。LSM 树包含 $k$ 个组件。数据首先进入 $C_0$ 层,随后被合并到 $C_1$ 层。最终,$C_1$ 层的数据会被合并到 $C_2$ 层,以此类推。

LSM 树会定期执行合并(Compaction)操作,将多个 SSTable 合并为一个仅包含“活跃数据”(Live data)的新 SSTable。合并操作有助于 LSM 树回收空间并降低读放大。合并策略主要有两种:大小分级合并策略(STCS)和层级合并策略(LBCS)。STCS 的核心思想是:当 LSM 树积累了足够的短小 SSTable 时,将其合并为中等大小的 SSTable;当中等 SSTable 足够多时,再将其合并为大型 SSTable。而 LBCS 的核心思想是将数据组织进不同的层级(Level),每个层级包含一个有序序列(Sorted run)。一旦某一层积累了足够的数据,该层级的部分数据就会被合并到更高层级中。

本文讨论 B+ 树和基于层级的(Level-Based)LSM 树的写放大与读放大。

B+ Tree

在 B+ 树中,键(Keys)的副本存储在内部节点中;键与记录(Records)存储在叶子节点中;此外,叶子节点可能还包含指向下一个叶子节点的指针,以提高顺序访问性能。

为了简化分析,假设树的块大小(Block size)为 $B$(以字节为单位),且键、指针和记录的大小都是固定的,因此每个内部节点包含 $O(B)$ 个子节点,每个叶子节点包含 $O(B)$ 条数据记录。(根节点是一个特例,在某些情况下可能几乎为空。)在上述所有假设下,B+ 树的深度为:$$O(\log_B \frac{N}{B})$$其中 $N$ 是数据库的总大小。

写放大 (Write Amplification)
在最坏情况的插入负载下,每次插入都需要重写包含该记录的整个叶子块,因此写放大为 $B$。

读放大 (Read Amplification)
每次查询的磁盘读取次数最多为 $O(\log_B \frac{N}{B})$,即树的深度

Level-Based LSM-tree

在基于层级的(Level-based)LSM 树中,数据按层组织。每一层包含一个有序序列。数据始于第 0 层(Level 0),随后被合并到第 1 层的序列中。最终,第 1 层的数据会被合并到第 2 层,以此类推。每一层的大小都受到限制。增长因子(Growth factor) $k$ 定义为每一层数据容量的放大倍数:

$$\text{level}i = \text{level}{i-1} \times k$$

我们可以对基于层级的 LSM 树进行如下分析:如果增长因子为 $k$,且最小的层级是一个大小为 $B$ 的单个文件,那么层级的数量为:

$$\Theta(\log_k N/B)$$

其中 $N$ 是数据库的大小。为了简化分析,我们假设数据库规模稳定且随时间增长缓慢,因此数据库的总大小几乎等同于最后一层的大小。

写放大 (Write Amplification)
数据必须从每一层移出一次,但给定层级的数据会与来自前一层的数据不断重复合并。平均而言,每个数据项在首次写入某一层后,会被重新合并回同一层约 $k/2$ 次。因此,总的写放大为:$$\Theta(k \times \log_k N/B)$$

读放大 (Read Amplification) 在冷缓存情况下执行短范围查询时,我们必须在每个层级上进行二分查找。

对于最高层级 $\text{level}_i$,数据大小为 $O(N)$,因此执行 $O(\log N/B)$ 次磁盘读取。

对于上一层级 $\text{level}_{i-1}$,数据大小为 $O(N/k)$,执行 $O(\log (N/kB))$ 次磁盘读取。

对于 $\text{level}_{i-2}$ 层,数据大小为 $O(N/k^2)$,执行 $O(\log (N/k^2B))$ 次磁盘读取。

……

以此类推。

因此,磁盘读取的总次数为:$$R = O(\log N/B) + O(\log (N/kB)) + O(\log (N/k^2B)) + \dots + 1 = O(\frac{\log^2 N/B}{\log k})$$

总结

下表总结了各种放大指标:

数据结构 写放大 (Write Amplification) 读放大 (Read Amplification)
B+ 树 Θ(B) O(logB​N/B)
层级 LSM 树 Θ(klogk​N/B) Θ(logklog2N/B​)

通过对比 B+ 树与基于层级的 LSM 树的各种放大指标,我们可以得出结论:基于层级的 LSM 树在写入性能上优于 B+ 树,但在读取性能上则逊于 B+ 树。TiKV 选用 LSM 树而非 B 树作为其底层存储引擎的主要原因在于:利用缓存技术来提升读取性能,要比提升写入性能容易得多。

pg_stat_statements 不能像 pageinspect 那样只靠 CREATE EXTENSION 就用,原因在于它们在 PostgreSQL 内核中的“级别”完全不同。

pg_stat_statements 和 pageinspect 的定位完全不同:它是一个 全局统计模块
它需要:

  1. 拦截每一条 SQL 的执行
    • 使用 executor hooks
    • 在 query 开始 / 结束时采样
  2. shared memory 中维护全局 hash 表
    • 跨 backend 共享
    • 保存长期累计统计
  3. 在 backend 启动阶段完成初始化
    • 分配 shared memory
    • 注册 hooks

这些事情 只能在 postmaster 启动时完成。

启动步骤

  1. 修改 postgresql.conf
    确认conf位置
    1
    2
    3
    4
    5
    gujinfei=# show config_file;
    config_file
    ---------------------------------------
    /home/gujinfei/pgdata/postgresql.conf
    (1 row)
    1
    shared_preload_libraries = 'pg_stat_statements'
  2. reboot database
    1
    pg_ctl restart
  3. 进入sql
    1
    2
    SHOW shared_preload_libraries;
    CREATE EXTENSION pg_stat_statements;
  4. Test
    1
    2
    3
    4
    5
    6
    7
    drop table if exists t;
    create table t(a text, b text, c int);
    SELECT pg_stat_statements_reset() IS NOT NULL AS t;
    explain(costs off, verbose) select count(*) from t group by a;
    explain(costs off, verbose) select count(*) from t group by b;
    explain(costs off, verbose) select count(*) from t group by c;
    SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
    result
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    gujinfei=# drop table if exists t;
    DROP TABLE
    gujinfei=# create table t(a text, b text, c int);
    CREATE TABLE
    gujinfei=# SELECT pg_stat_statements_reset() IS NOT NULL AS t;
    t
    ---
    t
    (1 row)

    gujinfei=# explain(costs off, verbose) select count(*) from t group by a;
    QUERY PLAN
    ---------------------------------------
    HashAggregate
    Output: count(*), a
    Group Key: t.a
    -> Seq Scan on public.t
    Output: a, b, c
    Query Identifier: 8735661759096201363
    (6 rows)

    gujinfei=# explain(costs off, verbose) select count(*) from t group by b;
    QUERY PLAN
    ---------------------------------------
    HashAggregate
    Output: count(*), b
    Group Key: t.b
    -> Seq Scan on public.t
    Output: a, b, c
    Query Identifier: 8735661759096201363
    (6 rows)

    gujinfei=# explain(costs off, verbose) select count(*) from t group by c;
    QUERY PLAN
    ---------------------------------------
    HashAggregate
    Output: count(*), c
    Group Key: t.c
    -> Seq Scan on public.t
    Output: a, b, c
    Query Identifier: 9219356609107396553
    (6 rows)

    gujinfei=# SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
    calls | rows | query
    -------+------+---------------------------------------------------------------
    1 | 1 | SELECT pg_stat_statements_reset() IS NOT NULL AS t
    2 | 0 | explain(costs off, verbose) select count(*) from t group by a
    1 | 0 | explain(costs off, verbose) select count(*) from t group by c
    (3 rows)

    gujinfei=#

在分布式数据库中,一行数据最终会被写入哪一台机器,取决于系统所采用的数据分布策略。在使用分布键(distribution key)的场景下,这一过程通常由哈希计算精确映射完成。

这个映射过程通常从一个 distribution key(分布键) 开始,经由哈希函数,最终落到某一个具体的数据节点上。

看似简单的映射关系,在系统扩容或缩容时,却会成为分布式系统设计中的一个核心难题。一致性 Hash,正是为了解决这个问题而出现的。


从一个分布式表说起

以一个分布式数据库中的表为例:

1
2
3
4
5
6
7
CREATE TABLE orders (
id bigint,
user_id bigint,
payload jsonb
);

SELECT create_distributed_table('orders', 'user_id');

在这个例子中:

  • user_id 被指定为 distribution key
  • 数据库会对 user_id 计算哈希值
  • 哈希值先映射到某个 shard
  • shard 再映射到具体的 database node
    (shard -> databasenode, 在本文后续省略,直接表示为 数据节点id,即database n)

从这一刻开始,user_id 就决定了这行数据在物理层面“住在哪里”。


最直观的方案:取模 Hash

在多数据节点之间,最容易想到的一种数据分布方式是取模 Hash

其基本思路非常直接:

  1. user_id 计算哈希值
  2. 用哈希值对数据库节点数量取模
  3. 取模结果即为目标数据库节点

示意图如下:

hash1

对应的算法可以表示为:

1
database_id = hash(user_id) % num_of_dbs

比如,当数据库集群拥有3个数据节点:

假设:
hash(123) = 7
hash(456) = 12
hash(789) = 5

1
2
3
user id:123 → 7 % 3  = 1 → Database 1
user id:456 → 12 % 3 = 0 → Database 0
user id:789 → 5 % 3 = 2 → Database 2

在数据节点数量固定的前提下,这种方式能够较为均匀地分布数据,实现也非常简单。

但问题很快就会出现。


问题一:扩容几乎等于“重来一遍”

随着业务增长,3 台数据节点已经无法承载当前数据量,需要扩容到 4 台。

算法随之变为:

1
database_id = hash(user_id) % 4

对于新写入的数据来说,这个变化影响不大。但对于已有数据而言,情况就完全不同了。

由于取模基数发生变化,几乎所有 user_id 的映射结果都会改变,意味着:

存量数据需要在节点之间进行大规模重分布。

示意如下:

hash2

例如,用户 123 过去映射到 Database 1,但现在:

1
hash(123) % 4 = 3 (即:7%4)

这行数据必须从Database 1迁移到 Database 3。

这种迁移不是个别现象,而是系统性问题,代价极其高昂。

对于取模 Hash,当节点从 n 变为 n+1 时,数据迁移率约为 n/(n+1)。这意味着如果从 9 台扩容到 10 台,会有 90% 的数据需要搬家


问题二:缩容同样不可接受

缩容的情况并不会更好。

当某个节点被下线时,取模 Hash 同样会导致大规模数据重新分布,带来:

  • 大量数据迁移
  • IO 抖动
  • 服务不稳定

问题的本质在于:

节点数量一旦发生变化,整个哈希空间的映射关系就被彻底打乱。

为了解决这一问题,我们需要一种在节点变化时“尽量少动数据”的方案。


一致性 Hash 的核心思想

一致性 Hash 的目标很明确:

在添加或删除节点时,尽可能减少需要重新分布的数据量。

其基本做法是:

  1. 将整个哈希值空间组织成一个虚拟的环
  2. 哈希空间通常是 0 ~ 2³²−1
  3. 数据节点和数据本身都映射到这个环上
  4. 数据沿顺时针方向,落到遇到的第一个节点上

为便于说明,我们将哈希空间简化为 0~100,并假设通过哈希计算,4 个数据库节点在环上大致分布在如下位置:

hash6

  • DB1 → 0
  • DB2 → 25
  • DB3 → 50
  • DB4 → 75

当我们对 user_id 计算哈希后:

  • 不再取模
  • 而是在环上找到该哈希值
  • 顺时针查找第一个数据库节点

扩容时发生了什么?

现在,我们在环上的位置 65 新增一个数据库节点 DB5。

hash3

此时:

  • 只有哈希值位于 (50, 65] 区间的数据需要迁移
  • 其他数据完全不受影响

相比取模 Hash,数据迁移范围被大幅缩小。


缩容时的行为

缩容同样遵循相同的规则。

如果移除某个节点,其负责的哈希区间会顺延给下一个节点,而不会影响整个系统的映射关系。

hash4


仍然存在的问题:负载不均

到这里,一致性 Hash 看起来已经非常理想了,但在工程实践中还会遇到一个问题:

节点之间的负载可能严重不均衡。

例如,在移除 DB3 的场景中:

原本属于 DB3 的全部数据会被顺延给同一个相邻节点

这可能导致某一台数据库瞬间成为性能瓶颈。


虚拟节点:工程上的关键改进

为了解决负载不均的问题,需要引入 虚拟节点(Virtual Nodes) 的概念。

核心思想是:

一个物理节点,在哈希环上不只占据一个位置。

具体做法是:

  • 对同一数据库节点构造多个不同的标识
  • 分别计算哈希值
  • 将这些位置都映射到同一台物理机器

例如,对于节点 database1,可以构造:

  • Hash("database1#1") -> pos(VDB1#1)
  • Hash("database1#2") -> pos(VDB1#2)
  • Hash("database1#3") -> pos(VDB1#3)
  • Hash("database1#4") -> pos(VDB1#4)

它们在逻辑上是 4 个独立节点,但在物理上都指向同一台服务器。

示意如下:

hash5

引入虚拟节点后:

  • 数据在环上的分布更加均匀
  • 单个节点的负载波动被显著降低
  • 扩容、缩容时的数据迁移成本进一步下降

这是一致性 Hash 能够在工程实践中落地的关键一步。


总结

一致性哈希的价值不在于分布是否更均匀,而在于节点变化时将重映射控制在最小范围

即:当系统发生变化时,如何控制变化的影响范围

特性 普通取模哈希 一致性哈希
节点变化影响 全局映射变化,大规模迁移 仅影响 hash 环上的局部区间
扩容/缩容 不支持平滑扩容 支持在线、低迁移成本扩容
数据迁移比例 约为 1 − 1/N 约为 1/N
均衡性 天然均匀(前提:hash 好) 原生不均,通过虚拟节点改善
典型应用 本地分片、静态分区 分布式缓存、KV、分布式存储

启动、停止

1
2
wsl #如果没有安装,会安装
wsl --shutdown

查看已安装的版本

1
2
3
4
5
PS C:\Users\gujinfei> wsl -l -v
NAME STATE VERSION
* Ubuntu Running 2
docker-desktop Stopped 2
PS C:\Users\gujinfei>

启用主机代理,加速github访问

进入: %UserProfile% 目录,编辑或者增加 .wslconfig 文件

1
2
3
4
5
[wsl2]
# 开启镜像网络模式
networkingMode=mirrored
# 自动代理转发
autoProxy=true

安装rust

1
2
sudo apt install -y build-essential curl pkg-config libssl-dev
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

一些配置

  1. 公私钥
    1
    ssh-keygen -t ed25519 -C "[email protected]"

本文为英文技术博客《Don’t give Postgres too much memory》的中文翻译。
原文作者:Tomas Vondra
原文链接:https://vondra.me/posts/dont-give-postgres-too-much-memory/
本文接近原文直译,仅做结构整理与术语统一,所有观点归原作者所有。


背景:一次“批处理”性能排查的经历

我时不时会被拉去排查一些与批处理(batch processing)相关的问题。
最近越来越常见的一种情况是:这些批处理进程会使用非常大的内存限制,尤其是:

  • maintenance_work_mem
  • work_mem

我猜不少 DBA 的思路是:

“内存越大越好。”

但他们往往没有意识到,这样做实际上可能会明显拖慢性能


一个触发问题的实际案例

我用一个在测试 GIN 索引并行构建修复 时遇到的例子来说明这个问题。

这个 bug 本身并不复杂,也不算特别有意思,但它需要一个相当高的 maintenance_work_mem 才能复现 —— 最初的报告里使用了 20GB

为了验证修复是否有效,我在:

  • 不同的 maintenance_work_mem 设置
  • 不同数量的并行 worker

组合下,反复执行 CREATE INDEX

本来的目标只是检查是否还会失败,但我同时也记录了执行时间,并将结果画成了一张图。

mem


测试环境说明

测试运行在 Azure 上的一台 D96v4 实例

  • CPU:Xeon Platinum 8573C
  • 内存:384GB
  • 存储:6 块 NVMe 组成 RAID0

这意味着:

  • 数据基本全部命中缓存
  • 瓶颈主要在 CPU,而不是磁盘 I/O

并行化的效果(符合预期)

并行化确实带来了明显收益:

  • 使用 2 个 worker(包括 leader)
    → 性能提升约 1.8 倍
    → 接近理想加速比(因为索引构建的最后阶段仍然是串行的)

  • 随着 worker 数量继续增加:

    • 加速比逐渐下降
    • 例如 8 个 worker 只有约 4.5 倍

这完全符合预期。


反直觉的现象:内存越大,反而越慢

真正令人意外的是图中展示的另一个趋势:

maintenance_work_mem 越大,索引构建反而越慢。

具体表现为:

  • 64MB 增加到 16GB
  • 索引构建时间增加了 约 30%
  • 并且 无论使用多少并行 worker,这一趋势都一致

为什么会这样?


原因概览

这很可能是多种因素共同作用的结果。
下面我解释两个我认为最重要的原因

  1. L3 Cache 的大小限制
  2. Linux 脏页(dirty page)回写机制

原因一:L3 Cache 的大小限制

系统中的内存并不是“同一种速度”。

在 CPU 内部,存在一小块极快的缓存(L3 Cache),访问延迟非常低。
但这部分内存通常只有 32MB~128MB

相比之下:

  • 主内存(RAM)容量巨大
  • 但访问延迟要高一个数量级

索引构建中的内存访问模式

在索引构建过程中,通常会经历以下流程:

  1. 将数据累积到一个内存缓冲区
  2. 缓冲区“满了”之后进行处理
  3. 再合并到最终的索引结构中

对于 GIN 索引 来说,这一步会把条目插入到一个哈希表中,这意味着:

大量随机内存访问


Cache Miss 的代价

一旦这个哈希表的大小超过 L3 Cache,CPU 就不得不频繁访问主内存。

大致的访问代价是:

  • L3 Cache:约 20 个 CPU cycle
  • 主内存:约 200 个 CPU cycle

也就是说,慢了一个数量级


更优的策略

因此:

  • 将数据拆分成更小的批次处理
  • 让工作集尽量能够放进 L3 Cache

往往是更优的策略。

即使需要处理更多批次,总体上仍然可能更快

推荐阅读
Ulrich Drepper,《What Every Programmer Should Know About Memory》(2007)
虽然年代久远,但内存层级的基本原理至今没有变化。


原因二:Linux 脏页(dirty page)回写机制

除了 CPU Cache,还有操作系统层面的因素。

当 GIN 的哈希表超过 maintenance_work_mem 限制时,数据会被写入临时文件
这些文件不需要持久化保证,因此写入时只进入 page cache


Linux 的脏页控制机制

Linux 内核通过两个阈值控制脏页数量:

  • vm.dirty_background_ratio
    • 达到后,后台开始异步回写
  • vm.dirty_ratio
    • 达到后,所有写入变成同步写(非常致命)

理想状态下:

后台回写足够快,永远不会触及 vm.dirty_ratio


大批量写入的问题

问题在于:
内核是否有“时间”去完成这些回写。

假设构建哈希表需要累积 8GB 数据,用时 1 分钟

情况 A:一次性写出

  • 1 分钟内几乎不写
  • 最后一次性写出 8GB
  • 短时间内产生大量脏页
  • 极易触发同步写

情况 B:分批写出

  • 64MB 写一次
  • 写操作均匀分布
  • 内核有足够时间后台回写

显然后者对系统更加友好。


总结(原文结论)

以上所有分析,**同样适用于 work_mem**。

唯一的区别在于:

  • maintenance_work_mem
    • 用于维护操作(CREATE INDEXVACUUM 等)
  • work_mem
    • 用于普通查询(hash joinhash aggregatesort 等)

但底层原理完全一致:

  • 哈希表超过 L3 Cache → 性能下降
  • 大块内存写入 → 脏页压力 → 可能触发同步写

作者建议

我并不知道 maintenance_work_memwork_mem 的“最佳值”是多少,这也不是这篇文章的重点。

重点在于:

盲目把内存参数调得很大,可能会显著伤害性能。

我的建议是:

  • 比较保守的值开始(例如 64MB
  • 只有在你能明确证明存在收益的情况下,才逐步调高

原文信息

pg_stats 视图中的 n_distinct 字段表示某一列中不同取值(distinct values)的数量。

如果 n_distinct 为负数,其绝对值表示该列中不同取值所占的比例,而不是实际的不同值个数。例如,−1 表示该列中所有值都是唯一的;−3 表示平均而言,每个不同的值大约出现在 3 行中。当估计的不同值数量超过表中总行数的 10% 时,分析器(analyzer)会使用这种“比例”的表示方式。

如果预期数据是均匀分布的,则会直接使用不同值的数量。例如,在估算 column = expression 这种条件的基数(cardinality)时,如果在规划阶段无法确定表达式的具体取值,查询规划器会假设该表达式可以以相同的概率取列中的任意一个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
=> EXPLAIN SELECT *
FROM flights
WHERE departure_airport = (
SELECT airport_code
FROM airports
WHERE city = 'Saint Petersburg'
);
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on flights (cost=30.56..5340.40 rows=2066 width=63)
Filter: (departure_airport = $0)
InitPlan 1 (returns $0)
> Seq Scan on airports_data ml (cost=0.00..30.56 rows=1 wi...
Filter: ((city −>> lang()) = 'Saint Petersburg'::text)
(5 rows)

虽然理论学家对此不以为然,但 NULL 值在关系型数据库中仍然扮演着重要角色:它提供了一种方便的方式来表示某个值要么未知,要么不存在。

然而,特殊的值需要特殊处理。除了理论上的不一致性之外,还有许多实际问题需要考虑。常规的布尔逻辑被三值逻辑取代,因此 NOT IN 的行为可能出乎意料。对于 NULL 值应该被视为大于还是小于普通值也不明确(因此存在用于排序的 NULLS FIRST 和 NULLS LAST 子句)。是否需要在聚合函数中考虑 NULL 值也并不十分明显。严格来说,NULL 根本不是一个值,因此优化器在处理它们时需要额外的信息。

除了在表级收集的最基本的统计信息之外,分析器还会为表的每一列收集统计信息。这些数据存储在系统目录的 pg_statistic 表中,但你也可以通过 pg_stats 视图访问,这个视图以更方便的格式提供这些信息。
列级统计信息中包括 NULL 值的比例;在分析过程中计算,并以 null_frac 属性表示。
例如,当我们查询尚未起飞的航班时,可以依赖它们的起飞时间未定义(NULL)这一事实:

1
2
3
4
5
=> EXPLAIN SELECT * FROM flights WHERE actual_departure IS NULL;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on flights (cost=0.00..4772.67 rows=16702 width=63) Filter: (actual_departure IS NULL)
(2 rows)

为了估算结果,优化器将表的总行数乘以 NULL 值的比例:

1
2
3
4
5
6
7
=> SELECT round(reltuples * s.null_frac) AS rows FROM pg_class
JOIN pg_stats s ON s.tablename = relname WHERE s.tablename = 'flights'
AND s.attname = 'actual_departure';
rows
−−−−−−−
16702
(1 row)

以下是实际的行数:

1
2
3
4
5
SELECT count(*) FROM flights WHERE actual_departure IS NULL;
count
−−−−−−−
16348
(1 row)

基本的 relation-level统计信息存储在系统目录的 pg_class 表中,包含以下数据:

  • relation中的元组(记录)数量(reltuples)
  • relation的大小,以页(page)为单位(relpages)
  • 在可见性映射(visibility map)中被标记的页数(relallvisible)

下面是 flights 表对应的这些值:

1
2
3
4
5
6
=> SELECT reltuples, relpages, relallvisible
FROM pg_class WHERE relname = 'flights';
reltuples | relpages | relallvisible
−−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−−−−−−−−
214867 | 2624 | 2624
(1 row)

如果查询未施加任何过滤条件,则 reltuples 的值将作为基数(cardinality)估计:

1
2
3
4
5
=> EXPLAIN SELECT * FROM flights;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on flights (cost=0.00..4772.67 rows=214867 width=63)
(1 row)

统计信息是在表分析(无论是手动还是自动)过程中收集的。此外,由于基本统计信息至关重要,这些数据也会在其他操作中计算(如 VACUUM FULL 和 CLUSTER,以及 CREATE INDEX 和 REINDEX),并在常规 VACUUM 过程中进一步精化。

为了分析目的,会从表中随机抽取 300 × default_statistics_target 行进行采样。为了构建具有特定精度的统计信息,所需的样本量与被分析数据的总体量关系不大,因此表的大小不会被考虑在内。

抽样行是从同样数量的随机页(300 × default_statistics_target 页)中选出的。显然,如果表本身较小,则可能读取的页数更少,选取用于分析的行数也会相应减少。

对于大表,统计信息收集不会包含所有行,因此估算值可能与实际值存在偏差。这是完全正常的:如果数据在不断变化,统计信息本身也不可能始终准确。通常,只要估算精度在数量级上足够,即可用来选择合理的查询执行计划。

我们创建一个 flights 表的副本,并禁用 autovacuum,这样我们就可以控制自动分析(autoanalysis)的启动时间:

1
=> CREATE TABLE flights_copy(LIKE flights) WITH (autovacuum_enabled = false);

目前新表没有统计信息

1
2
3
4
5
=> SELECT reltuples, relpages, relallvisible FROM pg_class WHERE relname = 'flights_copy';
reltuples | relpages | relallvisible
−−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−−−−−−−−
1 | 0 | 0
(1 row)

当 reltuples = −1 时,用于区分尚未分析的表和真正没有行的空表。

表创建后,很可能会马上插入一些行。由于优化器无法获知表的当前实际状态,它会假设该表包含 10 个页:

1
2
3
4
5
=> EXPLAIN SELECT * FROM flights_copy;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on flights_copy (cost=0.00..14.10 rows=410 width=170)
(1 row)

行数的估算是基于单行的大小(在执行计划中显示为 width)。行宽通常是在分析过程中计算的平均值,但由于此时尚未收集任何统计信息,这里只是根据列的数据类型做出的近似估算。

现在,我们从 flights 表拷贝数据同时完成分析:

1
2
=> INSERT INTO flights_copy SELECT * FROM flights; INSERT 0 214867
=> ANALYZE flights_copy;

收集到的统计信息反映了实际的行数(表足够小,分析器可以对所有数据收集统计信息):

1
2
3
4
5
=> SELECT reltuples, relpages, relallvisible FROM pg_class WHERE relname = 'flights_copy';
reltuples | relpages | relallvisible
−−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−−−−−−−−
214867 | 2624 | 0
(1 row)

relallvisible 值用于估算 index-only scan 的成本。该值由 VACUUM 更新:

1
2
3
4
5
6
=> VACUUM flights_copy;
=> SELECT relallvisible FROM pg_class WHERE relname = 'flights_copy';
relallvisible
−−−−−−−−−−−−−−−
2624
(1 row)

现在我们将行数翻倍,但不更新统计信息,检查查询计划中的基数估算:

1
2
3
4
5
6
7
8
9
10
11
12
=> INSERT INTO flights_copy SELECT * FROM flights; 
=> SELECT count(*) FROM flights_copy;
count
−−−−−−−−
429734
(1 row)

=> EXPLAIN SELECT * FROM flights_copy;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on flights_copy (cost=0.00..9545.34 rows=429734 width=63)
(1 row)

尽管 pg_class 中的数据已经过时,估算结果仍然准确:

1
2
3
4
5
6
=> SELECT reltuples, relpages
FROM pg_class WHERE relname = 'flights_copy';
reltuples | relpages
−−−−−−−−−−−+−−−−−−−−−−
214867 | 2624
(1 row)

问题在于,如果优化器发现 relpages 与实际文件大小之间存在差距,它可以通过比例调整 reltuples 的值来提高估算精度。由于文件大小相比 relpages 翻了一倍,优化器会调整估算的行数,同时假设数据密度保持不变:

1
2
3
4
5
6
7
=> SELECT reltuples *
(pg_relation_size('flights_copy') / 8192) / relpages AS tuples
FROM pg_class WHERE relname = 'flights_copy';
tuples
−−−−−−−−
429734
(1 row)

当然,这种调整并不总是有效(例如,如果删除了一些行,估算值将保持不变),但在某些情况下,它可以让优化器在下一次分析触发之前仍然保持合理的估算。

当使用简单查询协议时,每个命令(即使重复多次)都必须经历上述所有阶段:

  1. parsing
  2. transformation
  3. planning
  4. execution

但是,一次又一次地解析相同的查询是没有意义的。重复解析仅在常量上不同的查询也没有多大意义——解析树结构仍然保持不变。

简单查询协议的另一个缺点是客户端会立即收到整个结果,无论它可能包含多少行。

一般来说,使用 SQL 命令可以克服这些限制。要处理第一种情况,您可以在运行 EXECUTE 命令之前PREPARE查询;第二个问题可以通过使用 DECLARE 创建游标并通过 FETCH 返回行来解决。但在这种情况下,这些新创建的对象的命名必须由客户端处理,而服务器则需要解析额外命令的额外开销

扩展的客户端—服务器协议提供了一种替代方案,使得可以在协议本身的命令级别上,对各个算子执行阶段进行精确控制。

Preparation

在准备阶段,查询会像往常一样被解析并进行转换,但生成的解析树会保存在后端的内存中。

PostgreSQL 并不存在全局的查询缓存。这种架构的缺点是显而易见的:即使同一条查询已经被其他后端进程解析过,每个后端仍然必须重新解析其接收到的所有查询。但与此同时,这种设计也带来了一些好处。全局缓存由于需要加锁,很容易成为系统瓶颈。一个客户端如果频繁执行大量相似但不完全相同的小查询(例如仅常量不同的查询),会产生大量缓存访问流量,从而对整个实例的性能造成负面影响。在 PostgreSQL 中,查询是在各个后端本地解析的,因此不会对其他进程产生影响。

一条预处理(prepared)的查询可以带参数化。下面是一个使用 SQL 命令的简单示例(尽管这与协议层的预处理不完全相同,但最终效果是一样的)

1
2
=> PREPARE plane(text) AS
SELECT * FROM aircrafts WHERE aircraft_code = $1;

所有命名的预处理语句都显示在 pg_prepared_statements 视图中:

1
2
3
4
5
6
7
=> SELECT name, statement, parameter_types
FROM pg_prepared_statements \gx
−[ RECORD 1 ]−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
name | plane
statement | PREPARE plane(text) AS +
| SELECT * FROM aircrafts WHERE aircraft_code = $1;
parameter_types | {text}

这里不会显示任何未命名语句(即使用扩展查询协议或 PL/pgSQL 的语句)。其他后端准备的语句也不会显示:因为无法访问其他会话的内存。

Parameter Binding

在预处理语句被执行之前,必须先绑定实际的参数值。

1
2
3
4
5
=> EXECUTE plane('733');
aircraft_code | model | range
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−+−−−−−−−
733 | Boeing 737300 | 4200
(1 row)

在预处理语句中绑定参数,相比将字面量直接拼接到查询字符串中,其优势在于可以彻底杜绝 SQL 注入:绑定的参数值无法以任何方式修改已经构建完成的解析树。若不使用预处理语句而想达到同等的安全级别,就必须对来自不可信来源的每一个值进行非常谨慎的转义处理。

Planning and Execution

在执行预处理语句时,查询规划会基于实际的参数值来进行;随后生成的执行计划会交由执行器处理。

由于不同的参数值可能对应不同的最优执行计划,因此在规划阶段准确考虑具体参数值是非常重要的。举例来说,在查询价格较高的预订记录时,规划器会假定符合条件的行数不多,从而选择使用索引扫描。

1
2
3
4
5
6
7
8
9
10
11
=> CREATE INDEX ON bookings(total_amount);
=> EXPLAIN SELECT *
FROM bookings
WHERE total_amount > 1000000;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Bitmap Heap Scan on bookings (cost=86.49..9245.82 rows=4395 wid...
Recheck Cond: (total_amount > '1000000'::numeric)
> Bitmap Index Scan on bookings_total_amount_idx (cost=0.00....
Index Cond: (total_amount > '1000000'::numeric)
(4 rows)

但如果给定的条件对所有预订记录都成立,那么使用索引就没有意义了,因为最终仍然需要扫描整张表。

1
2
3
4
5
6
7
=> EXPLAIN SELECT *
FROM bookings
WHERE total_amount > 100;
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on bookings (cost=0.00..39835.88 rows=2111110 width=21) Filter: (total_amount > '100'::numeric)
(2 rows)

在某些情况下,规划器可能会同时保留解析树和查询计划,以避免重复进行规划。由于这种计划不会考虑具体的参数值,因此被称为通用计划(generic plan),以区别于基于实际参数值生成的定制计划(custom plan)。

参数化预处理语句的前五次执行,优化过程始终依赖于实际的参数值;规划器会基于这些参数值计算定制计划(custom plan)的平均成本。从第六次执行开始,如果通用计划(generic plan)在平均意义上比定制计划更高效(同时考虑到每次都需要重新生成定制计划的额外开销),规划器就会保留通用计划并继续使用它,从而跳过后续的优化阶段。

一个显而易见的场景是:当查询不包含任何参数时,数据库可以使用通用计划而不会对性能造成影响

1
2
3
4
5
6
7
 => EXECUTE plane('763');
=> EXECUTE plane('773');
=> EXPLAIN EXECUTE plane('319');
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on aircrafts_data ml (cost=0.00..1.39 rows=1 width=52)
Filter: ((aircraft_code)::text = '319'::text) (2 rows)

在第五次执行之后,规划器会切换为通用计划(generic plan):该计划与之前的定制计划并无差别,成本也相同,但后端只需构建一次即可,并且可以跳过优化阶段,从而降低规划开销。此时,EXPLAIN 命令显示参数是通过位置来引用的,而不再显示其具体取值。

1
2
3
4
5
6
 => EXECUTE plane('320');
=> EXPLAIN EXECUTE plane('321');
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on aircrafts_data ml (cost=0.00..1.39 rows=1 width=52)
Filter: ((aircraft_code)::text = $1) (2 rows)

“优化阶段”特指每次执行时基于参数值重新生成执行计划的那一部分工作。

我们可以很容易想象这样一种不利情况:前几次生成的 custom plan 比 generic plan 更昂贵;随后可能出现的更高效的 custom plan 却完全不会被考虑。此外,规划器比较的是估算成本而非实际执行成本,这也可能导致误判。

不过,如果规划器的自动决策出现偏差,你可以通过设置 plan_cache_mode 参数来覆盖自动选择,从而强制使用 generic plan 或 custom plan

1
2
3
4
5
6
=> SET plan_cache_mode = 'force_custom_plan';
=> EXPLAIN EXECUTE plane('CN1');
QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on aircrafts_data ml (cost=0.00..1.39 rows=1 width=52)
Filter: ((aircraft_code)::text = 'CN1'::text) (2 rows)

除了其他信息之外,pg_prepared_statements 视图还显示了所选计划的统计信息:

1
2
3
4
5
6
=> SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;
name | generic_plans | custom_plans
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−
plane | 1 | 6
(1 row)

Getting the Results

扩展查询协议允许按批次而不是一次性检索数据。SQL 游标(cursor)几乎具有相同效果(唯一的区别是服务器需要做一些额外处理,而且规划器只会优化前 cursor_tuple_fraction 行的获取,而不是整个结果集)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
=> BEGIN;
=> DECLARE cur CURSOR FOR SELECT *
FROM aircrafts
ORDER BY aircraft_code;
=> FETCH 3 FROM cur;
aircraft_code | model | range
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−+−−−−−−−
319 | Airbus A319−100 | 6700
320 | Airbus A320−200 | 5700
321 | Airbus A321−200 | 5600
(3 rows)
=> FETCH 2 FROM cur;
aircraft_code | model | range
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−+−−−−−−−
733 | Boeing 737300 | 4200
763 | Boeing 767300 | 7900
=> COMMIT;
(2 rows)

如果查询返回大量行,且客户端需要获取所有行,那么系统吞吐量高度依赖于批量大小(batch size)。批量包含的行数越多,每次访问服务器并获取响应时产生的通信开销就越小。然而,随着批量大小继续增加,这种优势会逐渐减弱:例如,将行一条条取出与每批取 10 行的差别非常明显,但将每批取 100 行与每批取 1000 行相比,性能提升就不那么显著了

参考书目

  1. Egor Rogov, PostgreSQL 14 Internals, https://postgrespro.com/community/books/internals
0%