Đồng bộ cho PWA local-first: last-writer-wins làm cho đúng

Đồng bộ offline với IndexedDB: đẩy lên idempotent, tombstone, giữ thứ tự seq bằng advisory lock, chỉnh đồng hồ theo server, tránh xung đột từ thiết kế dữ liệu.

· Đăng ngày · Cập nhật · 7 phút đọc · Về dự án →

Phần lớn web app coi server là nguồn sự thật: mỗi thao tác gửi một request, chờ trả lời, rồi mới cập nhật giao diện. Mất mạng là app đứng. Với Kế hoạch tài chính, một PWA lập kế hoạch thu chi theo tháng, mình chọn hướng ngược lại: local-first. Dữ liệu nằm trên máy, app chạy được khi không có mạng và cả khi server chết. Server chỉ để đồng bộ giữa các thiết bị và sao lưu.

Làm app offline thì không khó. Phần khó là đồng bộ khi cùng một người sửa trên laptop và điện thoại, có lúc online có lúc không. Bài này đi qua các quyết định chính và những ca biên đã gặp.

Kiến trúc

Kiến trúc app: trong trình duyệt, UI React đọc và ghi IndexedDB, nơi giữ nguồn sự thật và đánh dấu bản ghi bằng updatedAt và dirty, còn service worker cache toàn bộ app. Chỉ khi có mạng, IndexedDB mới đồng bộ với server qua một request POST /v1/sync, đẩy bản ghi dirty lên và kéo các thay đổi sau cursor về; server gán seq cho mỗi thay đổi trong PostgreSQL.Trình duyệtUI (React)không bao giờchờ mạngIndexedDB (Dexie)nguồn sự thậtupdatedAt, dirtyđọc/ghiService workercache toàn bộ appchỉ khi có mạng:đẩy dirty, kéo sau cursorServerPostgreSQLgán seq cho mỗi thay đổiPOST /v1/syncmột request, hai chiều
UI không bao giờ chạm tới mạng; chỉ bước đồng bộ làm việc đó.
  • UI chỉ đọc và ghi IndexedDB (qua Dexie), không bao giờ chờ mạng. Mỗi bản ghi có updatedAt và một cờ dirty đánh dấu "chưa lên server".
  • Service worker cache toàn bộ app (mọi route, font), nên mở được cả khi offline. Bản mới chỉ thay thế khi người dùng bấm "Tải lại", nên giao diện không tự đổi giữa chừng.
  • App gọi navigator.storage.persist() để trình duyệt không tự xoá dữ liệu khi máy thiếu dung lượng. Không có lời gọi này, IndexedDB chỉ là bộ nhớ "best-effort".
  • Schema dữ liệu viết bằng zod, nằm trong một package dùng chung. Client và server kiểm tra dữ liệu bằng cùng một schema.

Giao thức đồng bộ: một request, hai chiều

Mỗi lần đồng bộ là một request duy nhất: đẩy lên các bản ghi dirty, đồng thời kéo về mọi thay đổi kể từ cursor lần trước. Server gán cho mỗi thay đổi một số seq tăng dần, và lần kéo sau chỉ lấy những gì có seq lớn hơn cursor.

Đồng bộ chạy nền vào những lúc:

  • 1,5 giây sau mỗi thay đổi (để gộp nhiều lần gõ thành một request),
  • khi có mạng trở lại, khi người dùng quay lại tab,
  • và mỗi phút một lần.

Khi lỗi (server chết, timeout, 5xx, 429), app thử lại với exponential backoff từ 5 giây tới tối đa 5 phút. Thay đổi vẫn nằm an toàn trong hàng đợi trên máy.

Nhiều tab cũng là một ca dễ quên: hai tab cùng đồng bộ sẽ đẩy trùng và tranh nhau cursor. Web Locks API giải quyết gọn: tab nào lấy được khoá thì đồng bộ, tab khác bỏ qua. Viết gọn lại thì như sau:

await navigator.locks.request("finance-sync", { ifAvailable: true }, async (lock) => {
  if (!lock) return // tab khác đang đồng bộ
  await syncOnce()
})

Xung đột: bản mới nhất thắng, theo từng bản ghi

Có nhiều cách giải xung đột, từ đơn giản (last-writer-wins) tới phức tạp (CRDT). Với dữ liệu tài chính cá nhân, nơi hiếm khi hai thiết bị sửa cùng một ô trong cùng một lúc, last-writer-wins theo từng bản ghi là đủ, miễn là làm đúng mấy chi tiết sau:

  • Server chỉ ghi đè khi updatedAt mới hơn. Nhờ vậy gửi lại một thay đổi là vô hại (idempotent). Điều này quan trọng vì một request có thể đã thành công nhưng mất phản hồi, và client sẽ gửi lại.
  • Đẩy lên một bản cũ hơn bản trên server thì server trả lại bản hiện tại để client cập nhật.
  • Xoá là tombstone (data: null), không xoá thật. Nếu xoá thật, thiết bị khác không có cách nào biết bản ghi đã bị xoá, và sẽ đẩy nó lên lại.

Thứ tự seq phải đúng thứ tự commit

Đây là một lỗi tinh vi. Giả sử hai request đồng bộ của cùng một người chạy song song: request A lấy seq = 10 nhưng commit chậm, request B lấy seq = 11 và commit trước. Một lần kéo xen giữa sẽ thấy seq = 11, cập nhật cursor = 11, và bỏ lỡ vĩnh viễn thay đổi số 10.

Sơ đồ tuần tự: sync A lấy seq 10, sync B lấy seq 11 và commit trước, một lần kéo thấy seq 11 và đặt cursor bằng 11, rồi A mới commit seq 10. Lần kéo sau hỏi seq lớn hơn 11 nên thay đổi 10 không bao giờ tới.Sync ASync BDatabaseLần kéolấy seq = 10lấy seq = 11commit 11thấy seq 11cursor = 11commit 10, muộnlần kéo sau hỏi seq > 11:thay đổi 10 không bao giờ tới
Một seq được cấp trước khi commit có thể hiện ra sau một seq lớn hơn.

Cách xử lý: mỗi người dùng chỉ có một lần đồng bộ chạy tại một thời điểm, bằng advisory lock của PostgreSQL trong transaction:

SELECT pg_try_advisory_xact_lock(hashtextextended($userId, 0))

Lưu ý là try. Nếu đã có một lần đồng bộ đang chạy, request mới nhận ngay 429 và client tự thử lại, thay vì đứng chờ. Request đứng chờ sẽ giữ connection của pool, và người dùng khác phải xếp hàng phía sau.

Đồng hồ: đừng tin giờ của từng máy

Last-writer-wins dựa vào updatedAt, mà updatedAt lại lấy từ đồng hồ của thiết bị. Một chiếc điện thoại chạy nhanh 10 phút sẽ "thắng" mọi lần sửa ở máy khác trong 10 phút đó.

Hai lớp bảo vệ:

  1. Chỉnh theo giờ server, giống NTP. Mỗi phản hồi đồng bộ kèm serverTime. Client tính độ lệch so với điểm giữa của request, rồi đóng dấu thay đổi theo giờ đã chỉnh:

    const clockOffset = (serverTime: number, sentAt: number, receivedAt: number) =>
      Math.round(serverTime - (sentAt + receivedAt) / 2)
    
  2. Server không lưu thời điểm nào vượt quá giờ của nó 1 phút. Thay đổi nào đóng dấu xa hơn thì bị kéo về mức trần đó. Một máy có đồng hồ sai vì thế không thể thắng mãi.

Thêm một chi tiết nhỏ nhưng quan trọng: lưu lại một bản ghi y như cũ thì không tính là thay đổi. Nhiều form lưu lại cả những trường chỉ hiển thị. Nếu mỗi lần bấm "Lưu" đều đóng dấu thời gian mới, form trên laptop sẽ âm thầm ghi đè thay đổi vừa làm trên điện thoại.

Thiết kế dữ liệu để tránh xung đột

Cách hiệu quả nhất để giải xung đột là đừng để nó xảy ra. Ban đầu một tháng là một bản ghi, gồm cả kế hoạch lẫn số thực tế. Sửa kế hoạch trên laptop trong lúc nhập số thực tế trên điện thoại thì một bên sẽ mất.

Giải pháp là tách một tháng thành ba bản ghi: kế hoạch, số thực tế khi chốt tháng, và số thực tế đang nhập dở. Ba thứ này thường được sửa ở những thời điểm và thiết bị khác nhau, nên tách ra thì gần như không còn xung đột. Dữ liệu cũ dạng một bản ghi vẫn đọc được, và được tách dần sau khi máy đồng bộ xong.

Nguyên tắc chung: đơn vị đồng bộ nên khớp với đơn vị mà người dùng sửa cùng một lúc.

Những ca biên khác

  • App cũ gặp dữ liệu mới. Người dùng có thể mở một bản app cũ (đang cache) trong khi thiết bị khác đã đồng bộ lên một loại dữ liệu mới. Bản ghi thuộc loại chưa biết được giữ nguyên trên máy, không làm hỏng lần đồng bộ, và sẽ được dùng khi app cập nhật.
  • Đăng nhập không bắt buộc. Server xác thực chết thì app vẫn chạy. Token hết hạn mà không làm mới được do mất mạng thì chỉ tạm dừng đồng bộ, không đăng xuất người dùng.
  • Dữ liệu tạo trước khi đăng nhập. Nếu tài khoản còn trống thì dữ liệu tự lên tài khoản. Nếu tài khoản đã có dữ liệu thì app hỏi: dùng dữ liệu tài khoản, hay gộp dữ liệu trên máy vào. Đừng bao giờ tự quyết thay người dùng ở đây.
  • Máy mới đăng nhập phải chờ lần đồng bộ đầu tiên rồi mới hiện số liệu. Nếu không, nó có thể tạo một kế hoạch trống rồi đẩy lên đè dữ liệu thật.
  • Đăng xuất xoá dữ liệu trên máy (vì máy có thể dùng chung), nhưng chỉ sau khi đẩy nốt những thay đổi đang chờ. Còn thay đổi chưa gửi được thì cảnh báo trước.
  • Biết ai đã đổi gì. Mỗi bản ghi thiết bị đẩy lên đều được ghi vào một audit log mà chính lịch sử của nó cũng không sửa âm thầm được. Cách làm nằm trong bài audit log chống sửa trong PostgreSQL.

Khi nào nên chọn local-first

Local-first đáng công khi người dùng cần app chạy nhanh và chạy được lúc không có mạng, và dữ liệu chủ yếu là của một người dùng trên vài thiết bị. Nó kém phù hợp khi nhiều người cùng sửa một dữ liệu theo thời gian thực (khi đó nên xem CRDT hoặc server làm trọng tài), hoặc khi dữ liệu quá lớn để nằm trên máy.

Tóm tắt

  • UI chỉ đọc và ghi IndexedDB, không bao giờ chờ mạng; server chỉ đồng bộ giữa các thiết bị và giữ bản sao lưu.
  • Một request vừa đẩy các bản ghi dirty lên vừa kéo mọi thay đổi sau cursor về, có backoff khi lỗi và dùng Web Lock để chỉ một tab đồng bộ.
  • Với dữ liệu của một người, bản mới nhất thắng theo từng bản ghi là đủ, miễn là việc ghi đè idempotent và thao tác xoá là tombstone.
  • Một seq được cấp trước khi commit có thể hiện ra sau một seq lớn hơn, nên mỗi người dùng chỉ được một lượt đồng bộ tại một thời điểm, bằng advisory lock không chờ.
  • Đừng tin đồng hồ của máy: chỉnh theo giờ server, chặn mốc thời gian quá xa ở phía server, và lưu mà không đổi gì thì không tính là thay đổi.
  • Thiết kế dữ liệu sao cho đơn vị đồng bộ khớp với đơn vị người dùng sửa trong một lần; tách một tháng thành ba bản ghi đã gần như xoá hết xung đột.

Nếu muốn đi sâu hơn, bài Local-first software của Ink & Switch là điểm khởi đầu rất tốt. Còn nếu bạn cũng đang làm app offline và muốn trao đổi, email của mình ở trang chủ.