Local-first 软件:离线运行和通过 CRDT 同步的应用
1. 概述
当今大多数应用都把服务器视为数据的“真实”源头,客户端只负责展示。丧失网络连接时,应用几乎无法使用。Local-first 软件颠覆了这种做法:数据首先存储在用户设备上,服务器仅帮助同步。
本文解释了 local-first 的定义、为什么它受到关注,以及 CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型) 在无需加锁或“唯一胜者”的情况下合并数据中的作用。读完后,你将能用 Java 21 实现一些基本的 CRDT,用 Spring Boot 3 构建一个同步端点,并了解何时不应该选择这种方向。
2. Local-first 是什么及为什么受到关注
一个 local-first 应用通常具有以下特点:
- 在本地即时读/写:所有写操作都进入本地存储(SQLite、IndexedDB、文件等),无需等待网络往返。
- 离线运行:即使没有网络仍可编辑数据。有网络时则同步。
- 多设备、多人协作:多个副本(replica)可同时编辑,然后自动聚合到同一状态。
- 用户拥有数据:服务器丢失后,数据仍在设备上。
有几个原因使得这个方向受到越来越多的关注:
- 用户体验期待:用户习惯于应用立即响应,每次点击后都没有加载旋转符。
- 设备更强大:手机和笔记本电脑足以运行本地数据库和数据合并逻辑。
- 生态系统更成熟:像 Automerge、Yjs 这样的 CRDT 库,以及许多 SQLite 同步引擎,使得实际应用比从零开始编写要容易得多。
- 隐私和数据主权:将数据保存在用户设备上,减少了对中央服务器的依赖。
最大的挑战是同步。两个设备同时离线编辑,当重新连接时,谁是正确的?
3. CRDT 如何解决冲突
CRDT 是一种数据结构,设计使得每个副本在相互接收充分更新后,总是聚合到同一状态,无论接收顺序如何。无需服务器仲裁,无需锁。
主要有两类:
- State-based (CvRDT):副本相互发送完整状态,然后调用
merge。 - Operation-based (CmRDT):副本发送各个操作。传输通道必须确保每个操作恰好被交付一次。
本文使用 state-based 类型,因为它易于理解和演示。为了收敛,merge 函数必须满足三个性质:
交换律 : merge(a, b) == merge(b, a)
结合律 : merge(merge(a, b), c) == merge(a, merge(b, c))
幂等性 : merge(a, a) == a
多亏这三个性质,消息延迟、乱序或重复都不会导致结果错误。
4. 实现一些基本的 CRDT
下面的示例只使用 JDK。第 5 节的 Spring Boot 部分需要额外的依赖。
4.1. G-Counter:仅增长的计数器
假设你想计算一个 Order 在多个设备上的浏览次数。每个副本只增加自己的值。合并时,取每个值的最大值。
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 只修改 replicaId 对应的值,所以两个副本永不会相互覆盖。merge 使用 Math::max,max 操作本身满足交换律、结合律和幂等性。快速测试:
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 不会导致结果错误
System.out.println(phone.value() + " " + laptop.value());
3 3
如果要支持减少,可以使用 PN-Counter:两个 G-Counter,一个用于增加,一个用于减少,值等于两个计数器的差。
4.2. LWW-Register:“后写胜”的值
对于像 Customer.displayName 这样的单一字段,最简单的方法是保持时间戳最新的值。如果时间戳相同,则用 replicaId 破平局以确保结果总是确定的。
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
注意: 各设备上的时钟通常会漂移。一台快运行几分钟的机器会“赢”所有写操作。在实践中,人们通常使用 Hybrid Logical Clock 或 Lamport clock 来代替纯物理时间。
LWW 很简单,但会无声地丢失两个编辑中的一个。对于协作文本,这种方法是不可接受的。此时你需要用于字符串的 CRDT,例如 RGA 或 Automerge 和 Yjs 使用的算法。
4.3. OR-Set:可添加/删除的集合
例如我们有一个共享购物清单。天真的实现会出问题,当一台机器删除“牛奶”时,另一台机器正在重新添加“牛奶”。Observed-Remove Set 通过为每次 add 附加一个唯一标签来解决这个问题。remove 命令只删除该副本已看到的标签。
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) {}
工作原理分析:
add为每次添加生成一个新的 UUID,即使是重新添加旧元素。remove将元素的现有标签复制到removes。由其他副本添加但此副本未见过的标签仍然存活。elements返回至少有一个未删除标签的元素。结果是:当添加和删除同时发生时,添加赢(add-wins)。merge只是集合并运算,因此满足第 3 节的所有三个性质。ORSetState是一个简单的 record,用于 Jackson 在同步时序列化为 JSON。
5. 通过 Spring Boot 通过服务器同步
在 local-first 模型中,服务器不决定谁是对的。它只是一个始终在线的副本,帮助设备交换状态。添加 web 依赖(版本由 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 从客户端接收状态,合并到服务器副本中,然后返回合并后的状态:
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();
}
}
客户端做相反的操作:发送状态,接收状态返回,然后调用 merge 到本地副本。客户端可以多次发送重试,因为 merge 是幂等的,所以网络波动时重试是安全的。用 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"]}}
客户端从返回的状态计算 elements() 并获得 [Sữa]。
注意:
synchronized和ConcurrentHashMap只适合演示。在生产环境中,你需要将状态保存到数据库并只发送变更(delta)而不是整个状态。你还需要为每个listId进行身份验证和授权。
6. 测试收敛行为
最重要的测试是:两个副本同时编辑,按任何顺序合并,都必须得到相同结果。
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()); // 初始同步
phone.remove("Sữa"); // 离线:手机删除
laptop.add("Sữa"); // 离线:笔记本重新添加
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");
}
}
第一个测试验证交换律和 add-wins 规则。“牛奶”仍然存在,因为笔记本用手机未见过的标签添加了它。第二个测试验证幂等性。你应该补充 property-based 测试,即随机生成操作序列和合并顺序,以捕获难以发现的收敛错误。
7. 常见错误及何时不应使用
常见错误:
- 在 LWW 中使用系统时钟而不处理时钟漂移。结果是“新”数据被“旧”数据覆盖。
- 元数据膨胀:OR-Set 永远保留已删除元素的标签(墓碑)。你需要垃圾回收机制,这通常需要确认所有副本都收到删除操作。
- 误认为收敛意味着业务正确:CRDT 只保证所有副本相同,不保证结果符合用户意图。两个人同时更改标题,LWW 仍会丢失一个版本。
- 缺乏模式迁移:数据在设备上存活很久,因此应用的新旧版本必须能读取彼此的状态。
何时不应该使用 local-first 和 CRDT:
- 全局约束是必需的:账户余额不能为负,库存不能超售,每个座位只能一人预订。这些不变量需要集中协调(事务、锁),CRDT 无法维护它们。
- 数据必须受到严格控制:法律规定不允许在设备上存储数据,或需要立即撤销访问权限。
- 简单的 CRUD 应用,始终在线:复杂性成本大于收益。REST API 配合乐观锁足够了。
- 数据极大或被太多人共享:将完整状态同步到每个设备不可行。
实际方法通常是混合:协作部分如笔记、列表、草稿使用 local-first。交易部分如通过 PaymentService 的支付仍通过服务器进行严格约束。
8. 结论
Local-first 将数据优先放在用户设备上,帮助应用立即响应并在离线时工作。CRDT 是技术基础,允许多个副本同时编辑然后自动收敛,这要归功于 merge 函数的交换律、结合律和幂等性。
在本文中,你已经用 Java 21 实现了 G-Counter、LWW-Register 和 OR-Set,用 Spring Boot 构建了同步端点,并编写了验证收敛性的测试。对于真实产品,你应该考虑使用经过验证的 CRDT 库,而不是自己编写。只对真正受益于它的数据部分应用 local-first,并将需要全局约束的业务逻辑保持在服务器端。