一 内存屏障概念

屏障(fence)的核心作用是两件事:保证指令顺序跨线程可见性。两者听起来像一回事,但实现层面是分开的。

  1. 编译器屏障(compiler fence):只阻止编译器重排指令,不产生任何 CPU 指令。asm volatile("" ::: "memory") 就是典型例子,零运行时开销。
  2. CPU 内存屏障(hardware fence):实际生成一条 CPU 指令,阻止处理器乱序执行,并确保 store/load 结果对其他核可见。

两者都需要,缺一不可——即使 CPU 不乱序,编译器也可能把你的代码搬到错误位置。

二 CPU 内存模型对比

架构 内存模型 顺序保证
x86 / x86-64 TSO(Total Store Order) StoreStore / LoadLoad / LoadStore 有保证,Store→Load 可能乱序
ARM / AArch64 Weakly Ordered StoreStore / LoadLoad / StoreLoad 都可能乱序,需要显式 fence

StoreLoad hazard 是最常见的坑:当前核的 store 还在 store buffer 里没有刷到缓存,后续的 load 却绕过 store buffer 直接从缓存或内存读取,于是读到了旧值。x86 的 TSO 模型里,这是唯一允许的乱序方向;ARM 上四个方向都可能出问题。

解决办法是在写端用 release,读端用 acquire,在两个线程之间建立 happens-before 链。

三 Release / Acquire Fence

配对规则很简单,但很多人记不住,列个表:

写入端 读取端 结果
release store acquire load/fence ✅ 建立跨线程 happens-before
普通 store acquire load ❌ 无法保证可见性
release store 普通 load ❌ 读端可能看不到前写

release store(写端):在逻辑上声明”这个 store 之前的所有写都完成了”,对应 ARM 上的 STLR 指令。

acquire load(读端):在逻辑上声明”读到这个值之后,才能看后面的操作”,对应 ARM 上的 LDAR 指令。

不要靠 x86 的天然 TSO 顺序写无锁代码——那样的代码在 ARM 上会悄悄出错,而且很难复现。

四 示例

下面是一个单生产者单消费者(SPSC)环形队列的最简版本,用来演示 release/acquire 的典型用法。多生产者或多消费者场景需要额外的 CAS,不在本例讨论范围内。

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
#include <atomic>
#include <iostream>

// 对齐到缓存行,避免 false sharing
alignas(64) std::atomic<int> tail{0};
alignas(64) int head{0}; // head 只被消费者访问,不需要原子变量
int buffer[1024];

void producer() {
int t = tail.load(std::memory_order_relaxed);

buffer[t % 1024] = 42;

// release:保证 buffer 的写入在 tail 更新之前对其他核可见
// ARM 上生成 STLR 指令
tail.store(t + 1, std::memory_order_release);
}

void consumer() {
// acquire:读到 tail 的新值后,能看到 producer 在 release 之前写的所有内容
// ARM 上生成 LDAR 指令
while (tail.load(std::memory_order_acquire) <= head) {
// 自旋等待时可以加 CPU hint,让流水线让出资源给其他超线程
// ARM: __builtin_arm_yield()
// x86: _mm_pause()
}

int value = buffer[head % 1024];
std::cout << "Data: " << value << std::endl;

head++; // head 是消费者私有的,直接递增即可
}

这里有几个细节值得注意:

  • tailstd::atomic,因为生产者写、消费者读,存在跨线程访问
  • head 只有消费者自己读写,用普通 int 就够了,不需要原子操作
  • tail 的初始 load 用 relaxed,因为这里只是读自己上次写的值,不涉及跨线程同步

五 总结

x86 和 ARM 的主要差异不是”要不要写屏障”,而是”CPU 帮你做了多少”:

  • x86 TSO:CPU 层面保证了大部分顺序,memory_order_release/acquire 在 x86 上通常不会生成额外的 fence 指令。但编译器屏障依然必要,不能省略——省了之后编译器可能把你的代码重排到完全错误的地方。
  • ARM:什么都不帮你保证,每一处需要同步的地方都得显式写出来。好处是性能更可控,坏处是出错了很难调试。

实际写无锁代码的建议:先用 std::memory_order_seq_cst 把逻辑跑通,再在性能瓶颈处换成 release/acquire,最后用 ThreadSanitizer 跑一遍验证。别一上来就写 relaxed,省不了多少性能,却能坑死自己。

今天工作中遇到一个有意思的事,同事调试程序发现死锁了,但是一打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=#
0%