背景 链接到标题
我的笔记托管在 Obsidian vault 中,通过 Self-hosted LiveSync 插件 + CouchDB 实现多端同步。
问题是:我操作远端节点(Mac mini)上的 Obsidian vault 时,如果直接写文件到磁盘,Obsidian LiveSync 不会触发同步——除非 Obsidian 进程处于激活状态。Obsidian CLI 能绕过这个问题,但 CLI 依赖 Obsidian 进程的 Unix socket,存在连接挂死的风险(实测遇到过)。
所以需要一条不依赖 Obsidian 进程的同步路径:直接把笔记内容推送到 CouchDB,让其他节点上的 LiveSync 插件自动拉取。
CouchDB 中的 LiveSync 存储结构 链接到标题
LiveSync 在 CouchDB 中的文档结构如下:
plain文档 — 索引文件,记录笔记的元数据和分片引用leaf文档 — 存储笔记内容的分片
一条 “hello world” 笔记在数据库里是这样的:
plain 文档:
{
"_id": "inbox/hello-world.md",
"path": "Inbox/hello-world.md",
"type": "plain",
"children": ["h:xxxxxxxxxxxx", "h:yyyyyyyyyyyy"],
"ctime": 1782357049136,
"mtime": 1782357049137,
"size": 143,
"eden": {}
}
leaf 文档:
{
"_id": "h:xxxxxxxxxxxx",
"type": "leaf",
"data": "这段笔记内容的一部分..."
}
内容被切成若干块(leaf),plain 文档通过 children 字段列出所有块 ID。同步时按顺序拼接 leaf 的 data 即可恢复完整文件。
分块算法:Rabin-Karp 滚动哈希 链接到标题
第一步尝试是 120 字节定长切割——简单粗暴。推上去后日志提示:
Writing fetched chunks (2) to the database...
File xxx.md seems to be corrupted! Writing prevented. (165 != 201)
[ServiceFileHandler] Processing ... Done
LiveSync 拒绝了。查 LiveSync 配置发现 chunkSplitterVersion: v3-rabin-karp。
Rabin-Karp 是内容感知分块算法,不按固定长度切,而是通过滚动哈希找内容的「自然边界」。改了一行文件,只有被改的附近那几块会变,其他块保持不变。这对增量同步的效率至关重要。
LiveSync 的具体参数(文本文件):
chunkUnitPlain = 64 # 基础单位
avgChunkSize = 256 # 平均块大小(unit * 4)
maxChunkSize = 1024 # 最大块大小(unit * 16)
minChunkSize = 128 # 最小块大小(unit * 2)
windowSize = 48 # 滚动哈希窗口
算法过程:
- 用 48 字节的滑动窗口计算滚动哈希
- 当
hash % avgChunkSize == 1且当前块 >= 最小块大小时,标记为分块边界 - 如果达到最大块大小还没找到边界,强制切分
- 对于文本(UTF-8),检查不切在多字节字符中间
Rabin-Karp 的哈希更新是个经典操作:减去离开窗口的字节贡献,乘上素数,加上新进入的字节。
叶子 ID 生成 链接到标题
每个 leaf 的 _id 由块内容的 xxhash64 算出来:
def leaf_id_from_content(content: str) -> str:
hash_input = f"{content}-{len(content)}"
h = xxhash.xxh64(hash_input.encode()).intdigest()
return "h:" + to_base36(h)
这个设计使内容相同的块共享同一个 leaf ID。当多个文件包含相同片段时(比如代码块、公共模板),它们引用的 leaf 文档是同一个——天然的去重机制。
哈希碰撞需要处理:当 PUT leaf 返回 409 时,检查已有 leaf 的 data 是否一致。如果不一致(极低概率的碰撞),追加随机后缀生成不冲突的 ID。
size 字段的坑 链接到标题
一个容易踩的坑:size 存的是文件字节数,不是字符数。
Content: 165 chars, 201 bytes
如果 size 设为 165(字符数),LiveSync 校验时比较的是文件系统的实际字节数(201),发现不匹配,直接拒绝写入磁盘并标记为 corrupted。
CE 创建的文档 size 值为 111,用 len(content.encode("utf-8")) 算出字节数,少一个都不行。
完整推送流程 链接到标题
最终同步脚本的核心逻辑:
- 读取文件内容
- 用 Rabin-Karp 把内容切成块
- 对每个块用 xxhash64 生成 leaf ID 并 PUT 到 CouchDB
- 创建/更新 plain 文档,记录 path/children/ctime/mtime/size
- CouchDB 文档更新后,其他设备的 LiveSync 插件自动拉取
脚本处理了已存在文档的更新(获取 _rev 再 PUT),以及已存在 leaf 的复用(内容相同的块不需要重复存储)。
方案对比 链接到标题
| 同步方式 | 依赖 | 可靠性 | 技术复杂度 |
|---|---|---|---|
| Obsidian CLI | Obsidian 进程 | 中(socket 可能挂) | 低 |
| CouchDB 直写 | 无 | 高 | 中(需实现分块算法) |
| 直接写文件 | 无 | 低(不触发同步) | 低 |
CouchDB 直写作为主力路径,Obsidian CLI 作为保底方案。
总结 链接到标题
LiveSync 的 Rabin-Karp 分块看似复杂,提取出来也就几十行代码。反向理解它的存储协议后,不经过 Obsidian 进程也能把笔记推进去。这个方法不只适用于我,如果你的 Obsidian 同步也是 Self-hosted LiveSync + CouchDB 方案,同样的思路可以直接复用。