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
- UI chỉ đọc và ghi IndexedDB (qua Dexie), không bao giờ chờ mạng. Mỗi bản ghi có
updatedAtvà 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
updatedAtmớ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.
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ệ:
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)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
dirtylên vừa kéo mọi thay đổi saucursorvề, 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ộtseqlớ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ủ.