背景 链接到标题

我的笔记托管在 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      # 滚动哈希窗口

算法过程:

  1. 用 48 字节的滑动窗口计算滚动哈希
  2. hash % avgChunkSize == 1 且当前块 >= 最小块大小时,标记为分块边界
  3. 如果达到最大块大小还没找到边界,强制切分
  4. 对于文本(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")) 算出字节数,少一个都不行。

完整推送流程 链接到标题

最终同步脚本的核心逻辑:

  1. 读取文件内容
  2. 用 Rabin-Karp 把内容切成块
  3. 对每个块用 xxhash64 生成 leaf ID 并 PUT 到 CouchDB
  4. 创建/更新 plain 文档,记录 path/children/ctime/mtime/size
  5. CouchDB 文档更新后,其他设备的 LiveSync 插件自动拉取

脚本处理了已存在文档的更新(获取 _rev 再 PUT),以及已存在 leaf 的复用(内容相同的块不需要重复存储)。

方案对比 链接到标题

同步方式 依赖 可靠性 技术复杂度
Obsidian CLI Obsidian 进程 中(socket 可能挂)
CouchDB 直写 中(需实现分块算法)
直接写文件 低(不触发同步)

CouchDB 直写作为主力路径,Obsidian CLI 作为保底方案。

总结 链接到标题

LiveSync 的 Rabin-Karp 分块看似复杂,提取出来也就几十行代码。反向理解它的存储协议后,不经过 Obsidian 进程也能把笔记推进去。这个方法不只适用于我,如果你的 Obsidian 同步也是 Self-hosted LiveSync + CouchDB 方案,同样的思路可以直接复用。