Concevoir un système de notifications : push, email, SMS
1. Vue d’ensemble
Presque tous les produits ont besoin d’envoyer des notifications : un code OTP par SMS, un email de confirmation de commande, une notification push quand il y a un nouveau message. Envoyer une notification, c’est facile. Difficile d’en envoyer des millions sans en perdre, sans doublons, et faire en sorte que l’OTP ne soit pas en attente après un email marketing.
Dans cet article, nous concevons un système de notifications multi-canal de bout en bout. Le scope comprend les exigences, le modèle de données, l’API, le flux de traitement et comment résoudre les points difficiles : doublons, cohérence entre DB et Kafka, pics de charge. Le code d’illustration utilise Java 21, Spring Boot 3.x, PostgreSQL, Kafka et Redis.
2. Exigences
2.1. Exigences fonctionnelles
- D’autres services (Order, Auth, Marketing…) appellent une API pour envoyer une notification à un utilisateur via un ou plusieurs canaux : PUSH, EMAIL, SMS.
- Chaque notification a une priorité :
HIGH(OTP, alertes de sécurité),NORMAL(transactions),LOW(marketing). - Le système réessaie automatiquement lorsque le fournisseur a une erreur temporaire. Après un nombre maximal de tentatives, la notification est marquée
FAILEDpour interrogation. - Le système respecte les préférences de l’utilisateur, par exemple si l’utilisateur a désactivé les emails marketing.
2.2. Échelle et contraintes
- Environ 50 millions de notifications par jour. En moyenne près de 600/seconde, avec des pics lors de campagnes marketing pouvant atteindre 20 000/seconde.
- Les notifications
HIGHdoivent être envoyées en quelques secondes, même en cas de pic de charge. - Les fournisseurs de SMS/email limitent le débit, par exemple 500 requêtes/seconde par clé API.
- La sémantique de livraison est at-least-once au niveau de l’infrastructure, mais nous devons prévenir les doublons à un niveau raisonnable. Qu’un utilisateur reçoive deux codes OTP est une mauvaise expérience. Recevoir deux SMS facturés, c’est du gaspillage d’argent.
3. Architecture globale
Client service ──POST──▶ Notification API ──(1 transaction)──▶ PostgreSQL
│ notification, delivery (PENDING)
▼
Outbox Relay (poll, SKIP LOCKED)
│ publier selon priorité
┌───────────────────────────────┼───────────────────────────┐
▼ ▼ ▼
notifications.high notifications.normal notifications.low
│ │ │
└────────────▶ Delivery Workers ◀───────────────────────────┘
│ dedup (Redis) + rate limit (Redis)
▼
FCM/APNs · Email provider · SMS provider
│
succès → SENT │ erreur temporaire → PENDING + next_attempt_at
L’idée principale : la table delivery est la source de vérité. Kafka n’est que le transport. Retry n’a pas besoin d’un topic de retry séparé. Le worker n’a qu’à remettre l’enregistrement à PENDING avec next_attempt_at dans le futur, et Outbox Relay le republiera quand le moment vient.
4. Modèle de données
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';
Quelques points notables :
idempotency_key UNIQUEempêche le client de créer des notifications dupliquées lors des appels API répétés.- Chaque canal est une ligne
deliverydistincte. Une erreur d’email n’entraîne pas un renvoi de notification push. priorityest répété dansdeliverypour que l’index partielidx_delivery_dueserve exactement la requête du relay. L’index ne contient que les lignesPENDING, il reste donc petit, même si la table a des milliards de lignesSENT.
5. API de réception des notifications
D’abord, déclarez les dépendances nécessaires :
<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>
Voici l’API. Le client doit envoyer l’en-tête Idempotency-Key. Il s’agit généralement d’un identifiant métier, par exemple 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));
}
}
L’API retourne 202 Accepted car l’envoi se fait de manière asynchrone. La logique principale se trouve dans le service :
@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()) { // requête répétée → retourner l'ancienne notification
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("charge utile invalide", e);
}
}
}
ON CONFLICT ... DO NOTHING RETURNING id gère le conflit directement dans la base de données. Quand deux requêtes avec la même clé arrivent simultanément, une seule réussit à insérer. L’autre reçoit une liste vide et relit l’ancien ID. Vous n’avez pas besoin de verrou au niveau application.
Notez que le service n’envoie pas à Kafka dans cette transaction. C’est précisément le pattern Transactional Outbox, la section suivante expliquera pourquoi.
6. Outbox Relay et files d’attente par priorité
Supposons que vous écriviez dans la DB puis envoyiez à Kafka juste après. Si l’application s’arrête entre les deux étapes, la notification reste dans la DB mais ne sera jamais envoyée. Si vous faites l’inverse, envoyer à Kafka en premier puis écrire la DB, un worker pourrait traiter un enregistrement qui n’existe pas encore. Outbox résout ce problème : la DB est le seul endroit où l’on écrit dans la transaction, tandis que le relay lit la DB et publie aprè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);
}
}
}
Le code ci-dessus fait trois choses :
ORDER BY priority, next_attempt_atsélectionne d’abord les lignesHIGH. Même s’il y a 5 millions d’emails marketing en attente, les nouveaux OTP sont quand même sélectionnés au prochain sondage.FOR UPDATE SKIP LOCKEDpermet d’exécuter plusieurs instances de relay en parallèle. Chaque instance verrouille un lot différent et ignore les lignes détenues par d’autres instances. Aucune instance n’a à attendre une autre..join()attend la confirmation du broker avant de changer l’état. Si le relay s’arrête après l’envoi mais avant le commit, la ligne restePENDINGet sera renvoyée. C’est du at-least-once. Le worker doit de-dupliquer, voir section 7.2.
La priorité est garantie à nouveau côté consumer via des topics distincts et une concurrence distincte :
@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’a pas de file d’attente avec priorité au sein d’un topic. Par conséquent, la séparation des topics est la meilleure approche pratique. Quand une campagne marketing fait que notifications.low accumule des millions de messages, le consumer de notifications.high s’exécute toujours sur ses propres threads et n’est pas affecté. Le nombre de concurrency doit être inférieur ou égal au nombre de partitions du topic correspondant.
7. Worker d’envoi et retry
7.1. Politique de retry
Tous les erreurs ne doivent pas être renvoyées. Les timeouts ou les HTTP 429/503 sont des erreurs temporaires. Un numéro de téléphone incorrect ou un token push révoqué sont des erreurs permanentes, le retry ne ferait que gaspiller de l’argent.
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 : nombre de tentatives déjà effectuées (commence à 1). Empty = arrêter le 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())));
}
}
Avec base = 2s, max = 5m, maxAttempts = 6, les délais d’attente respectifs sont :
tentative 1 échoue → attendre 2s
tentative 2 échoue → attendre 4s
tentative 3 échoue → attendre 8s
tentative 4 échoue → attendre 16s
tentative 5 échoue → attendre 32s
tentative 6 échoue → FAILED
En production, vous devriez ajouter un jitter aléatoire aux délais d’attente. Sinon, des milliers de notifications ayant échoué au même moment (quand le fournisseur s’est arrêté) tenteront d’envoyer à nouveau à exactement le même instant. Ce phénomène s’appelle thundering herd.
Vous pouvez tester cette politique avec un test simple :
class RetryPolicyTest {
private final RetryPolicy policy =
new RetryPolicy(Duration.ofSeconds(2), Duration.ofSeconds(10), 6);
@Test
void backoffExponentielLimitePar() {
assertThat(policy.nextDelay(1)).contains(Duration.ofSeconds(2));
assertThat(policy.nextDelay(3)).contains(Duration.ofSeconds(8));
assertThat(policy.nextDelay(4)).contains(Duration.ofSeconds(10)); // limité par max
}
@Test
void stopRetryQuandMaxAttemptsAtteint() {
assertThat(policy.nextDelay(6)).isEmpty();
}
}
7.2. Worker : de-duplication, rate limiting, mise à jour d’état
public class TransientSendException extends RuntimeException {
public TransientSendException(String msg) { super(msg); }
}
public interface ChannelSender {
Channel channel();
void send(UUID deliveryId); // lever TransientSendException pour erreur temporaire
}
@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; // message dupliqué de Kafka
Boolean first = redis.opsForValue()
.setIfAbsent("notif:sending:" + id, "1", Duration.ofMinutes(10));
if (!Boolean.TRUE.equals(first)) return; // un autre worker est en train de l'envoyer
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"); // repousser, ne pas compter comme erreur du fournisseur
}
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) { // erreur 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;
}
}
Le worker a deux niveaux de de-duplication :
- Vérifier
status = 'SENT'rejette un message qui a été republié après l’envoi réussi. - Verrou Redis
SET NXavec TTL empêche deux consumers de recevoir le même message, par exemple lors d’un rééquilibrage Kafka au milieu. Un TTL de 10 minutes garantit que le verrou se libère automatiquement si le worker s’arrête.
Ce n’est toujours pas exactly-once. Si le worker appelle le fournisseur avec succès puis s’arrête avant UPDATE ... 'SENT', la notification peut être renvoyée après l’expiration du verrou. Pour être plus strict, vous devriez passer deliveryId comme clé d’idempotence au fournisseur, si celui-ci le supporte. De nombreux fournisseurs d’email et FCM offrent ce mécanisme.
acquireRate est un rate limiter à fenêtre fixe sur Redis. Toutes les instances du worker partagent un même compteur par seconde pour chaque canal, donc le débit total ne dépasse pas le quota 500 requêtes/seconde du fournisseur. Lorsque le seuil est dépassé, la notification est renvoyée à PENDING au lieu d’appeler le fournisseur et de recevoir HTTP 429.
8. Points difficiles et solutions
8.1. Pics de charge
Quand Marketing envoie 10 millions de notifications en une seule fois, le système supporte la charge par étage :
- L’API insère juste la DB et retourne, elle peut donc supporter une charge élevée. Pour les grandes campagnes, vous devriez fournir une API batch séparée qui reçoit une liste d’utilisateurs ou un segment. Un job d’arrière-plan génère alors les lignes
deliverypar lots de 1 000. - Kafka absorbe le flux. Avoir des retards sur le topic
lowest normal, tant que le topichighcontinue à circuler. - Le rate limiter protège le fournisseur. Si les notifications
LOWsont envoyées un peu plus lentement, personne ne s’en plaint.
Vous devriez ajouter une mesure supplémentaire : dans acquireRate, réserver une partie du quota pour HIGH, par exemple LOW n’utilise que 60% du seuil. De cette façon, les OTP ne sont jamais limités en débit à cause des emails marketing.
8.2. Cohérence entre DB et Kafka
Outbox combiné à at-least-once et des consumers idempotents forment un trio standard. Vous n’avez pas besoin de transactions distribuées (XA). Le prix à payer est une latence supplémentaire d’environ une période de sondage, ici 200ms. Si vous avez besoin d’une latence plus faible, vous pouvez remplacer le sondage par du CDC (lecture du WAL de PostgreSQL), mais l’infrastructure devient plus complexe.
8.3. Croissance des données
La table delivery ajoute environ 60–80 millions de lignes chaque jour. Vous devriez partitionner la table par jour (PARTITION BY RANGE (created_at)) et supprimer les partitions plus anciennes que 30–90 jours. L’index partiel de la section 4 maintient la requête du relay toujours rapide, mais ne remplace pas le nettoyage des données.
8.4. Préférences utilisateur et heures silencieuses
Vérifiez les préférences lors de la création de delivery dans NotificationService. Pour chaque canal que l’utilisateur a désactivé, n’insérez pas de ligne. Pour les heures silencieuses, par exemple ne pas envoyer de notifications push marketing après 22h, définissez simplement next_attempt_at à 8h le lendemain. Le mécanisme du relay gère le reste, pas besoin de scheduler supplémentaire.
Attention : N’appelez pas le fournisseur dans une longue transaction
@Transactional. Une requête HTTP lente de 30 secondes bloquerait la connexion DB et les verrous de ligne pendant tout ce temps, épuisant rapidement le pool. DansDeliveryWorker, chaqueUPDATEest une transaction courte, indépendante de l’appel au fournisseur.
Attention : Ne réessayez pas indéfiniment et ne réessayez pas les erreurs permanentes. Un numéro de téléphone incorrect, renvoyé 6 fois, multiplié par 1 million d’utilisateurs fait une très grosse facture SMS. Classifiez attentivement les codes d’erreur de chaque fournisseur.
8.5. Quand NE PAS utiliser cette conception
Si vous n’envoyez que quelques milliers d’emails par jour, PostgreSQL avec SKIP LOCKED suffit. Le worker sonde directement la table delivery, oubliez Kafka. Kafka ne vaut la peine que si vous avez besoin de séparer les consumers par priorité à grande échelle, ou si d’autres systèmes ont besoin de consommer le même flux d’événements. À l’inverse, si vous avez besoin de garantir l’ordre absolu entre les notifications d’un même utilisateur, cette conception ne suffit pas. Vous devez utiliser userId comme clé Kafka et éviter les retries asynchrones pour ces notifications.
9. Conclusion
Nous avons conçu un système de notifications multi-canal avec les décisions principales suivantes :
- La table
deliveryest la source de vérité. Retry n’est que de remettre àPENDINGavecnext_attempt_at. - Transactional Outbox avec
FOR UPDATE SKIP LOCKEDmaintient la cohérence entre DB et Kafka sans transaction distribuée, tout en permettant l’exécution parallèle de multiples instances de relay. - La priorité est garantie sur deux niveaux : l’ordre de sondage en SQL, et des topics avec concurrence propres pour Kafka.
- De-duplication multicouche :
Idempotency-Keyà l’API, vérification d’état et verrou Redis au worker, clé d’idempotence envoyée au fournisseur. - Rate limiter partagé sur Redis protège le quota du fournisseur lors des pics de charge.
À partir de ce framework, vous pouvez ajouter des templates multilingues, des webhooks recevant le statut “livré/lu” du fournisseur, ou un tableau de bord suivi du taux FAILED par canal.