昨天處理了 SQLite 在離線模式中的角色:散步進行期間,散步紀錄、GPS、暫停紀錄與導航狀態都先保存在本地,完成同步後才清除暫存資料。
但資料存進 SQLite,只能保證 App 關閉後還能找回來,真正困難的是恢復網路後,如何把本地資料安全送到後端。
尤其 GPS 不是只有幾筆資料,一場散步可能累積數百甚至上千個點。只要同步過程中漏掉一批,最後的距離、速度與推薦路線完成率都可能不正確。
因此今天要處理的問題是:如何確認資料真的已經同步到後端,以及什麼時候才能刪除 SQLite 中的暫存資料。
每一筆 GPS 都需要對應到一場散步,因此在上傳 GPS 前,後端必須先有相同的散步紀錄。
為了讓離線建立的散步也能順利同步,我不會等後端建立資料後才取得 ID,而是在 App 建立散步時,就先產生 UUID:
const walk: LocalWalk = {
id: crypto.randomUUID(),
status: 'active',
startedAt: new Date().toISOString(),
};
await walkRepository.insert(walk);
之後同步到後端時,直接沿用同一個 walk.id。
這樣本地的 walk_points.walkId 不需要在同步後重新替換,後端也能直接使用相同 ID 建立關聯。
但這裡還有一個問題:假設 App 已經成功把散步資料送到後端,後端也完成寫入,但 Response 回到 App 之前網路突然中斷。App 並不知道後端到底有沒有成功,因此下一次同步時,很可能會再次送出相同的請求。
所以後端不能單純把第二次請求視為錯誤,而是要以 id 判斷這筆散步是否已經存在。
例如:
const existingWalk = await walkRepository.findById(input.id);
if (existingWalk) {
return existingWalk;
}
return walkRepository.create(input);
相同 ID 重複送出時,最後仍然只會得到同一筆散步資料。
也就是說同步請求可以重複,但結果不能重複。
如果每收到一個 GPS 就立即呼叫 API,一場散步下來可能會產生大量 HTTP Request。
GPS 仍然先完整寫入 SQLite,需要同步時,再從 SQLite 取出一批尚未同步的資料,一次送到後端:
const points = await walkPointRepository.findPending({
walkId,
limit: 100,
});
每個 GPS 都有自己的 sequence:
type WalkPoint = {
walkId: string;
sequence: number;
latitude: number;
longitude: number;
recordedAt: string;
};
sequence 不只是排序用途,也能用來穩定識別同一場散步中的 GPS。
同一個 walkId 下,每個 sequence 都必須唯一,因此後端資料庫需要對這兩個欄位建立唯一限制:
CREATE UNIQUE INDEX idx_walk_points_walk_sequence
ON walk_points (walk_id, sequence);
這樣即使同一批 GPS 因為網路問題被送出兩次,也不會產生重複資料。
例如:
await db
.insert(walkPoints)
.values(input.points)
.onConflictDoNothing({
target: [
walkPoints.walkId,
walkPoints.sequence,
],
});
這裡的重點不是阻止 App 重送,而是讓重送不會破壞後端資料。
最危險的做法是發出 Request 後就直接刪除本地 GPS:
// 錯誤
await api.post('/walk-points/batch', points);
await walkPointRepository.delete(points);
因為「Request 已經送出」不代表 App 已經能確認後端成功保存。
例如:
App
↓
POST GPS Batch
↓
後端寫入成功
↓
後端準備回傳 Response
↓
網路中斷
↓
App 沒有收到 Response
這時候後端可能已經成功保存資料,但 App 並不知道。
如果 App 因為沒有收到 Response,就直接把 SQLite 中的資料刪掉,之後就失去了重新同步的機會。
反過來,如果 App 為了安全而不刪除,下一次同步又會再次送出同一批資料。
所以真正需要解決的不是「如何避免重送」,而是:即使資料被重送,也不能造成錯誤,同時 App 還要知道哪些資料已經可以安全刪除。
前面的 walkId + sequence 唯一限制解決了第一個問題,接下來還需要解決第二個問題。
批次上傳完成後,後端需要明確回傳目前可以確認的 GPS 範圍:
{
"walkId": "walk-id",
"acknowledgedThroughSequence": 199
}
這裡的 acknowledgedThroughSequence 不是「後端目前收到的最大 Sequence」。
它代表的是從第一筆開始,到這個 Sequence 為止,都已經完整保存,中間沒有缺。
例如後端目前有 0~99 和 200~299,即使 299 已經存在,也只能確認到 acknowledgedThroughSequence = 99,因為 100~199 還沒有保存。
只有中間的資料補齊後,才能繼續往後確認。App 也只會刪除後端明確確認的範圍。
因此即使同步途中失敗,尚未被確認的 GPS 仍然會留在 SQLite。
下一次同步時可以再次送出。
如果只檢查後端是不是已經存在 sequence = 299,很容易誤以為這場散步的 GPS 已經全部同步。
但實際上中間還可能存在缺口,因此同步進度不能只看「最大的 Sequence」。
必須確認從第一筆開始,資料是否連續存在。
這也是 acknowledgedThroughSequence 存在的原因。
只要中間還有缺口,就不能往後確認。
這其實很重要的一點。
使用者按下「結束散步」時,即使目前沒有網路,也應該能正常結束操作。
但這不代表後端現在就能把散步標記為 completed,因為此時可能還有 GPS、Pause 等資料尚未同步。
所以我把使用者結束散步和後端正式完成散步拆成兩個不同階段:
使用者操作上的「已結束」
↓
等待同步
↓
後端正式完成
按下結束後,App 可以先將本地 walk.status 設為 pending_sync,並保存:
{
endedAt,
status: 'pending_sync',
}
這樣使用者不需要等待網路,可以直接離開散步頁面。
等網路恢復後,再由同步流程處理剩下的工作。
完成同步後,本地需要同時處理兩件事情:
因此這個階段可以放在同一個 SQLite Transaction:
await database.transaction(async tx => {
await localWalkHistoryRepository.upsert(
tx,
completedWalk,
);
await walkPointRepository.deleteByWalkId(
tx,
walkId,
);
await walkPauseRepository.deleteByWalkId(
tx,
walkId,
);
await navigationStateRepository.deleteByWalkId(
tx,
walkId,
);
await walkRepository.delete(tx, walkId);
});
這裡的 Transaction 只負責本地資料的一致性。
後端 API 已經在前面的流程完成,SQLite Transaction 則負責確保:正式摘要保存 + 暫存資料清除
這兩個本地操作要嘛全部成功,要嘛全部rollback。
如果 App 剛好在 Transaction 執行期間發生錯誤,就不會出現歷史摘要沒有保存但 GPS 已經被刪掉,或歷史摘要已保存但只刪掉一部分暫存資料的狀況。
做到這裡,離線同步的基本流程就建立起來了。
SQLite 負責保存散步進行期間的本地資料;網路恢復後,再透過同步流程將資料送到後端。
其中:
walkId + sequence 保證資料唯一acknowledgedThroughSequence 確認哪些 GPS 已經完整保存pending_sync 讓使用者可以先結束散步,再等待後續同步不過目前這樣設計還有一個問題:如果同步到一半 API 失敗、App 被關閉,或者網路在同步過程中反覆斷線,系統還需要知道「哪些同步工作還沒完成,以及什麼時候應該再次執行。」
因此明天會開始處理 SyncWorker 與 Retry Queue,讓同步工作可以在失敗後自動恢復,而不是每次都從頭開始。