flowdb 0.8.0

A high-performance embedded time-series + JSON document storage engine (LSM-tree), with built-in IndexedDB-compatible API.
Documentation
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# 做减法的艺术:FlowDB 从全功能数据库到超越 RocksDB 的嵌入式引擎

> 一个 Rust 存储引擎的演进故事:从 v0.1.5 到 v0.3.0,我们砍掉了 HTTP 服务器、UDP 协议、Admin UI、Auth 模块、甚至 async runtime 本身。每一次做减法,性能都更好、代码都更简单。本文记录这一路的技术决策和背后的思考。

## 一、做减法的勇气:从"什么都做"到"只做一件事"

### v0.1.5:一个大而全的时序数据库

2026 年初,FlowDB v0.1.5 发布时,它的定位是"高性能时序数据库"。这句话意味着什么?意味着它要和 InfluxDB、TDengine、QuestDB 竞争。所以 v0.1.5 的架构是这样的:

![架构演进](svg/architecture-evolution.svg)

```
┌─────────────────────────────────────────────────────┐
│                  FlowDB v0.1.5                      │
├─────────────┬─────────────┬─────────────────────────┤
│  HTTP Server│  UDP Server │    Admin UI (HTML)      │
│  (axum)     │  (V1/V2)    │    + Auth (API Key)     │
├─────────────┴─────────────┴─────────────────────────┤
│              ServerConfig (TOML)                    │
├─────────────────────────────────────────────────────┤
│              Engine (async API)                     │
│  ┌──────────┬──────────┬──────────┬───────────────┐ │
│  │ MemTable │  WAL     │ SSTable  │ Compaction    │ │
│  │(BTreeMap)│(SipHash) │          │               │ │
│  └──────────┴──────────┴──────────┴───────────────┘ │
├─────────────────────────────────────────────────────┤
│              tokio runtime (full)                   │
└─────────────────────────────────────────────────────┘
```

它有:
- **HTTP 服务**:基于 axum,提供写入、查询、管理端点
- **UDP 协议**:V1/V2 两种帧格式,带鉴权和速率限制
- **Admin UI**:内嵌 HTML 管理界面
- **Auth 模块**:API Key 鉴权,HTTP 和 Admin 共享
- **TOML 配置**`ServerConfig` 包含 `http_addr``udp_addr``api_keys`
这些加起来大概 3000+ 行代码和 4 个额外依赖(axum、tower-http、base64、tokio full)。

### 问题出在哪里?

问题在于,**我们同时在做两件完全不同的事情**:

1. **存储引擎**——LSM-tree、WAL、SSTable、Compaction、Bloom Filter、Block Cache
2. **网络服务**——HTTP 路由、UDP 帧编解码、HTML 模板、API Key 鉴权

这两件事的技能树、测试策略、性能瓶颈、用户期望完全不同。存储引擎的核心竞争力是**写入吞吐和查询延迟**,而网络服务需要的是**连接管理、协议兼容、安全加固**。

当我们 benchmark 发现写入吞吐只有 RocksDB 的 63% 时,瓶颈在 MemTable 的 BTreeMap 插入开销,而不是 HTTP 路由。但我们花了大量精力在 UDP 速率限制器、Admin 端点鉴权这些与存储引擎核心竞争力无关的事情上。

### v0.2.1:砍掉整个 server 层

决定很果断:**FlowDB 只做嵌入式存储引擎,不做网络服务**。

```
v0.1.5                          v0.2.1
┌────────────────────┐          ┌────────────────────┐
│  HTTP / UDP / Auth │  ← 删除  │                    │
│  Admin UI          │          │                    │
├────────────────────┤          ├────────────────────┤
│  Engine (async)    │          │  Engine (async)    │
│  MemTable / WAL    │  保留    │  MemTable / WAL    │
│  SSTable / Compact │          │  SSTable / Compact │
├────────────────────┤          ├────────────────────┤
│  tokio runtime     │          │  tokio runtime     │
└────────────────────┘          └────────────────────┘
```

删除了什么:
- `src/http.rs` — axum 路由、handler、JSON 序列化
- `src/udp.rs` — UDP 帧编解码、V1/V2 协议
- `src/admin.rs` — 内嵌 HTML 管理界面
- `src/auth.rs` — API Key 鉴权
- `src/bin/flowdb-server.rs` — 服务端二进制
- `tests/http_integration.rs` — 32 个 HTTP 集成测试
- `tests/network_integration.rs` — 15 个网络集成测试
- `ServerConfig``server` feature、axum/tower-http/base64 依赖

这大概是整个代码库的 40%。删完之后,代码更清晰了,编译更快了,测试更聚焦了。

> **架构师最难的决定不是"加什么",而是"砍什么"。** 每一行代码都是负债——维护成本、测试成本、认知负担。砍掉不创造核心价值的代码,是提升工程效率最有效的方式。

## 二、正确性是一切的基石

### 在做性能优化之前,先确保功能是对的

v0.1.5 虽然功能"大而全",但存在几个严重的正确性问题。我们在 v0.2.0 集中修复了它们。

### P0:MemTable::get 返回已删除的数据

这是最严重的 bug。当用户先写入一条记录,再删除同一个 `(key, ts)`,随后的 `get` 查询竟然返回了已删除的数据。

**根因**:v0.1.5 的 `MemTable` 基于 `BTreeMap<(key, ts, seq), Record>`。`get` 方法用迭代器 `.next()` 取**第一个**匹配的记录,而 BTreeMap 按 `seq` 升序排列,所以取到的是**最旧**的版本。

但写入时 seq 是单调递增的——新写入的 delete tombstone 有更高的 seq,应该在查询时优先返回。正确做法是取**最后一个**匹配记录,即 `.rev().next()`。

```rust
// Bug: 返回 seq 最小的记录(最旧的版本)
records.range(start..=end).next()

// Fix: 返回 seq 最大的记录(最新的版本)
records.range(start..=end).rev().next()
```

这个 bug 意味着任何 "write → delete → read" 序列都会返回错误结果。对于数据库来说,这是致命的。

### P1:WAL 截断逻辑错误

WAL(Write-Ahead Log)在 memtable flush 到 SSTable 后应该被截断,释放磁盘空间。但 v0.1.5 的截断条件是 `max_seq < seq`,这意味着只要 WAL 中有任何一条记录的 seq >= 已 flush 的 max_seq,整个 segment 就不会被清理。

实际上应该是 `max_seq <= seq`——当 segment 的最大 seq 小于等于已 flush 的 seq 时,说明所有记录都已持久化到 SSTable,可以安全删除。

这个 bug 导致的后果:写入 50K 条记录后 flush,WAL 文件仍然占用 35.8MB。修复后降到 2.0MB。

### 方法论:系统性硬化

v0.2.0 不只是修了两个 bug,而是做了一次系统性的审查(P0-P3):

| 级别 | 问题 | 修复 |
|------|------|------|
| P0 | MemTable::get 返回已删除数据 | `.rev().next()` 取最新 seq |
| P1 | WAL 永不截断 | `< seq``<= seq` |
| P2 | Config 无校验,0 值导致除零 | 添加 `Config::validate()` |
| P2 | shutdown 不 flush,数据丢失 | `shutdown()` 先 flush 再退出 |
| P3 | 无 frozen memtable 背压 | 超过 `max_frozen_memtables` 时写阻塞 |
| P3 | SST 读取器不清理过期引用 | GC/Compaction 后 `evict_stale_readers()` |
| P3 | WAL 无单条记录校验和 | 每条记录附加 FxHash 校验和 |

> **先修正确性,再做性能优化。** 一个快的数据库如果返回错误结果,那它毫无价值。我们在性能优化之前,花了整整一个版本确保每一行数据都能正确写入、查询、删除和恢复。

## 三、Benchmark 驱动的性能突围

### v0.2.2 的起点:比 RocksDB 慢

v0.2.0 修完 bug 后,我们做了第一次正式的 FlowDB vs RocksDB benchmark。结果不理想:

| 类别 | FlowDB v0.2.0 | RocksDB | 差距 |
|------|---------------|---------|------|
| 顺序写入 | 2.0M ops/s | 3.1M ops/s | 慢 1.58x |
| 并发写入 | 3.2M ops/s | 4.4M ops/s | 慢 1.38x |
| 点查询 | 4.7M ops/s | 539K ops/s | **快 8.7x** |
| 前缀扫描 | 71K ops/s | 11K ops/s | **快 6.3x** |

读取已经碾压 RocksDB,但写入明显落后。问题出在哪里?

### 诊断:BTreeMap 是写入瓶颈

用 profiler 分析写入热点,发现 60% 的时间花在 `BTreeMap::insert` 上。原因很直接:

```rust
// v0.1.5 的写入路径
memtable.insert((rec.key.clone(), rec.ts, seq), rec);
//                ^^^^^^^^^^^^^^^^
//                每条记录都要 clone 一份 key 作为 BTreeMap 的排序键
```

`BTreeMap` 的 `insert` 需要:
1. **Clone key**`Vec<u8>` 深拷贝)
2. **树遍历**(O(log n) 比较 + 平衡)
3. **可能的节点分裂**(内存分配)

对于 128 字节的 key,每次 clone 就是一次 `malloc + memcpy`。写入 100K 条记录 = 100K 次 key clone = 100K 次 malloc。

### 优化 1:Vec 替代 BTreeMap

核心洞察:**active memtable 不需要排序**。排序可以推迟到 freeze 时一次性做。

![写入路径优化](svg/write-path-optimization.svg)

```
BTreeMap 方案 (v0.1.5):              Vec 方案 (v0.2.2):
                                     
每次 insert:                         每次 push:
  1. clone key (~100B)                 1. push record (移动,无拷贝)
  2. BTreeMap 遍历 O(log n)            2. 摊还 O(1)
  3. 可能触发节点分裂                   
                                    
查询: O(log n)                        查询: O(n) 线性扫描
冻结: O(1)                            冻结: O(n log n) 原地排序
```

写入是热路径(每次 `write_batch` 都走),冻结是冷路径(只在 memtable 满时触发一次)。把排序从热路径移到冷路径,是典型的**延迟计算**优化。

```rust
// v0.2.2 的写入路径
active.push(rec);  // O(1),无 key clone,无树遍历

// freeze 时排序
fn freeze(&mut self) {
    self.active.sort();  // 原地排序,O(n log n)
    // ... swap 到 frozen BTreeMap
}
```

### 优化 2:async 包装的隐性开销

v0.1.5 的写入方法是 `async fn`:

```rust
pub async fn write_batch(&self, batch: &[Record]) -> Result<()> {
    // ...
    self.do_write(records).await
}

async fn do_write(&self, records: Vec<InternalRecord>) -> Result<()> {
    // 全是同步代码!没有 .await!
    self.worker.lock().process_batch_encoded(...);
    Ok(())
}
```

审计发现:**`do_write` 的函数体里没有任何 `.await`**。它完全是同步的——获取锁、写 WAL、写 memtable,全是阻塞操作。但因为它被声明为 `async fn`,编译器为它生成了一个状态机(`Future`),每次调用都要经历 `poll` → `Pending`/`Ready` 的状态转换。

对于写入这种微秒级的操作,状态机的开销不可忽视。把 `write_batch` 从 `async fn` 改成 `fn`,直接消除了这部分开销。

### 优化 3:FxHash 替代 SipHash

WAL 的每条记录都需要校验和(checksum)来检测磁盘损坏。v0.1.5 用的是 Rust 标准库的 `DefaultHasher`(SipHash-1-3),这是一种加密级哈希函数。

但 WAL 校验和的场景是**检测磁盘损坏**,不是**防御恶意篡改**。SipHash 的安全性在这里完全用不上,而它的速度比非加密哈希慢约 10 倍。

我们实现了一个 FxHash 风格的快速哈希:

```rust
const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
fn fxhash(val: u64) -> u64 {
    (val.rotate_left(5) ^ val).wrapping_mul(SEED)
}
```

对于 100 字节的输入,SipHash 约 300ns,FxHash 约 30ns。写入 100K 条记录 = 节省 27ms,对于微秒级操作来说是显著的。

### 优化 4:WAL 截断修复(已在 v0.2.0 修复但效果在此显现)

v0.2.0 修复了 `max_seq < seq` → `max_seq <= seq` 后,flush 后 WAL 从 35.8MB 降到 2.0MB。这不只是省磁盘——更小的 WAL 意味着更少的 I/O,更快的恢复。

### v0.2.2 Benchmark 结果

![性能对比](svg/benchmark-comparison.svg)

| 类别 | v0.2.0 | v0.2.2 | RocksDB | vs RocksDB |
|------|--------|--------|---------|------------|
| 顺序写入 | 2.0M | **4.5M** | 3.1M | **快 1.42x** |
| 并发写入 | 3.2M | **9.4M** | 4.7M | **快 2.02x** |
| 点查询 | 4.7M | **6.0M** | 549K | **快 10.95x** |
| 前缀扫描 | 71K | **72K** | 11K | **快 6.39x** |
| 全表扫描 | 73 | **65** | 40 | **快 1.63x** |

从全面落后到全面领先。

> **Benchmark 是性能优化的指南针。** 没有 profiler,我们不会知道 60% 的时间花在 key clone 上;没有 RocksDB 对照,我们不会知道"慢 1.58x"需要优化。每一次改动后跑一次 benchmark,让数据驱动决策。

## 四、回归本质:彻底去掉 async

### 触发点:一次完整的 async 审计

v0.2.2 把写入方法改成了同步,但读取方法(`get`、`query`、`flush`、`shutdown`)仍然是 async。这造成了一个尴尬的局面:用户需要启动 tokio runtime 才能调用 `engine.open()` 和 `engine.get()`,即使他们可能在同步代码中使用 FlowDB。

于是我们做了一次彻底的审计:**哪些方法真正需要 async?**

| 方法 | 内部有 `.await`| 用了 `spawn_blocking`| 结论 |
|------|-------------------|------------------------|------|
| `open` ||| 假 async |
| `query` ||| 假 async |
|`query_*` | 仅委托 `query` || 假 async |
| `get` ||| 假 async(已有 `get_sync`|
| `get_latest_async` ||| 假 async(已有 `get_latest`|
| `shutdown` ||| 假 async |
| `flush` || **** | spawn_blocking 包装 |
| `trigger_gc` || **** | spawn_blocking 包装 |
| `trigger_compaction` || **** | spawn_blocking 包装 |
| `close` || **** | spawn_blocking 包装 |

**12 个 async 方法,8 个是"假 async"**——函数体里没有任何 `.await`,async 关键字纯粹是 API 一致性的产物。另外 4 个用了 `spawn_blocking`,但 `spawn_blocking` 的本质是"把同步操作丢到线程池"——既然操作本身是同步的,为什么要在 API 层面包一层 async?

![Async 审计](svg/async-audit.svg)

### async 为什么成了负担

async 在 FlowDB 中造成的问题:

**1. 强制运行时依赖**

```rust
// v0.2.2: 必须在 tokio runtime 内才能调用
#[tokio::main]
async fn main() {
    let engine = Engine::open(config).await?;  // 需要 runtime
    engine.write_batch(&records)?;              // 这个已经是同步的了
    let results = engine.query_by_prefix("k").await?;  // 又需要 runtime
}
```

用户如果在 `async-std` 或 `smol` 项目中用 FlowDB,就需要 `tokio::runtime::Handle::current()` 来桥接,增加了集成复杂度。

**2. 隐性性能开销**

每个 async fn 都会被编译成状态机。即使函数体是同步的,调用时仍然要走 `Future::poll` 流程。对于 `get()` 这种纳秒级操作,状态机开销占比不可忽视。

**3. 测试复杂度**

所有测试都必须标 `#[tokio::test] async fn`,并发测试必须用 `tokio::spawn`。这使得测试代码无法在非 tokio 环境中运行。

### 重构方案

核心思路:**所有 public 方法改为同步,后台维护改用 `std::thread`**。

```
v0.2.2 (async)                      v0.3.0 (sync)
                                    
Engine::open().await          →     Engine::open()
engine.get().await            →     engine.get()
engine.flush().await          →     engine.flush()
engine.shutdown().await       →     engine.shutdown()
                                    
后台维护:                             后台维护:
tokio::spawn {                       std::thread::spawn {
  tokio::select! {                     loop {
    flush_tick → spawn_blocking          if stop_flag { break }
    compact_tick → spawn_blocking        sleep(poll_interval)
    gc_tick → spawn_blocking             if flush_due { do_flush() }
    sync_tick → spawn_blocking           if compact_due { compact() }
  }                                     ...
}                                     }
                                    }
```

### MaintenanceHandle:优雅的生命周期管理

tokio 的 `JoinHandle` 有 `abort()` 方法可以强制中止 task。但 `std::thread::JoinHandle` 没有等价方法——Rust 不支持强制杀死线程。

解决方案是 `MaintenanceHandle` + `Arc<AtomicBool>` stop flag:

```rust
pub struct MaintenanceHandle {
    stop: Arc<AtomicBool>,
    thread: Option<std::thread::JoinHandle<()>>,
}

impl Drop for MaintenanceHandle {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);  // 通知线程退出
        if let Some(t) = self.thread.take() {
            let _ = t.join();  // 等待线程清理完毕
        }
    }
}
```

当 `Engine::shutdown()` 被调用时,`maintenance` 字段被 drop,触发 `MaintenanceHandle::drop()`,自动通知后台线程退出并 join。用户不需要显式管理线程生命周期。

### 后台线程的 poll loop

原来的 tokio 版本用了 4 个 `tokio::time::interval` + `tokio::select!`,行为是"哪个 timer 先到就执行哪个"。std::thread 版本用一个简单的 poll loop 替代:

```rust
std::thread::spawn(move || {
    loop {
        if stop.load(Relaxed) { break; }
        thread::sleep(poll_interval);  // 最短间隔(flush_interval 的 1/4)
        
        if now - last_flush >= flush_dur    { do_flush(); }
        if now - last_compact >= compact_dur { compact(); }
        if now - last_gc >= gc_dur          { gc(); }
        if now - last_sync >= sync_dur      { wal_sync(); }
    }
});
```

这个方案的优势:
- **无运行时依赖**:不需要 tokio runtime
- **更简单的调度**:一个线程,一个 loop,四个时间检查
- **更低的开销**:没有 async 状态机、没有 tokio scheduler
- **可控的退出**:stop flag 在每次循环开始检查,保证及时退出

### 最终结果

v0.3.0 实现了**零 tokio 依赖**:

```
[dependencies]          # v0.2.2                    # v0.3.0
tokio                   { version = "1", features = ["full"] }  ← 移除
serde                   ✓                           ✓
parking_lot             ✓                           ✓
zstd                    ✓                           ← 移除 (now only lz4)
...                     ✓                           ✓
```

| 指标 | v0.2.2 | v0.3.0 |
|------|--------|--------|
| async 方法数 | 12 | 0 |
| tokio 依赖 | required ||
| 测试中的 `#[tokio::test]` | ~70 | 0 |
| 顺序写入 | 3.7M ops/s | **4.5M ops/s** (+22%) |
| 编译依赖数 | ~300 | ~250 |
| 用户集成成本 | 需要 tokio runtime | 零运行时要求 |

> **技术选型要看场景。** async/await 是网络服务的利器——高并发 I/O 场景下它能用极少的线程处理海量连接。但存储引擎的 API 调用是同步的——用户写入一批数据,等它完成,再写下一批。在同步场景上叠加 async,就像在跑步鞋上穿高跟鞋——不仅没有帮助,还会绊倒你。

## 五、写在最后

### 四个版本,一条主线

从 v0.1.5 到 v0.3.0,FlowDB 经历了四次大改,但有一条清晰的主线:**做减法**。

```
v0.1.5 ────→ v0.2.0 ────→ v0.2.1 ────→ v0.2.2 ────→ v0.3.0
大而全       修正确性     砍 server    优化写入     去掉 async
15 模块      15 模块      12 模块      12 模块      12 模块
HTTP/UDP    P0-P3 fix   纯引擎       Vec memtable  零 tokio
Admin/Auth  WAL fix     无 server    FxHash        纯同步
tokio full  背压         async API    sync write    std::thread
```

每一次做减法,我们都问自己同一个问题:**这行代码/这个依赖/这个抽象层是否在创造核心价值?**

如果答案是否定的,就砍掉它。

### 工程方法论

回顾这四个版本,有几条方法论值得总结:

1. **先正确后性能**:v0.2.0 修完所有正确性 bug,才开始 v0.2.2 的性能优化。快的数据库如果返回错误结果,毫无价值。

2. **Benchmark 驱动**:每一次性能改动都跑 benchmark 对比 RocksDB。没有测量就没有优化——"感觉变快了"不是工程方法。

3. **Profile 定位瓶颈**:60% 的时间花在 key clone 上,这个发现来自 profiler,而不是猜测。定位到瓶颈后,优化方案(Vec 替代 BTreeMap)就显而易见了。

4. **审计驱动重构**:v0.3.0 去掉 async 的决策来自一次完整的 async 审计——12 个方法中 8 个是假 async。数据驱动的决策比直觉更可靠。

5. **覆盖率守护质量**:整个项目保持 94.8% 的行覆盖率,每次改动后跑 `cargo llvm-cov --summary-only`。高覆盖率让我们有信心做大重构。

### FlowDB 的定位

今天的 FlowDB v0.3.0 是:

- **纯嵌入式存储引擎**——没有网络层,不需要独立进程
- **完全同步 API**——不绑定任何 async runtime
- **原生时序支持**——`(key, ts)` 双维索引,TTL,多版本
- **全面超越 RocksDB**——写入快 1.4x,点查快 11x,扫描快 6x

它的用户画像是:**需要在应用进程内嵌入一个高性能时序存储引擎,且不想引入额外数据库进程的开发者**。

```toml
[dependencies]
flowdb = "0.3"
```

```rust
let engine = Engine::open(Config::default())?;
engine.write_batch(&records)?;
let results = engine.query_by_prefix("sensor.")?;
engine.shutdown()?;
// 没有 .await,没有 tokio,没有额外进程
```

这就是 FlowDB 的故事:**通过不断做减法,找到产品的核心价值**。

---

*FlowDB 是开源项目,代码托管在 [GitHub](https://github.com/restsend/flowdb),crate 发布在 [crates.io](https://crates.io/crates/flowdb)。*