Systemdesign für 1-1 und Gruppen-Chats: Nachrichtenspeicheru...

1. Übersicht

Chat sieht einfach aus: Tippe eine Zeile ein und zeige sie an. Aber mit Millionen von Benutzern musst du schwierigere Fragen beantworten. Wie behältst du die richtige Reihenfolge der Nachrichten? Wie stellst du sicher, dass Wiederholungen keine Duplikate erzeugen? Wie weißt du, wer bis wohin gelesen hat, ohne Milliarden von „Gelesen”-Datensätzen zu speichern?

Dieser Artikel zeigt dir ein vollständiges Design für 1-1 und Gruppen-Chats. Das Design umfasst Anforderungen, Datenmodell, API, Verarbeitungsfluss und Schwachstellen. Am Ende wirst du den Kern einer Nachrichtenspeicher- und Lesestatus-Implementierung mit Spring Boot 3, PostgreSQL und Kafka aufbauen können.

2. Anforderungen

2.1. Funktionalität

  • 1-1 und Gruppen-Chats (max. 1.000 Mitglieder).
  • Textnachrichten senden. Nachrichten werden in der richtigen Reihenfolge innerhalb eines Gesprächs angezeigt.
  • Verlauf anzeigen, rückwärts blättern.
  • Gesprächsliste (Inbox) mit Anzahl ungelesener Nachrichten.
  • Lesestatus: 1-1 Chat zeigt „Gelesen”, Gruppen-Chat zeigt „Gelesen von N Personen”.

2.2. Skalierung und Constraints

  • 10 Millionen DAU. Durchschnittlich sendet jede Person 40 Nachrichten/Tag, d. h. 400 Millionen Nachrichten/Tag, etwa 4.600 Nachrichten/Sekunde. In Spitzenlastzeiten können es etwa 30.000 Nachrichten/Sekunde sein.
  • Keine verlorenen Nachrichten, die als erfolgreich gesendet bestätigt wurden.
  • Die Reihenfolge muss nur innerhalb eines Gesprächs korrekt sein. Keine globale Reihenfolge erforderlich.
  • Der Client kann aufgrund von Netzwerk-Flakiness erneut senden. Der Server muss idempotent sein.
  • „Gelesen”-Ereignisse treten häufiger auf als Nachrichten-Ereignisse, da jedes Öffnen der App eines generiert.

Das dritte Constraint ist der Schlüssel des gesamten Designs. Da nur lokale Reihenfolge erforderlich ist, können wir alles nach conversation_id sharden.

3. Datenmodell

Die zentrale Idee: Jedes Gespräch hat eine aufsteigende Sequenznummer (seq). Die N-te Nachricht im Gespräch hat seq = N. Der Lesestatus eines Mitglieds ist nur eine einzige Zahl, last_read_seq. Es ist ein Watermark, keine Liste einzelner gelesener Nachrichten.

CREATE TABLE conversation (
    id              BIGINT PRIMARY KEY,
    type            VARCHAR(10) NOT NULL,          -- DIRECT | GROUP
    last_seq        BIGINT      NOT NULL DEFAULT 0,
    last_message_at TIMESTAMPTZ
);

CREATE TABLE conversation_member (
    conversation_id BIGINT NOT NULL REFERENCES conversation(id),
    user_id         BIGINT NOT NULL,
    last_read_seq   BIGINT NOT NULL DEFAULT 0,
    PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX idx_member_user ON conversation_member(user_id);

CREATE TABLE message (
    conversation_id BIGINT      NOT NULL,
    seq             BIGINT      NOT NULL,
    sender_id       BIGINT      NOT NULL,
    client_msg_id   UUID        NOT NULL,
    body            TEXT        NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (conversation_id, seq),
    UNIQUE (conversation_id, client_msg_id)
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    msg_key    VARCHAR(64) NOT NULL,
    payload    JSONB       NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Bemerkungen:

  • Der Primärschlüssel (conversation_id, seq) ermöglicht es, den Verlauf durch einen durchgehenden Index-Scan zu lesen. Dieser Schlüssel ist auch der natürliche Shard-Key, wenn du shardest.
  • UNIQUE (conversation_id, client_msg_id) ist die Ebene zur Verhinderung von Duplikaten auf DB-Ebene.
  • Die Anzahl ungelesener Nachrichten ist last_seq - last_read_seq. Diese Berechnung benötigt kein COUNT(*).
  • „Gelesen von” in Gruppen wird durch die Anzahl der Mitglieder mit last_read_seq >= seq berechnet. Du brauchst keine message_receipt-Tabelle mit Milliarden von Zeilen.
  • Für 1-1 Chats füge eine Tabelle direct_pair(user_low, user_high, conversation_id) mit einer Unique-Constraint hinzu. Zwei Personen, die den Chat gleichzeitig öffnen, erstellen nicht zwei Gespräche.

Hinweis: Watermark setzt voraus, dass Benutzer sequenziell lesen, d. h. das Lesen von Nachricht N bedeutet, dass alle vorherigen Nachrichten gelesen wurden. Diese Annahme gilt für fast alle Chat-Anwendungen. Wenn du genau wissen musst, welche spezifischen Nachrichten geöffnet wurden (z. B. selbstzerstörende Nachrichten), speichere zusätzliche Datensätze speziell für diesen Nachrichtentyp. Wende das nicht auf alle Nachrichten an.

4. API

Method Endpoint Beschreibung
POST /conversations/{id}/messages Nachricht senden. Body enthält clientMsgId und body
GET /conversations/{id}/messages?beforeSeq=&limit= Verlauf, Pagination nach Seq
POST /conversations/{id}/read Body { "seq": 120 }
GET /inbox?limit= Gesprächsliste und Anzahl ungelesener Nachrichten
WS /ws Server pushed message.new und read.updated Events

Nachrichten werden über REST statt WebSocket gesendet. Dies ermöglicht dir vertraute HTTP-basierte Wiederholungen, Timeouts und Idempotenz. WebSocket wird nur zum Pushen zu Clients verwendet.

5. Nachrichtensendefluss

Erforderliche Dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

Die gesamte Nachrichtensendevorgang läuft in einer einzigen Transaktion:

public record SentMessage(long conversationId, long seq, UUID clientMsgId) {}

@Service
public class MessageService {

    private final JdbcTemplate jdbc;

    public MessageService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public SentMessage send(long conversationId, long senderId, UUID clientMsgId, String body) {
        // 1. Conversation Row-Lock: alle Sends zum gleichen Gespräch werden sequenziert
        Long lastSeq = jdbc.queryForObject(
            "SELECT last_seq FROM conversation WHERE id = ? FOR UPDATE",
            Long.class, conversationId);

        // 2. Idempotenz: wenn der Client erneut sendet, gibt das alte Ergebnis zurück
        List<Long> existing = jdbc.queryForList(
            "SELECT seq FROM message WHERE conversation_id = ? AND client_msg_id = ?",
            Long.class, conversationId, clientMsgId);
        if (!existing.isEmpty()) {
            return new SentMessage(conversationId, existing.getFirst(), clientMsgId);
        }

        // 3. Neue Seq vergeben, Nachricht schreiben und Gespräch aktualisieren
        long seq = lastSeq + 1;
        jdbc.update("""
            INSERT INTO message(conversation_id, seq, sender_id, client_msg_id, body)
            VALUES (?, ?, ?, ?, ?)""", conversationId, seq, senderId, clientMsgId, body);
        jdbc.update("UPDATE conversation SET last_seq = ?, last_message_at = now() WHERE id = ?",
            seq, conversationId);

        // 4. Der Absender hat seine eigene Nachricht offensichtlich gelesen
        jdbc.update("""
            UPDATE conversation_member SET last_read_seq = GREATEST(last_read_seq, ?)
            WHERE conversation_id = ? AND user_id = ?""", seq, conversationId, senderId);

        // 5. Outbox: Event wird in der gleichen Transaktion geschrieben, niemals aus der Sync
        jdbc.update("INSERT INTO outbox(msg_key, payload) VALUES (?, ?::jsonb)",
            String.valueOf(conversationId),
            """
            {"type":"message.new","conversationId":%d,"seq":%d,"senderId":%d}"""
                .formatted(conversationId, seq, senderId));

        return new SentMessage(conversationId, seq, clientMsgId);
    }
}

Erklärung Schritt für Schritt:

  • Schritt 1 verwendet SELECT ... FOR UPDATE. Zwei Requests zum gleichen Gespräch werden sequenziell ausgeführt, daher kann seq nie dupliziert oder übersprungen werden. Verschiedene Gespräche blockieren sich nicht gegenseitig.
  • Schritt 2 prüft auf Duplikate nachdem die Sperre erworben wurde. Daher können auch zwei gleichzeitige Wiederholungen mit derselben clientMsgId nicht durchrutschen. Wenn du vor dem Lock prüfst, können beide Requests „nicht vorhanden” sehen. Der zweite Request würde dann in eine DuplicateKeyException geraten.
  • Schritt 3 erhöht seq kontinuierlich ohne Lücken. Dies ermöglicht dem Client, fehlende Nachrichten selbst zu erkennen (Abschnitt 7.3).
  • Schritt 5 wendet Transactional Outbox an. Wenn du direkt in Kafka während einer Transaktion sendest, kann Kafka die Nachricht erhalten, aber die DB rollback, oder umgekehrt.

Rufe send zweimal mit der gleichen clientMsgId auf:

send(1, 10, a1b2..., "Hallo Gruppe") -> SentMessage[conversationId=1, seq=42, ...]
send(1, 10, a1b2..., "Hallo Gruppe") -> SentMessage[conversationId=1, seq=42, ...]   // erstellt keine neue Nachricht

Ein separater Relay liest die outbox-Tabelle nach id, pushed zum chat.events-Topic mit conversationId als Key und löscht dann die gesendeten Zeilen. Du kannst auch Debezium für dies verwenden. Der Key nach Gespräch stellt sicher, dass alle Events des gleichen Gesprächs in die gleiche Partition gehen und die Reihenfolge beibehalten wird.

Die Consumer-Seite pushed Events zu WebSocket-Gateways:

@Component
public class FanoutListener {

    private final MemberRepository members;   // gibt UserIds des Gesprächs zurück
    private final GatewayRegistry gateways;   // userId -> online Sessions

    public FanoutListener(MemberRepository members, GatewayRegistry gateways) {
        this.members = members;
        this.gateways = gateways;
    }

    @KafkaListener(topics = "chat.events", groupId = "fanout")
    public void onEvent(ConsumerRecord<String, String> record) {
        long conversationId = Long.parseLong(record.key());
        for (long userId : members.findUserIds(conversationId)) {
            gateways.pushIfOnline(userId, record.value());
        }
    }
}

Offline-Benutzer erhalten keinen Push, und das ist in Ordnung. Nach dem Verbindungsaufbau ruft der Client /inbox auf und lädt selbst alle Nachrichten mit seq größer als die letzte seq, die er kennt.

6. Lesestatus

Das Markieren als gelesen erfordert nur eine einzige monotone UPDATE-Anweisung:

@Service
public class ReadStateService {

    private final JdbcTemplate jdbc;

    public ReadStateService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean markRead(long conversationId, long userId, long seq) {
        int updated = jdbc.update("""
            UPDATE conversation_member m
               SET last_read_seq = LEAST(?, c.last_seq)
              FROM conversation c
             WHERE c.id = m.conversation_id
               AND m.conversation_id = ? AND m.user_id = ?
               AND m.last_read_seq < LEAST(?, c.last_seq)""",
            seq, conversationId, userId, seq);
        return updated == 1;   // true wenn der Watermark tatsächlich voranrückt
    }

    public long unreadCount(long conversationId, long userId) {
        return jdbc.queryForObject("""
            SELECT c.last_seq - m.last_read_seq
              FROM conversation c JOIN conversation_member m ON m.conversation_id = c.id
             WHERE c.id = ? AND m.user_id = ?""", Long.class, conversationId, userId);
    }
}

Die UPDATE-Anweisung löst drei Probleme gleichzeitig:

  • Race Condition: Das Telefon sendet seq=50, der Laptop sendet seq=45 später. Die Bedingung last_read_seq < ... verhindert, dass eine alte Anfrage den Watermark zurückzieht. Du brauchst keine Sperre oder Versionierung.
  • Junk-Daten: LEAST(?, c.last_seq) verhindert, dass der Client ein seq sendet, das über den tatsächlichen Nachrichten liegt.
  • Redundanz reduzieren: Wenn updated == 0, ändert sich der Watermark nicht, daher wird kein read.updated-Event emittiert. Unnötige Fanout-Operationen werden vermieden.

Nur wenn markRead true zurückgibt, schreibt der Service ein read.updated-Event in die Outbox, ähnlich wie in Abschnitt 5.

7. Schwierigkeiten und Lösungen

7.1. “Heiße” Gespräche und Lock-Contention

FOR UPDATE serialisiert das Nachrichtenversenden innerhalb eines Gesprächs. Bei 1-1 Chats oder kleinen Gruppen ist dieser Overhead vernachlässigbar. Eine aktive Gruppe mit 1.000 Personen könnte Dutzende Nachrichten pro Sekunde in die gleiche Zeile senden. Jede Transaktion hält die Sperre nur einige Millisekunden, daher ist dieses Niveau handhabbar.

Wenn ein Gespräch mehrere hundert Nachrichten pro Sekunde übersteigt (wie ein Broadcast-Channel), verschiebe die Seq-Vergabe zu Redis mit INCR chat:seq:{conversationId}. Im Gegenzug musst du akzeptieren, dass seq Lücken haben kann, wenn DB-Writes fehlschlagen. Der Client muss zwischen „echten Lücken” und „Nachrichten, die noch nicht eingetroffen sind” unterscheiden.

Hinweis: Verwende nicht created_at oder globale IDs (wie Snowflake) zum Sortieren von Nachrichten im Gespräch. Ein Zeitversatz von nur wenigen Millisekunden zwischen Servern kann die Reihenfolge von zwei dicht beieinander liegenden Nachrichten invertieren.

7.2. Konsistenz zwischen DB, Kafka und Client

Die Konsistenzgarantie dieses Designs:

  1. Die DB ist die Quelle der Wahrheit. Nachrichten werden dem Client nur nach dem Commit bestätigt.
  2. Der Outbox garantiert at-least-once zu Kafka. Consumer können Duplikate erhalten.
  3. Der Client dedupliziert nach (conversationId, seq). Bereits vorhandene Nachrichten werden ignoriert.

7.3. Erkennung fehlender Nachrichten

Da seq kontinuierlich ist, kann der Client Lücken selbst erkennen. Angenommen, der Client hat seq=40 und erhält einen Push für seq=43. Er weiß, dass Nachrichten 41 und 42 fehlen, also ruft er GET /messages?afterSeq=40 auf. Du brauchst keinen komplexen ACK-Mechanismus vom Server für jede einzelne Nachricht.

7.4. Plötzliche Lastspitzen

  • Large-Group Fanout: Eine Nachricht in eine Gruppe mit 1.000 Personen erzeugt 1.000 Pushes. Für Gruppen oberhalb eines Schwellenwerts (z. B. 200 Mitglieder) push nur zu Online-Benutzern, nachschlagen im Redis-Set online:{conversationId}. Das Payload enthält nur eine leichte Benachrichtigung {conversationId, seq}, der Client zieht den Inhalt auf Anfrage.
  • „Gelesen”-Sturm: Nach einem Netzwerkausfall verbinden sich Millionen von Clients erneut und senden alle einen Read-Receipt. Der Client sollte etwa 1–2 Sekunden debounce und nur die höchste seq senden. Der Server kann in Redis weiterhin aggregieren, nur den max-Wert unter dem Schlüssel {conversationId}:{userId} behalten und dann in Batches zur DB flushen.
  • Kafka: Partitioniere nach conversationId, um die Reihenfolge zu bewahren. Wenn die Last steigt, kannst du einfach mehr Consumer hinzufügen, maximal gleich der Partitionsanzahl.
  • Speicher: 400 Millionen Nachrichten/Tag, jede etwa 300 Bytes, entsprechend etwa 120 GB/Tag. Partitioniere die message-Tabelle nach conversation_id (Hash) und migriere alte Nachrichten zu günstigerem Speicher wie Cassandra oder Object Storage. Historienabfragen enthalten immer conversation_id, daher funktioniert jedes Modell.

8. Test der Kernverhalten

Die zwei wichtigsten Verhalten zu testen sind: Wiederholtes Senden erzeugt keine Duplikate, und der Watermark läuft nie rückwärts. Du solltest mit echtem PostgreSQL über Testcontainers testen, da die Logik FOR UPDATE und GREATEST/LEAST verwendet.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Lege die DDL aus Abschnitt 3 in src/main/resources/schema.sql und aktiviere spring.sql.init.mode=always.

@SpringBootTest(properties = {
    "spring.sql.init.mode=always",
    "spring.kafka.listener.auto-startup=false"
})
@Testcontainers
class ChatServiceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired MessageService messages;
    @Autowired ReadStateService reads;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.execute("TRUNCATE message, outbox, conversation_member, conversation");
        jdbc.update("INSERT INTO conversation(id, type) VALUES (1, 'GROUP')");
        jdbc.update("INSERT INTO conversation_member(conversation_id, user_id) VALUES (1, 10), (1, 20)");
    }

    @Test
    void resendingSameClientMessageIdDoesNotCreateDuplicate() {
        UUID clientMsgId = UUID.randomUUID();

        SentMessage first = messages.send(1, 10, clientMsgId, "Hallo Gruppe");
        SentMessage retry = messages.send(1, 10, clientMsgId, "Hallo Gruppe");

        assertThat(retry.seq()).isEqualTo(first.seq()).isEqualTo(1L);
        assertThat(jdbc.queryForObject("SELECT count(*) FROM message", Long.class)).isEqualTo(1L);
    }

    @Test
    void readWatermarkNeverMovesBackward() {
        for (int i = 0; i < 5; i++) {
            messages.send(1, 10, UUID.randomUUID(), "Nachricht " + i);
        }

        assertThat(reads.markRead(1, 20, 4)).isTrue();
        assertThat(reads.markRead(1, 20, 2)).isFalse();   // alte Anfrage kommt zu spät
        assertThat(reads.markRead(1, 20, 99)).isTrue();   // begrenzt auf last_seq = 5

        assertThat(reads.unreadCount(1, 20)).isZero();
        assertThat(reads.unreadCount(1, 10)).isZero();    // Absender hat selbst gelesen
    }
}

Die Eigenschaft spring.kafka.listener.auto-startup=false verhindert, dass der Test versucht, sich mit Kafka zu verbinden.

9. Fazit

Das gesamte Design dreht sich um eine einzige Entscheidung: Jedes Gespräch hat eine durchgehende Seq. Aus der Seq erhältst du Nachrichtenreihenfolge, Verlauf-Paginierung, Erkennung fehlender Nachrichten und die Anzahl ungelesener Nachrichten durch eine einfache Subtraktion. Der Lesestatus ist ein monotoner Watermark, aktualisiert durch eine bedingte UPDATE-Anweisung. Dieser Ansatz eliminiert sowohl Race Conditions als auch riesige Receipt-Tabellen.

Der Rest ist vertraute Technik. clientMsgId zusammen mit einem Unique-Constraint ergibt Idempotenz. Transactional Outbox mit Kafka-Key nach Gespräch sorgt für Konsistenz und Event-Reihenfolge. Debouncing, nur Push zu Online-Nutzern und Pull nach Wiederverbindung helfen dem System, plötzliche Lastspitzen zu überstehen. Starten Sie mit PostgreSQL und FOR UPDATE. Verschiebe die Seq-Vergabe nur zu Redis oder migriere den Speicher, wenn echte Daten zeigen, dass du es wirklich brauchst.

system-designspring-bootpostgresqlkafkacase-study