Skip to content

第十一章:智能同步优化

11.1 背景与问题

SSE 的"即发即忘"局限

本应用的实时同步依赖 SSE(Server-Sent Events)推送。服务端在某个用户的待办数据发生变更时,向该用户所有在线设备广播事件:

设备 A 修改待办
  → 服务端广播 todos_updated 事件
  → 设备 B 收到事件 → 执行增量同步 ✅
  → 设备 C(离线)← 事件永久丢失 ✗

SSE 是单向推送,没有消息回执机制,服务端不存储历史事件,也不会重发。设备断线期间错过的所有事件永久丢失,没有任何补偿手段。

原有的全量同步兜底

为了解决断线丢事件的问题,原有方案是:设备重连时无条件执行 syncAll(),拉取服务端全部待办,强制对齐:

设备 C 重连
  → syncAll()
  → GET /todos/(全量)
  → 覆盖本地数据 → 数据一致 ✅

这个方案保证了最终一致性,但存在明显浪费:大多数重连场景(如网络短暂抖动、App 从后台切回前台),断线期间其他设备未必有操作,全量拉取完全没有必要。

写操作触发全量同步(已修复)

除断线重连外,原有代码还存在另一个问题:每次写操作(新建、编辑、删除、完成/撤销)后都调用 syncAll(),多了一次完全没有必要的全量拉取:

用户勾选完成 → 写入本地 SQLite → PATCH 上传 → syncAll() → GET /todos/(多余)

该问题通过引入 pushQueue() 方法解决:写操作后只调用 pushQueue()(只推,不拉),不再触发全量同步。


11.2 核心设计:服务端时间戳

设计思路

解决"要不要全量同步"的问题,本质是要回答一个问题:设备离线期间,服务端的数据有没有发生变化?

引入 last_todo_modified_at 字段解决这个问题:服务端在 users 表中维护每个用户待办数据的最后修改时间戳,设备重连时比对本地记录的上次同步时间与服务端时间戳,决定同步策略。

关键原则

时间戳由服务端生成,客户端只存储和上传,绝不自行生成。

这一原则规避了多设备时钟不同步的风险:

设备 A 系统时间比服务端快 5 分钟
  → 客户端生成的时间戳偏大
  → 设备 B 对比时判断"已是最新",实际上错过了变更 ✗

服务端时间是唯一可信基准,所有时间戳的生成和维护都在服务端完成。


11.3 服务端实现

数据库字段

users 表新增字段(兼容迁移,不影响现有数据):

sql
ALTER TABLE users
  ADD COLUMN IF NOT EXISTS last_todo_modified_at BIGINT NOT NULL DEFAULT 0;

初始值为 0,表示"尚未记录任何修改"。字段的更新完全由业务逻辑驱动,不需要触发器或定时任务。

写操作后更新时间戳

每次 todo 增删改成功后,立即更新 last_todo_modified_at

dart
// todo_handler.dart — POST/PATCH/DELETE 成功后
final lastModified = await AppDatabase.updateLastTodoModified(userId);

updateLastTodoModified 实现:

dart
static Future<int> updateLastTodoModified(String userId) async {
  final now = DateTime.now().millisecondsSinceEpoch;
  await conn.execute(
    'UPDATE users SET last_todo_modified_at = @now WHERE id = @userId',
    parameters: {'now': now, 'userId': userId},
  );
  return now;
}

响应和广播携带时间戳

时间戳通过两个渠道下发给客户端:

GET /todos/ 响应:拉取数据时同步返回当前时间戳:

json
{
  "todos": [...],
  "last_todo_modified_at": 1780061643815
}

SSE 广播事件:写操作触发的广播事件携带最新时间戳:

json
{ "type": "todos_updated", "deviceId": "...", "lastModifiedTime": 1780061643815 }
{ "type": "todo_deleted",  "todoId": "...",   "lastModifiedTime": 1780061643815 }

新增查询接口

重连时客户端需要单独查询服务端时间戳(不需要拉取全量数据):

GET /auth/last-modified  (需要 JWT 认证)

响应:{ "last_todo_modified_at": 1780061643815 }

11.4 客户端实现

lastSyncTime 的生命周期

客户端在本地(按 userId 隔离)存储 lastSyncTime,其含义是上次成功同步时,服务端的 last_todo_modified_at,不是客户端的本地时间。

时机更新来源
全量/增量拉取完成服务端响应里的 last_todo_modified_at
收到 SSE 事件事件里的 lastModifiedTime

这样做的好处:设备在线时收到每一个 SSE 事件后,lastSyncTime 就会跟着服务端时间戳同步更新。重连时对比结果为"一致",直接跳过同步。

smartSync() 三路决策

重连和切换前台时调用 smartSync(),替代原来的无条件 syncAll()

第一步:pushQueue()
  → 先推送离线期间积压的本地写操作到服务端
  → 确保本地操作不丢失

第二步:GET /auth/last-modified
  → 获取服务端当前 last_todo_modified_at

第三步:三路决策
  ┌─ clientLastSync == 0
  │   → 全量同步(首次登录或清除数据,建立基线)

  ├─ serverLastModified == 0
  │   → 跳过(服务端字段尚未初始化,见 11.6)

  ├─ clientLastSync >= serverLastModified
  │   → 跳过(数据已是最新)

  └─ clientLastSync < serverLastModified
      → 增量同步(GET /todos/?since=clientLastSync)

第四步:用服务端返回的 last_todo_modified_at 更新本地 lastSyncTime

增量同步的细节

增量同步通过 GET /todos/?since=T 只拉取 updated_at > T 的数据。删除操作不依赖 pull 同步——删除事件通过 SSE 推送,客户端收到后直接在本地执行删除,不需要拉取。

同步完成后,用服务端返回的时间戳(而非本地 DateTime.now())更新 lastSyncTime,确保时间基准的一致性。


11.5 同步策略全景

场景同步方式原因
写操作后(新建/编辑/删除/完成)pushQueue()(只推)只需上传本次操作,无需拉取
SSE 连接成功/重连smartSync()三路决策,按需同步
App 切回前台smartSync()三路决策,按需同步
App 首次启动syncAll()建立数据基线
下拉刷新syncAll()用户主动触发,保证强一致
密钥轮换前syncAll()确保本地是最新基准
15 分钟定时兜底smartSync()三路决策,SSE 事件悄悄丢失时按需补拉
60 分钟定时全量自愈syncAll()无条件全量拉取,最终一致性的最后保障

11.6 边界场景处理

服务端时间戳为 0(迁移初始状态)

last_todo_modified_at 通过 ALTER TABLE ... DEFAULT 0 添加,现有用户初始值均为 0。此时:

clientLastSync = 1780...(来自旧代码的同步时间)
serverLastModified = 0

判断:clientLastSync >= serverLastModified(1780... >= 0)→ 跳过

跳过是安全的,原因:

  • 部署新代码前,客户端已通过旧版 syncAll() 同步了所有数据,数据一致
  • 下一次任意 todo 写操作后,服务端时间戳更新为真实值,后续 smartSync 恢复正常判断

网络请求 GET /auth/last-modified 失败

服务端时间戳获取失败时,降级为全量同步,确保数据最终一致:

dart
if (!serverResult.isOk || serverResult.data == null) {
  AppLogger.sync('智能同步:获取服务端时间失败,降级全量同步');
  await _pullFromServer();
  return;
}

定时兜底的必要性

smartSync() 的三路决策依赖时间戳的准确性。SSE 可能出现"连接看似正常但事件悄悄丢失"的场景(Watchdog 没触发,但服务端广播的事件实际上没到达客户端),此时 lastSyncTime 不会更新,下次 smartSync() 比对结果会是"有变更",触发增量同步,数据最终对齐。

为此客户端设置了两层定时兜底:

  • 每 15 分钟执行一次 smartSync():走三路决策,绝大多数情况下判断为"已是最新"直接跳过,开销极小;一旦发现服务端时间戳更新则按需增量补拉。
  • 每 60 分钟执行一次 syncAll():最坏情况下(比如 SSE 持续丢事件、时间戳也没能对齐)无条件拉取全量数据,这是最终一致性的最后保障。

Token 刷新与 SSE 重连竞争

App 从后台切回前台时,SSE 立即重连,此时 access token 可能已过期。原有逻辑中 SSE 独立处理 401,连续 3 次失败后停止重连。

修复方案:SSE 收到 401 时,主动调用 ApiService.tryRefreshToken(),使用 Future<bool>? _refreshingFuture 作为并发锁,确保 SSE 和 API 请求的 token 刷新不会并发发出两次请求:

dart
Future<bool> _tryRefreshToken() {
  if (_refreshingFuture != null) return _refreshingFuture!; // 复用进行中的刷新
  _refreshingFuture = _doRefreshToken().whenComplete(() {
    _refreshingFuture = null;
  });
  return _refreshingFuture!;
}

刷新成功后延迟 100ms 重连(给网络栈留出稳定时间),失败时计入认证失败计数,累计 3 次才停止重连。