Local-first 是一个披着存储外衣的同步问题
把数据放到用户机器上是容易的那一半。真正难的部分,在第二台设备出现的那一刻才登场 —— 而 Git-native 的工具把这个选择的好处和坏处一并继承了。

Local-first 是一个披着存储外衣的同步问题 🗃️
这个月 Lobsters 上有一个 local-first、Git-native 的 issue 追踪器被讨论了一轮,走势和这类讨论一贯的形状一样:先是对"拥有自己的数据"的热情,然后是长长一串关于合并冲突的回复。
那条长尾才是真正的主题。Local-first 之所以难,很少是因为字节放在哪里。它难是因为"本地"和"优先"合起来是一个关于可用性的承诺,而跨多台机器的可用性,是你主动接过来的一个分布式系统问题。
这个标签到底让你承诺了什么
有用的定义不是"数据在磁盘上",大致是这样四条:
- 没有网络也能无限期工作,包括写入。
- 离线做的改动不会丢,也不是二等公民。
- 多台设备在没有服务器仲裁的情况下收敛到同一状态。
- 用户可以把数据带走。
第 1 和第 4 条是存储决策,确实直截了当。第 2 和第 3 条是全部的工程难题所在,而且不会因为你选了某种文件格式就变简单。
一旦两台设备在网络分区期间都能写,你就必须回答并发编辑怎么办。诚实的答案只有几个:一种能自动合并的无冲突数据类型、一个显式询问人类的合并界面,或者带时钟的"最后写入者获胜" —— 后者不是合并策略,是一种举止得体的数据丢失策略。
Git 给了你什么,说准确点
拿 Git 当底座是一个真实且被低估的选择,值得把它实际提供的东西点出来:
- 一个带历史的内容寻址存储。 持久化、diff 和审计轨迹,你一行都不用写。
- 一套人人都有的传输。 SSH、HTTPS、一个 U 盘。不用跑同步服务,不用注册账号,没有账单。
- 一套已经存在的权限模型。 能 push 的人就能写。粒度粗,但大家都懂,而且是在别处管理的。
- 把评审变成一等操作。 改动可以被提议、讨论、合并 —— 对某些数据来说这恰恰对路。
白白继承这么多是很划算的。对于本来就是文本、本来就有人评审的数据,这近乎理想。
代价是什么
Git 的合并是按行的,它不知道你的数据是什么意思。对散文和源码没问题,读冲突的人能判断。对结构化记录就很差,因为冲突是语义层面的,而行级视图反而把它盖住了。
两个人把同一个 issue 拖到了不同的列。按行看,那是一个文件里的一行变了,Git 会把它呈现成一个微不足道的冲突。但正确的解决方式不是"挑一行" —— 它取决于这两列是什么意思、两次状态转移是否都合法、其中一次是否触发了别的东西。格式把问题藏起来了,而不是暴露出来。
三个实际后果:
- 永远一条记录一个文件。 任何聚合多条记录的文件,都会把互不相干的并发编辑变成冲突。文件的粒度就是你的并发粒度,这个决定做一次,然后一直背着。
- 追加优于修改。 一个按并集合并的事件日志,比一个可变文档健壮得多,因为并集没有冲突。代价是读取复杂度和压实。
- 字段顺序要确定。 序列化必须是确定性的 —— 键序稳定、格式稳定 —— 否则你会造出一堆根本不代表任何分歧的冲突。两个客户端写同一个逻辑状态,必须产出逐字节相同的输出。
什么时候这笔交易划算
Git-native 适合写入少、有人评审、按记录天然分片的数据:issue、文档、配置、决策记录。写入频率低到真正的并发很罕见,而真发生时,人本来就是对的裁决者。
它不适合频繁、机器生成、或有顺序要求的写入:聊天、遥测、任何带实时协作光标的东西、任何"谁最后写"本身带含义的东西。对这些,CRDT 不是优化而是前提,你该直接用一个,而不是教 Git 变成一个。
无论如何都值得偷的部分
就算你永远不拿 Git 当存储,它有两个性质值得抄进你正在做的任何东西:
数据应当脱离你的应用也可读。 如果用户能用普通工具读、diff、grep 自己的记录,那"把数据带走"就是一个事实,而不是一个你得维护的导出按钮。这是"数据是你的"这个承诺最可信的形式。
历史应当便宜且完整。 不是"我们在表里留最近三十天",而是"这条记录经历过的每一个状态都可恢复"。Local-first 的系统会不断被要求调和分叉的历史,而一个丢弃历史的系统调和不了任何东西 —— 它只能覆盖。
这两条与传输、格式和合并策略都无关。它们也正是人们说"我想拥有自己的数据"时真正的意思。