iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 27

Day 27 — 跨裝置同步(一):Serverless 後端與衝突合併

  • 分享至 

  • xImage
  •  

今天要解的問題

Day 1 的「不做」清單第一條就是不做後端。今天要打破它。

先說為什麼現在才值得。26 天來我實際遇到的問題:

  • 在電腦上讀了 12 課,通勤時想用手機接著讀 → 進度是空的
  • 換瀏覽器(Safari → Chrome)→ 進度歸零。
  • 清了瀏覽器資料 → 三個月的紀錄消失。

Day 17 的匯出/匯入是止血(使用者可以手動搬),但沒人會每天匯出一次。

而「現在」的另一個意義是:同步的核心邏輯我已經在 Day 17 寫過了(合併策略),基礎建設也齊了(Day 22 的 CI、Day 25 的安全 header)。這是最省力的時機。

設計原則:localStorage 仍然是 source of truth

這是今天最重要的一個決定。

❌ 常見做法:雲端是真相,本地是快取
   → 沒網路 = 不能用;後端掛掉 = 網站掛掉

✅ 我的做法:本地是真相,雲端是備援與傳輸媒介
   → 沒網路完全正常;後端掛掉只是不同步

具體規則:

  1. 所有讀寫都先打 localStorage,UI 立刻更新,不等網路。
  2. 同步是背景行為,失敗不打擾使用者(最多一個小圖示)。
  3. 未登入時網站功能完全不變——雲端同步是加值,不是前提。
  4. Day 16 的離線能力不能因為加了後端而退化

這保護了前 26 天的所有成果。如果做成「雲端優先」,離線可用、file:// 可開這些特性全部作廢。

資料模型:只存必要的

progress: { userId, lessonKey, doneAt }

就這樣。不存:使用者名稱、email、瀏覽紀錄、IP、裝置資訊、測驗答錯了什麼。

理由(Day 28 會展開):沒收集的資料不會洩漏。 而且這網站的功能不需要那些。

XP 與徽章要同步嗎?我決定只同步 progress,XP 由各裝置從 progress 重算。理由:XP 是衍生資料(完成 N 課 = 20N XP),同步衍生資料只會製造不一致。徽章同理。

(streak 是唯一的例外——它依賴「連續天數」,無法從 progress 重建。我選擇不同步它,各裝置各自算。這是有意識的取捨:streak 是激勵機制,不精確可以接受。)

API 設計

三個端點:

POST /api/sync       同步(雙向):送本地變更,回合併後結果
GET  /api/progress   取完整進度(新裝置首次登入用)
DELETE /api/account  刪除帳號與所有資料(Day 28 的合規基本盤)

POST /api/sync 的請求與回應:

// 請求
{
  "since": 1754179200000,              // 上次同步時間(毫秒)
  "changes": { "ch01-1": 1754179300000, "ch09-2": 1754179400000 }
}

// 回應
{
  "serverTime": 1754179500000,
  "merged": { "ch01-1": 1754179300000, "ch05-3": 1754100000000, /* … */ },
  "count": 47
}

值存完成時間戳而不是 true。這解決了兩件事:

  1. Day 17 的熱力圖有真實資料(在雲端同步的裝置上)。
  2. 合併時可以「取較早的完成時間」——第一次完成的那個時刻才是事實。

衝突合併:逐課 union,不是 last-write-wins

這是整個功能的核心。考慮這個情境:

兩台裝置都離線:
  電腦:完成 ch01-1, ch01-2, ch01-3
  手機:完成 ch09-1, ch09-2

各自上線同步。

如果用整份文件的 last-write-wins(常見的簡單做法):後同步的那台會覆蓋前一台,其中一邊的進度直接消失

正解是逐項合併,而且方向明確:

/* 合併規則:完成過就永遠算完成(單調遞增),時間取較早的 */
function mergeProgress(a, b) {
  const out = { ...a };
  for (const [k, t] of Object.entries(b)) {
    if (!(k in out) || t < out[k]) out[k] = t;
  }
  return out;
}

為什麼是 union 而不是雙向同步?

因為「完成一課」在語意上是不可撤銷的事實。使用者不會想「取消完成」——就算想,那個需求的重要性遠低於「絕不弄丟進度」。

這讓合併變成一個 CRDT(grow-only set + min timestamp):無論同步順序如何、無論同步幾次、無論哪一邊先,結果都相同(可交換、可結合、幂等)。不需要 vector clock、不需要衝突解決 UI。

代價是「取消完成」無法跨裝置生效(本地會生效,下次同步又被拉回來)。我接受,並在 UI 上說明。

(這個合併邏輯與 Day 17 的匯入策略是同一套——那時的「XP 取 max、集合取聯集」就是為今天鋪的路。)

實作:Cloudflare Workers + D1

主站在 Cloudflare(Day 21),所以後端也放這裡,connect-src 可以維持同源。

-- schema.sql
CREATE TABLE IF NOT EXISTS users (
  id          TEXT PRIMARY KEY,           -- 隨機 UUID,不含任何個資
  created_at  INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS progress (
  user_id     TEXT NOT NULL,
  lesson_key  TEXT NOT NULL,
  done_at     INTEGER NOT NULL,
  PRIMARY KEY (user_id, lesson_key)
) WITHOUT ROWID;

CREATE INDEX IF NOT EXISTS idx_progress_user_time ON progress(user_id, done_at);

WITHOUT ROWID 對這種「純複合主鍵查詢」的表能省空間並加快查詢。

/* functions/api/sync.js — Cloudflare Pages Functions */
const KEY_RE = /^[a-z0-9]{2,10}\d{0,2}-\d{1,3}$/;      // 課文 key 的格式白名單

export async function onRequestPost({ request, env }) {
  const userId = await requireUser(request, env);        // Day 28 實作
  if (!userId) return json({ error: "unauthorized" }, 401);

  let body;
  try { body = await request.json(); }
  catch { return json({ error: "bad json" }, 400); }

  const changes = body?.changes;
  if (!changes || typeof changes !== "object" || Array.isArray(changes))
    return json({ error: "bad changes" }, 400);

  const entries = Object.entries(changes);
  /* 上限保護:正常使用者一次同步不會超過幾十筆 */
  if (entries.length > 500) return json({ error: "too many changes" }, 413);

  const now = Date.now();
  const valid = [];
  for (const [k, t] of entries) {
    if (typeof k !== "string" || !KEY_RE.test(k)) continue;    // 格式不符直接丟
    const ts = Number(t);
    if (!Number.isFinite(ts) || ts <= 0 || ts > now + 86400000) continue;  // 未來時間丟掉
    valid.push([k, Math.floor(ts)]);
  }

  /* 寫入:ON CONFLICT 取較早的時間(合併規則在 SQL 層實現) */
  if (valid.length) {
    const stmt = env.DB.prepare(
      `INSERT INTO progress (user_id, lesson_key, done_at) VALUES (?, ?, ?)
       ON CONFLICT(user_id, lesson_key) DO UPDATE SET done_at = MIN(done_at, excluded.done_at)`
    );
    await env.DB.batch(valid.map(([k, t]) => stmt.bind(userId, k, t)));
  }

  /* 回傳完整進度(規模小,全量回傳最簡單也最不容易錯) */
  const { results } = await env.DB
    .prepare("SELECT lesson_key, done_at FROM progress WHERE user_id = ?")
    .bind(userId).all();

  const merged = {};
  for (const r of results) merged[r.lesson_key] = r.done_at;

  return json({ serverTime: now, merged, count: results.length });
}

const json = (obj, status = 200) => new Response(JSON.stringify(obj), {
  status,
  headers: {
    "content-type": "application/json; charset=utf-8",
    "cache-control": "no-store",
  },
});

四個防護,每一個都對應一種實際攻擊:

防護 防什麼
KEY_RE 白名單 有人塞 10 萬個垃圾 key 把我的 D1 撐爆
entries.length > 500 單次請求的資源耗盡
未來時間戳過濾 竄改時間讓熱力圖顯示到 2099 年
ON CONFLICT ... MIN() 竄改時間把「完成日」改早(只能改早不能改晚,且無法刪除)

合併規則寫在 SQL 的 ON CONFLICT,而不是「先讀出來、在 JS 合併、再寫回去」。後者有 race condition(兩個請求同時進來會互相覆蓋),前者是原子操作。

cache-control: no-store 別漏——API 回應絕不能被 CDN 或瀏覽器快取。

前端:背景同步,不阻塞任何操作

/* js/models/sync.js */
const Sync = {
  KEY: "learnpath-sync-v1",         // { lastSync, pending: {key: ts} }
  _timer: null,
  _inflight: false,

  state() {
    try { const v = JSON.parse(localStorage.getItem(this.KEY));
          return (v && typeof v === "object") ? v : { lastSync: 0, pending: {} }; }
    catch { return { lastSync: 0, pending: {} }; }
  },
  saveState(s) { try { localStorage.setItem(this.KEY, JSON.stringify(s)); } catch {} },

  /* 每次完成一課就記進 pending,然後 debounce 同步 */
  queue(lessonKey) {
    const s = this.state();
    s.pending[lessonKey] = s.pending[lessonKey] || Date.now();
    this.saveState(s);
    this.schedule();
  },

  schedule(delay = 3000) {
    clearTimeout(this._timer);
    this._timer = setTimeout(() => this.run(), delay);
  },

  async run() {
    if (this._inflight || !Auth.isSignedIn()) return;
    if (!navigator.onLine) { this.setBadge("offline"); return; }

    const s = this.state();
    /* 送 pending,外加完整本地進度(讓伺服器補上它沒有的) */
    const changes = { ...s.pending };
    const local = Progress.load();
    for (const k of Object.keys(local)) if (!(k in changes)) changes[k] = 0;   // 0 = 時間未知
    if (!Object.keys(changes).length && s.lastSync) return;

    this._inflight = true;
    this.setBadge("syncing");
    try {
      const res = await fetch("/api/sync", {
        method: "POST",
        headers: { "content-type": "application/json" },
        credentials: "same-origin",
        body: JSON.stringify({ since: s.lastSync, changes: this.stripZeros(changes) }),
      });
      if (res.status === 401) { Auth.signOut(); this.setBadge("signed-out"); return; }
      if (!res.ok) throw new Error(`sync ${res.status}`);

      const data = await res.json();
      /* 把伺服器的合併結果寫回本地——同樣是 union,本地不會掉東西 */
      const before = Progress.doneCount();
      const localNow = Progress.load();
      for (const k of Object.keys(data.merged)) localNow[k] = true;
      Progress.save(localNow);

      this.saveState({ lastSync: data.serverTime, pending: {} });
      this.setBadge("ok");
      if (Progress.doneCount() !== before) {
        NavView.render();
        this.toast(`已同步:從其他裝置取得 ${Progress.doneCount() - before} 課進度`);
      }
    } catch (e) {
      this.setBadge("error");
      this.schedule(this._backoff());        // 指數退避重試
    } finally {
      this._inflight = false;
    }
  },

  _tries: 0,
  _backoff() {
    this._tries = Math.min(this._tries + 1, 6);
    return Math.min(5 * 60_000, 2000 * 2 ** this._tries);   // 4s, 8s, … 上限 5 分鐘
  },

  stripZeros(c) {
    const out = {};
    for (const [k, t] of Object.entries(c)) if (t > 0) out[k] = t;
    return out;
  },

  init() {
    if (!Auth.isSignedIn()) return;
    window.addEventListener("online", () => this.schedule(500));
    /* 分頁隱藏時把 pending 送出去(可能是最後機會) */
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") this.flushBeacon();
    });
    this.schedule(1500);
  },

  /* 分頁關閉時的最後嘗試:sendBeacon 不會被 unload 中斷 */
  flushBeacon() {
    const s = this.state();
    const changes = this.stripZeros(s.pending);
    if (!Object.keys(changes).length) return;
    try {
      navigator.sendBeacon("/api/sync",
        new Blob([JSON.stringify({ since: s.lastSync, changes })],
                 { type: "application/json" }));
    } catch {}
  },
};

掛在既有事件點上(加法式接入,不改既有邏輯):

/* chapter.js 的 onComplete —— 只多一行 */
Progress.setDone(chId, lessonId, true);
Activity.bump("lessons");
if (window.Gamify) Gamify.onLessonComplete(lessonKey(chId, lessonId));
if (window.Sync) Sync.queue(lessonKey(chId, lessonId));      // ← 新增

注意 Progress.setDone 仍然是第一行。 UI 更新完全不等網路,這是「本地是真相」的具體體現。

三個工程細節:

  • debounce 3 秒:連續完成幾課只發一次請求。
  • 指數退避:後端掛掉時不要每 3 秒打一次(那是自製 DDoS)。
  • sendBeacon 在分頁隱藏時發送fetch 在 unload 時會被中斷,sendBeacon 不會。這是唯一可靠的「離開前送出」手段。

CSP 要放寬(但只放寬到必要程度)

Day 25 設的 connect-src 'self'。API 同源(/api/sync 在同一個 Pages 專案),所以不需要改

這是把後端放同源的實質好處:CSP 不用動、沒有 CORS、cookie 自動帶。如果 API 在別的網域,就得:

connect-src 'self' https://api.learnpath.example.com

並且處理 CORS 與 SameSite cookie —— 三個額外的出錯機會。同源優先。

Workers vs Lambda:實測比較

我兩邊都寫了一版(同樣的 schema 與 API):

Cloudflare Workers + D1 API Gateway + Lambda + DynamoDB
冷啟動 無(isolate 模型,實測 P99 12 ms) Node runtime 約 400–900 ms
本機開發 wrangler pages dev(含 D1 本機模擬) SAM CLI / LocalStack,較重
部署 與前端同一次(Pages Functions) 另一套 IaC
SQL ✅ D1 是 SQLite ❌ DynamoDB 是 KV,ON CONFLICT MIN() 那招做不到
免費額度 10 萬請求/日 100 萬請求/月
成本(我的量) $0 $0
多區域一致性 D1 單一主寫入 + 唯讀複本 DynamoDB 全球表(更強但複雜)

選 Workers + D1。 決定性因素是 SQL 的 ON CONFLICT ... DO UPDATE SET done_at = MIN(...)——它讓合併規則變成一個原子操作。DynamoDB 要達到同樣效果得用 conditional update 加重試迴圈,複雜度高好幾倍。

Lambda 版本留在 repo 的 alt/lambda/ 當對照組(文章寫得出來就有價值),但不部署。

踩到的雷

時間戳是不可信的輸入。 第一版我直接信任前端送來的 done_at。用 console 改一下 localStorage,就能讓熱力圖顯示「2099 年完成了 200 課」。

修法是兩層:伺服器端過濾未來時間(ts > now + 86400000 丟棄),以及 MIN() 語意(只能改早不能改晚)。留 24 小時容差是為了處理裝置時鐘偏差——使用者的系統時間本來就可能不準,不要假設它精確。

「完整進度全量回傳」在規模上是對的選擇。 我一開始想做增量(只回 since 之後的變更),但:256 課的完整進度 JSON 約 8 KB,gzip 後 1.5 KB。增量的複雜度(要處理 since 的時鐘偏差、要處理刪除、要處理漏送)完全不值得。

先量再優化。 如果哪天有 10 萬課,再回來做增量。

visibilitychangebeforeunload 可靠。 iOS Safari 的 beforeunload 經常不觸發(分頁被系統回收時)。visibilitychangehidden 是目前最可靠的「使用者要離開了」信號。

D1 的 batch() 不是 transaction。 它是「一次往返送多個 statement」,效能好,但不保證原子性。對我的 union 合併沒問題(每個 statement 獨立幂等,部分成功下次同步會補上)。如果邏輯需要真正的原子性,要用 env.DB.transaction() 或改寫成單一 statement。

未登入時一切必須照常。 我在 Sync.init() 第一行就 if (!Auth.isSignedIn()) return,而所有掛鉤都是 if (window.Sync)。實測:把 sync.js 從 HTML 移除,網站行為與 Day 26 完全一致。這個回歸測試每次改同步邏輯都要跑——它保證我沒有把「加值功能」變成「必要依賴」。

驗證

# 本機開發(含 D1 模擬)
npx wrangler pages dev . --d1 DB=learnpath-db

同步邏輯的單元測試(純函式,好測):

/* scripts/check-merge.js */
const merge = (a, b) => {
  const out = { ...a };
  for (const [k, t] of Object.entries(b)) if (!(k in out) || t < out[k]) out[k] = t;
  return out;
};

const A = { "ch01-1": 100, "ch01-2": 200 };
const B = { "ch09-1": 150, "ch01-1": 50 };

const cases = [
  ["交換律", JSON.stringify(merge(A, B)) === JSON.stringify(merge(B, A))],
  ["幂等",   JSON.stringify(merge(A, A)) === JSON.stringify(A)],
  ["union",  Object.keys(merge(A, B)).length === 3],
  ["取較早", merge(A, B)["ch01-1"] === 50],
  ["結合律", JSON.stringify(merge(merge(A, B), { "x-1": 1 }))
             === JSON.stringify(merge(A, merge(B, { "x-1": 1 })))],
];
let bad = 0;
for (const [n, ok] of cases) { console.log(`${ok ? "✓" : "✗"} ${n}`); if (!ok) bad++; }
process.exit(bad ? 1 : 0);

驗 CRDT 的三個性質(交換、結合、幂等)比驗「同步能跑」有價值得多——它們保證了「無論同步順序如何,結果都一致」。接進 Day 22 的 CI。

端到端衝突測試(今天的驗收重點):

1. 裝置 A(Chrome)登入,完成 ch01-1, ch01-2, ch01-3,等同步完成。
2. 裝置 B(Firefox)登入,確認拉到 3 課。
3. 兩邊都開 DevTools > Network > Offline。
4. A 離線完成 ch01-4, ch01-5;B 離線完成 ch09-1, ch09-2。
5. A 先上線 → 同步。
6. B 後上線 → 同步。
7. 檢查:兩邊都應有 7 課。

第 7 步是整個功能的核心驗收。 如果用 last-write-wins,這裡會掉 2 課。

其他:

# API 防護
curl -X POST https://learnpath.example.com/api/sync \
  -H 'content-type: application/json' \
  -d '{"changes":{"'"$(python3 -c 'print("x"*200)')"'":1}}'
# 預期:垃圾 key 被靜默丟棄,不寫入

curl -X POST … -d '{"changes":{"ch01-1":99999999999999}}'
# 預期:未來時間被丟棄

# 未登入回歸
# 從 HTML 移除 sync.js → 網站行為與 Day 26 完全相同

小結與明天預告

今天的重點:

  1. 打破「不做後端」是有條件的:本地仍是 source of truth,雲端只是備援。離線能力零退化。
  2. 逐項 union 合併,不是 last-write-wins。 「完成過就永遠算完成」讓合併成為 CRDT——不需要 vector clock、不需要衝突 UI。
  3. 合併規則寫進 SQL 的 ON CONFLICT,原子操作,沒有 race condition。
  4. 只存必要資料{userId, lessonKey, doneAt}。衍生資料(XP、徽章)各裝置自己算。
  5. 前端輸入全部不可信,時間戳尤其(我差點讓熱力圖顯示 2099 年)。
  6. 選 Workers + D1 的決定性理由是 SQL 讓合併變成一個原子語句
  7. sendBeacon + visibilitychange 是「離開前送出」的唯一可靠組合。

明天做登入。重點不是「怎麼接 OAuth」,而是怎麼在收集最少資料的前提下做身分驗證——passkey(WebAuthn)為主、資料最小化、帳號刪除與資料匯出。


上一篇
Day 26 — 第二套:S3 + CloudFront + OAC(用 IaC,不點 console)
下一篇
Day 28 — 跨裝置同步(二):Passkey 登入與資料保護
系列文
知識圖譜 : 技能樹式學習歷程29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言