如果你也经历过那种场景——团队在偏远的现场干活,网络说断就断,大家手里的在线表格、在线任务系统全部变成只读状态,更崩溃的是,断网之前最后两个小时录入的数据,因为"本地只缓存没提交"而彻底消失——你会理解为什么我们后来会把一个同步引擎项目命名为"Apollo-11"。
这是个内部项目的代号,目标听起来有点狂妄:断网三天,所有人照常工作;网络恢复,数据自动归位;任何人改任何东西,最终都不会被覆盖丢失。这个项目从一次数据事故开始,经历了选型摇摆、协议设计、三轮排坑,最终落地成了一台"离线优先"的数据同步引擎。这篇文章就把整个项目的需求来源、技术选型、核心协议、实现细节和排坑实录完整复盘一遍,如果你也在做类似的双端同步、离线缓存或者多人协作场景,应该能找到不少可以直接抄走的经验。
1. 为什么团队会把一个同步引擎命名为"Apollo-11":一次断网事故催生的需求定义
1.1 原点:不是想登月,是那次数据丢失太疼了
事情的起因特别朴素。我们团队有个跨地的作业场景,现场人员用平板录入巡检记录和任务状态,办公室人员用桌面端做汇总和派单。原本大家用的是市面上常见的在线协作工具,数据都在云上,看起来没什么问题。
直到有一次,现场部署的网络中断了将近一天。网络断开之后,平板端还能勉强打开界面,但所有写入操作都提示"同步失败"。现场人员以为点保存就保存了,实际上几个小时的记录全部留在内存里,一旦应用被系统杀掉,全部丢掉。第二天网络恢复,办公室看到的还是断网前的旧数据,两边对不上账,最后只能靠人工重新补录。
这件事让人意识到一个以前被忽略的事实:只要数据的第一落点是服务器,离线场景下用户就是"裸奔"的。我们把需求写成了三句话:本地必须能完整读写,断网不能影响任何操作;联网后数据要自动合并,不能因为"双端同时改了同一处"就丢数据;整个同步过程要可审计,出问题能定位到具体是哪条变更导致。
1.2 代号的意义:把验收标准先钉死
项目代号是在一次需求评审会上定的。当时有人开玩笑说,这个要求高得像是要登月。我们干脆就把代号定为"Apollo-11",作为一种自我提醒:这个项目的验收标准不是"能用",而是"恶劣环境下不丢数据"。代号本身没有降低任何技术难度,但它让每个参与者在做技术决策时多问一句:如果网络恢复时有一千条冲突变更同时涌进来,这个方案还成立吗?
从这句话开始,项目范围也被强行收窄了。第一版只做两件事:离线优先的数据读写,和基于变更日志的自动同步。实时多人协作、在线编辑冲突提示、Web端支持,这些全都不做。收窄范围不是偷懒,是因为实时协同和离线同步解决的是两套不同的问题,混在一起做,大概率两个都做不好。
1.3 第一版需求清单长什么样
我们把第一版的需求边界写得很具体:
- 支持的实体只有三种:任务事项、巡检记录、配置项。三种实体各有固定字段,不搞通用文档模型
- 每个用户最多三台设备(一台桌面端、两台移动端),设备之间同步数据
- 断网状态下所有本地操作正常完成,网络恢复后自动同步,无需人工干预
- 同一个字段被两端同时修改时,系统自动选一个结果,但必须保留"被合并掉的版本"供事后查看
- 所有同步行为要有日志,能回答"这条数据是什么时候、从哪台设备、基于哪个版本修改的"
这就把"无限可能性"压缩成了可实现的工程问题。同步引擎的定位也清晰了:它不是一个通用的分布式数据库,而是一个"局域网/广域网环境下、面向少量设备和少量实体类型的变更同步层"。
2. 选型战场:去中心化同步、变更日志与本地优先存储的取舍
2.1 先别急着写代码,把三条路线摆上桌
第一轮技术讨论,我们把同步方案的大方向列成了三张卡片,摆在桌上逐条淘汰。
| 方案路线 | 核心思路 | 优势 | 致命弱点 |
|---|---|---|---|
| 中心化服务器同步 | 所有写入先到服务器,再由服务器分发 | 实现简单,冲突处理集中可控 | 断网即不可写,绕回原点 |
| 设备间P2P直连 | 设备之间直接交换数据,不经过服务器 | 离线也可靠,没有中心瓶颈 | 设备发现和NAT穿透成本高,调试困难 |
| 中继式双端同步 | 每台设备本地存储,服务器只做"数据中转站" | 离线可用,拓扑简单,实现可控 | 需要自己设计一致性协议,工作量大 |
第一张卡片第一个被划掉,因为它的核心假设"必须在线才能写入"恰恰是我们要消灭的痛点。第二张卡片看着很美,但实地场景中两台设备并不总在同一网络,NAT穿透的稳定性是个定时炸弹。最后选了第三张:服务器不做权威数据源,只负责"转发"设备之间的变更。每台设备本地有一份完整数据,服务器断电、机房网络抖动,都不影响本地读写。
2.2 同步原语:为什么选"变更日志"而不是CRDT
确定了拓扑之后,下一个问题是怎么描述"数据的修改"。这里有两条技术路线:
第一条是CRDT(无冲突复制数据类型),理论上两个副本无论怎么并发修改,最终都能自动收敛到一致状态,不需要"解决冲突"这一步。但CRDT在字符串、列表这类结构上的语义很绕,团队里没人有相关生产经验,而且CRDT的状态膨胀问题在移动端存储上也不友好。
第二条是变更日志(Operation Log / Event Sourcing 的轻量变体),每个修改动作记录成一条不可变的操作,设备之间互相补"对方缺的操作"。它的优点是直观、可审计、实现确定性高;缺点是冲突需要自行设计合并规则。
我们选了变更日志,但补了一个关键约束:变更粒度细化到字段级。不记录"我改了这条任务",而是记录"我把这个任务的标题从A改为B"。字段级粒度让后续冲突合并的争议范围大大缩小,两个端改的如果是不同字段,根本不会撞车,可以自然合并。
2.3 本地存储的底子:嵌入式数据库加一张专用的变更日志表
本地数据存储我们用了一款嵌入式关系型数据库,理由很实在:单文件、事务、跨平台。抓数据同步的项目,绝不能再用"JSON文件存内存"这种方案,断电丢数据的问题会以另一种形式回来。
数据层一共两张核心表:业务表(存实体的当前状态)和变更表(存所有修改操作)。两者放在同一个数据库事务里提交,保证"业务数据写了、变更日志必须同步写了"的原子性。这个设计是整个"零丢失"承诺的地基——如果本地先改数据后写日志,中途断电就会产生"数据变了但日志丢了"的不一致状态,一旦开始同步就会污染远端。
变更表的最小结构类似这样:
{ "changeId": "550e8400-e29b-41d4-a716-446655440000", "siteId": "device-mobile-7f3a", "timestamp": "2025-06-14T08:30:00.123Z", "entityId": "task-1024", "field": "title", "op": "set", "value": "修复水泵A的震动异常", "baseVersion": "sha256-08a7f9..." }每个字段都有唯一的changeId保证幂等,siteId标识是哪台设备产生的变更,timestamp用于合并排序,baseVersion记录这次修改基于的数据指纹,用于校验因果链。
2.4 传输层:长连接负责实时通道,分块拉取负责大包传输
传输上我们用了双通道设计。控制通道是WebSocket长连接,负责设备上线通知、版本向量交换这些轻量信令;数据通道则复用普通的HTTP接口,按变更ID分页拉取大批量数据。
这么拆分的原因是:同步的最大流量不是实时消息,而是"断网恢复后的补数据"。如果全走长连接,要么把连接占死,要么需要自己实现流控,还不如让数据走HTTP,天然的断点续传和CDN加速都能直接用。长连接只需要维持"心跳"和"有新增变更就通知对方来拉",剩下的脏活累活全交给HTTP分页。
加密方面,第一版没有做端到端加密。原因不是技术做不到,而是移动端的密钥管理和找回流程在项目周期内做不踏实。最终方案是传输层TLS加密,本地数据库用了SQLCipher方案做整库加密。这个取舍在实测阶段被验证是合适的,因为项目最大的风险是"离线丢失",不是"被窃听"。
3. 同步协议拆解:版本向量、墓碑与字段级合并规则怎么协作
3.1 四个基础概念:站点ID、混合时钟、变更条目、版本向量
同步协议一开始很容易走入"把全量数据推给对方"的误区。全量推送在数据量小的时候看不出问题,一旦业务跑半年,每次同步都全量比对,性能迟早崩。我们的协议建立在四个基础概念上:
站点ID:每台设备在首次启动时生成一个UUID,作为这台设备的永久标识。修改数据时,变更条目上都会盖上这个站点ID的章,用来区分"这条数据是谁写的"。
混合逻辑时钟:我们不能直接用物理时间戳排序。系统时钟可能回拨,两台设备的时钟可能不一致。用的是HLC(Hybrid Logical Clock)的思路:每个变更条目携带一个逻辑时间,物理时钟如果前进,就取物理时间;如果同一毫秒内发生了多次变更,就用递增量来区分因果顺序。
变更条目:就是前面展示的JSON结构,一条不可变的操作记录。
版本向量:每台设备维护一张表,记录"我已经收到了哪些站点ID的哪些变更"。比如{device-a: 103, device-b: 87}表示这台设备已经合并了A站点的第103条变更、B站点的第87条变更。同步时只需交换这张表,就能算出自己缺哪些数据,从"全量比对"降级为"精确差量补录"。
这三个概念合起来解决了一个核心问题:我怎么知道对方缺什么?我怎么知道自己缺什么?答案是大家各自报版本号,一对账就清楚了。
3.2 字段级冲突合并:默认规则与三个例外
当两台设备修改了同一个字段,协议会进入冲突合并分支。我们的默认规则是LWW(Last Write Wins),但排序键不是纯物理时间,而是"混合时钟+站点ID"的复合排序。引入站点ID是为了保证总排序是严格的全序,避免两个设备在同一毫秒写入后无法决定先后。
默认规则之外,还有三个例外是血泪教训换来的:
第一,删除优先于修改。如果A设备删除了一个实体,B设备恰好还在改这个实体的某个字段,合并时删除获胜。数据的逻辑模型里,删除是一条特殊字段deleted_at的写入,它一旦成为最新版本,实体整体就被判为已删除。
第二,用户显式操作优先于系统自动操作。比如A设备用户手动勾选了"已完成",B设备只是自动更新了标签颜色,即便自动操作的排序时间更新,也绝不能覆盖用户的显式意图。
第三,"被合并掉"的版本不能静默消失。同步引擎每次发生冲突合并,都会把落选版本存进一张"冲突历史表",界面层在后台记录里可以看到"该字段曾有一个来自某设备的修改被自动合并替代"。这保证了即使自动合并选错了,也能人工恢复。
3.3 三步握手协议:PULL、PUSH、ACK
实际同步过程不是一个复杂的算法,而是三步走:
- PULL阶段:设备A把自己的版本向量发给设备B或中继服务器,对方返回"你缺的变更列表"
- PUSH阶段:设备A把对方缺的变更条目批量推送过去,分页和断点续传都发生在这个阶段
- ACK阶段:双方在各自本地执行变更合并,合并完成后更新版本向量,并返回确认信号
为什么需要ACK?因为"对方收到了数据"和"对方把数据落库了"是两件事。ACK之后,发送方才能把对应的待同步缓冲区清空。如果ACK超时,发送方会把整批数据重新发送一遍,接收方靠changeId幂等去重,保证重复数据不会造成重复写入。
协议设计里还有一个容易被忽略的细节:任何变更条目在进入本地数据库时,都要校验它的baseVersion是否匹配。如果本地的数据指纹和baseVersion不一致,说明这条变更基于的旧版本已经被其他变更覆盖了,必须进入冲突处理分支,不能闷头执行写入。这一步是防止"幽灵更新"的关键。
3.4 一次真实合并过程的推演
为了让大家直观理解,我用一个例子说明。任务"task-1024"的标题是"检查水泵A"。
断网期间:
- A设备(平板端):用户把标题改成"检查水泵A的震动"
- B设备(桌面端):用户把标题改成"检查水泵A和阀门B"
网络恢复,两台设备交换版本向量后发现,两条变更都基于同一个baseVersion,形成冲突。进入合并逻辑:比较两条变更的HLC时间戳。假设A设备的修改在时间上更靠后,则 A 的标题胜出,当前版本变为"检查水泵A的震动",B 的修改进入冲突历史表,并生成一条合并通知(不直接弹出,只在历史记录里可见)。
如果当时B设备的用户正在界面里注视这个任务,他会发现标题"跳了一下",但打开变更历史,能看到两条并行的修改。自动合并保证了不丢数据,人工查看保证了不背黑锅。
4. 实现与调优:增量传输、断点续传和流量控制的实测结果
4.1 删除为什么不能直接删:墓碑机制与垃圾回收
第一版实现时我们对"删除"的处理过于天真,直接删行、生成一条"删除日志"。结果一个设备删除了数据,另一个设备离线期间不知道删除操作,等它上线时,被删的数据又从删除方传了回来,反复"复活"。
正确的做法是墓碑机制。实体删除时,不是物理删除这一行,而是把deleted_at字段置为当前HLC时间并附上站点ID。其他设备同步到这条变更后,把实体标记为删除状态。墓碑不能永远存在,否则存储会无限膨胀。我们设了一个保留窗口:墓碑在最后一次被所有活跃设备确认同步之后保留90天,之后由GC任务清理。
90天不是拍脑袋定的,它等于"用户平均离线时长的最大容忍值"乘以3。做过一次统计,绝大多数移动端设备不会连续离线超过30天,90天能覆盖99%的场景,同时又不会让存储失控。
4.2 增量传输的细节:分页、压缩与断点续传
断网恢复之后,可能积压几千条变更。我们采用了"按版本向量排序 + 每批500条分页拉取"的策略,客户端带着自己当前的版本向量请求下一批,服务器从版本向量的位置开始往后数500条返回,并带上"是否还有更多"的标记。
传输格式第一版用的是纯JSON,几十KB一页的时候毫无压力,但积压到上万条变更时,JSON的冗余就让人肉疼。后来切换到紧凑的二进制编码方案,再套上压缩算法,同样内容体积降到了原来的三分之一左右。压缩工具的选择没有太多玄学,zstd在这个量级上速度最快,压缩率也够用。
断点续传的实现比想象中简单:客户端记录"上次成功落库的最后一条changeId",每批数据真正合并进数据库之后,才推进这个游标。如果中途网络断开,下次同步时直接带着游标位置继续拉,不需要重新传整批。注意,游标的推进必须与本地数据库写入在同一事务内,否则会出现"数据已传却反复重传"的死循环。
4.3 性能实测:三万条积压数据要多久才能追上
在3节点测试环境(一台桌面端,两台移动端,模拟现场弱网)里,我们做了积压数据追赶测试。制造了三万条变更堆积在服务器,然后让一台断网已久的设备上线。
| 指标 | 实测结果 |
|---|---|
| 首次版本向量交换耗时 | 约120ms |
| 三万条变更全量拉取耗时 | 约46秒(弱网模拟,限速2Mbps) |
| 平均每条变更落库耗时 | 约1.4ms |
| 接口传输至落库完成后端到端延迟 | 约2.1ms/条 |
| 同步期间业务操作可用性 | 未受影响,可正常新增和编辑 |
数据跑完,我们松了一口气。46秒追赶三万条积压,对实际场景来说完全可接受。真正影响体验的不是总量,而是同步触发的时机:如果设备一联网就立刻把所有积压数据一次性拉回来,小内存设备会短时间CPU跑满。我们的解法是给同步任务加了"带宽限制"配置,默认占用上限不超过40%的可用带宽,并且支持在后台任务里运行,避免阻塞用户正在操作的前台界面。
4.4 资源占用与流量控制:移动端不能做"野马"
移动端的资源控制是个重点。我们不希望同步引擎成为一个后台偷跑流量、耗电的大户。实测阶段出了这么几个约束:
- 只在WiFi环境自动同步,蜂窝网络下需要用户手动触发或开启"允许移动网络同步"开关
- 同步任务在系统网络状态变化时触发一次,不搞定时轮询
- 本地变更产生后,先写入变更日志,最多延迟5秒再通知服务器"有新数据",用批量通知代替每条变更都发一次信令
这套策略保证了同步引擎在后台静默运行一个月,电池消耗占总耗电比例低于3%。用户对于"自动同步"的感知应该像空气一样——存在,但感觉不到。
5. 三个排坑实录:时钟回拨、自动覆盖与大包同步的完整排查链路
5.1 坑一:时钟回拨导致"改了又变回去"
现象:某台设备在同步后,用户明明修改过的字段,过一会儿又变回了旧值。
排查链路:
第一步,怀疑是LWW排序问题,先看冲突历史表,发现落选版本居然是用户刚刚写的那条。第二步,检查两条变更的HLC时间戳,惊讶地发现用户设备本地的物理时钟比实际时间慢了十分钟。那台设备之前做过一次手动校时,系统时钟发生回拨,新产生的变更挂上了"过去"的时间戳,在和远端变更合并时永远会输。第三步,复现验证:把设备时间调慢五分钟,立刻触发问题。
根因:纯LWW合并依赖"变更时间越新越有效",但设备物理时钟并不可靠。
修复:分两层处理。第一层,HLC实现中增加"对端时钟领先检测",两台设备同步时如果发现对端HLC远超本地HLC,立即警告本地时钟异常,并暂时拒绝本地新增变更的传播。第二层,冲突合并时不再只用HLC时间戳做全序,而是引入"写入来源权重":用户当前正在操作的设备,其变更在接下来5分钟内获得临时权重加成,防止"用户手写的内容被旧时间戳拖入历史尘埃"。
5.2 坑二:自动合并把用户的明确选择覆盖掉了
现象:用户手动把任务状态从"进行中"改成"已完成",并且明确点击了保存。同步后,状态却回退成了"进行中"。
排查链路:
先从同步日志出发,找到两条矛盾的变更。一条来自移动端:"状态"字段改为"已完成",HLC时间T1;另一条来自桌面端的自动脚本:"状态"字段改成"进行中"(用户改的是"预计工时",但代码里把整个实体序列化了),HLC时间T2。合并时T2晚于T1,所以"进行中"获胜。
再往深处看,发现桌面端那次"自动变更"并不是用户主动操作的,而是某个定时脚本基于旧数据重新生成了一整版实体对象,把用户刚才的修改覆盖了。
根因:两个问题叠加。一是回写逻辑粒度太粗,脚本序列化整个实体,把用户修改的字段也带上了;二是LWW规则没有区分"人类操作"和"系统自动行为"。
修复:先在代码层面把脚本回写改成"只更新指定字段";再把变更条目的结构里加了一个来源字段,取值分为user和system。合并规则改成:user来源的变更总是优先于system来源的变更,除非system变更携带了显式的优先级标记(比如管理员命令)。从此以后,再没出现过用户操作被脚本覆盖的投诉。
5.3 坑三:大文件同步拖垮整网带宽
现象:某次现场网络恢复后,整个办公网络突然变卡,在线视频会议开始掉线,路由器CPU飙升。
排查链路:先看网络监控,发现一台移动端设备正在持续占用上传带宽,超过10Mbps。再查这台设备的同步日志,发现它正在同步一个特别大的附件实体。这个附件不是独立文件,而是被塞进了"配置项"实体的一个文本字段里,Base64编码之后超过20MB,同步时要分两百多批拉取。由于没有单独做限流,它一直在用满带宽抢数据。
根因:业务方把一个本不该进入结构化数据库的大文件塞进了配置字段,导致同步引擎被迫在业务通道里传输大块数据。
修复:三步走。第一步,把已有的大附件字段从业务实体中拆出来,改为独立文件存储,同步时采用单独的传输通道和限流策略。第二步,为所有变更条目增加了"预估字节数"元信息,同步调度器按总额度限制并发传输量。第三步,针对单条超过1MB的变更直接返回告警,不允许再进入数据库。这类问题最难的往往不是技术,而是组织边界——同步引擎的权限范围必须包含"拒绝不合理的写入"。
6. 复盘之后我才明白的事:哪些决策值得复用,哪些应该早改
6.1 这个设计在真实故障演习中表现如何
项目上线前,我们做了一次全员参与的故障演习。模拟条件很极端:三台设备全部离线,离线期间每台设备都修改了同一个任务的不同字段和同一个任务的同一字段,网络恢复时同时上线;中场再随机杀掉一台设备的同步进程,制造"拉到一半断了"的场景。演习结果:三台设备最终都在5分钟内收敛到一致状态,所有并发修改都能在冲突历史表中找到,无一条数据丢失。这个结果验证了"变更日志+版本向量+字段级合并"这套组合在中小规模场景下的稳定度。
轻量级替代方案我们在后续也讨论了,结论是如果业务方不想自己维护同步引擎,完全可以考虑基于现有同步能力封装成一套离线优先的Web应用。但凡是涉及离线写入、多端冲突、断点续传的,自研的成本不会比这更低。
6.2 如果重来一次,哪些决策会改
排坑过程中,我们积攒了一些"如果重来会怎么做"的清单:
- 变更条目的传输格式应该从第一天就用二进制编码,而不是先用JSON后迁移。迁移本身不难,但中间要处理新旧版本兼容,浪费了不少QA时间
- "用户显式操作优先"这个规则应该在最开始就设计进协议,而不是等踩了坑再补。原因很朴素:所有会覆盖用户意图的系统最终都会被用户骂
- GC清理应该有个"预演"开关。上线初期墓碑清理跑得太早,导致过一台离线设备上线后数据缺失,后来给清理任务加了"先统计未确认设备数量再决定是否清理"的检查逻辑
技术栈之外,项目管理上还有一个教训:同步引擎这种底层组件,必须给足"故障演习时间",不能只看Demo跑通就认为完成。同步类的Bug有一个共性——只在特定时序下出现,常规测试根本碰不到,留足时间做故障注入非常有必要。
6.3 这套方案的适用红线在哪里
不是说"离线优先"是万灵药。它适合数据量不大、实体关系清晰、多端并发写入频率可接受的场景;它不适合强一致性要求极高的场景——比如金融转账、库存扣减、多人实时编辑同一份白板。如果业务核心是"任何时刻全局唯一正确",离线优先架构会让你付出巨大的补偿成本。
适用范围打个比方:它适合"各自的笔记本,写完之后拼成一本书";它不适合"所有人同时在同一张纸上写字"。做技术选型时先想清楚自己的业务到底是哪一种,比选择任何具体的算法都重要。
最后分享一个经验。项目代号叫"Apollo-11",我们并没有真的登月,但这个代号在团队里起到的最大作用,是让所有人在做每一个"看起来差不多就行了"的决策时,都能停下来问一句:如果真的把可靠性标准抬高一个量级,现在这个方案还站得住吗?如果你也想做类似的多端同步或者离线优先系统,我建议你也给自己的项目立一个"超出能力范围一点点的代号"。名字本身无关紧要,关键是它能不能时刻提醒你:这个项目不是为了在正常网络下跑通,而是为了在一切都不正常的时候,仍然不丢数据。