因为工作原因,我常年需要手持两台电脑:一台公司的电脑用于收发邮件和日常办公,另一台个人电脑用来处理一些受权限限制的操作。
我一直习惯用 Firefox 作为主力浏览器。但由于公司 IT 安全策略限制,所有浏览器统一禁止登录个人账号。这导致了一个很实际的问题:我在个人电脑上查资料时随手收藏的书签,在工作电脑上找不到;而在公司电脑存的实用链接,回到家也没法直接打开。
我也试过社区里常用的现成方案(比如 floccus),但这类工具往往面向通用需求,功能多,配置和依赖也相对繁琐。我的诉求很简单:几台电脑之间能稳定完成双向同步,数据只保存在我自己的 VPS 上,不需要任何多余功能。
既然找不到完全契合的,我就自己写了一个工具,叫 bmsync。它包含一个极简的 Firefox 扩展和一个轻量后端。每次点击插件图标,各设备的书签树就会完成一次双向同步,数据全程只在自己的服务器上流转。
这里记录一下设计思路、技术取舍,以及中途调整方案的过程。
整体设计:服务端合并与全量传输
做多端数据同步,最容易出错的地方是让各个客户端自己计算 Diff(差异)。一旦两端状态不一致,合并逻辑就很容易产生冲突。
为了压低整体复杂度,我把浏览器插件做得足够简单,它只负责两件事:
- Collect:读取本地书签树,转换成全量状态后发给服务端;
- Apply:接收服务端计算后的最新完整状态,直接覆盖本地书签。
所有的合并(Merge)计算全部收拢在服务端。 这样设计的好处是操作天然具备幂等性:哪怕网络出现重试、手抖连续点击,或者两台设备短时间内先后发起同步,服务端的最终结果都是一致的,避免了多端并发计算可能导致的数据错乱。
在传输层面,我直接采用了全量状态传输。实测 5000 条书签的 JSON 状态大概在 800 KB 左右,经 gzip 压缩后体积更小。在现有的网络条件下,相比去维护一套增量版本号、处理断点和乱序逻辑所消耗的维护精力,全量传输多消耗的这点带宽是完全可以接受的。
没有全局 ID,如何识别同一条书签?
Firefox 的 Bookmarks API 只为书签分配本地自增的整数 ID。不同设备上,同一个书签的 ID 并不一致,而且 API 也不支持写入自定义全局字段。
解决办法是基于内容哈希来生成统一的唯一标识:
- 书签的 Key:
SHA-256("u:" + url)取前 16 字节; - 文件夹的 Key:
SHA-256("f:" + 父目录Key + NUL + 标题)取前 16 字节。
这样设计带来了一个明确的逻辑:书签的 Key 故意脱离了父文件夹信息。当你把一个书签从 A 目录移动到 B 目录时,书签本身的 Key 保持不变,仅仅是它的父级关联发生了变化。服务端可以直接识别出这是一次“位置移动”,而不是把它处理成“先删除旧项再新建一项”。
相应的代价是:如果修改了书签的 URL,或者重命名了上级文件夹,系统会判定为一次“删除旧项 + 创建新项”。
删除操作与墓碑机制(Tombstones)
在同步系统中,删除往往比新增更难处理。
如果仅仅对比两端的书签列表,当服务端发现电脑 A 有某条书签而电脑 B 没有时,无法判断这是“电脑 B 刚刚删除了它”,还是“电脑 B 是一台新设备,尚未拉取到这条数据”。
因此,本地的删除操作不会直接物理移除记录,而是打上一个带 d: true 属性的“墓碑标记”(Tombstone)。墓碑与普通书签一样,遵循最后写入为准(Last-Write-Wins)的规则覆盖旧状态,并在服务端保留 90 天。这个周期足够让常用的设备开机联网并同步该删除状态。超过 90 天后,过期的墓碑记录才会被服务端彻底清理。
避免时钟误差:引入混合逻辑时钟(HLC)
既然依赖时间戳决定状态覆盖顺序,就绕不开设备间物理时钟的误差。如果一台电脑的本地时间偏快几分钟,很可能导致它较早生成的旧状态覆盖掉另一台时间正常的设备上较新的修改。
为了消除物理时间漂移的影响,项目引入了 HLC(混合逻辑时钟)。当设备 B 接收过设备 A 的状态后,B 接下来生成的所有操作,其逻辑时间戳都保证严格大于 A。
服务端与插件端各自实现了一套 HLC,并通过一组共享的测试用例验证行为一致性,确保两端在推进逻辑时间时理解一致。
技术选型:从 Go 改为 C#
项目后端的最初版本是用 Go 写的。考虑到这是一个自用小工具,Go 单文件打包、内存占用低的特点在部署上确实方便。
但在实际 Review 最初借助 AI 快速搭建的 Go 代码时,我发现维护体验并不顺手:由于缺乏严格的类型约束和面向对象抽象,逻辑分散在各个扁平函数中,每次修改都需要反复梳理上下文依赖。
权衡之后,我还是换回了自己更熟悉的 C#,并借助 NativeAOT 来满足轻量化部署的诉求:
- 代码结构更清晰:强类型系统配合模式匹配和清晰的分层,逻辑一眼就能看懂,即使隔几个月回来看也容易维护;
- NativeAOT 满足资源控制:编译成单一机器码可执行文件,放进 Docker 的
FROM scratch基础镜像后,体积只有 20~30 MB,运行时内存占用也很低; - 插件端保持纯净:扩展使用纯原生 JavaScript(ES Modules)编写,没有引入任何前端框架、打包工具或 npm 依赖。修改代码后在 Firefox 的
about:debugging中点一下刷新即可生效; - 测试保证可靠性:编写了测试脚本(
./test/all.sh),包含 250 多个测试用例,覆盖从静态检查到端到端的接口行为验证。
在交互上,我也特意保留了手动触发同步的设计。扩展不做后台定时轮询,点一下图标同步一次。这种确定性更符合我个人对小工具的使用预期。
一些已知的取舍
作为一个量身定做的自用工具,很多边缘场景我直接做了取舍:
- 不保证文件夹内的书签顺序:同步过来的书签默认追加在末尾,只保证书签内容和目录层级的一致性;
- URL 全局唯一:如果在不同文件夹里收藏了同一个网址,同步后会合并为一条;
- 文件夹重命名成本较高:修改文件夹名称会导致整棵子树的 Key 重新计算,虽然内容不会丢失,但会产生一组旧的墓碑记录;
- 离线不可超过 90 天:长期断网(超过 90 天)的设备再次接入时,因服务端的墓碑已被清理,可能会把旧书签重新作为新数据上传;
- 只做核心书签同步:不支持 Tags、备注、书签图标以及阅读列表等附加数据。
相关地址
目前服务端和浏览器扩展均已开源,扩展也已发布至 Firefox 官方商店:
- Firefox 插件商店: bm-sync on Firefox Add-ons
- GitHub 仓库: tilv37/sync-bookmark
这不是公开的 SaaS 服务,使用前需要自行部署后端。仓库中提供了 docker-compose.yml,启动后在插件设置页中填入服务器地址和访问 Token 即可。
如果你在多台受管控的设备之间也有类似的书签同步需求,并且希望数据完全保留在自己的服务器上,欢迎尝试。