Requirements

  • Bypass internet restrictions
  • Docker Desktop (or Docker Engine) + Docker Compose v2
  • Enough disk for images + logs

Get code from github

1
git clone https://github.com/openclaw/openclaw.git

Containerized Gateway

From repo root:

1
./docker-setup.sh

This script:

  • builds the gateway image
  • runs the onboarding wizard
  • prints optional provider setup hints
  • starts the gateway via Docker Compose
  • generates a gateway token and writes it to .env

Control UI token

If you see “unauthorized” or “disconnected (1008): pairing required”

1
2
3
"gateway": {
"controlUi": { "dangerouslyDisableDeviceAuth": true }
}

By default, OpenClaw requires every connecting device to be “paired” (approved by an administrator). Since the Control UI is running in an “insecure” context
(HTTP inside Docker), it cannot generate a persistent device identity. Setting allowInsecureAuth: true tells the gateway to trust the Control UI if it provides
the correct token, skipping the pairing requirement.

Install feishu plugin

See URL below:
Feishu

Install skill

  1. goto the following site:
    https://clawhub.ai/skills

  2. look for the skill you need. e.g. sonoscli

  3. Execute

    1
    npx clawhub@latest install sonoscli

Everything is ok

FEISHU

Some problems:

  1. “ plugin feishu: duplicate plugin id detected; later plugin may be overridden”
    1
    2
    3
    node@45122743ed12:/app$ find / -name "openclaw.plugin.json" 2>/dev/null | grep feishu   
    /home/node/.openclaw/extensions/feishu/openclaw.plugin.json
    /app/extensions/feishu/openclaw.plugin.json
    You only need one feishu, delete the other one (I deleted the one under .openclaw, and deleting the one under app will cause the container to fail to start)

概念

CAP 定理指出,在分布式系统中,系统只能在以下三个属性中同时保证两个:

一致性(Consistency,C):所有节点看到相同的数据。对任一节点的写入操作,其后的所有读取都会返回更新后的值。

可用性(Availability,A):每个发给非故障节点的请求都会得到响应,但不保证响应包含最新数据。

分区容错性(Partition Tolerance,P):即使节点间发生网络分区或消息丢失,系统仍能继续运行。

在理想情况下(网络永不中断),可以同时拥有 C 和 A。但在实际环境中,网络延迟或中断不可避免,因此 P 通常被视为默认前提。真正的取舍在于 A 与 C:当网络分区发生时,是优先保证一致性,还是优先保证可用性?

CAP

一致性优先场景

  • 票务系统(如 12306):若两个用户同时预订同一座位,系统必须确保只有一个用户成功,以避免重复分配。

  • 账户余额:扣款成功后,用户在任何终端查询余额都应反映扣款后的最新结果

可用性优先场景

  • 社交媒体:用户更新个人信息后,短时间内其他用户可能仍看到旧信息,但系统不会返回错误。

  • OLAP 系统:处理海量报表数据时,由于同步延迟,系统可能无法实时反映最新写入,但仍能提供历史数据查询,保证系统可用性。

一致性的补充说明

CAP 定理中所指的一致性通常是强一致性,即所有读取操作都反映最新写入。除此之外,还有其他一致性模型:

强一致性(Strong Consistency)

所有读取均返回最新写入的数据,开销较大,但对银行账户等需要绝对准确性的系统至关重要。

因果一致性(Causal Consistency)

  • 保证具有因果关系的操作顺序一致,但允许无因果关系的操作顺序不同。
  • 因果相关操作:如用户先发帖,再有回复,系统保证先看到发帖再看到回复。
  • 并发无关操作:如不同用户同时点赞不同帖子,其顺序可以在各节点不同。

特殊实现
读己所写(Read-Your-Own-Writes Consistency):用户在同一会话中总能立即看到自己提交的更新。常用于社交媒体。

最终一致性(Eventual Consistency):

系统在经过一段时间后最终达到一致状态,但短期内可能存在不一致数据。典型应用:ClickHouse、分布式缓存等。

物理结构图

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 "jeffreyfly@icloud.com"

本文为英文技术博客《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)
0%