iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 22

離線模式(3)——建立可恢復的同步機制

  • 分享至 

  • xImage
  •  

前兩天處理了離線同步,但還少考慮一個問題:如果同步失敗,要怎麼重新同步?

網路可能突然中斷、後端可能暫時無法回應,App 也可能在同步途中被系統關閉。

如果同步工作只存在記憶體中,一旦中斷,尚未完成的工作也會跟著消失。

因此我會加入兩個核心元件:

  • sync_queue:保存在 SQLite 中的待同步工作
  • SyncWorker:負責執行 Queue 中的工作,並統一處理失敗、重試與恢復

不讓畫面負責同步

最直接的方式,是在需要同步時直接呼叫 API:

try {
  await uploadWalkPoints();
} catch {
  // 之後再試
}

但「之後再試」並沒有真正保存任何資訊。

如果 App 被關閉,這段程式也不會自己再次執行;如果不同畫面各自處理重試,也可能同時送出相同請求。

因此同步不應該依附在某個畫面上。

當本地資料發生需要同步的變化時,只需要在 SQLite 建立對應的工作:

await syncQueueRepository.enqueue({
  jobType: 'upload_walk_points',
  entityId: walkId,
});

至於什麼時候呼叫 API、失敗後要不要重試,以及下一次何時執行,全部交給 SyncWorker

整體流程變成:

畫面 / App Lifecycle
        ↓
     SQLite
        ↓
   sync_queue
        ↓
   SyncWorker
        ↓
      後端

畫面只需要負責操作本地資料,不需要知道同步工作的細節。

Queue 保存的是工作

GPS 本身仍然保存在 walk_pointssync_queue 不會複製一份 GPS,而是記錄「接下來要執行什麼同步工作」。

例如建立一場離線散步時,除了將散步資料寫入 walks,也會建立對應的 create_walk 工作。之後暫停、恢復、GPS 累積或結束散步時,再根據需要建立對應的同步工作。

因此一場散步可能會有:

create_walk
→ upload_walk_points
→ pause_walk
→ resume_walk
→ upload_walk_points
→ complete_walk

sync_queue 主要保存以下資訊:

欄位 用途
job_type 要執行的同步工作
entity_id 工作所屬的散步
job_sequence 同一場散步中的工作順序
status 目前的同步狀態
retry_count 已經重試的次數
next_retry_at 下次可以重試的時間
last_error 最近一次失敗原因

工作會經過幾種狀態:

  • pending:等待執行
  • processing:正在執行
  • retry_wait:暫時失敗,等待下一次重試
  • completed:已完成
  • blocked:無法靠一般重試解決,需要額外處理

這些資訊都保存在 SQLite,因此即使 App 在同步過程中被關閉,下次啟動時仍然可以知道哪些工作尚未完成,並從中斷的位置繼續處理。

GPS 不需要一筆建立一個工作

GPS 每幾秒就會新增一筆,如果每個點都建立 upload_walk_points,Queue 很快就會累積大量重複工作。

因此新增 GPS 時,只需要確認目前是否已經存在待處理的上傳工作:

await database.transaction(async tx => {
  await walkPointRepository.insert(tx, point);

  const existingJob =
    await syncQueueRepository.findPendingPointUpload(
      tx,
      walkId,
    );

  if (!existingJob) {
    await syncQueueRepository.enqueue(tx, {
      jobType: 'upload_walk_points',
      entityId: walkId,
    });
  }
});

這樣同一場散步在同一時間只需要一筆「有 GPS 待同步」的工作。

真正執行時,再從 walk_points 取出尚未被後端確認的資料,批次上傳。

如果同步完成後又新增 GPS,再建立下一筆工作即可。

因此 Queue 管理的是同步工作,不是每一筆 GPS。

工作也有先後順序

不同同步工作之間存在依賴:

create_walk
      ↓
upload_walk_points
      ↓
complete_walk

例如 complete_walk 不能在必要的 GPS 與 Pause 資料尚未同步前執行。

因此每場散步的同步工作會有自己的 job_sequence

0 create_walk
1 upload_walk_points
2 pause_walk
3 resume_walk
4 upload_walk_points
5 complete_walk

這裡的 job_sequence同步工作的順序,和昨天提到的 GPS sequence 不同。

實際產生的工作數量會依散步過程而變化,這裡只是示意。

SyncWorker 取出工作時,會先確認前面的工作是否都已完成:

AND NOT EXISTS (
  SELECT 1
  FROM sync_queue earlier
  WHERE earlier.entity_id = q.entity_id
    AND earlier.job_sequence < q.job_sequence
    AND earlier.status != 'completed'
)

這樣就能避免 complete_walk 提前執行。

SyncWorker 負責執行與重試

SyncWorker 會不斷從 Queue 取出目前可以執行的工作:

取出可執行工作
      ↓
呼叫對應 API
      ↓
成功 → completed
      ↓
繼續下一筆

失敗
 ↓
判斷是否可以重試
 ↓
可以 → retry_wait
 ↓
不可以 → blocked

例如:

while (true) {
  const job = await claimNextReadyJob();

  if (!job) return;

  try {
    await process(job);
    await markJobCompleted(job.id);
  } catch (error) {
    if (!isRetryableSyncError(error)) {
      await markJobBlocked(job.id, getErrorMessage(error));
      return;
    }

    await markJobRetry(job);
    return;
  }
}

不同的 job_type 再交給對應的處理邏輯:

switch (job.type) {
  case 'create_walk':
    // 建立散步
    break;

  case 'upload_walk_points':
    // 批次上傳 GPS
    break;

  case 'pause_walk':
    // 同步暫停
    break;

  case 'resume_walk':
    // 同步恢復
    break;

  case 'complete_walk':
    // 完成散步並取得正式統計
    break;
}

失敗後不要一直重送

同步失敗後,需要先判斷是不是暫時性的錯誤。

像 Network Error、Timeout、429、500、502、503、504,都有機會在稍後恢復。

這些錯誤會進入 retry_wait,並使用 Exponential Backoff

2 秒
→ 4 秒
→ 8 秒
→ 16 秒
→ …

到達 next_retry_at 後,再次喚醒 SyncWorker 執行工作;如果 App 在等待期間被關閉,則由下次 App 啟動、回到前景或網路恢復時重新檢查 Queue。

例如:

const BASE_RETRY_MS = 2_000;
const MAX_RETRY_MS = 15 * 60_000;

function calculateBackoffMs(retryCount: number) {
  const exponential = Math.min(
    MAX_RETRY_MS,
    BASE_RETRY_MS * 2 ** Math.max(0, retryCount - 1),
  );

  return Math.round(exponential * 1.2);
}

重試次數與 next_retry_at 都會保存到 SQLite。

如果是 400、403、404、422 等通常無法靠重送解決的錯誤,則標記為 blocked,避免 App 無限重試。

401 則可以先處理登入狀態,重新取得授權後再嘗試同步。

App 關閉後怎麼恢復?

這也是整個 Queue 最重要的地方。

假設:

App
 ↓
POST /walk-points/batch
 ↓
後端已經成功保存
 ↓
Response 尚未回來
 ↓
App 被系統關閉

這時 Queue 可能還停留在 processing

因此 App 下一次啟動時,會先執行:

await repairSyncQueue();
await syncWorker.run();

repairSyncQueue() 會檢查本地資料與 Queue 狀態:

processing 工作
→ 恢復成 pending

仍有未同步 GPS
→ 補上 upload_walk_points

尚未建立的散步
→ 補上 create_walk

已結束但尚未完成同步
→ 補上 complete_walk

這裡 Queue 並不是唯一依據。

sync_queue 記錄「目前有哪些同步工作」,而 walkswalk_points 等資料則代表「實際還有哪些資料需要同步」。

因此即使 App 在建立 Queue 工作之前就被關閉,也能從 SQLite 中重新推導出缺少的同步工作。

而昨天已經建立的 Idempotency 則負責處理另一個問題:即使 Crash Recovery 讓同一個 Request 再送一次,也不會在後端產生重複資料。

Queue 負責記住「還要做什麼」,Idempotency 負責確保「做兩次也沒關係」。

避免同時啟動多個 SyncWorker

同步可能被很多事件觸發:

  • App 啟動
  • 回到前景
  • 網路恢復
  • GPS 累積到一定數量
  • 結束散步
  • 使用者手動同步

因此 SyncWorker 本身也需要避免重複執行:

private running: Promise<void> | null = null;

run(): Promise<void> {
  if (this.running) {
    return this.running;
  }

  this.running = this.drain().finally(() => {
    this.running = null;
  });

  return this.running;
}

如果同步已經執行中,後續觸發只會共用同一個 Promise。

這只能避免同一個 App Process 中的重複執行;App 閃退或重新啟動時,仍然要依靠 SQLite Queue 與 Backend Idempotency 恢復。

後端確認完成後才清除資料

使用者按下結束散步時,不會立即刪除 SQLite 中的 GPS 與其他資料。

流程會是:

必要資料同步完成
        ↓
complete_walk
        ↓
後端重新計算正式統計
        ↓
保存 local_walk_history
        ↓
清除散步暫存資料

local_walk_history 保存後端確認過的正式結果,因此即使之後離線,也能查看歷史摘要。

最後的本地整理會放在同一個 SQLite Transaction:保存正式歷史 + 清除 GPS / Pause / Navigation + 清除已完成的同步工作。

如果 Transaction 失敗,所有本地操作都會一起 rollback。

因此不會發生「歷史紀錄還沒保存,GPS 卻已經被刪掉」的情況。

小結

今天處理的是離線模式最後一個問題:資料保存下來之後,如果同步失敗或 App 中途被關閉,要怎麼繼續?

因此加入 sync_queue 保存待同步工作,再由 SyncWorker 統一負責執行、重試與恢復。

GPS 不會每筆建立同步工作,而是由 upload_walk_points 批次處理尚未同步的資料;不同同步工作則透過 job_sequence 控制依賴順序。

遇到暫時性錯誤時使用 Exponential Backoff,無法自動恢復的錯誤則標記為 blocked

即使 App 在同步途中被關閉,下次啟動時也能透過 repairSyncQueue() 根據 SQLite 中的資料重新補齊同步工作,再配合 Day 21 的 Idempotency 安全重送。

直到後端完成正式統計,而且正式結果成功保存到本地後,才會清除這場散步的暫存資料。

至此,離線模式從資料保存、可靠同步,到同步失敗後的恢復,整個流程完整收尾啦!


上一篇
離線模式(2)——安全同步本地資料
系列文
30 天開發一款真正能每天使用的散步 App22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言