无过
发布于 2026-10-09 / 0 阅读
0

多端同步工具配置:五个让数据「看起来同步了其实没同步」的坑

我把本机 2.3G 的 AI 工具目录压缩到 13M 同步到了另一台设备。过程里踩了五个坑,其中一个让同一条会话在侧边栏出现了两次,另一个差点把数据库写坏。这篇写那五个坑 —— 以及怎么验证它们确实被修好了。

背景:为什么不是「整个目录 rsync 一下」

目标很朴素:让两台机器上的 AI 助手「差不多是同一个」—— 身份记忆、技能、会话历史都在。

第一反应当然是整个目录同步。但一上手就发现不对:本机目录 2.3G,而真正值得跨机搬运的资产只有十几个 M。剩下的是日志、运行时二进制、插件市场缓存、链路追踪 —— 全是每台机器自己装的东西,搬过去纯属浪费。

更要命的是那 2.3G 里有几类搬过去会坏事的东西。

所以整个方案的第一原则是:先分类,再决定谁进同步通道。


坑 1:进程独占的数据库文件,双向同步必坏

目录里有个 workbuddy.db(SQLite),旁边还有 workbuddy.db-wal 和 -shm。

直觉上这是「最重要的数据」,必须同步 —— 于是灾难开始了。

WAL(预写日志)是 SQLite 的事务机制:写入先落到 -wal 文件,再择机合并回主库。这个文件由持有数据库的进程独占,而且它的状态与主库严格配对。

两台机器各自开着这个数据库,同时持有 -wal:A 机器的未合并事务对 B 机器完全不可见,B 的写入也可能覆盖 A 的中间状态。结果不是「数据错了一点」,而是可能直接损坏。

我的处理方式是把铁律写死成三条:

1. 只 INSERT 缺失行,绝不 UPDATE / DELETE
2. 写库前自动备份
3. 同名但内容不同 → 跳过并报告,不静默覆盖

只 INSERT,不 UPDATE,这一条是全部安全性的来源:本机已有的行永远不会被远端数据改写。同步只能「补充」,不能「篡改」。

# 伪代码:判重后只插入缺失的
for row in incoming_sessions:
    if exists(row.id):
        if same_content(row):
            skipped_same += 1
        else:
            conflicts.append(row)      # 留给人判断
        continue
    insert(row)                        # ← 只有这一条写路径

配套的还有 verify(全量 sha256 比对)和 restore(幂等)两个子命令,以及一个 selftest —— 在临时目录里跑完整链路并做 13 项断言,不碰真实数据。

顺便一提:定时任务里跑 push 时,这个库可能被主进程占用。打包阶段对它只读就没事,所以定时 push 是安全的;写库只发生在 pull 阶段。


坑 2:会话数据存在两个地方,只搬一个等于没搬

这是最容易「看起来成功了」的一个坑。

会话在磁盘上有两份:

位置内容作用
workbuddy.db 的 sessions 表标题、时间、摘要侧边栏列表的数据源
projects/**/*.jsonl逐条消息正文点进去看到的对话内容

我只同步了 jsonl 正文,结果:文件确实在磁盘上,但侧边栏空空如也 —— 因为列表读的是数据库,不读文件。

反过来只补数据库也一样:侧边栏点得到会话,点进去一片空白。

实测的差集数据:

DB 会话行:      48
jsonl 正文:     31~38
DB 有、正文没有:17 条    ← 清理过的工作区
正文有、DB 没有: 0 条    ← 反向差集为空

反向差集为 0 这个结果很有价值 —— 它说明「正文一定有对应的 DB 行」,所以补 DB 是安全的(只需要 INSERT 那些正文已存在的会话)。

所以两份必须成对搬,一次做完。

这个坑的通用形态:任何「列表 + 详情」结构的数据,如果它们分属不同存储,同步时就必须成对处理。 只搬详情,列表不会有。


坑 3:同一个 ID 有两种格式,同步后同一条会话出现两次

修好坑 2,我在侧边栏看见同一条会话出现了两次。

排查后发现,ID 有两种写法:

标准 UUID:      550e8400-e29b-41d4-a716-446655440000
无连字符 32 位:550e8400e29b41d4a716446655440000

它们指的是同一个东西,但在判重时是两个不同的字符串。

于是同步逻辑这样写:

# ✗ 有问题:把两种格式当成两个不同的会话
if row.id not in existing_ids:
    insert(row)

# ✓ 先归一化再判重
def norm_sid(s):
    s = s.strip().lower()
    if len(s) == 32 and "-" not in s:
        return f"{s[:8]}-{s[8:12]}-{s[12:16]}-{s[16:20]}-{s[20:]}"
    return s

教训:任何跨系统的 ID 判重,都要先确认「同一个 ID 在源系统里有没有多种表示」。 不统一格式就去判重,等于没判。


坑 4:压缩包每次都不一样,Git 仓库迅速膨胀

把资产打包成 gzip 提交进 Git。第一次很顺利,第二次就发现仓库体积开始不正常增长。

原因是 gzip 默认把当前时间写进文件头。于是同一个文件,两次打包产生两个不同内容的压缩包 —— Git 认为你改了两份文件,各自存一份完整副本。

原始会话 72M  →  gzip 后 12.6M   ← 压缩本身很有效
但重复提交后仓库体积翻倍          ← 差异性带来的额外开销

解法是让压缩确定性:固定 mtime,这样同样的输入永远得到同样的字节。

# 固定 mtime,让 gzip 输出可复现
buf = io.BytesIO()
with gzip.GzipFile(filename="", mode="wb",
                   fileobj=buf, mtime=0) as gz:
    gz.write(data)
deterministic = buf.getvalue()

改完之后,重复 push 不再产生任何新差异。这是个小改动,但影响仓库长期健康度的修正。


坑 5:大体积的技能目录,逐文件同步毫无意义

技能目录里有个 skillhub 包,7063 个文件、36M。大部分是各机器都装得上的通用内容。

逐文件进 Git 的问题不只是体积,还有维护成本:它会一直变动,每次变动都是几百个文件的 diff,把提交历史冲得稀碎。

我改成分级策略:

体积处理方式
小(自建 skill)完整打包进仓库
大(超阈值)只在 manifest 里登记一份清单(路径 + 数量 + 体积),目标机按名重装

这样清单里只有一行 aliyun-ecs-skill__skillhub 7063 files 36M,真正的内容交给「每台机器自己装」这条铁律。

通用原则:能用「装一次」解决的,不要用「搬一次」解决。 同步通道应该只承载本机独有的东西。


附带的两个设计:敏感资产与体积控制

敏感文件:默认只记指纹,不打包

密钥材料、凭据目录这类东西,我的默认行为是只登记 sha256 指纹,不把明文放进仓库:

sensitive inventory:
  keyblob              sha256:9f2c...  (未打包)
  security/            sha256:1a7e...  (未打包)
  preferences.json     sha256:4b03...  (未打包)

清单本身是公开的(可以告诉别人「我这里有个叫 keyblob 的文件」),但内容不外流。确实需要同步时,用 age 加密:公钥进仓库,私钥离线。

排除清单要硬编码,不能靠配置文件

真正危险的几个目录必须在代码里硬编码排除,不能交给配置文件决定:

*.db-wal   *.db-shm   logs/   traces/   cache/
binaries/  plugins/cache/   app/   __pycache__   *.pyc   *.lock

理由很直接:配置文件可能被误提交、可能被覆盖、可能因为跨版本不兼容而失效。安全相关的排除必须写死在代码里,不能依赖外部输入。


小结:五个坑的共同点

坑表面症状真实原因
双向同步数据库可能损坏并发进程独占同一文件
只搬正文不搬索引侧边栏空列表与详情分属不同存储
ID 两种格式同一条出现两次判重前没归一化
gzip 不确定仓库膨胀输出字节每次不同
大目录逐文件搬diff 噪声本可「装一次」而非「搬一次」

前四个有个共同特征:失败是静默的。数据看起来搬过去了,只有在特定视角(换台机器打开、跑第二次同步)才暴露。

所以这类工具必须配一个不碰真实数据的自检(selftest),在临时目录里跑完整链路 + 断言。我的清单是 13 项,包括:

  • 日志/缓存/WAL/密钥未被打包
  • 数据库补插幂等(跑两次结果一致)
  • 正文确实到位
  • 重复执行 restore 不产生副作用

断言要写在临时目录里跑。拿真实数据做自测,一次误操作就够受的。

如果你要给自己的工具做同步

三句话版本:

  1. 先分类,把「本机独有的」和「每台都该装的」分开 —— 只同步前者。
  2. 成对处理:列表数据与详情数据分属不同存储时,一次搬完。
  3. 判重前先归一化 ID,并且压缩输出要可复现。

剩下的都是细节,但细节决定了这个工具是「能用一年」还是「三个月后不敢动」。