Diseño de sistema de notificaciones: push, email, SMS con reintentos y prioridades

1. Descripción general

Casi cualquier producto necesita enviar notificaciones: códigos OTP por SMS, correos de confirmación de pedidos, push cuando hay nuevos mensajes. Enviar una notificación es fácil. Difícil es enviar millones sin perderlas, sin duplicarlas, y que los OTP no se atasquen esperando tras correos de marketing.

En este artículo, diseñaremos un sistema de notificaciones multicanal desde cero. El alcance incluye requisitos, modelo de datos, API, flujos de procesamiento y cómo resolver los puntos delicados: duplicados, consistencia entre BD y Kafka, picos de carga. El código usa Java 21, Spring Boot 3.x, PostgreSQL, Kafka y Redis.

2. Requisitos del problema

2.1. Requisitos funcionales

  • Otros servicios (Order, Auth, Marketing…) llaman a una API para enviar notificaciones a un usuario a través de uno o más canales: PUSH, EMAIL, SMS.
  • Cada notificación tiene una prioridad: HIGH (OTP, alertas de seguridad), NORMAL (transacciones), LOW (marketing).
  • El sistema reintenta automáticamente cuando el proveedor falla temporalmente. Después del máximo de intentos, la notificación se marca como FAILED para auditoría.
  • El sistema respeta las preferencias del usuario, por ejemplo si desactivó correos de marketing.

2.2. Escala y restricciones

  • Alrededor de 50 millones de notificaciones/día. En promedio cerca de 600/segundo, pero en campaña marketing puede llegar a 20.000/segundo en picos.
  • Las notificaciones HIGH deben entregarse en segundos, incluso bajo carga máxima.
  • Los proveedores de SMS/email limitan velocidad, por ejemplo 500 requests/segundo por API key.
  • La semántica de entrega es at-least-once en infraestructura, pero debe haber deduplicación a nivel razonable. Recibir dos códigos OTP es una mala experiencia. Recibir dos SMS cuesta dinero.

3. Arquitectura general

Servicio cliente ──POST──▶ API de notificaciones ──(1 transacción)──▶ PostgreSQL
                                                                │ notification, delivery (PENDING)
                                                                ▼
                                                   Outbox Relay (poll, SKIP LOCKED)
                                                                │ publicar por prioridad
                                        ┌──────────────────────┼──────────────────────┐
                                        ▼                      ▼                      ▼
                                   notif.high            notif.normal            notif.low
                                        │                      │                      │
                                        └──────────▶ Delivery Workers ◀───────────────┘
                                                      │  dedup (Redis) + rate limit (Redis)
                                                      ▼
                                           FCM/APNs · Proveedor email · Proveedor SMS
                                                      │
                                    éxito → SENT │ error temporal → PENDING + next_attempt_at

La idea clave: la tabla delivery es la fuente de verdad. Kafka es solo el transporte. Reintentos no necesitan un topic aparte. El worker solo pone el registro en PENDING con next_attempt_at en el futuro, y el Outbox Relay volverá a empujarlo cuando llegue la hora.

4. Modelo de datos

CREATE TABLE notification (
    id              UUID PRIMARY KEY,
    idempotency_key VARCHAR(100) NOT NULL UNIQUE,
    user_id         BIGINT       NOT NULL,
    template        VARCHAR(50)  NOT NULL,
    payload         JSONB        NOT NULL,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE TABLE delivery (
    id              UUID PRIMARY KEY,
    notification_id UUID        NOT NULL REFERENCES notification(id),
    channel         VARCHAR(10) NOT NULL,          -- PUSH | EMAIL | SMS
    priority        SMALLINT    NOT NULL,          -- 0 HIGH, 1 NORMAL, 2 LOW
    status          VARCHAR(10) NOT NULL,          -- PENDING | QUEUED | SENT | FAILED
    attempts        INT         NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_error      TEXT,
    UNIQUE (notification_id, channel)
);

CREATE INDEX idx_delivery_due
    ON delivery (priority, next_attempt_at)
    WHERE status = 'PENDING';

Algunos puntos relevantes:

  • idempotency_key UNIQUE evita que si el cliente reintenta la llamada API, se cree una notificación duplicada.
  • Cada canal es una fila delivery independiente. Si email falla, no obliga a push a reintentarse también.
  • priority se repite en delivery para que el partial index idx_delivery_due sirva exactamente a la consulta del relay. El índice solo contiene filas PENDING, así que siempre es pequeño, aunque la tabla tenga miles de millones de filas SENT.

5. API de recepción de notificaciones

Primero, declara las dependencias necesarias:

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

Luego la API. El cliente debe enviar obligatoriamente el header Idempotency-Key. Generalmente es un ID de negocio, como order-123-confirmed.

public enum Priority { HIGH, NORMAL, LOW }
public enum Channel { PUSH, EMAIL, SMS }

public record SendRequest(long userId, String template, Map<String, Object> payload,
                          Priority priority, Set<Channel> channels) {}

@RestController
@RequestMapping("/api/notifications")
class NotificationController {

    private final NotificationService service;

    NotificationController(NotificationService service) { this.service = service; }

    @PostMapping
    ResponseEntity<Map<String, UUID>> send(@RequestHeader("Idempotency-Key") String key,
                                           @RequestBody SendRequest req) {
        UUID id = service.accept(key, req);
        return ResponseEntity.accepted().body(Map.of("id", id));
    }
}

La API devuelve 202 Accepted porque el envío es asincrónico. El procesamiento real ocurre en el servicio:

@Service
class NotificationService {

    private final JdbcTemplate jdbc;
    private final ObjectMapper mapper;

    NotificationService(JdbcTemplate jdbc, ObjectMapper mapper) {
        this.jdbc = jdbc;
        this.mapper = mapper;
    }

    @Transactional
    public UUID accept(String key, SendRequest req) {
        List<UUID> inserted = jdbc.queryForList("""
            INSERT INTO notification (id, idempotency_key, user_id, template, payload)
            VALUES (?, ?, ?, ?, ?::jsonb)
            ON CONFLICT (idempotency_key) DO NOTHING
            RETURNING id
            """, UUID.class, UUID.randomUUID(), key, req.userId(), req.template(), toJson(req.payload()));

        if (inserted.isEmpty()) {   // solicitud repetida → devolver notificación antigua
            return jdbc.queryForObject(
                "SELECT id FROM notification WHERE idempotency_key = ?", UUID.class, key);
        }
        UUID id = inserted.getFirst();
        for (Channel ch : req.channels()) {
            jdbc.update("""
                INSERT INTO delivery (id, notification_id, channel, priority, status)
                VALUES (?, ?, ?, ?, 'PENDING')
                """, UUID.randomUUID(), id, ch.name(), req.priority().ordinal());
        }
        return id;
    }

    private String toJson(Map<String, Object> payload) {
        try {
            return mapper.writeValueAsString(payload);
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("payload inválido", e);
        }
    }
}

ON CONFLICT ... DO NOTHING RETURNING id maneja el conflicto directamente en la base de datos. Cuando dos solicitudes con la misma clave llegan simultáneamente, solo una insert tiene éxito. La otra recibe una lista vacía y luego lee el ID antiguo. No necesitas bloqueos en la capa de aplicación.

Ten en cuenta que el servicio no publica en Kafka dentro de esta transacción. Eso es exactamente el patrón Transactional Outbox, que explica la siguiente sección.

6. Outbox Relay y colas por prioridad

Supón que escribes en BD y luego publicas a Kafka. Si la aplicación muere entre los dos pasos, la notificación queda en BD pero nunca se envía. Si lo haces al revés, publicas primero y luego escribes, el worker puede procesar un registro que aún no existe. Outbox resuelve esto: BD es el único lugar donde escribes en la transacción, y relay lee BD y publica después.

@Component
class OutboxRelay {

    private static final String[] TOPICS =
        {"notifications.high", "notifications.normal", "notifications.low"};

    private final JdbcTemplate jdbc;
    private final KafkaTemplate<String, String> kafka;

    OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 200)
    @Transactional
    public void relay() {
        List<Map<String, Object>> due = jdbc.queryForList("""
            SELECT id, priority FROM delivery
            WHERE status = 'PENDING' AND next_attempt_at <= now()
            ORDER BY priority, next_attempt_at
            LIMIT 500
            FOR UPDATE SKIP LOCKED
            """);
        for (Map<String, Object> row : due) {
            UUID id = (UUID) row.get("id");
            int priority = ((Number) row.get("priority")).intValue();
            kafka.send(TOPICS[priority], id.toString(), id.toString()).join();
            jdbc.update("UPDATE delivery SET status = 'QUEUED' WHERE id = ?", id);
        }
    }
}

El código anterior hace tres cosas:

  • ORDER BY priority, next_attempt_at selecciona filas HIGH primero. Aunque hay 5 millones de correos de marketing esperando, un OTP nuevo se elige en el siguiente poll.
  • FOR UPDATE SKIP LOCKED permite que múltiples instancias de relay corran en paralelo. Cada instancia bloquea su propio lote y omite filas que otras instancias están reteniendo. Ninguna espera a otra.
  • .join() espera confirmación del broker antes de cambiar estado. Si relay muere después de enviar pero antes de hacer commit, la fila sigue en PENDING y se reenviará. Esto es at-least-once. El worker debe deduplicar, ver sección 7.2.

La prioridad se asegura nuevamente en el lado del consumidor mediante topics separados y concurrencia separada:

@Component
class DeliveryListeners {

    private final DeliveryWorker worker;

    DeliveryListeners(DeliveryWorker worker) { this.worker = worker; }

    @KafkaListener(topics = "notifications.high", groupId = "delivery", concurrency = "12")
    void high(String deliveryId) { worker.process(UUID.fromString(deliveryId)); }

    @KafkaListener(topics = "notifications.normal", groupId = "delivery", concurrency = "6")
    void normal(String deliveryId) { worker.process(UUID.fromString(deliveryId)); }

    @KafkaListener(topics = "notifications.low", groupId = "delivery", concurrency = "2")
    void low(String deliveryId) { worker.process(UUID.fromString(deliveryId)); }
}

Kafka no tiene cola de prioridades dentro de un topic. Por eso, separar topics es la solución práctica. Cuando una campaña de marketing hace que notifications.low se atasque con millones de mensajes, el consumer de notifications.high sigue corriendo en sus propios threads y no se ve afectado. El número de concurrency debe ser menor o igual al número de particiones del topic correspondiente.

7. Worker: envío y reintentos

7.1. Política de reintentos

No todo error debe reintentarse. Timeouts o HTTP 429/503 son errores temporales. Un número de teléfono incorrecto o un token push revocado son errores permanentes; reintentar solo cuesta dinero.

public class RetryPolicy {

    private final Duration base;
    private final Duration max;
    private final int maxAttempts;

    public RetryPolicy(Duration base, Duration max, int maxAttempts) {
        this.base = base;
        this.max = max;
        this.maxAttempts = maxAttempts;
    }

    /** intento: número de intentos ya realizados (comienza en 1). Empty = detener reintentos. */
    public Optional<Duration> nextDelay(int attempt) {
        if (attempt >= maxAttempts) return Optional.empty();
        long millis = base.toMillis() * (1L << Math.min(attempt - 1, 20));
        return Optional.of(Duration.ofMillis(Math.min(millis, max.toMillis())));
    }
}

Con base = 2s, max = 5m, maxAttempts = 6, los intervalos de espera son respectivamente:

intento 1 falló → esperar 2s
intento 2 falló → esperar 4s
intento 3 falló → esperar 8s
intento 4 falló → esperar 16s
intento 5 falló → esperar 32s
intento 6 falló → FAILED

En producción, deberías sumar un jitter aleatorio al intervalo de espera. De lo contrario, miles de notificaciones que fallan al mismo tiempo en un proveedor reintentan exactamente en el mismo momento. Este fenómeno se llama thundering herd.

Puedes probar esta política con un test simple:

class RetryPolicyTest {

    private final RetryPolicy policy =
        new RetryPolicy(Duration.ofSeconds(2), Duration.ofSeconds(10), 6);

    @Test
    void backoffCrecerEnExponencialYLimitarseAMax() {
        assertThat(policy.nextDelay(1)).contains(Duration.ofSeconds(2));
        assertThat(policy.nextDelay(3)).contains(Duration.ofSeconds(8));
        assertThat(policy.nextDelay(4)).contains(Duration.ofSeconds(10)); // limitado por max
    }

    @Test
    void detenerReintentoCuandoSeLlegaAlMaximo() {
        assertThat(policy.nextDelay(6)).isEmpty();
    }
}

7.2. Worker: deduplicación, rate limit, actualización de estado

public class TransientSendException extends RuntimeException {
    public TransientSendException(String msg) { super(msg); }
}

public interface ChannelSender {
    Channel channel();
    void send(UUID deliveryId);   // lanzar TransientSendException si error temporal
}

@Service
class DeliveryWorker {

    private final JdbcTemplate jdbc;
    private final StringRedisTemplate redis;
    private final Map<Channel, ChannelSender> senders;
    private final RetryPolicy retry = new RetryPolicy(Duration.ofSeconds(2), Duration.ofMinutes(5), 6);

    DeliveryWorker(JdbcTemplate jdbc, StringRedisTemplate redis, List<ChannelSender> list) {
        this.jdbc = jdbc;
        this.redis = redis;
        this.senders = list.stream().collect(Collectors.toMap(ChannelSender::channel, s -> s));
    }

    public void process(UUID id) {
        Map<String, Object> d = jdbc.queryForMap(
            "SELECT channel, attempts, status FROM delivery WHERE id = ?", id);
        if ("SENT".equals(d.get("status"))) return;               // mensaje duplicado de Kafka

        Boolean first = redis.opsForValue()
            .setIfAbsent("notif:sending:" + id, "1", Duration.ofMinutes(10));
        if (!Boolean.TRUE.equals(first)) return;                  // otro worker está enviando

        Channel channel = Channel.valueOf((String) d.get("channel"));
        int attempt = ((Number) d.get("attempts")).intValue() + 1;
        try {
            if (!acquireRate(channel)) {
                throw new TransientSendException("rate limitado");  // posponer, no contar como error
            }
            senders.get(channel).send(id);
            jdbc.update("UPDATE delivery SET status='SENT', attempts=? WHERE id=?", attempt, id);
        } catch (TransientSendException e) {
            redis.delete("notif:sending:" + id);
            retry.nextDelay(attempt).ifPresentOrElse(
                delay -> jdbc.update("""
                    UPDATE delivery SET status='PENDING', attempts=?, last_error=?,
                           next_attempt_at = now() + make_interval(secs => ?)
                    WHERE id=?""", attempt, e.getMessage(), delay.toSeconds(), id),
                () -> markFailed(id, attempt, e.getMessage()));
        } catch (RuntimeException e) {                             // error permanente
            markFailed(id, attempt, e.getMessage());
        }
    }

    private void markFailed(UUID id, int attempt, String error) {
        jdbc.update("UPDATE delivery SET status='FAILED', attempts=?, last_error=? WHERE id=?",
            attempt, error, id);
    }

    private boolean acquireRate(Channel channel) {
        String key = "rate:" + channel + ":" + Instant.now().getEpochSecond();
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
        return count != null && count <= 500;
    }
}

El worker tiene dos capas de deduplicación:

  1. Verificar status = 'SENT' elimina mensajes republicados después de completarse el envío.
  2. Bloqueo Redis SET NX con TTL evita que dos consumidores procesen el mismo mensaje, por ejemplo cuando Kafka rebalancea a mitad del camino. El TTL de 10 minutos asegura que el bloqueo se libere automáticamente si el worker muere.

Esto aún no es exactly-once. Si el worker llama al proveedor con éxito pero muere antes de hacer UPDATE ... 'SENT', la notificación puede reenviarse después de que expire el bloqueo. Para ser más estricto, pasa deliveryId como clave de idempotencia al proveedor, si lo soporta. Muchos proveedores de email y FCM tienen este mecanismo.

acquireRate es un rate limiter de ventana fija en Redis. Todas las instancias de worker comparten un contador por segundo para cada canal, así el total de solicitudes no excede la cuota de 500 por segundo del proveedor. Cuando se supera el umbral, la notificación se aplaza a PENDING en lugar de llamar al proveedor y recibir HTTP 429.

8. Puntos difíciles y sus soluciones

8.1. Picos de carga

Cuando Marketing envía 10 millones de notificaciones a la vez, el sistema siente la carga en cada capa:

  • API solo inserta en BD y devuelve, así que aguanta carga alta. Con campañas grandes, proporciona un API batch separado que reciba una lista de usuarios o un segmento. Un job de fondo genera filas de delivery en lotes de 1.000.
  • Kafka absorbe el exceso. Que notifications.low se atasque es normal, siempre que notifications.high fluya.
  • Rate limiter protege al proveedor. Que notificaciones LOW se envíen un poco lentamente no molesta a nadie.

Añade una medida más: en acquireRate, reserva parte de la cuota para HIGH, por ejemplo LOW usa solo el 60% del umbral. Así, OTP nunca se rate-limitará por culpa de correos de marketing.

8.2. Consistencia entre BD y Kafka

Outbox combinado con at-least-once más consumer idempotente es el estándar de oro. No necesitas transacciones distribuidas (XA). El precio es una latencia adicional de aproximadamente un ciclo de poll, aquí 200ms. Si necesitas latencia más baja, reemplaza polling con CDC (leer WAL de PostgreSQL), pero la infraestructura se vuelve más compleja.

8.3. Datos que crecen sin control

Cada día la tabla delivery crece con 60–80 millones de filas. Deberías particionar la tabla por día (PARTITION BY RANGE (created_at)) y eliminar particiones mayores a 30–90 días. El partial index de la sección 4 mantiene rápida la consulta del relay, pero no sustituye la limpieza de datos.

8.4. Preferencias de usuario y horarios silenciosos

Comprueba preferencias justo al crear delivery en NotificationService. Si el usuario desactivó un canal, no crees su fila. Para horarios silenciosos, por ejemplo no enviar push de marketing después de las 22:00, solo establece next_attempt_at a las 8:00 de la mañana siguiente. El mecanismo del relay maneja el resto, sin scheduler adicional.

Nota: No llames al proveedor dentro de @Transactional largo. Una solicitud HTTP lenta 30 segundos retendrá la conexión a BD y los bloqueos de fila todo ese tiempo, y el pool se agotará rápidamente. En DeliveryWorker, cada UPDATE es una transacción corta, independiente de la llamada al proveedor.

Nota: No reintentar infinito ni reintentar errores permanentes. Un número de teléfono incorrecto, reintentado 6 veces, multiplicado por 1 millón de usuarios es una factura de SMS muy cara. Clasifica códigos de error de cada proveedor con cuidado.

8.5. Cuándo NO usar este diseño

Si solo envías unos pocos miles de correos/día, PostgreSQL más SKIP LOCKED es suficiente. Los workers consultan directamente la tabla delivery, sin Kafka. Kafka vale la pena cuando necesitas separar consumidores por prioridad a escala grande, o cuando muchos sistemas consumen el mismo flujo de eventos. Al revés, si necesitas garantizar orden absoluto entre notificaciones del mismo usuario, este diseño es insuficiente. Deberías usar userId como clave de Kafka y evitar reintentos asincronos para esas notificaciones.

9. Conclusión

Hemos diseñado un sistema de notificaciones multicanal con decisiones arquitectónicas principales:

  • La tabla delivery es la fuente de verdad. Reintentos son solo reestablecer PENDING con next_attempt_at.
  • Transactional Outbox con FOR UPDATE SKIP LOCKED mantiene BD y Kafka consistentes sin transacciones distribuidas, y permite múltiples relays en paralelo.
  • Prioridad garantizada en dos capas: orden de poll en SQL, y topics más concurrencia separados en Kafka.
  • Deduplicación multicapa: Idempotency-Key en API, verificación de estado y bloqueo Redis en worker, clave de idempotencia enviada al proveedor.
  • Rate limiter compartido en Redis protege cuota del proveedor cuando hay picos de carga.

Desde este marco, puedes expandir con plantillas multiidioma, webhooks recibiendo estado “entregado/leído” del proveedor, o dashboard rastreando proporción de FAILED por cada canal.

system-designspring-bootkafkaredispostgresql