使用 Zig 构建 Redis 克隆 — 第4部分
Source: Dev.to
旅程:从网页开发到系统编程
我在构建 Zedis(一个兼容 Redis 的内存数据库)的过程中学到了很多,我鼓励你也尝试一下。如果你和我一样来自网页开发,构建数据库可能会让人感到望而生畏——但它极具回报,你会发现许多之前从未想象过的计算基础。
基准概览
我使用 50 个并发客户端对 Zedis 进行了 1 000 000 次请求的基准测试。下面的 flame graph 立即揭示了问题:
| 函数 | 执行时间百分比 |
|---|---|
Parse.parse | 75 % |
Client.executeCommand | 13 % |
Command.deinit | 8 % |
对于 Redis 克隆来说,解析应该很快,命令执行才应当占据主要时间。
深入探讨 perf
火焰图显示了 时间 花在哪里,而不是 原因。为了获取更多细节,我使用了 Linux 的 perf 工具:
perf stat -e cache-references,cache-misses,\
L1-dcache-loads,L1-dcache-load-misses,\
dTLB-loads,dTLB-load-misses \
./zedis benchmark --clients 50 --requests 1000000
输出
Performance counter stats for ‘zedis’:
20,718,078,128 cache-references
1,162,705,808 cache-misses # 5.61% of all cache refs
81,268,003,911 L1-dcache-loads
8,589,113,560 L1-dcache-load-misses # 10.57% of all L1-dcache accesses
520,613,776 dTLB-loads
78,977,433 dTLB-load-misses # 15.17% of all dTLB cache accesses
22.936441891 seconds time elapsed
15.761103000 seconds user
64.451493000 seconds sys
该程序在用户态花费 15.76 秒,在系统态花费 64.45 秒,总计 22.93 秒 的实际运行时间——这表明它是 I/O 受限的,更多时间在等待网络操作,而不是在执行有用的计算。
解析瓶颈
在从 Zig 0.14 迁移到 0.15 的过程中,reader API 发生了变化。由于不熟悉新的接口,我默认使用了 readSliceShort,它 一次读取一个字节:
var b_buf: [1]u8 = undefined;
const bytes_read = try reader.readSliceShort(&b_buf);
这对性能是灾难性的:每读取一个字节都会产生一次系统调用和函数开销。
Redis 协议示例消息
*3\r\n
$3\r\nSET\r\n
$4\r\nkey1\r\n
$5\r\nvalue\r\n
*3– 接下来有三个批量字符串$3– 一个 3 字节的字符串(SET)$4– 一个 4 字节的字符串(key1)$5– 一个 5 字节的字符串(value)
缓冲读取 – 解决方案
与其逐字节读取,不如一次性分配缓冲区,让读取器使用它:
var reader_buffer: [1024 * 8]u8 = undefined;
var sr = self.connection.stream.reader(&reader_buffer);
const reader = sr.interface();
const line_with_crlf = reader.takeDelimiterInclusive('\n') catch |err| {
if (err == error.ReadFailed) return error.EndOfStream;
return err;
};
现在解析器一次处理 大块数据,而不是成千上万次微小读取,这正是高性能网络服务器应当采用的方式。
仍有进一步优化的空间(例如直接从缓冲区解析而不逐行处理),但当前的做法保持了代码的可读性。
内存分配开销
最初我对所有分配都使用 std.heap.GeneralPurposeAllocator。默认情况下它会启用许多安全检查、堆栈跟踪和账务功能,这会导致 显著的开销。火焰图显示大量时间花在分配器内部的互斥锁上。
切换到更精简的分配器后,大部分问题得到了解决:
// Example: using the page allocator
const allocator = std.heap.page_allocator;
在多核场景下,std.heap.smp_allocator 也是一个可选方案。
经验教训
- 系统编程需要好奇心,了解库和运行时的内部实现。
- 阅读源代码(Zig、Redis、TigerBeetle、PostgreSQL)是非常宝贵的。
- 性能分析工具(
perf、火焰图)帮助你定位真正的瓶颈。 - 缓冲 I/O 和 轻量级分配器 对于高吞吐量服务器至关重要。
这是一项艰苦的工作,但回报巨大。
基准测试结果
使用的命令行工具:
./redis-benchmark -t get,set -n 1000000 -c 50
SET
| 请求数 | 时间 (秒) | 并行客户端 | 负载 | 吞吐量 (请求/秒) | 平均延迟 (毫秒) |
|---|---|---|---|---|---|
| 1 000 000 | 4.25 | 50 | 3 bytes | 235,294.12 | 0.115 |
| 1 000 000 | 4.60 | 50 | 3 bytes | 217,344.06 | 0.121 |
GET
| 请求数 | 时间 (秒) | 并行客户端 | 负载 | 吞吐量 (请求/秒) | 平均延迟 (毫秒) |
|---|---|---|---|---|---|
| 1 000 000 | 4.29 | 50 | 3 bytes | 233,045.92 | 0.113 |
| 1 000 000 | 4.53 | 50 | 3 bytes | 220,799.30 | 0.119 |
延迟汇总 (毫秒)(针对首次 SET/GET 运行)
SET:
avg 0.115 min 0.056 p50 0.119 p95 0.127 p99 0.143 max 2.223
GET:
avg 0.113 min 0.048 p50 0.119 p95 0.127 p99 0.135 max 0.487
Zedis 当前在两种操作上 比 Redis 慢 5–8 %。虽然它尚未在原始吞吐量上超越 Redis,但在最优化的内存存储之一仅相差个位数百分比,这对一个学习项目来说已经是相当不错的成就。
结束语
我对 Zedis 当前的性能相当满意。这可能是该项目的暂时告一段落——至少现在是这样。请关注后续更新,我将继续学习和探索系统编程!
# Systems Programming and Database Internals
If you’re a web developer curious about systems programming, I can’t recommend this enough. Start small, make mistakes (you will—I made plenty!), profile them, fix them, and watch your understanding deepen. You don’t need to be a C wizard or have a CS degree—just curiosity and persistence.
Thanks for reading! Subscribe to follow my journey—I’m learning systems programming and sharing everything I discover along the way.
**Zedis Source Code**
---
**F2023 #25 – Potpourri:** Redis, CockroachDB, Snowflake, MangoDB, TabDB