Appearance
第十一章:智能同步优化
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 次才停止重连。