5.3 离线优先的同步设计与冲突和解


5.3 离线优先的同步设计与冲突和解

本节摘要:移动网络的真相是随时会断——地铁、电梯、地下室。离线优先的设计让断网不阻断记账:写入永远先落本地,同步是后台的补课。本节设计完整的同步路径:未同步标记、补传队列、增量拉取与冲突和解规则,把第 4 章埋的 synced 标志位正式用起来。读完你将能回答"弱网环境这个应用还能用吗"的问题。

在线优先为什么行不通

先把反面立起来。在线优先(每个操作都实时发请求)在移动场景有四连败:断网即不可用——记一笔账要等服务器确认,地铁里什么也干不了;体验绑住网络速度——每次写入都是一次往返延迟;服务器是单点——服务端抖一下,记账这个本地动作跟着抖;电量与流量——高频小请求是移动设备的慢性失血。

离线优先的翻转在于写入路径与同步路径分离:用户的一切操作只碰本地存储(毫秒级完成),网络同步退到后台慢慢做。本地数据库是权威副本,服务端是副本的副本。代价是必须回答三个新问题:哪些还没同步(标记)、什么顺序补传(队列)、两边都改了听谁的(和解)。本节就回答这三问。

图 13 离线优先的写入与同步双路径

图 13 离线优先的写入与同步双路径

标记与队列:哪些没同步、按什么顺序

第 4 章建表时的 synced 字段此刻登场。写入路径在仓库里完成"落盘加标记",同步任务按固定顺序补传:

class DbBillRepository implements BillRepository { // 第 4 章的 save 原样可用:新账单 synced 默认为 0(未同步) Future<List<Bill>> pendingSync() async { final db = await _open(); final rows = await db.query('bills', where: 'synced = 0', orderBy: 'createdAt ASC'); // 按发生顺序补传 return rows.map(Bill.fromMap).toList(); } Future<void> markSynced(String id) async { final db = await _open(); await db.update('bills', {'synced': 1}, where: 'id = ?', whereArgs: [id]); } }

同步任务本身是一个可重入的过程——失败随时可能发生,所以它必须幂等:跑到一半被杀,下次从头跑也不会出错或重复:

class SyncUseCase { SyncUseCase(this._local, this._remote); final DbBillRepository _local; final LedgerApiClient _remote; Future<void> run() async { // 一、补传:按发生顺序逐条上报,成功即置已同步 for (final bill in await _local.pendingSync()) { try { await _remote.upsert(bill.toMap()); await _local.markSynced(bill.id); } on AppNetworkException { rethrow; // 离线:整批暂停,状态保持未同步,下次继续 } on AppServerException { continue; // 单条服务端拒绝:跳过不阻塞队列,留给上报排查 } } // 二、拉增量:服务端给本地没有的与本地之后的变更 final fresh = await _remote.fetchBillsSince(await _local.lastPulledAt()); await _local.mergeRemote(fresh); await _local.saveLastPulledAt(DateTime.now().toUtc()); } }

两处错误分类对应两种产品语义:网络失败(离线)整批暂停——队列不会乱序也不会丢;服务端拒绝(数据问题)单条跳过——一条坏数据不该卡住整个队列。补传按 createdAt 升序,保证服务端看到的操作顺序与用户实际操作顺序一致。

和解:两边都改了听谁的

多设备必然撞车:手机上把账单金额改成 200,平板离线把它改成 250,先后同步到服务端,听谁的?完全的冲突解决是分布式系统的深水区,移动应用的务实做法是选一条简单规则,并让用户能理解它。轻记账用两条规则:

规则一:改过时间新的赢(最后写入胜出)。 每条账单带 updatedAt,合并时比时间。简单、可解释,代价是"新"覆盖"旧"可能吃掉改动——但它吃掉的是低频事件(同一笔账在两台设备上同时被改),对记账场景足够。

规则二:删除赢过修改。 一台设备删了这条账单,另一台改了它——结果是被删除。理由:删除的意图更强烈,且"删了又活过来"是用户最恼火的幽灵缺陷。

Future<void> mergeRemote(List<RemoteBill> fresh) async { final db = await _open(); await db.transaction((txn) async { for (final r in fresh) { final localRow = await txn.query('bills', where: 'id = ?', whereArgs: [r.id]); if (localRow.isEmpty) { await txn.insert('bills', r.toLocalMap()); // 本地没有:直接插入 continue; } final local = localRow.first; final localDeleted = local['deletedAt'] != null; if (localDeleted) continue; // 规则二:删除赢过修改 final localAt = DateTime.parse(local['updatedAt']! as String); if (r.updatedAt.isAfter(localAt)) { await txn.update('bills', r.toLocalMap(), where: 'id = ?', whereArgs: [r.id]); // 规则一:时间新的赢 } } }); }

注意两点工程细节:合并包在事务里——中断的合并不留半套状态;本地的已同步数据不会被远端旧数据覆盖(时间比较天然挡住),只有真正的新变更才落地。更精细的方案(按字段合并、服务端仲裁队列)在冲突成为真实痛点前都不要上——同步方案的复杂度要由冲突的实际频率买单。

同步的体感:把等待还给状态条

离线优先的最后一块拼图是体感设计:同步在后台跑,界面只表达状态不阻塞操作。第 4 章的同步状态机在此收紧为三态循环——空闲、同步中、有未同步项。账单列表里未同步的条目加一枚小标记,用户对"这笔还没传上去"有明确预期;同步完成的瞬间标记消失,闭环完成。用户从始至终没有等待过任何网络请求——离线优先的终极目标不是技术上多先进,而是用户感知不到网络的存在。

本节要点回顾

  • 在线优先在移动场景四连败,离线优先把写入与同步拆成两条路径;
  • synced 标志加按时间排序的补传队列,幂等可重入是同步任务的底线;
  • 错误分类对应产品语义:离线整批暂停,单条服务端拒绝跳过不阻塞;
  • 和解规则务实优先:最后写入胜出加删除赢过修改,复杂方案由真实冲突频率买单;
  • 合并包事务、界面只表达状态不阻塞操作,用户感知不到网络才算成功。

数据通道全部打通,支撑它的第三方包也越引越多。下一节管好依赖这摊事,并看看接入现成后端服务的两条路。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U