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)可同时编辑,然后自动聚合到同一状态。
  • 用户拥有数据:服务器丢失后,数据仍在设备上。

有几个原因使得这个方向受到越来越多的关注:

  1. 用户体验期待:用户习惯于应用立即响应,每次点击后都没有加载旋转符。
  2. 设备更强大:手机和笔记本电脑足以运行本地数据库和数据合并逻辑。
  3. 生态系统更成熟:像 Automerge、Yjs 这样的 CRDT 库,以及许多 SQLite 同步引擎,使得实际应用比从零开始编写要容易得多。
  4. 隐私和数据主权:将数据保存在用户设备上,减少了对中央服务器的依赖。

最大的挑战是同步。两个设备同时离线编辑,当重新连接时,谁是正确的?

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,并将需要全局约束的业务逻辑保持在服务器端。

local-firstcrdtoffline-firstdistributed-systemsspring-boot