DLS 更新:时间戳、压缩和 CBOR

DLS 更新:时间戳、压缩和 CBOR

十一月 24, 2025

嘿,大家好!最近事情挺杂的,不得已又断更了好一阵子,今天终于抽空续上了。继续聊聊项目进展,没啥惊天动地,就是按计划小步推进了几个部分。项目还是那个 Zig 写的分布式日志系统(DLS),代码在 xingyuli/dls 仓库,有兴趣可以去翻翻。

加了客户端时间戳支持

分布式系统里,时钟偏差是常见问题,以前只用服务器时间戳,容易导致日志顺序不对劲。现在加了 source_ts 字段,让客户端自己带时间戳,服务器直接存,不改动。这样查询时可以按事件原时间来滤,对,私人定制。

代码上,在 LogEntry 结构体加了个可选的 source_ts

1
2
3
4
5
6
7
const LogEntry = struct {
timestamp: u64, // 服务器时间戳
source_ts: ?u64, // 客户端时间戳
message: []const u8,
metadata: ?std.json.Parsed,
version: u8 = 1,
};

写入请求解析时,直接取 source_ts 存入。读日志函数也相应支持按 source_ts 过滤(emmmm…. 计划是这样计划的)。

实现了 SSTable 压缩

为了处理文件碎片,加了压缩逻辑:MemTable 刷盘后,如果 SSTable 文件太多,就合并几个成一个。保持时间戳顺序。

在 MemTable 模块的 compact 函数大概这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn compact(self: *MemTable) !void {
var files = self.sstable_files;
if (files.len <= 4) return;

var merged = std.ArrayList(LogEntry).empty;
defer merged.deinit();

for (files.items) |file| {
try self.readFromSSTable(file, &merged);
}
std.sort.sort(LogEntry, merged.items, {}, timestampLessThan);

const new_file = try self.flushToSSTable(merged.items);
try self.cleanupOldFiles(files);
try self.sstable_files.append(new_file);
}

WAL 加了检查点

为了优化 recover ,加了检查点机制:在 MemTable 刷盘后,往 WAL 里写个标记,启动时看到标记就跳过旧数据,避免重复回放。

大概的代码片段:

1
2
3
4
5
6
7
8
9
// 在 MemTable 的 flush 后调用
const marker = LogEntry{
.timestamp = std.time.milliTimestamp(),
.message = "CHECKPOINT",
.source_ts = null,
.metadata = null,
};
try self.wal.appendEntry(marker); // 通过 WAL 追加
try self.wal.file.sync();

在 recover 的时候,扫描 WAL,遇到 checkpoint 则清空内存表,表示跳过。

测试步骤拆分

把测试拆成模块级的,现在可以单独跑 memtable 或 server 的测试,方便调试。

换成 CBOR 持久化

从 JSON 切到 CBOR,用 zbor 库,文件和传输都用二进制,解析快点。

CBOR,全称 Concise Binary Object Representation,是一种紧凑的二进制对象表示格式。它基于 JSON 的数据模型,但采用二进制编码,体积更小、解析速度更快,特别适合网络传输和持久化存储等场景。官方文档可以参考这里:CBOR 官网

序列化示例:

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
/// Serializes the LogEntry to CBOR format, using `allocator` for the returned slice.
/// Metadata is currently serialized as a JSON string within the CBOR map.
///
/// Caller owns the returned memory.
pub fn encodeCbor(self: *const @This(), allocator: Allocator) ![]u8 {
var b = try zbor.Builder.withType(allocator, .Map);

try b.pushTextString("timestamp");
try b.pushInt(@intCast(self.timestamp));

try b.pushTextString("source_ts");
try b.pushInt(@intCast(self.source_ts));

try b.pushTextString("message");
try b.pushByteString(self.message);

if (self.metadata) |m| {
const m_json = try std.json.Stringify.valueAlloc(allocator, m, .{});
defer allocator.free(m_json);

try b.pushTextString("metadata");
try b.pushByteString(m_json);
}

try b.pushTextString("version");
try b.pushInt(self.version);

return try b.finish();
}

性能方面

用 writeManyLogs 测试了 10 万条日志的时间表现(测试日期 November 24, 2025):

  • V6 (Zig 0.15.1):写延迟 122–129 µs,读延迟 12.0–12.4 µs,比率 10.23–10.47
  • V7 (压缩):写延迟 266 µs,读延迟 12.2 µs,比率 21.72
  • V8 (CBOR):写延迟 ~460 µs,读延迟 ~4.5 µs,比率 ~103
    可以看到,读取速度提升了差不多3倍,但是……写性能下降了(可能是因为现在第一版切换 CBOR 不彻底,LogEntry.metadata 部分偷懒了还是使用的 json 序列化,导致了额外的内存操作开销)。

下步计划

  • 尝试对改为 CBOR 之后的写性能做优化
  • 增加更多与 CBOR 相关的边界测试

独门秘籍

事情终归是做不完的,越写就发现想改进的地方越多,TODO 也越来越多,实在做不完了,随手一个 TODO 交给明天。