今天工作中遇到一个有意思的事,同事调试程序发现死锁了,但是一打pstack就过去了。分析过去的堆栈,发现线程卡在 write() 上,打 pstack 后就突然继续执行了。
恰好还有一台出现问题的环境留着,等待分析验证

一 堆栈现象

1
2
3
#0 ... write() from libpthread.so.0
#1 ... ?? () from libasan.so.5
#2 ... NetWrite(...) // 自己封装的写函数
  • 卡住现象是在发送端
  • 打 pstack 后线程立即恢复,行为类似“虚假唤醒”

二 原因分析

同事反馈,数据量很大。查看代码,发现是阻塞的 TCP write
那么怀疑到了发送端缓冲区:

2.1 阻塞的 TCP write

条件 write 返回 阻塞情况
send buffer 空间 ≥ size 写入全部字节 不阻塞
send buffer 空间 < size 写入可用空间(短写) 不阻塞
send buffer 满 阻塞等待 buffer 空 阻塞

所以,如果对端不读取数据:

  • 发送端 send buffer 满
  • write() 阻塞,线程挂起
  • 网络层也没有错误,写线程只能等待

2.2 信号打断 (EINTR)

  • 阻塞的 write() 可以被 可中断信号打断:返回值:-1, errno=EINTR
  • pstack attach 就是触发 signal 的一种情况,所以线程看似“自己醒了”

注意:信号打断不会解决 send buffer 满导致的阻塞,它只是让系统调用提前返回。

2.3 关于接收端

接收端是基于 libevent 的协程处理,观察堆栈没有触发事件 → 没有调用 read(),然后发送端 write() 阻塞

最终确认
杀掉接收端后,发送端 write() 立即返回。 和pstack效果一样

三 解决方式

得排查为什么接收端不收数据,而不是死锁问题。

四 总结

  1. TCP write 阻塞的根本原因:对端不读 → send buffer 满
  2. 信号(如 pstack attach)可以打断阻塞,返回 EINTR
  3. TCP write 短写是常态,循环处理保证数据完整
  4. ASan 堆栈出现只是包装函数,不是问题根源
  5. 安全的写函数必须循环处理短写 + EINTR,阻塞问题需用非阻塞或专门线程解决

一、栈的基本性质

在 x86_64 架构(Linux / macOS)下:

  • 栈从 高地址向低地址增长
  • 栈顶由 rsp(stack pointer)指示
  • push → rsp -= 8
  • pop → rsp += 8

原因:

  • 早期内存布局约定(低地址给 code / data)
  • 向下增长可以避免与 heap 冲突(heap 通常向上增长)

二、Sample code

1
2
3
4
5
6
7
8
9
10
11
12
13
int add(int a, int b)
{
int c = a + b;
return c;
}

int main()
{
int x = 3;
int y = 4;
int z = add(x, y);
return z;
}

三、Disassem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(lldb) disassem
test`main:
0x100003f80 <+0>: pushq %rbp
0x100003f81 <+1>: movq %rsp, %rbp
0x100003f84 <+4>: subq $0x10, %rsp
0x100003f88 <+8>: movl $0x0, -0x4(%rbp)
0x100003f8f <+15>: movl $0x3, -0x8(%rbp)
-> 0x100003f96 <+22>: movl $0x4, -0xc(%rbp)
0x100003f9d <+29>: movl -0x8(%rbp), %edi
0x100003fa0 <+32>: movl -0xc(%rbp), %esi
0x100003fa3 <+35>: callq 0x100003f60 ; add(int, int)
0x100003fa8 <+40>: movl %eax, -0x10(%rbp)
0x100003fab <+43>: movl -0x10(%rbp), %eax
0x100003fae <+46>: addq $0x10, %rsp
0x100003fb2 <+50>: popq %rbp
0x100003fb3 <+51>: retq
1
2
3
4
5
6
7
8
9
10
11
12
13
(lldb) disassemble --name add
test`add:
0x100003f60 <+0>: pushq %rbp
0x100003f61 <+1>: movq %rsp, %rbp
0x100003f64 <+4>: movl %edi, -0x4(%rbp)
0x100003f67 <+7>: movl %esi, -0x8(%rbp)
0x100003f6a <+10>: movl -0x4(%rbp), %eax
0x100003f6d <+13>: addl -0x8(%rbp), %eax
0x100003f70 <+16>: movl %eax, -0xc(%rbp)
0x100003f73 <+19>: movl -0xc(%rbp), %eax
0x100003f76 <+22>: popq %rbp
0x100003f77 <+23>: retq
0x100003f78 <+24>: nopl (%rax,%rax)

四、函数栈帧的建立(Prologue)

前3条指令:

1
2
3
pushq %rbp
movq %rsp, %rbp
subq $0x10, %rsp

作用:

  • 保存上一层函数的 rbp
  • 建立当前函数栈帧基址
  • 为局部变量分配 16 字节空间

注意:
即便只需要 12 字节(3 个 int),编译器仍分配 16 字节。

原因:

  • 栈 16 字节对齐(System V ABI 要求)
  • 调用其他函数前必须满足对齐约束

五、System V ABI 参数传递

在 x86_64 下(System V ABI):

前 6 个整数参数通过寄存器传递:

参数序号 寄存器
1 rdi
2 rsi
3 rdx
4 rcx
5 r8
6 r9

因此:

1
2
movl -0x8(%rbp), %edi
movl -0xc(%rbp), %esi

并不是通过栈传参。

六、call 指令的真实语义

1
callq 0x100003f60

等价于

1
2
3
rsp -= 8
*(rsp) = rip_after_call
rip = target

即:

返回地址被压入栈中。在本例中:

1
0x100003fa8 被压入栈

七、执行 call 后的栈结构

假设进入 add 之前,main 的栈帧如下:

1
2
3
4
5
6
7
8
9
10
11
12
高地址
-------------------------
return address (to caller of main)
-------------------------
saved rbp
------------------------- ← rbp (main)
x (-0x8)
y (-0xc)
z (-0x10)
padding
------------------------- ← rsp
低地址

执行 call 后:

1
2
3
4
5
6
7
8
9
10
11
高地址
-------------------------
return address to main caller
-------------------------
saved rbp (main)
------------------------- ← rbp(main)
local variables
-------------------------
return address (0x100003fa8) ← rsp
-------------------------
低地址

八、add 函数栈帧

add 的 prologue:

1
2
3
pushq  %rbp
movq %rsp, %rbp
movl %edi, -0x4(%rbp)

这里 没有 sub rsp。

该版本编译器没有显式为 add 分配额外栈空间。

本例中 add 为 leaf function。虽然未显式 sub rsp 分配空间,但由于使用了 push %rbp,局部变量仍然位于当前栈帧中,并未实际使用 red zone。
在 macOS / Linux 的 System V ABI 下:

栈顶以下 128 字节称为 red zone,可在 leaf function 中使用。

所以 add 是一个 leaf function:

  • 没有再调用其他函数
  • 不需要保持 16-byte 对齐给下一级
  • 编译器优化掉 sub rsp

栈变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
高地址
--------------------------------
main 的局部变量
--------------------------------
saved rbp (main)
--------------------------------
return addr → main caller
--------------------------------
return addr → main ← 8(%rbp_add)
saved rbp (main) ← 0(%rbp_add)
-------------------------------- ← rbp(add)
local a copy
local b copy
local c
-------------------------------- ← rsp
低地址

注意:
每次函数调用都会:

  • 保存调用者 rbp
  • 建立新的栈帧

形成一条链。

本例中 add 为 leaf function,未显式执行 sub rsp。
在 System V ABI 下允许使用 128 字节 red zone,因此编译器可省略栈空间分配。

九、rbp 与 rsp 的角色

寄存器 作用
rsp 当前栈顶
rbp 当前函数栈帧基址

局部变量用 rbp - offset 访问。

返回地址在:

1
8(%rbp)

saved rbp 在:

1
0(%rbp)

十、ret 的行为

1
ret

等价于

1
2
rip = *(rsp)
rsp += 8

即:

  • 弹出返回地址
  • 跳转回调用点

其他

函数调用 =

  1. 保存返回地址
  2. 建立栈帧
  3. 执行函数
  4. 恢复栈帧
  5. 跳回

栈本质是:

一种严格的 LIFO 调用上下文保存机制

总结

  • 栈从高地址向低地址增长
  • call 会压入返回地址
  • 每个函数建立自己的栈帧
  • rbp 固定当前帧
  • rsp 始终指向栈顶
  • System V ABI 下参数主要走寄存器

addr2line 是 GNU Binutils 提供的一个调试工具,用于将程序地址转换为源代码文件 + 行号。
其底层依赖 ELF 文件中的 DWARF 调试信息。

典型使用场景:

  • core dump 分析
  • 崩溃栈回溯解析
  • 日志中只有地址信息
  • 线上二进制与调试符号分离

示例分析

例如某崩溃日志中看到:

1
2
.../your_binary(_ZN19MyTableC1EPS_+0x10d)[0x1f792fd]
......

含义:

  • ZN19MyTableC1EPS → C++ mangled 名字
  • +0x10d → 相对于函数起始地址的偏移
  • [0x1f792fd] → 实际程序地址
  1. 如果符号信息在可执行文件上

    1
    addr2line -e your_binary -f -C 0x1f792fd
  2. use eu-addr2line 加载symbol

    1
    eu-addr2line -e ./your_binary --debuginfo-path=./your_binary.debug 0x1f792fd

    output:
    mytable/MyTable.cpp:1070

  3. 只有函数名 + 偏移
    例如日志只有: MyTable::insert+0x52
    需要手动计算地址。

    • 查函数起始地址
      1
      nm -C your_binary | grep my_func

      如果 binary 已 strip,需要对 debug 文件执行 nm:

      1
      objdump -t your_binary | grep MyTable::insert
      假设得到:
      1
      0000000000401230 T MyTable::insert
    • 加上偏移
      1
      0x401230 + 0x52 = 0x401282
    • 再 addr2line
      1
      addr2line -e your_binary -f -C 0x401282

PIE / ASLR 注意事项

如果二进制是 PIE(Position Independent Executable):

1
readelf -h your_binary | grep Type

如果看到:

1
Type: DYN (Position-Independent Executable file)

说明是 PIE。

这时:

  • 崩溃日志中的地址 = load_base + offset

  • 必须减去加载基地址

在 core dump 中:

1
info proc mappings

或者

1
cat /proc/<pid>/maps

找到加载基址,例如:

1
2
7f3c2d400000-...
真实地址 = 崩溃地址 - 基址

再传给 addr2line。

否则会解析失败或定位错误。

常用排错检查

  1. 是否包含调试信息

    1
    readelf --sections your_binary | grep debug
  2. 是否被 strip

    1
    file your_binary

2026年,春节从老家返程途中游玩连云港。愿孩子们永远健康快乐

liandao

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、分布式存储
0%