
DLS 进展:客户端时间戳、SSTable 压缩与 CBOR
嘿,大家好!最近事情挺杂的,不得已又断更了好一阵子,今天终于抽空续上了。继续聊聊项目进展,没啥惊天动地,就是按计划小步推进了几个部分。项目还是那个 Zig 写的分布式日志系统(DLS),代码在 xingyuli/dls 仓库,有兴趣可以去翻翻。
加了客户端时间戳支持
分布式系统里,时钟偏差是常见问题,以前只用服务器时间戳,容易导致日志顺序不对劲。现在加了 source_ts 字段,让客户端自己带时间戳,服务器直接存,不改动。这样查询时可以按事件原时间来滤,对,私人定制。
代码上,在 LogEntry 结构体加了个可选的 source_ts:
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 函数大概这样:
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 里写个标记,启动时看到标记就跳过旧数据,避免重复回放。
大概的代码片段:
// 在 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 官网。

序列化示例:
/// 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 交给明天。