Obsidian 多机同步:Syncthing 星型部署
选型判断(先说结论)
- 机器数 ≤2 且愿意折腾:Syncthing 或 Obsidian Sync 都行。
- 机器数 ≥3:Obsidian Sync 优先——它两档套餐均「设备数量无限制」,$4/月(1 vault/1GB/5MB 单文件/1 月版本历史)、$8/月(10 vault/10GB/200MB/12 月版本历史),成本不随机器增加。
- 坚持免费/数据不出局域网 → Syncthing 星型拓扑。
- 绝对不要把 vault 放进百度网盘同步文件夹:按需下载占位文件会让 Obsidian 读到空内容;
.obsidian配置被同步后多机互相覆盖;冲突副本;特殊字符文件名被拒;机器越多冲突概率越高。iCloud/OneDrive 同样有风险,只是比百度轻。
安装(macOS,图形化优先)
brew install --cask obsidian # 1.13.x → /Applications/Obsidian.app
brew install --cask syncthing-app # 图形版,菜单栏有图标;不要用纯命令行 formula
open -a /Applications/Syncthing.app
Windows 叶子机装 SyncTrayzor(图形外壳)。
关键路径(容易踩)
- 配置与本机设备 ID 来源:
~/Library/Application Support/Syncthing/config.xml(不是~/.config/syncthing/,那是 Linux 路径)。 - apikey:
grep -o '<apikey>[^<]*' "<上面的 config.xml>" | sed 's/<apikey>//'。 - GUI:
http://127.0.0.1:8384,Header 用X-API-Key。 - 检查在跑:
lsof -nP -iTCP:8384 -sTCP:LISTEN。 - Homebrew 环境没有 jq,解析 JSON 用
~/。
配置步骤
- 建 vault 目录,例如
~/Documents/Obsidian(各机路径可不同,文件夹 ID 必须一致)。 - 写入
.stignore(见下)。 - 用
scripts/setup_folder.py通过 REST API 添加共享文件夹,并打印本机设备 ID。 - 枢纽机加开机自启:
osascript -e 'tell application "System Events" to make login item at end with properties {path:"/Applications/Syncthing.app", hidden:false}' - 确认发现/中继:
GET /rest/config/options中globalAnnounceEnabled、localAnnounceEnabled、relaysEnabled、natEnabled均为 true(默认如此)。 - 叶子机:装 Syncthing → Web GUI「添加远程设备」粘贴枢纽 ID → 枢纽机上弹窗点「添加设备」并勾选共享
obsidian→ 叶子机接受共享、选本地路径。 - 叶子机上建议把枢纽设备勾上 Introducer,以后枢纽新增设备时叶子能自动接受。
.stignore 模板
.obsidian/workspace.json
.obsidian/workspace-mobile.json
.obsidian/cache
.kb
.DS_Store
.Trash
*.sync-conflict-*
理由:窗口布局/滚动位置各机不同,同步会互相抢;插件配置(.obsidian/plugins)要同步,否则各机插件不一致。
.kb 是本机索引缓存(含绝对路径),同步过去无效。
坑
PUT /rest/config成功时返回空 body,直接json.loads会崩,必须先判空。- 同步会同步「删除」:一台误删,全机跟着删。必须另配备份——Syncthing 用
versioning: {type: simple, keep: 10},或定期整包 zip。 - 星型结构的代价:枢纽关机期间,叶子之间同步不上。枢纽最好不休眠(查
pmset -g | grep sleep)。 - 多机同时编辑同一篇笔记,任何方案都会产生冲突副本(Syncthing 生成
.sync-conflict文件,不丢数据)。
设备 ID 记录(2026-09-17 三机已全部配对)
| 角色 | 机器 | 主机名 | 设备 ID |
|---|---|---|---|
| 枢纽 | scg的Mac mini(Mac16,10) | scgdeMac-mini.local |
MMAH322-NK7M5FV-DDHFJRE-4QZQX47-G47LK2C-ICIQXQ2-74O3CGM-LEPOKAP |
| 叶子 | MacBook Air(v2.1.2) | macbookdeMacBook-Air.local |
6NIALRO-ECJLTRF-7LJCR55-VXGK7QO-EUU7SNM-ASEVNSN-5243SU5-TYUNOAS |
| 叶子 | Windows 工作机(v2.1.5) | Windows-主机 |
6GLH6YS-B5JPRIR-2GC6ZQH-GOXR52L-ALER4YE-XC4MBBZ-5C4QUOK-JS63JA2 |
文件夹 id 统一为 scgzsk,path ~/,type sendreceive,versioning simple/keep=10,三机共享。
> 别把枢纽自己误记成"对端":config.xml 里第一个 <device> 就是本机(name 是主机名)。
⚠️ folder ID 必须三机一致(配不上的头号原因)
Syncthing 靠 folder id 配对,不是靠名字或路径。ID 不同 → 永远配不上,且日志只给一条含糊的警告:
WRN Unexpected folder ID in ClusterConfig; ensure that the folder exists
and that this device is selected under "Share With"
症状:两台设备已互相批准、连接显示 connected,但某台完成度恒为 0%、needBytes 一动不动。
诊断(一条命令看全):
C="$HOME/Library/Application Support/Syncthing/config.xml"; K=$(grep -o '<apikey>[^<]*' "$C" | sed 's/<apikey>//')
curl -s -H "X-API-Key: $K" http://127.0.0.1:8384/rest/cluster/pending/folders
# 列出「对端提议、本机没有」的 folder id —— 就是 ID 对不上的那些
修复:统一 ID(改叶子机,或在本机按对端 ID 建 folder 接住再迁内容)。
folder id 创建后不可改,只能「删配置 + 按目标 ID 重建」,索引会重扫(数据不动)。
顺带确认对端是否已接受共享:日志里有 Peer has a new index ID (... folder.id=xxx) 才算接受了;
一条都没有 = 对端还没点「添加」,此时本机怎么推都推不动。
v2 REST API 变更(踩过的坑,2026-09-17)
| 目的 | v1 写法 | v2 正确写法 |
|---|---|---|
| 删除 folder | PUT /rest/config/folders(传整个列表) |
DELETE /rest/config/folders/<id> |
| 触发重连 / 重新协商 | POST /rest/system/connect |
POST /rest/system/restart |
| 给设备喂静态地址 | POST /rest/system/discovery/hint |
直接改设备配置的 addresses 数组 |
| 暂停 / 恢复 folder | POST /rest/folder/pause\|resume |
PUT /rest/config/folders/<id>,body 改 paused |
⚠️ 最危险的一条:PUT /rest/config/folders 是「更新已有 + 追加新的」,不是替换整个列表。
想「删旧增新」而用它 → 结果两个 folder 同时指向同一路径(双写同一目录,极易互相踩)。
删一定要用 DELETE。做完立刻 GET /rest/config/folders 核对只剩预期的那些。
新设备自动批准 + 通知(用户明确要求)
枢纽机 24 小时开着,新机器随时可能来敲门;不自动批就会一直刷
Connection rejected ... unknown device,同步永远不通。用户要求自动批准并通知。
脚本 ~/.workbuddy/bin/obsidian-kb/syncthing_auto_accept.py + 守护 com.scg.syncthing-auto-accept
(StartInterval=30 + RunAtLoad):轮询 /rest/cluster/pending/devices,自动
① 加入设备配置 ② 共享 scgzsk folder ③ 弹 macOS 通知 + 写日志。
安全边界:只批「主动来敲门的」(本机设备 ID 是 8 段 ×7 位随机串,猜不出来 → 能敲门的就是自己配的机器);
只共享 scgzsk 一个 folder;全程只做加法。
⚠️ 脚本与 folder id 必须同步(2026-09-17 修的静默 bug):syncthing_auto_accept.py 里
SHARE_FOLDERS 曾写旧值 ["obsidian"],而三机 folder id 后来统一成了 scgzsk → 结果新设备会被批准、
但共享那步读不到 folder 就 continue 静默跳过,用户以为接好了其实没有 folder。改 folder id 后务必 grep
一遍所有依赖它的脚本(grep -rn "obsidian\"\|SHARE_FOLDERS\|folder" ~/.workbuddy/bin/)。
⚠️ 判断这个守护在不在跑,不能用 pgrep:它设计上「跑一轮即退出」,靠 launchd StartInterval=30 定时拉起,
pgrep 必然查不到。WorkBuddy 的 Bash 又加载不了用户级 LaunchAgent(EIO(5),见全局策略),
所以脚本已加 --loop 常驻模式(每 30 秒一轮),可直接:
nohup ~/ \
~/.workbuddy/bin/obsidian-kb/syncthing_auto_accept.py --loop \
>>~/.workbuddy/logs/syncthing-auto-accept.log 2>&1 &
⚠️ config.xml 会长出畸形空 folder:PUT /rest/config/folders 是追加语义,反复调用会留下
<folder id="" label="" path=""> 的空壳(只共享给本机自己),后果是同一目录被同步两遍。排查:
# 打印每个 folder 的属性,出现 id="" / path="" 即为畸形,用正则整块删掉
~/ -c "
import re; t=open('$HOME/Library/Application Support/Syncthing/config.xml',encoding='utf-8',errors='ignore').read()
[print(' '.join(re.findall(r'(\w+)=\"([^\"]*)\"', m.group(0).split('>')[0]))) for m in re.finditer(r'<folder\b.*?</folder>', t, re.S)]"
⚠️ 数设备数别用 grep -c '<device':folder 块内部每个共享设备也有 <device id=...> 子元素,
会数出「7 台」而实际只有 3 台。正确姿势走 API:/rest/config/devices。
枢纽结构清理后,别直接双向 resume(2026-09-17 定论)
在枢纽机上做完归档/改名/合并等结构性清理后,如果直接把 folder 从 paused 改成 false 双向开跑,
叶子机上那些刚被归档的旧文件(INDEX / CLAUDE / AGENTS / 旧 README 等)会原样回灌,清理全部白做。
正确顺序:先把 folder type 改成 sendonly 单向推一轮 —— 枢纽的新增/改名/删除会覆盖到叶子机,
而叶子机上枢纽没有的额外文件不会回灌;等确认对端干净了,再切回 sendreceive 双向。
# 只连不传(用户要「链接但不要同步」时的标准配法):设备已配对连接,folder 保持 paused
curl -s -H "X-API-Key: $KEY" http://127.0.0.1:8384/rest/system/connections # 看两台叶子机 connected=true
# 恢复时再显式改 paused=false(或 GUI 点 Resume)
三机内容合并 + 去重 SOP(2026-09-17 实操)
场景:各机各自有一份库(ID 不同、内容互有独有),要合并成一个统一库。
顺序至关重要:
| # | 动作 | 为什么 |
|---|---|---|
| 1 | 先快照 cp -R ~/scgzsk ~/scgzsk-合并前快照-<日期> |
合并不可逆,先留退路 |
| 2 | 用临时区接住对端:按对端 folder id 建 folder,path 指向 ~/scgzsk-待合并/<id> |
不污染主库就能看到对端有什么 |
| 3 | 离线比对再合并(别直接靠同步互灌) | 主库 42 文件 vs 对端 1269 文件,直接同步会瞬间淹没主库 |
| 4 | 统一 folder id(DELETE 旧的 + POST 新的) | 见上文,别用 PUT 追加 |
| 5 | 清理临时 folder 配置(磁盘数据留着当备份) | 否则一直报"未知 folder" |
合并规则(脚本思路):跳过 .obsidian/.stfolder/.stignore;同名同内容 → 跳过;
同路径不同内容 → 保留本机的,对方存成 <name>.并入-<来源>;内容已在库内别处存在 → 跳过;
其余才是真增量 → 复制进来。
去重规则(关键:别把"多版本资源"当重复删):
- 先按 MD5 分组,再分两类:
- 同目录内、文件名不同 → 这是 App 图标/资源的多版本副本(
icon_v7.icns、icon_v16.icns内容相同
但都被源码引用)→ 绝不能删,删了直接毁 App。 - 跨目录重复 → 才是真正可去重的。
- 去重方向用「包含关系」判断,不要凭目录名猜:算
A的每个文件在B里是否有同相对路径同内容的对等物。
若 A ≈ 被 B 完全覆盖 → A 是副本;若 A 有大量独有 → 把独有并入 B,剩下的才归档(别整目录删)。 - 一律 移到
~/scgzsk-整理归档/<日期>-去重/,不真删。 - 判定完先复扫一遍:数量级应显著下降,
Top 重复对里不该再有「成百上千」的条目。
实例数字(可作规模参照):1800 文件 / 唯一 1214 / 冗余 586 → 并入 641、归档 261 → 1541 文件 / 唯一 1207;
剩余 305 个是 App 间共享的图标资源,保留。
本机验证过的坑(2026-09-17)
/rest/folder/unpause在 v2 上返回 404。解除暂停要走配置接口:
python
d = GET /rest/config/folders/<id> # 读回完整对象
d["paused"] = False
PUT /rest/config/folders/<id> (body=d) # 成功返回 200 + 空 body
同理,判断是否暂停看 paused 字段,不要看 /rest/db/status 的 state——
暂停时 state 是空字符串、globalFiles 是 0,看起来像"文件夹坏了",其实只是 paused=True。
-
前一晚为改名而暂停 folder,第二天极容易忘了恢复。 暂停→改名→恢复要当成一个原子动作。
排查"同步没动静"时,第一件事就是查paused。 -
.kb已写进上面的.stignore模板——它是本机索引缓存,里面存的是绝对路径,
同步到别的机器上无效,只会白白 churn。.obsidian/plugins必须同步,不能忽略。
红线自检(只读,加机器/排障前跑一遍)
> 用户只要问「我的同步目录有没有红线问题」,就跑这套。全部只读,不改文件。
> 完整版已沉淀为库内笔记:WorkBuddy-实时沉淀/2026-09-17-同步红线自检清单.md。
红线一句话:vault 的物理路径绝不能落在网盘(iCloud/百度/OneDrive/坚果云/夸克)同步目录里。
一条命令覆盖 6 个致命项:
V=~/scgzsk # 换成实际 vault 路径
echo "① Documents 是否被 iCloud 接管:"; ls -la ~ | grep " Documents" # 健康:不是符号链接
echo "② iCloud 桌面/文档同步开关:"; plutil -p ~/Library/Preferences/com.apple.finder.plist | grep -i icloud
# 健康:FXICloudDriveDesktop => 0 且 FXICloudDriveDocuments => 0。为 1 = 已中招
echo "③ .icloud 占位文件数:"; find "$V" -name "*.icloud" | wc -l # 健康:0
echo "④ 云盘挂载点内容:"; ls -A ~/Library/CloudStorage/ 2>/dev/null # 健康:空
echo "⑤ 库路径是否在网盘下:"
~/ -c "
import os; v=os.path.realpath(os.path.expanduser('$V'))
d=['Library/Mobile Documents','Library/CloudStorage','Dropbox','OneDrive','坚果云','Nutstore','百度网盘','BaiduNetdisk','阿里云盘']
h=[x for x in d if x.lower() in v.lower()]; print('命中:', h if h else '无 ✓')"
echo "⑥ 库内软链接数(隐形红线):"; find "$V" -type l | wc -l # 健康:0
echo "⑦ 卷类型(须为本地卷):"; df -h "$V" | tail -1
Syncthing 侧再补一条(查 paused,见上文坑 #1/#2):
C="$HOME/Library/Application Support/Syncthing/config.xml"; K=$(grep -o '<apikey>[^<]*' "$C" | sed 's/<apikey>//')
curl -s -H "X-API-Key: $K" http://127.0.0.1:8384/rest/config/folders | \
~/ -c "
import sys,json
for f in json.load(sys.stdin): print(f['id'], f['path'], 'paused=%s'%f.get('paused'), 'ver=%s'%(f.get('versioning') or {}).get('type'))"
# 健康:仅 scgzsk,paused=False,ver=simple
潜在雷区(不是红线但会变红线):本机装了 4 个网盘客户端
(BaiduNetdisk.app、BaiduNetdisk_mac.app、「移动到百度网盘 v7」、夸克网盘.app)。
它们目前都没碰 vault,但只要有人勾上「文档自动备份」,~/Documents 会被整体揽走。
每次升级网盘客户端后,检查一遍它的「自动备份 / 同步文件夹」里有没有 Documents。
2026-09-17 枢纽机体检结论:13 项全绿——iCloud 桌面/文档同步是关的,CloudStorage 为空,
0 个 .icloud 占位,0 个软链接,卷为本地 APFS,网盘配置里不含库名,Syncthing paused=False。
vault 搬家 SOP(2026-09-17 实操,含两个必踩的坑)
场景:把 vault 从 ~/Documents/xxx 搬到 ~/xxx,脱离网盘「文档备份」的默认扫描区。
必须严格按顺序,否则踩坑:
| # | 动作 | 不做的后果 |
|---|---|---|
| 0 | 先停索引守护 | kb.py / organize.py 在模块导入时就 makedirs,会按旧路径造出一个空壳库;Documents 里"复活"一个假库,看着像搬家失败 |
| 1 | 先暂停 Syncthing folder | 本地"删除+重建"被同步成对端清空 |
| 2 | 退出 Obsidian | 文件句柄被占用 |
| 3 | 同卷 mv |
(瞬时完成,属性全保留;跨卷才需要 cp+rm) |
| 4 | 改所有写死路径 | 见下方清单 |
| 5 | 恢复守护 + 解除暂停 | 忘了恢复 = 同步一直是停的(这个坑踩过两次) |
# 0. 停守护
launchctl bootout gui/$(id -u)/com.scg.obsidian-kb
# 1. 暂停 Syncthing folder(PUT /rest/config/folders/<id>,body 加 "paused": true)
# 2. 退出 Obsidian
osascript -e 'quit app "Obsidian"'
# 3. 搬家
mv ~/Documents/xxx ~/xxx
# 4. 改配置 —— 用「长路径优先」的替换规则一次搞定,避免前缀误伤:
# ~/Documents/xxx-备份 → ~/xxx-备份 (先)
# ~/Documents/xxx → ~/xxx (后)
# 必查清单:
# Syncthing config.xml folder path(建议走 REST PUT,让它自己写 XML)
# obsidian.json vaults[].path
# kb.py discover_kb_dir() 的 fallback
# config.json kb_dir
# backup.py OUT_DIR + 源目录
# organize.py ARCHIVE_ROOT(顶层 makedirs,不改会自己长回来)
# obsidian_to_ima_scan.py VAULT
# launchd plist 一般只写脚本路径,不用改;但确认一下
# skills/*/SKILL.md grep 一遍
# MEMORY.md 长期记忆
# 5. 恢复
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.scg.obsidian-kb.plist
# Syncthing: PUT folder 加 "paused": false,再 POST /rest/db/scan
搬完必须逐项验证:
文件数前后一致(find | wc -l 对比)
kb.py status → 指向新路径
kb.py search "关键词" → 可召回
Syncthing db/status → state=idle, errors=0, needFiles=0
backup.py --upload → 实跑一次,确认冷备链仍通(会真传一份到百度云,可接受)
Obsidian → 打开的是新路径
Documents 下 → 本库相关目录已清零
通用排查口诀:找不到东西时,grep -rln "<旧路径>" ~/.workbuddy ~/Library/LaunchAgents ~/Library/Application\ Support/{obsidian,Syncthing} 一次扫干净。