资讯动态

离线优先同步引擎 Apollo-11:从断网事故到变更日志与字段级合并

发布时间:2026/10/15 1:20:28 来源:尧图企业网站定制
如果你也经历过那种场景——团队在偏远的现场干活网络说断就断大家手里的在线表格、在线任务系统全部变成只读状态更崩溃的是断网之前最后两个小时录入的数据因为本地只缓存没提交而彻底消失——你会理解为什么我们后来会把一个同步引擎项目命名为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的章用来区分这条数据是谁写的。混合逻辑时钟我们不能直接用物理时间戳排序。系统时钟可能回拨两台设备的时钟可能不一致。用的是HLCHybrid Logical Clock的思路每个变更条目携带一个逻辑时间物理时钟如果前进就取物理时间如果同一毫秒内发生了多次变更就用递增量来区分因果顺序。变更条目就是前面展示的JSON结构一条不可变的操作记录。版本向量每台设备维护一张表记录我已经收到了哪些站点ID的哪些变更。比如{device-a: 103, device-b: 87}表示这台设备已经合并了A站点的第103条变更、B站点的第87条变更。同步时只需交换这张表就能算出自己缺哪些数据从全量比对降级为精确差量补录。这三个概念合起来解决了一个核心问题我怎么知道对方缺什么我怎么知道自己缺什么答案是大家各自报版本号一对账就清楚了。3.2 字段级冲突合并默认规则与三个例外当两台设备修改了同一个字段协议会进入冲突合并分支。我们的默认规则是LWWLast 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我们并没有真的登月但这个代号在团队里起到的最大作用是让所有人在做每一个看起来差不多就行了的决策时都能停下来问一句如果真的把可靠性标准抬高一个量级现在这个方案还站得住吗如果你也想做类似的多端同步或者离线优先系统我建议你也给自己的项目立一个超出能力范围一点点的代号。名字本身无关紧要关键是它能不能时刻提醒你这个项目不是为了在正常网络下跑通而是为了在一切都不正常的时候仍然不丢数据。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑