386 lines
16 KiB
Markdown
386 lines
16 KiB
Markdown
# 9.1 Kvstore
|
||
|
||
## 环境安装与编译
|
||
```shell
|
||
# xml
|
||
sudo apt install libxml2 libxml2-dev
|
||
# hiredis client
|
||
sudo apt install -y libhiredis-dev
|
||
# bpftrace
|
||
sudo apt install -y bpftrace libelf libelf-dev clang
|
||
# jemalloc
|
||
sudo apt install libjemalloc-dev
|
||
|
||
git clone git@gitlab.0voice.com:lianyiheng/9.1-kvstore.git
|
||
cd 9.1-kvstore/
|
||
git submodule update --init --recursive
|
||
make
|
||
|
||
|
||
```
|
||
|
||
## REDIS 对比测试
|
||
### 数据口径(2026-03-07 大 key 5轮复测)
|
||
- 参数:`requests=1000000 pipeline=128 keyspace=1000000 value-size=256`
|
||
- 轮次:每个策略 `5` 轮,按场景剔除 `1` 个异常轮次后取 `4` 轮均值
|
||
- kvstore 源数据:`test-redis/results/hash_bench_fair_summary_20260307_062549.csv`
|
||
- redis 源数据:`test-redis/results/redis_bigkey_summary_20260307_063158.csv`
|
||
- 去异常结果:
|
||
- `test-redis/results/hash_bench_fair_trimmed_20260307_062549.csv`
|
||
- `test-redis/results/redis_bigkey_trimmed_20260307_063158.csv`
|
||
|
||
### kvstore 协议开销(基线:nopersist,去异常后)
|
||
| 策略 | set 均值QPS | set 均值us/op | get 均值QPS | get 均值us/op |
|
||
|---|---:|---:|---:|---:|
|
||
| nopersist | 150084.25 | 6.67 | 163760.25 | 6.11 |
|
||
| persist_no | 140206.50 | 7.13 | 164554.25 | 6.08 |
|
||
| persist_everysec | 133105.75 | 7.52 | 163358.25 | 6.12 |
|
||
|
||
| 相对 nopersist 的开销 | set QPS 变化 | set us/op 变化 | get QPS 变化 | get us/op 变化 |
|
||
|---|---:|---:|---:|---:|
|
||
| persist_no | -6.58% | +6.90% | +0.48% | -0.49% |
|
||
| persist_everysec | -11.31% | +12.74% | -0.25% | +0.16% |
|
||
|
||
### Redis 协议开销(基线:none,去异常后)
|
||
| 策略 | set 均值QPS | set 均值us/op | get 均值QPS | get 均值us/op |
|
||
|---|---:|---:|---:|---:|
|
||
| none | 207470.00 | 4.82 | 217642.50 | 4.59 |
|
||
| aof_no | 143399.50 | 6.98 | 214023.50 | 4.68 |
|
||
| aof_everysec | 141893.00 | 7.05 | 208713.75 | 4.79 |
|
||
|
||
| 相对 none 的开销 | set QPS 变化 | set us/op 变化 | get QPS 变化 | get us/op 变化 |
|
||
|---|---:|---:|---:|---:|
|
||
| aof_no | -30.88% | +44.81% | -1.66% | +1.96% |
|
||
| aof_everysec | -31.61% | +46.27% | -4.10% | +4.36% |
|
||
|
||
结论:本轮数据下,持久化开销仍主要体现在 `set`,`get` 相对更稳;Redis 的 AOF 写路径开销显著高于无持久化。
|
||
|
||
## 调用开销
|
||
### gprof Flat Profile(Top 12,按 self time)
|
||
|
||
| 排名 | 函数 | self time % | self seconds | calls |
|
||
|---:|---|---:|---:|---:|
|
||
| 1 | `rbtree_node_get_key` | 58.34 | 0.56 | 103209091 |
|
||
| 2 | `rbtree_search` | 7.29 | 0.07 | 1874362 |
|
||
| 3 | `kvs_keycmp` | 5.73 | 0.06 | 74287566 |
|
||
| 4 | `ascii_casecmp` | 3.13 | 0.03 | 21490996 |
|
||
| 5 | `task_init` | 3.13 | 0.03 | 1397974 |
|
||
| 6 | `mp_page_create` | 3.13 | 0.03 | 23556 |
|
||
| 7 | `need` | 2.08 | 0.02 | 14042759 |
|
||
| 8 | `parse_i64` | 2.08 | 0.02 | 7029783 |
|
||
| 9 | `mp_page_alloc` | 2.08 | 0.02 | 5122487 |
|
||
| 10 | `rbtree_node_size` | 2.08 | 0.02 | 1860739 |
|
||
| 11 | `submit_write` | 2.08 | 0.02 | 1394599 |
|
||
| 12 | `rbtree_insert` | 2.08 | 0.02 | 926531 |
|
||
|
||
## 其他测试
|
||
### 测试1:性能测试
|
||
测试条件:
|
||
1. 不启用持久化。
|
||
2. 不启用主从同步。
|
||
3. pipline:
|
||
1. RSET 100w 条, p:i v:i -> +OK
|
||
2. RGET 100w 条, p:i -> +v:i
|
||
3. RDEL 100w 条。 p:i -> +OK
|
||
4. 本机发送请求。
|
||
|
||
#### 内存分配: malloc
|
||
```bash
|
||
lian@ubuntu:~/share/9.1-kvstore$ ./test-redis/testcase 192.168.10.129 8888 3
|
||
average qps:880462
|
||
ALL TESTS PASSED.
|
||
```
|
||
|
||
#### 内存分配: 自实现内存池
|
||
```bash
|
||
lian@ubuntu:~/share/9.1-kvstore$ ./test-redis/testcase 192.168.10.129 8888 3
|
||
average qps:942837
|
||
ALL TESTS PASSED.
|
||
```
|
||
|
||
#### 内存分配:jemalloc
|
||
```shell
|
||
lian@ubuntu:~/share/9.1-kvstore$ ./test-redis/testcase 192.168.10.129 8888 3
|
||
average qps:892493
|
||
ALL TESTS PASSED.
|
||
```
|
||
|
||
### 测试2:持久化
|
||
测试条件:
|
||
1. 启用持久化。
|
||
2. 不启用主从同步。
|
||
3. pipline:
|
||
1. RSET 100w 条, p:i v:i -> +OK
|
||
2. RGET 100w 条, p:i -> +v:i
|
||
3. RDEL 100w 条。 p:i -> +OK
|
||
5. 本机发送请求。
|
||
|
||
```shell
|
||
lian@ubuntu:~/share/9.1-kvstore$ ./test-redis/testcase 192.168.10.129 8888 4
|
||
average qps:870227
|
||
ALL TESTS PASSED.
|
||
```
|
||
|
||
### 测试3:内存
|
||
|
||
#### malloc
|
||
```shell
|
||
VIRT 58504
|
||
RES 4604
|
||
|
||
插入 20w 删除 10w,重复 10 次,共计插入 200w 删除 100w。
|
||
BATCH (N=9000000) --> time_used=12897 ms, qps=1395673
|
||
|
||
VIRT 489M
|
||
RES 430M
|
||
|
||
插入 10w 删除 20w,重复 10 次,共计插入 100w 删除 200w。
|
||
BATCH (N=9000000) --> time_used=10033 ms, qps=1794079
|
||
|
||
VIRT 208M
|
||
RES 155M
|
||
```
|
||

|
||

|
||

|
||
|
||
#### jemalloc
|
||
```shell
|
||
VIRT 69376
|
||
RES 5408
|
||
|
||
插入 20w 删除 10w,重复 30 次,共计插入 600w 删除 300w。
|
||
BATCH (N=9000000) --> time_used=9436 ms, qps=1907587
|
||
|
||
VIRT 356M
|
||
RES 294M
|
||
|
||
插入 10w 删除 20w,重复 30 次,共计插入 300w 删除 600w。
|
||
BATCH (N=9000000) --> time_used=9353 ms, qps=1924516
|
||
|
||
VIRT 356M
|
||
RES 119M
|
||
```
|
||

|
||

|
||

|
||
|
||
#### mypool
|
||
```shell
|
||
VIRT 58504
|
||
RES 4636
|
||
|
||
插入 20w 删除 10w,重复 30 次,共计插入 600w 删除 300w。
|
||
BATCH (N=3000000) --> time_used=3184 ms, qps=1884422
|
||
|
||
VIRT 625M
|
||
RES 572M
|
||
|
||
插入 10w 删除 20w,重复 10 次,共计插入 100w 删除 200w。
|
||
BATCH (N=3000000) --> time_used=3022 ms, qps=1985440
|
||
|
||
VIRT 122M
|
||
RES 71492
|
||
```
|
||

|
||

|
||

|
||
|
||
### 测试4:主从同步
|
||
测试条件:
|
||
1. 不启用持久化。
|
||
2. 启用主从同步。
|
||
3. pipline:
|
||
1. RSET 100w 条, p:i v:i -> +OK
|
||
2. RGET 100w 条, p:i -> +v:i
|
||
3. RDEL 100w 条。 p:i -> +OK
|
||
5. 本机发送请求。
|
||
```shell
|
||
lian@ubuntu:~/share/9.1-kvstore$ ./test-redis/testcase 192.168.10.129 8888 4
|
||
Connected to 192.168.10.129:8888
|
||
BATCH (N=3000000) --> time_used=3702 ms, qps=810372
|
||
BATCH (N=3000000) --> time_used=3804 ms, qps=788643
|
||
BATCH (N=3000000) --> time_used=4076 ms, qps=736015
|
||
BATCH (N=3000000) --> time_used=3840 ms, qps=781250
|
||
BATCH (N=3000000) --> time_used=3824 ms, qps=784518
|
||
average qps:780159
|
||
ALL TESTS PASSED.
|
||
lian@ubuntu:~/share/9.1-kvstore$ ./test-redis/testcase 192.168.10.129 8888 4
|
||
Connected to 192.168.10.129:8888
|
||
BATCH (N=3000000) --> time_used=3958 ms, qps=757958
|
||
BATCH (N=3000000) --> time_used=4043 ms, qps=742023
|
||
BATCH (N=3000000) --> time_used=3729 ms, qps=804505
|
||
BATCH (N=3000000) --> time_used=3989 ms, qps=752068
|
||
BATCH (N=3000000) --> time_used=3603 ms, qps=832639
|
||
average qps:777838
|
||
ALL TESTS PASSED.
|
||
```
|
||
|
||
## 项目收获
|
||
#### reactor网络模型,用户态网络缓冲区的写法。
|
||
|
||
#### 特殊字符串支持的引擎层数据结构设计,支持\0作为键值存储。
|
||
|
||
1. 长度前缀 + 内容的 binary-safe 字符串表示,支持 \0 作为普通字符。
|
||
|
||
#### 实现RESP协议的服务端协议解析。
|
||
|
||
1. 解析流程:
|
||
1. 内核 拷贝到 用户态缓冲区
|
||
2. 用户态缓冲区 就地解析 bulk-string 为执行引擎可以看得懂的结构体
|
||
3. 执行引擎 拷贝 结构体的内容,插入到底层存储结构中
|
||
4. 循环解析直到 用户态缓冲区为空
|
||
2. 实现了 pipeline 支持:每次从 buffer 读到一个完整命令就交给应用层处理,应用层返回已消费字节数。如果 buffer 里有半包,连接层保留下次继续解析。
|
||
|
||
#### 快照异步创建。
|
||
|
||
1. 使用fork的Copy On Write机制,实现的异步快照创建,不会受到后续更新请求的影响
|
||
|
||
#### 实现SPSC/SPMC,专门uring线程实现异步的增量、全量落盘操作。
|
||
|
||
1. 熟悉了SPSC无锁队列的实现方案。
|
||
1. 无锁、cache friendly
|
||
2. 流程:
|
||
1. 生产者组装task,丢给SPSC。
|
||
2. 消费者从中取出然后执行,置入destroy_queue,触发eventfd。
|
||
3. 生产者释放destroy_queue。
|
||
3. 解决的问题:
|
||
1. iouring 由于 cqe 接收不及时导致的 task 丢失无法释放。
|
||
1. 通过背压解决,设置inflight上限。
|
||
2. 背压后,生产者速度 > 消费者速度,ringbuffer 满导致只能阻塞主线程或降低速度。
|
||
1. 通过修改SPSC为SPMC,构建落盘线程组实现,当task_queue满,触发扩容线程组。
|
||
2. 每个落盘线程私有一个SPSC,减少竞争。
|
||
3. 简易工作负载,生产者线程随机选取两个uring线程选取任务少的push。
|
||
3. 采用读写段采用轮询方案导致乒乓现象,性能下降。
|
||
1. MESI协议定义了缓存行的四种状态:Modified表示独占且已修改,Exclusive表示独占且未修改,Shared表示多核共享只读,Invalid表示缓存行无效。
|
||
- **关键点:** 但从S状态读没有什么开销,从I状态读则需要向其他CPU申请。
|
||
- **关键点:** 从E/M状态写也没有什么开销,从S状态写则需要广播invalidate并且等待ACK(50-100时钟周期)。
|
||
- 原子操作由于内存序的顺序性和可见性语义,也有额外开销(刷新invalidate queue、阻止指令重排)
|
||
2. 短临界区:分层退避。自旋-让出CPU时间片-较长时间休眠。允许生产者在期间无争用的写入一批数据,然后统一读。
|
||
3. 更通用的情况:事件驱动。用futex替代轮询。Fast Userspace Mutex。
|
||
1. **消费者:** 调用`syscall(SYS_futex, &futex_word, FUTEX_WAIT, old_val, &timeout, NULL, 0)` , 如果futex_word仍等于old_val,线程进入内核等待队列,真正睡眠,不占用CPU。
|
||
2. **生产者:** 调用`syscall(SYS_futex, &futex_word, FUTEX_WAKE, 1, NULL, NULL, 0)` 唤醒一个等待线程。
|
||
4. 优化:生产者速度 > 消费者速度,则写端不需要关注读指针,或者极少关注读指针(缓冲区大小的1/2)次写入才考虑一次。
|
||
|
||
#### 基于BinLog上OffSet的主从同步(已有数据+实时数据)设计。
|
||
|
||
1. 初始化阶段:
|
||
1. master 持续将收到的更新请求+seq_id 落盘到本地 binlog 中。
|
||
2. slave 向 master 发起连接并且发送本地binlog中最大的seq_id 为 slave_seq_id。
|
||
2. 执行阶段:
|
||
1. master启动一个独立同步线程与 slave 构建连接。同步线程有两个阶段:
|
||
1. slave_seq_id < local_min_seq_id:master通过fork创建内存快照发送,且发送同时刻的local_max_seq_id。
|
||
2. slave_seq_id < local_max_seq_id:通过自实现同步协议批量发送binlog的seq,并且回包new_slave_seq_id。
|
||
3. slave_seq_id == local_max_seq_id:线程休眠。
|
||
2. master收到新的请求的时候,会通过条件变量唤醒同步线程。
|
||
|
||
#### 基于ebpf的实时数据同步设计。
|
||
|
||
**基准性能**:Kvstore QPS ~90w(无持久化/同步)。
|
||
|
||
ebpf对程序的影响要考虑如下方面:
|
||
|
||
1. eBPF 探针的触发频率(上下文切换)
|
||
|
||
2. 数据拷贝方式
|
||
|
||
|
||
|
||
**用户态探针 (Uprobe) 的开销**,
|
||
|
||
1. **逐条命令探测**。QPS大概在 25w左右。**原因**:Uprobe 基于断点指令(int3)实现,用户态 → 异常 → 内核 → eBPF → 返回用户态,高频触发会导致 CPU 流水线停顿严重。
|
||
2. **批处理探测**。QPS大概在85w左右。大幅减少了上下文切换次数,平摊了中断开销。
|
||
|
||
**内核态探针 (Tracepoint/Kprobe) 的开销**
|
||
|
||
1. **Tracepoint (sys_exit_recvfrom)等**
|
||
1. 纯探测:QPS ~85w。
|
||
2. 带数据拷贝 (read_user):QPS 降至 ~70w。**原因**:**bpf_probe_read_user** 是一个非常重的 helper,开销远大于一次 memcpy。
|
||
2. **Kprobe (tcp_rcv_established)等**
|
||
1. 纯探测:QPS ~86w。
|
||
2. 带数据拷贝 (read_kernel):QPS ~83w。**原因**:比**bpf_probe_read_user**轻得多。
|
||
3. 问题:此时数据位于 TCP 协议栈底层,可能存在分片(Fragment)、乱序或非线性存储(Paged SKB),需要处理复杂的数据重组逻辑。
|
||
|
||
工作流程:
|
||
|
||
1. **内核态捕获**:
|
||
1. eBPF 程序挂载于内核网络路径( TC 或 TCP 层),拦截流量。
|
||
2. 使用 bpf_probe_read_kernel 或 bpf_skb_load_bytes 高效提取数据载荷。
|
||
3. 通过 bpf_ringbuf_submit 将数据写入环形缓冲区。
|
||
2. **用户态转发**:
|
||
1. 独立进程消费 RingBuffer。
|
||
2. 将数据暂存入本地队列,平滑突发流量,防止 RingBuffer 溢出导致的数据丢失。
|
||
3. **状态机控制**:
|
||
1. **SYNC 阶段**:探测到 __ssync,识别 Slave 连接信息(IP/Port),标记状态为“同步中”。
|
||
2. **READY 阶段**:探测到 __sready,标志全量同步完成。
|
||
3. **实时转发**:Agent 启动独立线程,消耗 Pending Queue,将增量数据通过标准 TCP 发送给 Slave。
|
||
|
||
##### TC 与 XDP
|
||
|
||
网络栈
|
||
|
||
```
|
||
-> [ 网卡 (NIC) ]
|
||
-> [ XDP (eXpress Data Path) ] <--- xdp 处理原始帧 no skb
|
||
-> [ sk_buff 分配 ]
|
||
-> [ TC (Traffic Control) Ingress ] | tc 可操作 on skb
|
||
-> [ Netfilter (PREROUTING) ]
|
||
-> [ IP 协议栈 ] | ip_rcv -> ip_local_deliver
|
||
-> [ TCP 协议栈 ] | tcp_v4_rcv -> tcp_rcv_established -> tcp_data_queue
|
||
producer skb -> sk->sk_receive_queue
|
||
____________________________________________________________________
|
||
consumer sk->sk_receive_queue
|
||
-> [ Socket Layer ] | tcp_recvmsg 拷贝到内存
|
||
-> [ Syscall Interface ] | sys_exit_recvfrom
|
||
-> [ 用户态应用 (Kvstore) ]
|
||
```
|
||
|
||
XDP 是网卡驱动层的探点,操作系统刚刚收到数据包(DMA读入ringbuffer,CRC校验),还没有分配sk_buffer。数据形态是**裸的以太网帧** 。
|
||
|
||
TC 是在 sk_buff 分配之后,IP 协议栈处理之前的探测点。数据形态是__sk_buffer。
|
||
|
||
TCP协议栈是分界点,
|
||
|
||
#### 内存泄露探测功能,实现热插拔。
|
||
|
||
方案1 基于bpf
|
||
|
||
1. 通过预定义宏__FILE__等封装一个内存分配宏定义,向真正的分配函数传递代码位置等信息。
|
||
2. 通过bpf探测内存分配的地址、大小、文件、代码位置、函数等信息,并且记录。
|
||
3. 通过bpf探测内存释放的指针信息,然后释放。
|
||
4. 打印最终剩余的内存地址。
|
||
5. 缺点:bpf 探测 malloc 等对性能的影响非常的大。
|
||
|
||
方案2 基于全局变量启用的代码内置探测工具,在网络层接收启用/关闭探测工具的请求。
|
||
|
||
1. 分配时打开一个文件,关闭时删除。
|
||
2. 分配时使用kv保存,删除时删除k。
|
||
|
||
都对性能和内存有很大的影响,不建议长时间启用。
|
||
|
||
#### 实现支持分配可变长度内存块的内存池。
|
||
|
||
1. 熟悉了glibc 的 ptmalloc的底层操作。默认阈值约 128KB,且会根据分配行为动态调整。
|
||
|
||
1. '<= 默认阈值 通过 brk/sbrk 扩展连续堆,堆里维护了 chunk 结构。
|
||
2. '> 默认阈值 的块用 mmap 独立申请,释放时 munmap。
|
||
3. 每个 chunk 头部存大小信息(通常 8~16 字节),用户拿到的是 chunk + 头部后的地址。
|
||
4. 空闲 chunk 按大小分到多个 bin(tcache、fastbin、small bin、large bin 等),fastbin 和 tcache 为了速度不合并相邻空闲块。
|
||
5. 缺点:
|
||
1. 分配路径有较多分支判断和链表遍历,不是严格 O(1)。
|
||
2. 小块故意不合并(fastbin/tcache)会导致外部碎片。
|
||
3. 长期运行内存利用率下降。
|
||
|
||
2. 内存池实现:
|
||
|
||
1. 内存池有多个桶,桶中存储固定大小的块。分配/释放均为 O(1)
|
||
2. 以 huge page 为单位向系统申请内存并切分为固定块。
|
||
3. 当页内块全部释放时整页归还系统,显著降低长期碎片。
|
||
4. 通过地址对齐 O(1) 反推出页头元信息(所属桶、空闲计数)。
|
||
5. malloc通常向上对齐,桶对应的存储大小可以根据不同系统设定,减少内部碎片。
|
||
6. 使用 bitmap + freelist 防 double free 且无额外查找开销。
|
||
7. 每线程独立内存池,相对malloc更少的锁竞争。
|
||
8. 大块分配自动退化为 malloc 处理。
|
||
|
||
相比 ptmalloc,该设计消除了外部碎片,降低了系统调用次数,并在多线程场景下显著提升分配性能与内存利用率。
|