Design de sistema de notificações: push, email, SMS com retry e prioridade

1. Visão geral

Praticamente todo produto precisa enviar notificações: código OTP por SMS, email de confirmação de pedido, push quando há nova mensagem. Enviar uma notificação é fácil. Difícil é enviar milhões de notificações sem perdê-las, sem duplicação, e garantir que OTP não fique na fila atrás de email de marketing.

Neste artigo, vamos projetar um sistema de notificações multicanal do início ao fim. O escopo inclui requisitos, modelo de dados, API, fluxo de processamento e soluções para os pontos desafiadores: deduplicação, consistência entre DB e Kafka, picos de carga. O código usa Java 21, Spring Boot 3.x, PostgreSQL, Kafka e Redis.

2. Requisitos

2.1. Requisitos funcionais

  • Outros serviços (Order, Auth, Marketing…) chamam uma API para enviar notificações para um usuário através de um ou mais canais: PUSH, EMAIL, SMS.
  • Cada notificação tem uma prioridade: HIGH (OTP, alertas de segurança), NORMAL (transações), LOW (marketing).
  • O sistema faz retry automático quando o provedor falha temporariamente. Após o número máximo de tentativas, a notificação é marcada como FAILED para auditoria.
  • O sistema respeita as preferências do usuário, por exemplo, se o usuário desativou emails de marketing.

2.2. Escala e restrições

  • Cerca de 50 milhões de notificações/dia. Média de ~600/segundo, com picos durante campanhas de marketing chegando a 20.000/segundo.
  • Notificações HIGH devem ser entregues em poucos segundos, mesmo durante picos de carga.
  • Provedores de SMS/email limitam a taxa, por exemplo, 500 requisições/segundo para cada chave de API.
  • A semântica de entrega é at-least-once na camada de infraestrutura, mas devemos implementar deduplicação em nível aplicação. Receber dois OTPs é uma experiência ruim. Receber dois SMSs ainda custa dinheiro.

3. Arquitetura geral

Client service ──POST──▶ Notification API ──(1 transação)──▶ PostgreSQL
                                                         │ notification, delivery (PENDING)
                                                         ▼
                                              Outbox Relay (poll, SKIP LOCKED)
                                                         │ publicar por prioridade
                         ┌───────────────────────────────┼───────────────────────────┐
                         ▼                               ▼                           ▼
              notifications.high              notifications.normal          notifications.low
                         │                               │                           │
                         └────────────▶ Delivery Workers ◀───────────────────────────┘
                                         │  dedup (Redis) + rate limit (Redis)
                                         ▼
                              FCM/APNs · Email provider · SMS provider
                                         │
                       sucesso → SENT │ erro temporário → PENDING + next_attempt_at

A ideia principal: a tabela delivery é a fonte da verdade. Kafka é apenas um meio de transporte. Retry não precisa de um tópico separado. Os workers apenas definem o registro como PENDING com next_attempt_at no futuro, e o Outbox Relay irá republicar quando chegar o momento.

4. Modelo de dados

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

Alguns pontos a notar:

  • idempotency_key UNIQUE evita que o cliente chame novamente a API e crie notificações duplicadas.
  • Cada canal é uma linha delivery separada. Um email que falha não faz o push ser retentado.
  • priority é duplicado em delivery para que o índice parcial idx_delivery_due sirva perfeitamente a consulta do relay. O índice contém apenas linhas PENDING, então permanece pequeno mesmo que a tabela tenha bilhões de linhas SENT.

5. API para receber notificações

Primeiro, você declara as dependências necessárias:

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

Depois vem a API. O cliente deve enviar o header Idempotency-Key. Geralmente é o ID de negócio, por exemplo 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));
    }
}

A API retorna 202 Accepted porque o envio ocorre de forma assíncrona. A lógica principal está no serviço:

@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()) {   // requisição repetida → retorna a notificação antiga
            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 lida com concorrência diretamente no banco. Quando duas requisições com a mesma chave chegam simultaneamente, apenas uma insert tem sucesso. A outra recebe uma lista vazia e relê o ID antigo. Você não precisa de lock na camada de aplicação.

Note que o serviço não publica Kafka nesta transação. Este é o padrão Transactional Outbox, explicado na seção seguinte.

6. Outbox Relay e fila por prioridade

Suponha que você escreve no DB e depois publica Kafka. Se a aplicação morre entre os dois passos, a notificação fica no DB mas nunca é enviada. Se fazer o oposto, publicar Kafka antes de escrever no DB, um worker pode processar um registro que ainda não existe. O Outbox resolve isso: o DB é o único lugar escrito na transação, e o relay lê o DB e publica depois.

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

O código acima faz três coisas:

  • ORDER BY priority, next_attempt_at pega linhas HIGH primeiro. Mesmo que haja 5 milhões de emails de marketing esperando, um novo OTP ainda será escolhido na próxima votação.
  • FOR UPDATE SKIP LOCKED permite executar múltiplas instâncias do relay em paralelo. Cada instância bloqueia um lote diferente e pula linhas que outras instâncias estão segurando. Nenhuma instância precisa esperar por outra.
  • .join() aguarda a confirmação do broker antes de mudar o status. Se o relay morre após enviar mas antes de fazer commit, a linha permanece PENDING e será enviada novamente. Isto é at-least-once. O worker deve implementar deduplicação, veja seção 7.2.

A prioridade é garantida novamente no lado do consumer através de tópicos separados e concorrência 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 não tem fila de prioridade dentro de um tópico. Portanto, separar tópicos é a abordagem mais prática. Quando uma campanha de marketing deixa milhões de mensagens em notifications.low, o consumer de notifications.high ainda roda em suas próprias threads e não é afetado. O número de concurrency deve ser menor ou igual ao número de partições do tópico correspondente.

7. Worker de envio e retry

7.1. Política de retry

Nem todo erro merece retry. Timeout ou HTTP 429/503 são erros temporários. Um número de telefone inválido ou um token push revogado é erro permanente, e retry só custa dinheiro.

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

    /** attempt: número de tentativas já feitas (começando em 1). Empty = parar retry. */
    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())));
    }
}

Com base = 2s, max = 5m, maxAttempts = 6, os intervalos de espera são:

tentativa 1 falha → aguardar 2s
tentativa 2 falha → aguardar 4s
tentativa 3 falha → aguardar 8s
tentativa 4 falha → aguardar 16s
tentativa 5 falha → aguardar 32s
tentativa 6 falha → FAILED

Em produção, você deve adicionar jitter aleatório ao intervalo. Caso contrário, milhares de notificações com o mesmo erro quando o provedor cai vão tentar retry no mesmo momento. Esse fenômeno é chamado de thundering herd.

Você pode testar essa política com um teste simples:

class RetryPolicyTest {

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

    @Test
    void backoffExponencialComLimiteSuperior() {
        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 pararRetryAoAtingirMaximoTentativas() {
        assertThat(policy.nextDelay(6)).isEmpty();
    }
}

7.2. Worker: deduplicação, limitação de taxa, atualização de status

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

public interface ChannelSender {
    Channel channel();
    void send(UUID deliveryId);   // lança TransientSendException se erro temporário
}

@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;               // mensagem duplicada do Kafka

        Boolean first = redis.opsForValue()
            .setIfAbsent("notif:sending:" + id, "1", Duration.ofMinutes(10));
        if (!Boolean.TRUE.equals(first)) return;                  // outro 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 limited");  // adia, não conta como erro do provedor
            }
            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) {                             // erro 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;
    }
}

O worker tem duas camadas de deduplicação:

  1. Verificar status = 'SENT' elimina mensagens que foram republicadas após envio bem-sucedido.
  2. Lock Redis SET NX com TTL evita que dois consumers processem a mesma mensagem, por exemplo durante rebalanceamento de Kafka. TTL de 10 minutos garante que o lock seja liberado se o worker morrer.

Isto ainda não é exactly-once. Se o worker chama o provedor com sucesso mas morre antes de UPDATE ... 'SENT', a notificação pode ser enviada novamente após o lock expirar. Para ser mais rigoroso, passe deliveryId como chave de idempotência para o provedor, se ele suportar. Muitos provedores de email e FCM têm esse mecanismo.

acquireRate é um rate limiter de janela fixa em Redis. Todas as instâncias do worker compartilham um contador por segundo para cada canal, então a taxa total não ultrapassa a cota de 500 requisições/segundo do provedor. Quando excede, a notificação é adiada para PENDING em vez de chamar o provedor e receber HTTP 429.

8. Desafios e soluções

8.1. Picos de carga

Quando o Marketing envia 10 milhões de notificações de uma vez, o sistema enfrenta carga em cada camada:

  • API apenas insere no DB e retorna, então pode lidar com carga alta. Para campanhas grandes, você deve fornecer uma API batch separada que aceite uma lista de usuários ou um segmento. Um trabalho em background gerará linhas delivery em lotes de 1.000 registros.
  • Kafka absorve o excesso. Atraso em low é normal, contanto que high continue fluindo.
  • Rate limiter protege o provedor. Notificações LOW serem entregues um pouco mais lentamente não incomoda ninguém.

Você deve adicionar uma medida extra: em acquireRate, reserve parte da cota para HIGH, por exemplo LOW pode usar apenas 60% do limite. Assim, OTP nunca sofre rate limit por causa de emails de marketing.

8.2. Consistência entre DB e Kafka

Outbox combinado com at-least-once e consumer idempotente é a tríade padrão. Você não precisa de transação distribuída (XA). O preço a pagar é latência adicional de um ciclo de poll, aqui 200ms. Se precisar de latência menor, substitua polling por CDC (ler WAL do PostgreSQL), mas a infraestrutura fica mais complexa.

8.3. Explosão de dados

A tabela delivery cresce ~60–80 milhões de linhas por dia. Você deve particionar a tabela por dia (PARTITION BY RANGE (created_at)) e dropar partições mais antigas que 30–90 dias. O índice parcial na seção 4 mantém a consulta do relay rápida, mas não substitui a limpeza de dados.

8.4. Preferências do usuário e horários de silêncio

Verifique as preferências no momento de criar delivery em NotificationService. Se o usuário desativou um canal, não crie a linha. Para horários de silêncio, por exemplo não enviar push de marketing após as 22h, apenas defina next_attempt_at para 8h da manhã seguinte. O mecanismo relay cuida do resto, sem precisar de scheduler adicional.

Nota: Não chame o provedor dentro de @Transactional longo. Uma requisição HTTP lenta de 30 segundos segura a conexão DB e row lock o tempo todo, e o pool esgota rápido. Em DeliveryWorker, cada UPDATE é uma transação curta, independente da chamada ao provedor.

Nota: Não faça retry infinito e não retry erros permanentes. Um número de telefone errado, retry 6 vezes, multiplicado por 1 milhão de usuários é uma conta de SMS muito grande. Classifique os códigos de erro de cada provedor com cuidado.

8.5. Quando NÃO usar este design

Se você enviar apenas alguns milhares de emails/dia, PostgreSQL com SKIP LOCKED é suficiente. Workers consultam diretamente a tabela delivery, pulam Kafka. Kafka só vale a pena quando você precisa separar consumers por prioridade em escala grande, ou quando muitos sistemas precisam consumir o mesmo fluxo de eventos. Por outro lado, se você precisa garantir ordem absoluta entre notificações do mesmo usuário, o design acima é insuficiente. Você deve usar userId como chave Kafka e remover retry assíncrono para essas notificações.

9. Conclusão

Projetamos um sistema de notificações multicanal com as seguintes decisões principais:

  • A tabela delivery é a fonte da verdade. Retry é apenas definir de volta para PENDING com next_attempt_at.
  • Transactional Outbox com FOR UPDATE SKIP LOCKED mantém DB e Kafka consistentes sem precisar de transação distribuída, enquanto permite executar múltiplos relays em paralelo.
  • Prioridade é garantida em duas camadas: ordem de poll em SQL e tópicos separados com concorrência separada em Kafka.
  • Deduplicação em múltiplas camadas: Idempotency-Key na API, verificação de status e lock Redis no worker, chave de idempotência enviada ao provedor.
  • Rate limiter compartilhado em Redis protege a cota do provedor durante picos de carga.

A partir deste framework, você pode expandir com templates multilíngues, webhooks para receber status “entregue/lido” do provedor, ou dashboard para rastrear taxa de FAILED por canal.

system-designspring-bootkafkaredispostgresql