Local-first software: app chạy offline và đồng bộ bằng CRDT
1. Tổng quan
Phần lớn ứng dụng hiện nay coi server là nơi giữ bản dữ liệu “thật”, còn client chỉ hiển thị lại. Mất mạng thì app gần như không dùng được. Local-first software đảo ngược cách làm này: dữ liệu nằm trên thiết bị của người dùng trước, server chỉ giúp đồng bộ.
Bài này giải thích local-first là gì, vì sao nó được quan tâm, và vai trò của CRDT (Conflict-free Replicated Data Type) trong việc hợp nhất dữ liệu mà không cần khoá hay “người thắng duy nhất”. Đọc xong, bạn sẽ tự cài được vài CRDT cơ bản bằng Java 21, dựng một endpoint đồng bộ bằng Spring Boot 3, và biết khi nào không nên chọn hướng này.
2. Local-first là gì và vì sao đang được quan tâm
Một ứng dụng local-first thường có các đặc điểm sau:
- Đọc/ghi tức thì trên máy: mọi thao tác ghi vào storage cục bộ (SQLite, IndexedDB, file…), không chờ round-trip mạng.
- Hoạt động offline: mất mạng vẫn sửa được dữ liệu. Khi có mạng thì đồng bộ lại.
- Cộng tác nhiều thiết bị, nhiều người: các bản sao (replica) được sửa đồng thời rồi tự hội tụ về cùng một trạng thái.
- Người dùng sở hữu dữ liệu: server mất đi thì dữ liệu vẫn còn trên thiết bị.
Có vài lý do khiến hướng này được bàn tới nhiều hơn:
- Kỳ vọng về trải nghiệm: người dùng quen với app phản hồi ngay, không có spinner sau mỗi cú click.
- Thiết bị mạnh hơn: điện thoại và laptop đủ sức chạy database cục bộ và logic hợp nhất dữ liệu.
- Hệ sinh thái trưởng thành hơn: các thư viện CRDT như Automerge, Yjs, cùng nhiều engine đồng bộ cho SQLite, đã giúp việc áp dụng thực tế dễ hơn so với tự viết từ đầu.
- Quyền riêng tư và chủ quyền dữ liệu: giữ dữ liệu trên máy người dùng giảm bớt phụ thuộc vào một server trung tâm.
Thách thức lớn nhất chính là đồng bộ. Hai thiết bị cùng sửa offline thì khi gặp lại, ai đúng?
3. CRDT giải quyết xung đột như thế nào
CRDT là cấu trúc dữ liệu được thiết kế sao cho mọi replica, sau khi nhận đủ cập nhật của nhau, luôn hội tụ về cùng một trạng thái, bất kể thứ tự nhận. Không cần server làm trọng tài, không cần khoá.
Có hai họ chính:
- State-based (CvRDT): các replica gửi toàn bộ trạng thái cho nhau rồi gọi
merge. - Operation-based (CmRDT): các replica gửi từng thao tác. Kênh truyền phải đảm bảo mỗi thao tác được giao đúng một lần.
Bài này dùng kiểu state-based vì dễ hiểu và dễ minh hoạ. Để hội tụ, hàm merge phải thoả ba tính chất:
Giao hoán : merge(a, b) == merge(b, a)
Kết hợp : merge(merge(a, b), c) == merge(a, merge(b, c))
Lũy đẳng : merge(a, a) == a
Nhờ ba tính chất này, tin nhắn đến trễ, đến lộn xộn hay đến trùng đều không làm sai kết quả.
4. Cài đặt vài CRDT cơ bản
Các ví dụ dưới đây chỉ dùng JDK. Phần Spring Boot ở mục 5 sẽ cần thêm dependency.
4.1. G-Counter: bộ đếm chỉ tăng
Giả sử bạn muốn đếm số lượt xem một Order trên nhiều thiết bị. Mỗi replica chỉ tăng ô của chính nó. Khi merge, ta lấy giá trị lớn nhất của từng ô.
import java.util.HashMap;
import java.util.Map;
public final class GCounter {
private final Map<String, Long> counts = new HashMap<>();
public void increment(String replicaId) {
counts.merge(replicaId, 1L, Long::sum);
}
public long value() {
return counts.values().stream().mapToLong(Long::longValue).sum();
}
public void merge(GCounter other) {
other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
}
}
increment chỉ đụng tới ô của replicaId, nên hai replica không bao giờ ghi đè lên nhau. merge dùng Math::max, và phép max vốn giao hoán, kết hợp và lũy đẳng. Thử nhanh:
GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");
phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // merge lặp lại không làm sai kết quả
System.out.println(phone.value() + " " + laptop.value());
3 3
Muốn hỗ trợ cả giảm, bạn dùng PN-Counter: hai G-Counter, một cho tăng và một cho giảm, giá trị bằng hiệu của hai bộ đếm.
4.2. LWW-Register: giá trị “ghi sau thắng”
Với một trường đơn như Customer.displayName, cách đơn giản nhất là giữ giá trị có timestamp mới nhất. Nếu timestamp trùng nhau thì phân xử bằng replicaId để kết quả luôn xác định.
public record LwwRegister<T>(T value, long timestamp, String replicaId) {
public LwwRegister<T> merge(LwwRegister<T> other) {
if (other.timestamp != timestamp) {
return other.timestamp > timestamp ? other : this;
}
return other.replicaId.compareTo(replicaId) > 0 ? other : this;
}
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyễn", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyễn | Lan Nguyễn
Lưu ý: Đồng hồ trên các thiết bị thường lệch nhau. Một máy chạy nhanh vài phút sẽ “thắng” mọi lần ghi. Trong thực tế, người ta hay dùng Hybrid Logical Clock hoặc Lamport clock thay cho thời gian vật lý thuần.
LWW đơn giản nhưng sẽ âm thầm làm mất một trong hai lần sửa. Với văn bản cộng tác, cách này không chấp nhận được. Khi đó bạn cần CRDT cho chuỗi ký tự, ví dụ RGA hoặc các thuật toán mà Automerge và Yjs đang dùng.
4.3. OR-Set: tập hợp thêm/xoá được
Ví dụ ta có danh sách mua sắm dùng chung. Cài đặt ngây thơ sẽ hỏng khi một máy xoá “Sữa” trong lúc máy kia vừa thêm lại “Sữa”. Observed-Remove Set giải quyết bằng cách gắn mỗi lần add một tag duy nhất. Lệnh remove chỉ xoá những tag mà replica đó đã nhìn thấy.
import java.util.*;
public final class ORSet {
private final Map<String, Set<String>> adds = new HashMap<>();
private final Map<String, Set<String>> removes = new HashMap<>();
public void add(String item) {
adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
}
public void remove(String item) {
removes.computeIfAbsent(item, k -> new HashSet<>())
.addAll(adds.getOrDefault(item, Set.of()));
}
public Set<String> elements() {
Set<String> result = new TreeSet<>();
adds.forEach((item, tags) -> {
Set<String> alive = new HashSet<>(tags);
alive.removeAll(removes.getOrDefault(item, Set.of()));
if (!alive.isEmpty()) result.add(item);
});
return result;
}
public void merge(ORSet other) {
union(adds, other.adds);
union(removes, other.removes);
}
private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
}
public ORSetState toState() {
return new ORSetState(adds, removes);
}
public static ORSet fromState(ORSetState state) {
ORSet set = new ORSet();
union(set.adds, state.adds());
union(set.removes, state.removes());
return set;
}
}
import java.util.Map;
import java.util.Set;
public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}
Phân tích cách hoạt động:
addsinh một UUID mới cho mỗi lần thêm, kể cả khi thêm lại phần tử cũ.removechép các tag hiện có của phần tử sangremoves. Những tag do replica khác thêm mà replica này chưa thấy thì vẫn còn sống.elementstrả về phần tử còn ít nhất một tag chưa bị xoá. Kết quả: khi thêm và xoá xảy ra đồng thời, thêm sẽ thắng (add-wins).mergechỉ là phép hợp tập hợp, nên thoả cả ba tính chất ở mục 3.ORSetStatelà record đơn giản để Jackson serialize thành JSON khi đồng bộ.
5. Đồng bộ qua server với Spring Boot
Trong mô hình local-first, server không quyết định ai đúng. Nó chỉ là một replica luôn online, giúp các thiết bị trao đổi trạng thái. Thêm dependency web (phiên bản được quản lý bởi spring-boot-starter-parent):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Controller nhận trạng thái từ client, merge vào bản của server, rồi trả lại trạng thái đã hợp nhất:
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {
private final Map<String, ORSet> store = new ConcurrentHashMap<>();
@PostMapping("/sync")
public synchronized ORSetState sync(@PathVariable String listId,
@RequestBody ORSetState clientState) {
ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
serverCopy.merge(ORSet.fromState(clientState));
return serverCopy.toState();
}
}
Phía client làm thao tác ngược lại: gửi state lên, nhận state về, rồi gọi merge vào bản cục bộ. Client có thể gửi đi gửi lại nhiều lần vì merge lũy đẳng, nên retry khi mạng chập chờn là an toàn. Thử bằng curl:
curl -s -X POST localhost:8080/lists/family/sync \
-H "Content-Type: application/json" \
-d '{"adds":{"Sữa":["t1"],"Trứng":["t2"]},"removes":{"Trứng":["t2"]}}'
{"adds":{"Sữa":["t1"],"Trứng":["t2"]},"removes":{"Trứng":["t2"]}}
Client tính elements() từ state trả về và nhận được [Sữa].
Lưu ý:
synchronizedvàConcurrentHashMapchỉ phù hợp để minh hoạ. Trong production, bạn cần lưu state vào database và chỉ gửi phần thay đổi (delta) thay vì toàn bộ state. Bạn cũng cần xác thực và phân quyền cho từnglistId.
6. Kiểm thử hành vi hội tụ
Điều quan trọng nhất cần test là: hai replica sửa đồng thời, merge theo bất kỳ thứ tự nào, cũng phải ra cùng kết quả.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ORSetTest {
@Test
void concurrentAddAndRemove_convergeWithAddWins() {
ORSet phone = new ORSet();
phone.add("Sữa");
ORSet laptop = ORSet.fromState(phone.toState()); // đồng bộ ban đầu
phone.remove("Sữa"); // offline: điện thoại xoá
laptop.add("Sữa"); // offline: laptop thêm lại
laptop.add("Bánh mì");
ORSet a = ORSet.fromState(phone.toState());
a.merge(laptop);
ORSet b = ORSet.fromState(laptop.toState());
b.merge(phone);
assertThat(a.elements()).isEqualTo(b.elements())
.containsExactly("Bánh mì", "Sữa");
}
@Test
void merge_isIdempotent() {
ORSet list = new ORSet();
list.add("Trứng");
list.merge(ORSet.fromState(list.toState()));
assertThat(list.elements()).containsExactly("Trứng");
}
}
Test đầu tiên kiểm chứng tính giao hoán và quy tắc add-wins. “Sữa” vẫn còn vì laptop thêm nó bằng một tag mà điện thoại chưa từng thấy. Test thứ hai kiểm chứng tính lũy đẳng. Bạn nên bổ sung property-based test, tức là sinh ngẫu nhiên các chuỗi thao tác và thứ tự merge, để bắt lỗi hội tụ khó thấy.
7. Lỗi hay gặp và khi nào không nên dùng
Lỗi hay gặp:
- Dùng đồng hồ hệ thống cho LWW mà không xử lý lệch giờ. Kết quả là dữ liệu “mới” bị dữ liệu “cũ” ghi đè.
- Metadata phình to: OR-Set giữ tag của các phần tử đã xoá (tombstone) mãi mãi. Bạn cần cơ chế dọn rác, và việc này thường đòi hỏi biết chắc mọi replica đã nhận được thao tác xoá.
- Tưởng hội tụ nghĩa là đúng nghiệp vụ: CRDT chỉ đảm bảo mọi replica giống nhau, không đảm bảo kết quả hợp ý người dùng. Hai người cùng đổi tiêu đề thì LWW vẫn bỏ mất một bản.
- Không có migration schema: dữ liệu sống lâu trên thiết bị, nên phiên bản app cũ và mới phải đọc được state của nhau.
Khi nào không nên dùng local-first và CRDT:
- Ràng buộc toàn cục bắt buộc: số dư tài khoản không được âm, tồn kho không được bán vượt, mỗi ghế chỉ một người đặt. Những bất biến này cần phối hợp tập trung (transaction, khoá), CRDT không giữ được chúng.
- Dữ liệu phải được kiểm soát chặt: quy định pháp lý không cho lưu dữ liệu trên thiết bị, hoặc cần thu hồi quyền truy cập ngay lập tức.
- App CRUD đơn giản, luôn online: chi phí phức tạp lớn hơn lợi ích. Một REST API kèm optimistic locking là đủ.
- Dữ liệu cực lớn hoặc dùng chung bởi quá nhiều người: đồng bộ toàn bộ state xuống mỗi thiết bị không khả thi.
Cách thực tế thường là lai: phần cộng tác như ghi chú, danh sách, bản nháp dùng local-first. Phần giao dịch như thanh toán qua PaymentService vẫn đi qua server với ràng buộc chặt.
8. Kết luận
Local-first đặt dữ liệu trên thiết bị người dùng trước, giúp app phản hồi tức thì và chạy được khi offline. CRDT là nền tảng kỹ thuật cho phép nhiều replica sửa đồng thời rồi tự hội tụ, nhờ hàm merge có tính giao hoán, kết hợp và lũy đẳng.
Trong bài, bạn đã cài G-Counter, LWW-Register và OR-Set bằng Java 21, dựng endpoint đồng bộ với Spring Boot, và viết test kiểm chứng tính hội tụ. Với sản phẩm thật, bạn nên cân nhắc dùng thư viện CRDT đã được kiểm chứng thay vì tự viết. Hãy chỉ áp dụng local-first cho phần dữ liệu thực sự hưởng lợi từ nó, và giữ các nghiệp vụ cần ràng buộc toàn cục ở phía server.