Local-first software: App läuft offline und synchronisiert mit CRDT

1. Übersicht

Die meisten heutigen Anwendungen behandeln den Server als die Quelle der „wahren” Daten, während der Client diese nur anzeigt. Fällt das Netzwerk aus, ist die App praktisch unbrauchbar. Local-first software kehrt diesen Ansatz um: Daten liegen zuerst auf dem Gerät des Nutzers, der Server hilft nur bei der Synchronisierung.

Dieser Artikel erklärt, was Local-first ist, warum es an Aufmerksamkeit gewinnt, und welche Rolle CRDT (Conflict-free Replicated Data Type) bei der Zusammenführung von Daten ohne Sperren oder „einen einzigen Gewinner” spielt. Nach dem Lesen werden Sie einige grundlegende CRDTs in Java 21 implementieren können, einen Synchronisierungsendpoint mit Spring Boot 3 aufbauen und wissen, wann Sie diesen Ansatz nicht verwenden sollten.

2. Local-first ist – und warum es zunehmend beachtet wird

Eine Local-first-Anwendung hat typischerweise folgende Merkmale:

  • Sofortiges Lesen/Schreiben auf dem Gerät: Jeder Schreibvorgang geht an den lokalen Speicher (SQLite, IndexedDB, Datei…), nicht zum Warten auf Netzwerk-Roundtrips.
  • Funktioniert offline: Ohne Netzwerk können Sie Daten immer noch bearbeiten. Mit Netzwerk synchronisieren Sie sie wieder.
  • Zusammenarbeit auf mehreren Geräten und von mehreren Personen: Die Replikate werden gleichzeitig bearbeitet und konvergieren dann automatisch zu demselben Zustand.
  • Der Benutzer besitzt die Daten: Wenn der Server ausfällt, sind die Daten noch auf dem Gerät vorhanden.

Es gibt mehrere Gründe, warum dieser Ansatz immer mehr diskutiert wird:

  1. Erwartungen an die Benutzererfahrung: Nutzer erwarten sofortige Reaktion, ohne Spinner nach jedem Klick.
  2. Leistungsfähigere Geräte: Smartphones und Laptops sind stark genug, um lokale Datenbanken und Datenzusammenführungslogik zu betreiben.
  3. Ein reiferes Ökosystem: CRDT-Bibliotheken wie Automerge, Yjs und zahlreiche Synchronisierungs-Engines für SQLite machen die praktische Umsetzung viel einfacher als früher von Grund auf.
  4. Datenschutz und Datensouveränität: Das Speichern von Daten auf dem Benutzergerät mindert die Abhängigkeit von einem zentralisierten Server.

Die größte Herausforderung ist die Synchronisierung. Wenn zwei Geräte offline dasselbe bearbeiten, wer hat recht, wenn sie sich wiedersehen?

3. Wie CRDT Konflikte löst

CRDT ist eine Datenstruktur, die so konzipiert ist, dass jedes Replikat, nachdem es alle Updates vom anderen erhalten hat, immer zum gleichen Zustand konvergiert, unabhängig von der Empfangsreihenfolge. Es braucht keinen Server als Schiedsrichter und keine Sperren.

Es gibt zwei Hauptfamilien:

  • State-based (CvRDT): Replikate senden den gesamten Zustand untereinander und rufen merge auf.
  • Operation-based (CmRDT): Replikate senden einzelne Operationen. Der Transport muss garantieren, dass jede Operation genau einmal zugestellt wird.

Dieser Artikel verwendet state-based, weil es verständlicher und leichter zu zeigen ist. Für die Konvergenz muss die merge-Funktion drei Eigenschaften erfüllen:

Kommutativ  : merge(a, b) == merge(b, a)
Assoziativ  : merge(merge(a, b), c) == merge(a, merge(b, c))
Idempotent  : merge(a, a) == a

Dank dieser drei Eigenschaften schadet es nicht, wenn Nachrichten spät ankommen, durcheinander kommen oder sich wiederholen.

4. Implementierung einiger grundlegender CRDTs

Die folgenden Beispiele verwenden nur die JDK. Der Spring Boot-Teil in Abschnitt 5 benötigt zusätzliche Abhängigkeiten.

4.1. G-Counter: Ein nur-erhöhender Zähler

Angenommen, Sie möchten die Anzahl der Aufrufe einer Order auf mehreren Geräten zählen. Jedes Replikat erhöht nur sein eigenes Feld. Beim Merge nehmen wir den Maximalwert jedes Feldes.

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 berührt nur das Feld von replicaId, daher wird ein Replikat nie das andere überschreiben. merge verwendet Math::max, und die Max-Operation ist von Natur aus kommutativ, assoziativ und idempotent. Kurzer Test:

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); // Mehrfaches Merge schadet nicht
System.out.println(phone.value() + " " + laptop.value());
3 3

Um auch Verringerung zu unterstützen, verwenden Sie PN-Counter: zwei G-Counter, einen für Erhöhungen und einen für Verringerungen, der Wert ist die Differenz der beiden Zähler.

4.2. LWW-Register: Der Wert „Last Write Wins”

Für ein einzelnes Feld wie Customer.displayName ist die einfachste Methode, den Wert mit dem neuesten Zeitstempel beizubehalten. Wenn die Zeitstempel identisch sind, wird ein Tiebreaker basierend auf replicaId verwendet, um ein deterministisches Ergebnis zu gewährleisten.

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

Hinweis: Uhren auf verschiedenen Geräten weichen oft ab. Ein Gerät, das ein paar Minuten voraus läuft, wird bei jedem Schreiben „gewinnen”. In der Praxis verwendet man häufig Hybrid Logical Clock oder Lamport-Uhr anstelle der reinen physischen Zeit.

LWW ist einfach, aber wird stillschweigend eines der beiden Edits löschen. Bei kollaborativem Text ist dieser Ansatz nicht akzeptabel. Dann benötigen Sie einen CRDT für Strings, wie RGA oder die Algorithmen, die Automerge und Yjs verwenden.

4.3. OR-Set: Eine Menge mit Hinzufügen/Entfernen

Beispiel: Sie haben eine gemeinsame Einkaufsliste. Eine naive Implementierung schlägt fehl, wenn ein Gerät „Milch” löscht, während ein anderes „Milch” gerade erneut hinzufügt. Observed-Remove Set löst dies, indem jedem add ein eindeutiger Tag zugeordnet wird. Der remove-Befehl löscht nur Tags, die das Replikat bereits gesehen hat.

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) {}

Funktionsweise:

  • add generiert eine neue UUID für jedes Hinzufügen, auch wenn das Element bereits vorhanden war.
  • remove kopiert die aktuellen Tags des Elements in removes. Tags, die von anderen Replikaten hinzugefügt wurden, die dieses Replikat aber noch nicht gesehen hat, bleiben erhalten.
  • elements gibt Elemente zurück, die mindestens ein nicht gelöschtes Tag haben. Ergebnis: Wenn Hinzufügen und Löschen gleichzeitig auftreten, gewinnt Hinzufügen (add-wins).
  • merge ist nur eine Mengenunion und erfüllt daher alle drei Eigenschaften aus Abschnitt 3.
  • ORSetState ist ein einfacher Record für Jackson, um zu JSON zu serialisieren während der Synchronisierung.

5. Synchronisierung über Server mit Spring Boot

Im Local-first-Modell entscheidet der Server nicht, wer recht hat. Er ist nur ein immer erreichbares Replikat, das Geräten hilft, Zustände auszutauschen. Fügen Sie die Web-Abhängigkeit hinzu (Version wird von spring-boot-starter-parent verwaltet):

<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>

Der Controller nimmt den Zustand vom Client entgegen, führt ihn mit dem Zustand des Servers zusammen und gibt den zusammengeführten Zustand zurück:

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();
    }
}

Die Client-Seite führt die umgekehrte Operation aus: Sie sendet den Zustand hoch, erhält den Zustand zurück und ruft dann merge auf die lokale Kopie auf. Der Client kann mehrmals senden und erneut senden, da merge idempotent ist, daher ist Retry bei Netzwerkfehlern sicher. Probieren Sie es mit curl:

curl -s -X POST localhost:8080/lists/family/sync \
  -H "Content-Type: application/json" \
  -d '{"adds":{"Milch":["t1"],"Ei":["t2"]},"removes":{"Ei":["t2"]}}'
{"adds":{"Milch":["t1"],"Ei":["t2"]},"removes":{"Ei":["t2"]}}

Der Client berechnet elements() aus dem zurückgegebenen Zustand und erhält [Milch].

Hinweis: synchronized und ConcurrentHashMap sind nur zur Veranschaulichung geeignet. In der Produktion sollten Sie den Zustand in einer Datenbank speichern und nur die Änderungen (Delta) statt des gesamten Zustands senden. Sie müssen auch Authentifizierung und Autorisierung für jede listId implementieren.

6. Testen des Konvergenzverhaltens

Das Wichtigste zu testen ist: Zwei Replikate bearbeiten gleichzeitig, Merge in beliebiger Reihenfolge, und sollten immer zum gleichen Ergebnis führen.

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("Milch");
        ORSet laptop = ORSet.fromState(phone.toState()); // Initiale Synchronisierung

        phone.remove("Milch");   // offline: Telefon löscht
        laptop.add("Milch");     // offline: Laptop fügt erneut hinzu
        laptop.add("Brot");

        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("Brot", "Milch");
    }

    @Test
    void merge_isIdempotent() {
        ORSet list = new ORSet();
        list.add("Ei");
        list.merge(ORSet.fromState(list.toState()));
        assertThat(list.elements()).containsExactly("Ei");
    }
}

Der erste Test verifiziert Kommutativität und die Add-Wins-Regel. „Milch” bleibt bestehen, weil das Laptop es mit einem Tag hinzugefügt hat, das das Telefon nie gesehen hat. Der zweite Test verifiziert Idempotenz. Sie sollten Property-based Tests hinzufügen, d. h. zufällig Operationssequenzen und Merge-Reihenfolgen generieren, um schwer zu erkennende Konvergenzfehler zu finden.

7. Häufige Fehler und wann Sie es nicht verwenden sollten

Häufige Fehler:

  • Verwendung der Systemuhr für LWW ohne Behandlung der Zeitabweichung. Das Ergebnis ist, dass „neue” Daten von „alten” Daten überschrieben werden.
  • Metadaten-Überläufung: OR-Set behält Tags gelöschter Elemente (Tombstones) auf ewig bei. Sie benötigen einen Garbage-Collection-Mechanismus, was normalerweise erfordert, dass Sie sicherstellen können, dass alle Replikate die Löschoperation erhalten haben.
  • Annahme, dass Konvergenz bedeutet Geschäftskorrektheit: CRDT garantiert nur, dass alle Replikate identisch sind, nicht dass das Ergebnis den Benutzerintentionen entspricht. Wenn zwei Personen gleichzeitig den Titel ändern, verliert LWW immer noch eine Änderung.
  • Keine Schema-Migration: Da Daten lange auf Geräten verbleiben, müssen alte und neue App-Versionen den Zustand voneinander lesen können.

Wann Sie nicht Local-first und CRDT verwenden sollten:

  • Zwingend globale Constraints: Kontosaldo darf nicht negativ sein, Lagerbestände können nicht überverkauft werden, jeder Platz kann nur von einer Person reserviert werden. Diese Invarianten erfordern zentrale Koordination (Transaktionen, Sperren), die CRDT nicht gewährleisten können.
  • Daten müssen streng kontrolliert werden: Rechtliche Vorschriften verbieten die Speicherung von Daten auf Geräten, oder der Zugriff muss sofort widerrufen werden können.
  • Einfache CRUD-App, immer online: Die Komplexität überwiegt die Vorteile. Eine REST-API mit optimistischen Sperren ist ausreichend.
  • Extrem große Daten oder von zu vielen Personen gemeinsam genutzt: Die vollständige Statussynchronisierung auf jedes Gerät ist nicht machbar.

Der praktische Ansatz ist oft hybrid: Kollaborative Teile wie Notizen, Listen und Entwürfe verwenden Local-first. Transaktionsteilsegmente wie Zahlungen über PaymentService gehen immer noch über den Server mit strikten Constraints.

8. Fazit

Local-first platziert Daten zuerst auf den Geräten der Benutzer, sodass die App sofort reagiert und offline funktioniert. CRDT ist die technische Grundlage, die es mehreren Replikaten ermöglicht, gleichzeitig zu bearbeiten und automatisch zu konvergieren, dank einer merge-Funktion, die kommutativ, assoziativ und idempotent ist.

In diesem Artikel haben Sie G-Counter, LWW-Register und OR-Set in Java 21 implementiert, einen Synchronisierungsendpoint mit Spring Boot erstellt und Tests zur Überprüfung des Konvergenzverhaltens geschrieben. Bei echten Produkten sollten Sie sich für bewährte CRDT-Bibliotheken entscheiden, anstatt sie selbst zu schreiben. Verwenden Sie Local-first nur für Datenteile, die wirklich davon profitieren, und behalten Sie Geschäftslogik, die globale Constraints erfordert, serverseitig.

local-firstcrdtoffline-firstdistributed-systemsspring-boot