-
Notifications
You must be signed in to change notification settings - Fork 1
10 Persistence_zh.md
Quiver 4.0 使用段式二进制文件(QDB\x04)作为唯一的主存储格式。WAL 已被移除,取而代之的是三个显式的持久化方法,覆盖从全量快照到增量写入的完整谱系:
// 全量快照 —— 原子重写整个文件(临时文件 + File.Move)
await db.SaveAsync();
await db.SaveAsync(@"C:\backup\mydata.vdb");
// 增量追加 —— 把当前内存中的实体作为一个**新段**追加到现有 v4 文件,
// 仅重写 footer。磁盘开销 O(Δ),无 WAL 那种内存翻倍问题。
await db.AppendAsync();
// 仅追加墓碑段 —— 把挂起的删除作为 Tombstone 段写入,不重写存活实体,
// 适合“只删不增”的批次。
await db.FlushTombstonesAsync();
// 加载 —— 读取 header + 段表,按顺序回放每段,最后应用墓碑;
// 当 Vectors.MemoryMode = MemoryMapped / Auto 时还会重新建立 mmap region。
await db.LoadAsync();
await db.LoadAsync(@"C:\backup\mydata.vdb");
// 导出 / 导入旁路(不参与主存储持久化)
await db.ExportAsync("backup.json", ExportFormat.Json);
await db.ImportAsync("backup.json", ExportFormat.Json);
await db.ExportAsync("backup.xml", ExportFormat.Xml);
⚠️ await using默认不会自动保存:DisposeAsync()只有在QuiverDbOptions.SaveOnDispose = true时才会对当前内存状态执行一次完整的SaveAsync()。批量入库管线使用AppendAsync()+Clear()释放内存时,建议使用同步using并显式调用AppendAsync/SaveAsync,避免启用自动保存后用空快照覆盖刚追加的段。
sequenceDiagram
participant Ctx as QuiverDbContext
participant Set as QuiverSet
participant BSP as BinaryStorageProvider
participant File as QDB\x04 文件
Note over Ctx: SaveAsync() 全量快照
Ctx->>Set: 快照所有实体 + 抽取墓碑
Ctx->>Set: 枚举可持久化索引快照(如 HNSW)
Ctx->>BSP: 写入 Header + Mixed 段 + IndexSnapshot 段 + Footer
BSP->>File: 临时文件 + File.Move 原子替换
Note over Ctx: AppendAsync() 增量段追加
Ctx->>Set: 快照当前内存实体
Ctx->>BSP: 追加 EntityMeta + VectorBlob(+ Blob)段
BSP->>File: 以追加模式打开,仅重写 footer
opt EnableBackgroundMerge
Ctx->>Ctx: MaybeAutoMergeAsync(filePath)
end
Note over Ctx: FlushTombstonesAsync() 仅墓碑
Ctx->>Set: 抽取挂起的墓碑
Ctx->>BSP: 追加 Tombstone 段,重写 footer
Note over Ctx: LoadAsync() 段回放
Ctx->>BSP: 读取 header + 段表
Ctx->>BSP: 读取 IndexSnapshot 段(如存在)
Ctx->>Set: 先恢复索引拓扑并记录覆盖的 NextId
loop 按顺序每个段
BSP-->>Ctx: EntityMeta / VectorBlob / Blob / Tombstone
Ctx->>Set: 重建实体 + 仅补建快照未覆盖的索引 id
end
opt Vectors.MemoryMode = MemoryMapped / Auto
Ctx->>BSP: 在 VectorBlob 范围上打开 MmapVectorRegion
end
Quiver 4.0 的主存储持久化是二进制独占的,JSON / XML 仅作为导出/导入旁路。
| 角色 | 实现 | 说明 |
|---|---|---|
| 主存储 |
BinaryStorageProvider(v4 QDB\x04) |
SaveAsync / LoadAsync / AppendAsync / FlushTombstonesAsync 均使用此路径。段式格式,每段 CRC32,MemoryMarshal 零拷贝 |
| 文件工具 |
QuiverDbFile 静态类 |
InspectAsync(版本 + 段表 + CRC 校验)与 MergeAsync(多文件合并,支持 Append / FirstWriterWins / LastWriterWins 三种策略) |
| mmap 视图 | MmapVectorRegion |
在 VectorBlob 段上的只读 MemoryMappedFile 视图,由 MmapVectorStore 在 Vectors.MemoryMode = MemoryMapped / Auto 时使用 |
| 导出 / 导入 | JsonExportProvider |
可读 JSON,调试与互操作 |
| 导出 / 导入 | XmlExportProvider |
Base64 向量的 XML,兼容性场景 |
// 不加载文件就检查它的版本与段表
var info = await QuiverDbFile.InspectAsync("mydata.vdb", verifyCrc: true);
foreach (var seg in info.Segments)
Console.WriteLine($"{seg.Kind} offset={seg.Offset} size={seg.Size} crc={(seg.CrcOk ? "OK" : "FAIL")}");
// 合并多个文件,按主键去重
await QuiverDbFile.MergeAsync(
sources: ["shard-0.vdb", "shard-1.vdb", "shard-2.vdb"],
destination: "merged.vdb",
options: new MergeOptions { ConflictPolicy = MergeConflictPolicy.LastWriterWins },
typeMap: db.GetTypeMap());仅通过 ExportAsync / ImportAsync 使用,不参与主存储持久化。输出结构:
{
"MyNamespace.FaceFeature": [
{ "personId": "P001", "name": "Alice", "embedding": [0.1, 0.2, ...] },
{ "personId": "P002", "name": "Bob", "embedding": [0.3, 0.4, ...] }
]
}- JSON 选项(
WriteIndented、命名策略)直接传给ExportAsync - 默认
WriteIndented = true+CamelCase - 导入时使用
JsonDocumentDOM 逐元素反序列化 - 未知的类型名自动跳过(向前兼容)
仅通过 ExportAsync / ImportAsync 使用,不参与主存储持久化。输出结构:
<?xml version="1.0" encoding="utf-8"?>
<QuiverDb version="1">
<Set type="FaceFeature" count="2">
<Entity>
<PersonId>P001</PersonId>
<Name>Alice</Name>
<Embedding>Base64EncodedBytes...</Embedding>
</Entity>
</Set>
</QuiverDb>- 向量数据使用 Base64 编码(
MemoryMarshal.AsBytes→Convert.ToBase64String),紧凑且无精度损失 - DateTime 使用 ISO 8601 往返格式(
"O") - 数字值使用
CultureInfo.InvariantCulture,确保跨地区一致性
Quiver 4.0 使用段式二进制容器。文件由一系列有类型的段构成,末尾是顶层 footer;footer 列出每个段的类型、字节范围和 CRC32。
┌─ 文件头 ──────────────────────────────────────────────────
│ Magic: "QDB\x04" (4B) ← v4 标识(v1–v3 旧文件读取时也被接受)
├─ Segment × N ─────────────────────────────────────────────
│ [Kind 1B] Mixed / EntityMeta / VectorBlob / Blob / Tombstone / IndexSnapshot
│ [Length u64] ← 有效载荷字节数
│ [Payload …] ← 各类型对应的内容
│ [CRC32 u32] ← 覆盖有效载荷
├─ Footer(顶层)───────────────────────────────────────────
│ [SegmentTable] (offset, length, kind, crc) × N
│ [Trailer Magic + Footer offset/length + CRC]
└───────────────────────────────────────────────────────────
段类型(SegmentKind):
| Kind | 内容 | 写入者 |
|---|---|---|
Mixed |
EntityMeta + VectorBlob(+ Blob),单段完整表达一个类型 | SaveAsync |
EntityMeta |
仅属性记录(键 + 标量字段 + 向量引用) |
AppendAsync(拆分路径) |
VectorBlob |
原始 float[] 连续布局,mmap 友好 |
AppendAsync(拆分路径) |
Blob |
[QuiverLargeField] byte[] 有效载荷,位于 EntityMeta 之外 |
AppendAsync / SaveAsync
|
Tombstone |
删除记录(类型 + 键),加载时过滤 |
FlushTombstonesAsync / AppendAsync
|
IndexSnapshot |
可选索引拓扑快照,目前由 HNSW 使用 | SaveAsync |
SaveAsync() 会为支持快照的索引写出 IndexSnapshot 段。HNSW 快照包含图入口点、最大层级、每个节点的层数和邻居列表,以及快照覆盖的 NextId。加载时,如果快照与当前相似度类型、HNSW 参数和有效维度匹配,LoadAsync() 会先恢复图拓扑,再跳过已覆盖 id 的 index.Add(id),只补建新增段或未覆盖段。
快照是纯优化:旧文件没有快照、快照 CRC 失败、参数不匹配或索引类型不支持快照时,自动回退到原来的重建流程。快照不保存实体数据或向量副本,因此不会改变非 InMemory 向量、[QuiverLargeField] 大对象或 mmap 向量读取的语义。
支持的属性 TypeCode(用于 EntityMeta / Mixed 内部):
| TypeCode | CLR 类型 | 存储方式 |
|---|---|---|
| 0 | string |
BinaryWriter.Write(长度前缀) |
| 1 | int |
4 字节 |
| 2 | long |
8 字节 |
| 3 | float |
4 字节 |
| 4 | double |
8 字节 |
| 5 | bool |
1 字节 |
| 6 | DateTime |
ToBinary() → 8 字节 |
| 7 | Guid |
16 字节 |
| 8 | decimal |
16 字节 |
| 9 | float[] |
[length int32] + [原始字节零拷贝] |
| 10 | string[] |
[length int32] + [逐元素字符串] |
| 11 | byte |
1 字节 |
| 12 | short |
2 字节 |
| 13 | Half |
2 字节(半精度浮点,ML/AI 常见) |
| 14 | DateTimeOffset |
[Ticks int64] + [OffsetMinutes int16] = 10 字节 |
| 15 | TimeSpan |
Ticks → 8 字节 |
| 16 | byte[] |
[length int32] + [原始字节] |
| 17 | double[] |
[length int32] + [原始字节零拷贝] |
| 18 | ushort |
2 字节 |
| 19 | uint |
4 字节 |
| 20 | ulong |
8 字节 |
| 21 | sbyte |
1 字节 |
| 22 | char |
以 UInt16 存储,2 字节 |
| 23 | DateOnly |
DayNumber → 4 字节 |
| 24 | TimeOnly |
Ticks → 8 字节 |
| 25 | ushort[] |
[length int32] + [原始字节零拷贝] |
| 26 | uint[] |
[length int32] + [原始字节零拷贝] |
| 27 | ulong[] |
[length int32] + [原始字节零拷贝] |
| 28 | sbyte[] |
[length int32] + [原始字节零拷贝] |
| 29 | (保留) | 曾用于 char[],已移除:char[] 语义与 string 重叠,请改用 string
|
| 30 | DateOnly[] |
[length int32] + [逐元素 DayNumber int32] |
| 31 | TimeOnly[] |
[length int32] + [逐元素 Ticks int64] |
| 32 | short[] |
[length int32] + [原始字节零拷贝] |
| 33 | int[] |
[length int32] + [原始字节零拷贝] |
| 34 | long[] |
[length int32] + [原始字节零拷贝] |
| 35 | bool[] |
[length int32] + [每元素 1 字节] |
| 36 | Half[] |
[length int32] + [原始字节零拷贝,每元素 2 字节] |
类型不在上表中的属性(例如
int?等可空值类型、List<T>等泛型集合、自定义/复杂类型)不受支持。持久化此类属性会在首次SaveAsync()时抛出NotSupportedException,错误信息会指明出错的实体、属性,并列出全部受支持类型。
4.0 中 WAL 子系统已被彻底移除。增量持久化现在直接在文件层面表达:每次保存调用写入一个或多个新段,并只重写 footer。
| 维度 | SaveAsync() |
AppendAsync() |
FlushTombstonesAsync() |
|---|---|---|---|
| 持久化内容 | 所有存活实体,单个 Mixed 段 |
当前内存实体的新 EntityMeta + VectorBlob(+ Blob)段 |
仅挂起删除的 Tombstone 段 |
| 磁盘复杂度 | O(N)(全量重写) | O(Δ)(段追加) | O(Δ)(墓碑追加) |
| 文件写入策略 | 临时文件 + File.Move 原子替换 |
打开已有文件,追加 + 重写 footer | 同 AppendAsync
|
| 内存特征 | 单一内存镜像 | 无双缓冲;mmap 友好 | 极小 |
| 典型场景 | 初始保存、定期碎片整理 | 流式 / 批量入库 | 删除密集型工作负载 |
当 EnableBackgroundMerge = true 时,每次 AppendAsync 和 FlushTombstonesAsync 调用结束后都会执行一次 MaybeAutoMergeAsync 检查,满足条件时触发 QuiverDbFile.MergeAsync 进行进程内合并:
| 选项 | 默认值 | 含义 |
|---|---|---|
EnableBackgroundMerge |
false |
自动合并总开关 |
AutoMergeMaxSegments |
32 |
活跃段数超过此值时触发合并 |
AutoMergeTombstoneRatio |
0.25 |
墓碑与存活实体之比超过此值时触发合并 |
flowchart TD
subgraph Write Phase
W1["用户调用 Add / Upsert / Remove / Clear"] --> W2["QuiverSet 更新内存数据 + 索引"]
W2 --> W3["每个 Set 跟踪挂起墓碑"]
end
subgraph AppendAsync
A1["快照当前内存实体"] --> A2["写入 EntityMeta + VectorBlob(+ Blob)段"]
A2 --> A3["如有挂起墓碑则抽取并写入 Tombstone 段"]
A3 --> A4["重写 footer(段表 + CRC)"]
A4 --> A5{{"EnableBackgroundMerge?"}}
A5 -- "是" --> A6["MaybeAutoMergeAsync → QuiverDbFile.MergeAsync"]
A5 -- "否" --> A7["完成"]
end
subgraph LoadAsync
L1["读取 header + 段表"] --> L2["按顺序回放各段"]
L2 --> L3["最后应用 Tombstone 段"]
L3 --> L4["如 Vectors.MemoryMode = MemoryMapped / Auto 则重建 MmapVectorRegion"]
end
W3 --> A1
A7 --> L1
A6 --> L1
- 每个段都携带自己的 CRC32;加载时任何段校验失败,回放即在该处停止,文件的剩余部分被视为截断/未提交。
- footer 最后写入,且是原子操作;半途中断的追加永远不会破坏已提交的段。
-
QuiverDbFile.InspectAsync(path, verifyCrc: true)可在不修改文件的情况下生成逐段健康报告。
await QuiverDbFile.MergeAsync(
sources: ["a.vdb", "b.vdb", "c.vdb"],
destination: "merged.vdb",
options: new MergeOptions
{
ConflictPolicy = MergeConflictPolicy.LastWriterWins, // 或 FirstWriterWins / Append
},
typeMap: db.GetTypeMap());Append 按原样保留所有条目;FirstWriterWins / LastWriterWins 按主键去重。
| # | 章节 |
|---|---|
| 01 | 版本说明 |
| 02 | 产品概述 |
| 03 | 架构概述 |
| 04 | 快速开始 |
| 05 | 核心概念 |
| 06 | 距离度量 |
| 07 | 索引类型 |
| 08 | CRUD 操作 |
| 09 | 向量搜索 |
| 10 | 持久化存储 |
| 11 | 迁移系统 |
| 11a | 模式迁移 |
| 12 | 多向量字段支持 |
| 13 | 线程安全与并发 |
| 14 | 生命周期管理 |
| 15 | 配置选项 |
| 16 | 内部实现细节 |
| 17 | 完整示例 |
| 18 | API 参考速查表 |
| 19 | 使用建议 |