有个门店场景的终端装在信号不稳定的位置,运营商线路割接那天上午直接断网三个多小时,但终端不能停,店员照常录入业务记录。等网络恢复,问题来了:补传的数据有的重复进了两遍,有的干脆丢了,还有几条在断网期间被另一台终端改过、合并时互相覆盖。这其实是典型的离线优先(offline-first)数据同步问题,跟具体业务无关,核心是本地队列、幂等补传和冲突合并。这篇把这套机制的落地过程记下来。
一、思路:断网时写本地,恢复后按队列补传
不能等网络可用才让用户操作,而是把终端本地当成可靠的第一写入点:每条记录产生时先落本地存储并标记状态,网络可用时再按顺序补传到服务端。
每条本地记录维护一个操作日志(oplog)结构:
{
"clientOpId": "c_8a3f_1694_0001", // 终端生成的全局唯一操作ID,幂等键
"seq": 2049, // 本终端单调递增的操作序号
"type": "upsert_record",
"payload": { ... }, // 具体数据
"createdAt": 1726450000000,
"status": "pending" // pending / synced / conflict
}
两个标识各有用途:clientOpId 用于服务端幂等去重,同一操作补传多少次都只生效一次;seq 用于本终端操作排序和断点续传,告诉服务端"我已经同步到第几号",漏传的能被发现。这套离线同步机制是在唯顿收银系统的终端上落地的,我主要负责本地 oplog 设计、补传幂等和冲突合并这几块,做完之后断网半天再恢复,数据也能对齐。
二、补传:幂等键 + 顺序 + 断点续传
补传不是把本地数据一股脑重发,而是有明确的去重和续传规则。
服务端以 clientOpId 做幂等:建唯一约束,已存在的 clientOpId 直接返回成功但不重复落库。这样终端因为超时、重试、重启而重复发送同一条操作,服务端都能识别。
// 服务端处理一条补传(伪代码)
async function applyOp(op) {
const existed = await db.opLog.findOne({ clientOpId: op.clientOpId });
if (existed) return { duplicated: true, status: existed.status }; // 幂等返回
// 校验 seq 连续性,发现缺口说明有操作漏传
const lastSeq = await db.opLog.maxSeq(op.deviceId);
if (op.seq > lastSeq + 1) {
return { needGap: true, fromSeq: lastSeq + 1 }; // 要求终端补传缺口段
}
await db.opLog.insert({ ...op });
await applyToBusiness(op.payload, op.version);
return { duplicated: false, status: "synced" };
}
终端侧补传按 seq 升序、小批量(比如每批 50 条)顺序提交,一批全部成功才把本地状态改成 synced;中途失败从最后一个未确认的 seq 续传,而不是从头再来。本地存储要选落盘可靠的方案(终端上用 SQLite 或带持久化的本地库),避免应用被杀掉后 pending 记录丢失。
三、冲突合并:版本号 + 最后写入获胜 + 冲突标记
多台终端或终端与后台同时改同一条记录时,需要一套确定性的冲突规则。我用的是"版本号 + 最后写入获胜(LWW)+ 冲突兜底":
服务端更新逻辑:
1. 每条记录带 version(整数)和 updatedAt;
2. 终端更新时携带它读到的 baseVersion;
3. 若 baseVersion === 服务端当前 version,直接更新,version + 1;
4. 若 baseVersion < 服务端 version,说明期间被别人改过:
- 字段级可合并的(不同字段),自动合并;
- 同一字段都被改,按 updatedAt 较新者胜出(LWW);
- 无法安全合并的,置为 conflict 状态并保留两份,交后台规则或人工确认。
// 字段级合并示意
function merge(serverDoc, clientDoc, baseVersion) {
if (clientDoc.baseVersion === serverDoc.version) return { ...clientDoc, version: serverDoc.version + 1 };
const merged = { ...serverDoc };
for (const key of Object.keys(clientDoc.changes)) {
const sTime = serverDoc.fieldUpdatedAt[key] || 0;
const cTime = clientDoc.fieldUpdatedAt[key] || 0;
if (cTime >= sTime) merged[key] = clientDoc.changes[key]; // 较新者胜
}
merged.version = serverDoc.version + 1;
return merged;
}
纯 LWW 会静默丢数据,所以对"同一关键字段被双方修改"这种情况,我没有让它无声覆盖,而是标记 conflict 并保留双方内容,宁可让人确认一次,也不丢一条记录。
四、我踩过的五个坑
- 补传没有幂等键:超时重试导致同一条记录落两遍,加 clientOpId 唯一约束后彻底解决;
- 只靠 updatedAt 去重:终端时钟不准会错乱,幂等交给服务端生成约束、时钟只用于 LWW 比较;
- 一次性全量重发:断网半天积压上千条,整包重发又慢又容易超时,改成按 seq 小批量顺序补传;
- 冲突一律后写覆盖:同字段被改时静默丢了较早的修改,加版本号判断和 conflict 标记后才安全;
- pending 状态只放内存:终端重启后待补传记录丢失,oplog 必须落盘并在启动时扫描恢复。
五、几个容易忽略的细节
- 同步状态机要显式区分 pending、synced、conflict,UI 上让用户看得到"还有多少条没同步";
- 补传要处理终端时钟回拨和跨天积压,排序一律以服务端确认结果为准;
- 删除操作也要进 oplog(软删除标记),否则断网期间删的记录恢复后又冒出来;
- 建议在服务端按 deviceId 记录已同步水位,方便排查"终端说发了、服务端没收到"的纠纷。
复盘要点
- 离线优先的关键是"本地可靠落盘 + 全局幂等键 + 单调序号 + 小批量顺序补传",让重复发送无害、漏传可发现;
- 冲突不要无脑后写覆盖,版本号做并发检测、字段级合并加 LWW、不安全就标记保留;
- oplog 覆盖增删改全部操作并持久化,是断网恢复后数据不重不丢的底座。
以上是个人实践记录,各平台具体功能以官方实时信息为准。
你们做弱网或断网场景的终端数据同步时,冲突是怎么处理的?是直接最后写入获胜,还是做了字段级合并?