Diseño de sistema de chat 1-1 y en grupo: almacenar mensajes y estado de lectura

1. Descripción general

El chat parece simple: envías un mensaje y lo muestras. Pero cuando tienes millones de usuarios, tienes que responder preguntas más difíciles. ¿Cómo mantener el orden correcto de los mensajes? ¿Cómo reintentar un envío sin crear mensajes duplicados? ¿Cómo saber hasta dónde ha leído cada usuario sin guardar miles de millones de registros de “visualización”?

Este artículo presenta un diseño completo para chat 1-1 y chat en grupo. El diseño incluye requisitos, modelo de datos, APIs, flujo de procesamiento y desafíos. Después de leerlo, podrás construir el núcleo para almacenar mensajes y estado de lectura usando Spring Boot 3, PostgreSQL y Kafka.

2. Requisitos

2.1. Funcionalidad

  • Chat 1-1 y chat en grupo (máximo 1.000 miembros).
  • Enviar mensajes de texto. Los mensajes se muestran en el orden correcto dentro de una conversación.
  • Ver historial, desplazarse hacia atrás por páginas.
  • Lista de conversaciones (inbox) con el número de mensajes sin leer.
  • Estado de lectura: chat 1-1 muestra “Visto”, chat en grupo muestra “Visto por N personas”.

2.2. Escala y restricciones

  • 10 millones de DAU. En promedio, cada usuario envía 40 mensajes/día, es decir, 400 millones de mensajes/día, alrededor de 4.600 mensajes/segundo. En horas pico puede llegar a alrededor de 30.000 mensajes/segundo.
  • No perder mensajes que fueron confirmados como enviados exitosamente.
  • El orden solo necesita ser correcto dentro de una conversación. No se requiere ordenamiento global.
  • El cliente puede reintentar debido a problemas de red. El servidor debe ser idempotente.
  • Los eventos de “lectura” son más frecuentes que los eventos de envío, ya que cada vez que se abre la app se generan.

La tercera restricción es la clave de todo el diseño. Como solo se necesita orden local, todo puede particionarse por conversation_id.

3. Modelo de datos

La idea central es que cada conversación tiene un número de secuencia incrementado (seq). El mensaje N en una conversación tiene seq = N. El estado de lectura de un miembro es solo un número, last_read_seq. Es un watermark, no una lista de cada mensaje leído.

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

Puntos a destacar:

  • La clave primaria (conversation_id, seq) permite leer el historial escaneando solo un rango contiguo del índice. Esta clave también es la clave de sharding natural cuando particionar.
  • UNIQUE (conversation_id, client_msg_id) es la capa que previene mensajes duplicados en la base de datos.
  • El número de mensajes sin leer es last_seq - last_read_seq. Este cálculo no necesita COUNT(*).
  • “Visto por” en el grupo se calcula por el número de miembros con last_read_seq >= seq. No necesitas una tabla message_receipt con miles de millones de filas.
  • Para chat 1-1, añade una tabla direct_pair(user_low, user_high, conversation_id) con restricción unique. Dos personas abriendo chat al mismo tiempo no crearán dos conversaciones.

Nota: El watermark asume que los usuarios leen secuencialmente, es decir, al leer el mensaje N se considera que se leyeron todos los mensajes anteriores. Esta suposición es válida para casi todas las aplicaciones de chat. Si necesitas saber exactamente qué mensaje específico fue abierto (por ejemplo, para mensajes que se autodestruyen), guarda registros adicionales para ese tipo de mensaje. No lo apliques a todos los mensajes.

4. API

Method Endpoint Descripción
POST /conversations/{id}/messages Enviar mensaje. El body contiene clientMsgId y body
GET /conversations/{id}/messages?beforeSeq=&limit= Historial, paginación por seq
POST /conversations/{id}/read Body { "seq": 120 }
GET /inbox?limit= Lista de conversaciones y número de mensajes sin leer
WS /ws El servidor envía eventos message.new y read.updated

El envío de mensajes pasa por REST en lugar de WebSocket. Esto te da reintentos, timeouts e idempotencia al estilo HTTP familiar. WebSocket solo se usa para enviar al cliente.

5. Flujo de envío de mensajes

Thư viện cần dùng:

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

La operación completa de envío de mensajes ocurre en una transacción:

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. Bloquear fila de conversación: todos los envíos a la misma conversación se encolan
        Long lastSeq = jdbc.queryForObject(
            "SELECT last_seq FROM conversation WHERE id = ? FOR UPDATE",
            Long.class, conversationId);

        // 2. Idempotencia: si el cliente reintenta, devuelve el resultado anterior
        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. Asignar nuevo seq, escribir mensaje y actualizar conversación
        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. El remitente obviamente ha leído su propio mensaje
        jdbc.update("""
            UPDATE conversation_member SET last_read_seq = GREATEST(last_read_seq, ?)
            WHERE conversation_id = ? AND user_id = ?""", seq, conversationId, senderId);

        // 5. Outbox: los eventos se escriben en la misma transacción, nunca divergen de los datos
        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);
    }
}

Explicación paso a paso:

  • Paso 1 usa SELECT ... FOR UPDATE. Dos solicitudes enviadas a la misma conversación se ejecutarán secuencialmente, por lo que seq nunca se duplica ni salta. Diferentes conversaciones no se bloquean entre sí.
  • Paso 2 verifica duplicados después de mantener el bloqueo. Así, dos reintentos simultáneos del mismo clientMsgId tampoco pueden pasar. Si verificas antes de bloquear, ambas solicitudes podrían ver “no existe”. La segunda solicitud entonces resultaría en DuplicateKeyException.
  • Paso 3 incrementa seq continuamente, sin espacios. Gracias a esto, el cliente puede detectar automáticamente mensajes faltantes (sección 7.3).
  • Paso 5 aplica transactional outbox. Si envías directamente a Kafka dentro de la transacción, puede pasar que Kafka reciba pero la BD haga rollback, o viceversa.

Llamar a send dos veces con el mismo clientMsgId:

send(1, 10, a1b2..., "Hola grupo") -> SentMessage[conversationId=1, seq=42, ...]
send(1, 10, a1b2..., "Hola grupo") -> SentMessage[conversationId=1, seq=42, ...]   // no crea nuevo mensaje

Una retransmisión separada lee la tabla outbox por id, la envía al topic chat.events con la clave como conversationId, y luego elimina las filas enviadas. También puedes usar Debezium para esto. La clave por conversación asegura que todos los eventos de la misma conversación vayan a la misma partición y mantengan el orden.

Phía consumer đẩy sự kiện xuống các gateway WebSocket:

@Component
public class FanoutListener {

    private final MemberRepository members;   // retorna la lista de userIds de la conversación
    private final GatewayRegistry gateways;   // userId -> sesiones actualmente en línea

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

Los usuarios offline no reciben push, y eso está bien. Cuando se reconectan, el cliente llama a /inbox y carga automáticamente los mensajes con seq mayor que el último seq que tiene.

6. Estado de lectura

Marcar como leído requiere solo una sentencia UPDATE simple y monótona:

@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 si el watermark realmente avanzó
    }

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

La sentencia UPDATE resuelve tres problemas a la vez:

  • Carreras: el teléfono envía seq=50, la laptop envía seq=45 más tarde. La condición last_read_seq < ... previene que la solicitud antigua tire del watermark hacia atrás. No necesitas bloqueo ni versionado.
  • Datos basura: LEAST(?, c.last_seq) previene que el cliente envíe seq mayor al mensaje real.
  • Reducción de carga: cuando updated == 0, el watermark no cambia, así que no se emite el evento read.updated. El fanout innecesario se evita.

Solo cuando markRead devuelve true, el servicio escribe un evento read.updated al outbox, similar a la sección 5.

7. Desafíos y soluciones

7.1. Conversaciones “calientes” y contención de bloqueos

FOR UPDATE serializa el envío de mensajes en una conversación. Para chat 1-1 o grupos pequeños, este costo es insignificante. Un grupo de 1.000 personas activas puede tener decenas de mensajes/segundo en la misma fila. Cada transacción solo sostiene el bloqueo unos pocos milisegundos, así que este nivel se puede soportar.

Si una conversación excede algunos cientos de mensajes/segundo (como un canal de broadcast), extrae el generador de seq a Redis usando INCR chat:seq:{conversationId}. A cambio, tienes que aceptar huecos en seq cuando la escritura a BD falla. El cliente debe diferenciar entre “huecos reales” y “mensajes que aún no llegan”.

Nota: No uses created_at ni IDs globales (Snowflake) para ordenar mensajes en una conversación. Diferencias de reloj de unos pocos milisegundos entre servidores son suficientes para invertir el orden de dos mensajes enviados casi simultáneamente.

7.2. Consistencia entre BD, Kafka y cliente

La cadena de garantías de este diseño:

  1. La BD es la fuente de verdad. Los mensajes se confirman al cliente solo después de commit.
  2. El outbox garantiza at-least-once a Kafka. El consumer puede recibir duplicados.
  3. El cliente desduplica por (conversationId, seq). Cualquier mensaje que ya existe se ignora.

7.3. Detección de mensajes faltantes

Como seq es continuo, el cliente puede detectar automáticamente los huecos. Supongamos que el cliente tiene seq=40 y recibe push seq=43. Sabe que faltan los mensajes 41 y 42, así que llama a GET /messages?afterSeq=40. No necesitas un complejo mecanismo de ACK del servidor para cada mensaje.

7.4. Picos de carga

  • Fanout de grupo grande: un mensaje a un grupo de 1.000 personas crea 1.000 pushes. Para grupos por encima de un umbral (por ejemplo, 200 miembros), solo envía a quienes están en línea, consultando el conjunto Redis online:{conversationId}. El payload solo contiene una notificación ligera {conversationId, seq}, el cliente tira el contenido cuando lo necesita.
  • Tormenta de “lectura”: después de una interrupción de red, millones de clientes se reconectan y envían receipts de lectura. El cliente debe debounce alrededor de 1–2 segundos y enviar solo el seq máximo. El servidor puede consolidar aún más en Redis, manteniendo solo el valor máximo por clave {conversationId}:{userId}, luego hacer flush a la BD en lotes.
  • Kafka: particiona por conversationId para mantener el orden. Cuando la carga aumenta, simplemente aumenta el número de consumidores, máximo igual al número de particiones.
  • Almacenamiento: 400 millones de mensajes/día, cada mensaje alrededor de 300 bytes, equivalente a alrededor de 120 GB/día. Particiona la tabla message por conversation_id (hash) y mueve mensajes antiguos a almacenamiento más barato como Cassandra o object storage. Las consultas de historial siempre incluyen conversation_id, así que cualquier modelo funciona.

8. Pruebas del comportamiento principal

Los dos comportamientos más importantes para probar son: reintentar no crea duplicados, y el watermark nunca retrocede. Debes probar con PostgreSQL real a través de Testcontainers porque la lógica depende de FOR UPDATE y GREATEST/LEAST.

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

Coloca el DDL de la sección 3 en src/main/resources/schema.sql y habilita 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, "Hola grupo");
        SentMessage retry = messages.send(1, 10, clientMsgId, "Hola grupo");

        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(), "msg " + i);
        }

        assertThat(reads.markRead(1, 20, 4)).isTrue();
        assertThat(reads.markRead(1, 20, 2)).isFalse();   // solicitud anterior llega tarde
        assertThat(reads.markRead(1, 20, 99)).isTrue();   // limitado a last_seq = 5

        assertThat(reads.unreadCount(1, 20)).isZero();
        assertThat(reads.unreadCount(1, 10)).isZero();    // el remitente automáticamente ha leído
    }
}

La segunda prueba verifica tres situaciones. El watermark avanza a 4. La solicitud seq=2 que llega tarde se ignora y devuelve false. Seq que excede lo real se limita a 5, así que el número de mensajes sin leer es 0. La propiedad spring.kafka.listener.auto-startup=false previene que la prueba intente conectarse a Kafka.

9. Conclusión

Todo el diseño gira alrededor de una decisión: cada conversación tiene un seq continuo. A partir del seq, obtienes orden de mensajes, paginación de historial, detección de mensajes faltantes y número de mensajes sin leer solo con una resta. El estado de lectura es un watermark monótono, actualizado con una sentencia UPDATE condicional. Esto elimina tanto las carreras como las enormes tablas de receipts.

El resto es técnica familiar. clientMsgId más restricción unique para idempotencia. Transactional outbox con clave Kafka por conversación para consistencia y orden de eventos. Debounce, solo enviar a quienes están en línea y pull al reconectarse ayuda al sistema a sobrevivir picos de carga. Comienza con PostgreSQL y FOR UPDATE. Solo extrae el generador de seq a Redis o cambia el almacenamiento cuando los datos reales muestren que realmente lo necesitas.

system-designspring-bootpostgresqlkafkacase-study