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
FAILEDpara 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
HIGHdeben 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 UNIQUEevita que si el cliente reintenta la llamada API, se cree una notificación duplicada.- Cada canal es una fila
deliveryindependiente. Si email falla, no obliga a push a reintentarse también. priorityse repite endeliverypara que el partial indexidx_delivery_duesirva exactamente a la consulta del relay. El índice solo contiene filasPENDING, así que siempre es pequeño, aunque la tabla tenga miles de millones de filasSENT.
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_atselecciona filasHIGHprimero. Aunque hay 5 millones de correos de marketing esperando, un OTP nuevo se elige en el siguiente poll.FOR UPDATE SKIP LOCKEDpermite 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 enPENDINGy 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:
- Verificar
status = 'SENT'elimina mensajes republicados después de completarse el envío. - Bloqueo Redis
SET NXcon 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
deliveryen lotes de 1.000. - Kafka absorbe el exceso. Que
notifications.lowse atasque es normal, siempre quenotifications.highfluya. - Rate limiter protege al proveedor. Que notificaciones
LOWse 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
@Transactionallargo. 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. EnDeliveryWorker, cadaUPDATEes 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
deliveryes la fuente de verdad. Reintentos son solo reestablecerPENDINGconnext_attempt_at. - Transactional Outbox con
FOR UPDATE SKIP LOCKEDmantiene 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-Keyen 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.