Architecture logicielle
Systèmes réactifs : backpressure, Reactive Streams et Project Reactor
Avancé10 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
La programmation réactive répond à un problème précis : servir un grand nombre de connexions simultanées sans consacrer un thread à chacune, tout en permettant à un consommateur lent de demander à un producteur rapide de ralentir. Ce guide distingue les propriétés architecturales du modèle de programmation, explique rigoureusement la backpressure et le contrat Reactive Streams, déroule du code Reactor et dit franchement quand le modèle ne vaut pas ce qu'il coûte.
Ce que vous allez apprendre
- Expliquer les quatre propriétés d'un système réactif et ce qui les distingue de la programmation réactive
- Décrire ce qui bloque un thread et pourquoi les I/O non bloquantes changent la courbe de montée en charge
- Appliquer le contrat Reactive Streams et raisonner sur les stratégies de backpressure
- Composer des pipelines asynchrones avec Flux et Mono de Reactor, gestion des erreurs comprise
- Choisir entre une stack réactive et les virtual threads pour une charge de travail donnée
« Réactif » désigne deux choses différentes, et la confusion entre les deux explique l'essentiel du flou qui entoure le terme. Un système réactif est une propriété architecturale : il reste disponible sous la charge et face aux pannes, grâce à des composants isolés reliés par des messages asynchrones. La programmation réactive est un style de code qui exprime des flux de données asynchrones sous forme de pipelines d'opérateurs. On peut construire un système parfaitement réactif avec de simples files et des consommateurs bloquants, et l'on peut écrire du code réactif qui n'apporte aucune de ces propriétés architecturales. Mieux vaut savoir de laquelle des deux on parle.
Le problème d'ingénierie sous-jacent est étroit, mais bien réel. Un serveur qui consacre un thread du système d'exploitation à chaque requête en cours peut en tenir quelques milliers avant que les coûts de mémoire et d'ordonnancement ne l'emportent, et la plupart de ces threads passent leur temps bloqués à attendre une base de données ou une API en aval, sans rien calculer. Les bibliothèques réactives permettent à un petit nombre de threads de porter beaucoup de requêtes en cours, et ajoutent un mécanisme par lequel un consommateur lent limite un producteur rapide. Tout le reste en découle.
Les quatre propriétés
Le manifeste réactif qualifie un système de réactif lorsqu'il présente quatre propriétés liées.
Disponible (responsive) : il répond dans un délai prévisible et, quand il ne le peut pas, il le signale rapidement au lieu de laisser l'appelant attendre. C'est l'objectif ; les trois autres propriétés sont des moyens.
Résilient : la panne d'un composant reste confinée et ne devient pas la panne de l'ensemble. On l'obtient par l'isolation, la réplication et la délégation de la reprise à un superviseur, plutôt qu'en comptant sur le composant défaillant pour se réparer lui-même.
Élastique : le débit suit les ressources, à la hausse comme à la baisse, ce qui suppose l'absence de goulet d'étranglement central et d'état mutable partagé à coordonner.
Orienté messages : les composants communiquent par passage de messages asynchrones. C'est ce qui crée les frontières rendant possibles l'isolation et l'élasticité, et ce qui permet d'exprimer explicitement la gestion de la charge et la backpressure.
Vu sous cet angle, « système réactif » décrit des objectifs que poursuit déjà toute plateforme de microservices événementielle bien conçue. L'apport du manifeste est d'avoir nommé le lien entre frontières asynchrones et résilience, pas d'avoir inventé une nouvelle architecture.
Ce que coûte le blocage
Pour comprendre l'intérêt du non-bloquant, suivez un thread au fil d'une requête typique. Le handler reçoit la requête, lance une requête SQL et appelle ResultSet.next(). Cet appel finit par une lecture sur socket, et le thread ne peut pas avancer tant que les octets ne sont pas arrivés. Le système d'exploitation le met en attente : ses 512 Ko à 1 Mo de pile restent alloués, ses registres sont sauvegardés et l'ordonnanceur passe à autre chose. Pendant les 20 ms que dure la requête SQL, le thread ne fait rien et occupe de la mémoire.
Avec un pool de 200 threads et une latence moyenne des dépendances de 20 ms, le calcul est sans appel. La loi de Little dit que la concurrence est égale au débit multiplié par la latence : 200 threads simultanés à 20 ms donnent au mieux environ 10 000 requêtes par seconde. Au-delà, chaque requête qui arrive fait la queue, ce qui ajoute de la latence, donc augmente la concurrence, donc allonge la file. C'est le mécanisme qui explique le décrochage brutal des serveurs bloquants, approfondi dans performance des applications.
Les I/O non bloquantes cassent le couplage entre une requête et un thread. La socket est enregistrée auprès d'un sélecteur ; quand des données sont prêtes, un thread de la boucle d'événements (event loop) récupère la continuation et l'exécute. Une poignée de threads d'event loop, un par cœur par convention, portent des milliers de requêtes en cours, car une requête en cours ne coûte qu'un petit objet et non une pile de thread.
La contrepartie, c'est que le code n'est plus une suite d'instructions. La continuation doit s'exprimer sous forme de callback, et des callbacks qui appellent des callbacks deviennent vite illisibles. Les bibliothèques réactives existent pour rendre cette composition supportable.
Reactive Streams et backpressure
Le push asynchrone a son propre mode de défaillance : un producteur plus rapide que son consommateur remplit la mémoire jusqu'à ce que le processus meure. La backpressure est la capacité du consommateur à signaler combien il peut absorber.
La spécification Reactive Streams définit le contrat minimal pour cela, avec quatre interfaces, reprises dans le JDK sous le nom java.util.concurrent.Flow.
public interface Publisher<T> { void subscribe(Subscriber<? super T> s); }
public interface Subscriber<T> {
void onSubscribe(Subscription s);
void onNext(T item); // never called more times than requested
void onError(Throwable t); // terminal
void onComplete(); // terminal
}
public interface Subscription {
void request(long n); // demand: "I can handle n more"
void cancel();
}
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {}
La règle essentielle : rien ne circule tant que l'abonné n'a pas appelé request(n). Le publisher peut émettre au plus n éléments avant que l'abonné n'en redemande : la demande est tirée, les données sont poussées. Les signaux sont sérialisés (jamais de onNext concurrents pour un même abonné), et onError comme onComplete sont terminaux : plus rien n'arrive ensuite. Comme le contrat est un ensemble d'interfaces et non une implémentation, les bibliothèques interopèrent : un Flux Reactor peut s'abonner à un publisher produit par une autre implémentation.
Quand une source ne peut vraiment pas être ralentie (relevés de capteurs, flux de données de marché, amont qui ne respecte pas la demande), il faut choisir ce que l'on sacrifie.
| Stratégie | Comportement | Pertinente quand |
|---|---|---|
| Buffer | Mettre le surplus en file en mémoire | Les pics sont courts et bornés ; fixez toujours une limite |
| Drop | Jeter les nouveaux éléments quand le consommateur est en retard | Flux de dernière valeur, où l'obsolescence est pire que la perte |
| Latest | Ne garder que l'élément le plus récent | Tableaux de bord, cotations de prix, mises à jour de position |
| Error | Faire échouer le flux avec un signal de débordement | Perdre des données est inacceptable et échouer bruyamment est le bon choix |
| Bloquer la source | Ralentir le producteur à la source | Le producteur vous appartient et peut être bridé |
Choisir « buffer » sans borne revient à choisir « manquer de mémoire plus tard ».
Reactor en pratique
Project Reactor implémente Reactive Streams et fournit deux types de publisher : Mono<T> pour zéro ou un élément, Flux<T> pour zéro à plusieurs. Tous deux sont paresseux : déclarer un pipeline ne fait rien tant que personne ne s'y abonne, ce qui est une source fréquente de « mon code ne s'est jamais exécuté ».
Mono<OrderView> orderView(String orderId) {
Mono<Order> order = orders.findById(orderId) // Mono<Order>
.switchIfEmpty(Mono.error(new UnknownOrder(orderId)));
return order.flatMap(o ->
Mono.zip( // run both calls concurrently
customers.findById(o.customerId())
.timeout(Duration.ofMillis(300))
.onErrorResume(e -> Mono.just(Customer.unknown())),
pricing.quote(o)
.retryWhen(Retry.backoff(3, Duration.ofMillis(50)).jitter(0.5))
)
.map(t -> new OrderView(o, t.getT1(), t.getT2())));
}
Trois réflexes distinguent le code Reactor maintenable de celui que les équipes finissent par abandonner. Utilisez flatMap quand la transformation renvoie elle-même un publisher, et map sinon : les confondre produit un Mono<Mono<T>>. Utilisez zip pour exécuter en parallèle des appels indépendants plutôt que de les enchaîner, car des flatMap successifs s'attendent les uns les autres. Et placez timeout, retryWhen et onErrorResume là où l'erreur a un sens, pas une seule fois en fin de pipeline.
Le streaming d'un gros jeu de résultats est le terrain où le modèle excelle vraiment, car rien n'est jamais entièrement matérialisé :
Flux<Trade> stream = trades.findByBook(bookId) // reactive driver, backpressure-aware
.limitRate(256) // request in batches of 256
.filter(t -> t.notional().compareTo(THRESHOLD) > 0)
.buffer(Duration.ofSeconds(1)) // group by time window
.flatMap(batch -> enrichment.enrich(batch), 4) // at most 4 concurrent enrichments
.onBackpressureBuffer(10_000,
dropped -> log.warn("dropping {}", dropped.id()),
BufferOverflowStrategy.DROP_OLDEST);
L'ordonnancement est explicite, et il vaut la peine de le comprendre. Reactor ne choisit pas les threads à votre place : par défaut, le travail s'exécute sur le thread qui a émis le signal. subscribeOn détermine où s'exécute l'abonnement, donc la source, et agit sur toute la chaîne en amont. publishOn change de thread pour tout ce qui se trouve en aval de ce point. Quand du code bloquant est inévitable, isolez-le :
Mono.fromCallable(() -> legacyJdbcLookup(id)) // blocking, third-party, no reactive driver
.subscribeOn(Schedulers.boundedElastic()) // dedicated pool, never the event loop
.timeout(Duration.ofSeconds(2));
Le débogage mérite un avertissement. Une stack trace issue d'un pipeline réactif montre la mécanique des opérateurs, pas la ligne de code métier qui a assemblé le pipeline. Activez Hooks.onOperatorDebug() en développement ou ajoutez l'agent reactor-tools, et propagez un identifiant de corrélation via le contexte Reactor plutôt que via un ThreadLocal, puisqu'aucune affinité de thread n'est garantie. Pour la même raison, la journalisation fondée sur le MDC a besoin d'un coup de pouce explicite.
Quand le réactif est rentable, et quand il ne l'est pas
Le modèle ne justifie sa complexité que sur une bande étroite de charges de travail.
Il est rentable quand un service est un agrégateur limité par les I/O qui maintient de nombreuses connexions pour la plupart inactives, quand vous avez besoin d'une sémantique de streaming (server-sent events, WebSockets, consommateur Kafka avec contrôle de flux, comme dans producteurs et consommateurs Kafka), quand la mémoire par connexion est la contrainte déterminante, ou quand la charge a réellement la forme d'un flux et que le vocabulaire d'opérateurs pour fusionner, fenêtrer et limiter le débit économise beaucoup de code.
Il n'est pas rentable quand le travail est limité par le CPU, car les I/O non bloquantes n'apportent rien au calcul. Il ne l'est pas quand la stack bloque quelque part où cela compte : une couche web réactive devant un driver JDBC bloquant vous donne la difficulté de débogage sans aucun gain de montée en charge. Il ne l'est pas pour des services qui traitent quelques centaines de requêtes simultanées, soit la plupart des services internes d'entreprise. Et il ne l'est pas quand l'équipe ne le maintiendra pas : le code réactif est plus difficile à lire, plus difficile à déboguer et impitoyable envers le moindre appel bloquant, et une base de code à moitié convertie est pire que chacun des deux extrêmes.
Les virtual threads comme alternative
Les virtual threads, standardisés dans le JDK 21, changent la donne pour la plupart des applications. Un virtual thread est ordonnancé par la JVM sur un petit pool de threads porteurs (carrier threads) ; lorsqu'il effectue une opération bloquante sur un channel ou une socket, la JVM le démonte de son porteur, qui exécute autre chose. Le coût du blocage disparaît, et le code reste du Java séquentiel ordinaire.
| Aspect | Réactif (Reactor) | Virtual threads |
|---|---|---|
| Style de code | Pipelines d'opérateurs, callbacks | Code séquentiel ordinaire |
| Appels bloquants | À isoler sur un scheduler dédié | Aucun problème : c'est tout l'intérêt |
| Stack traces | Frames d'opérateurs, hooks de débogage nécessaires | Complètes et familières |
| Backpressure | Intégrée au contrat | Non fournie ; utilisez des files bornées et des sémaphores |
| Opérateurs de streaming | Vocabulaire riche | À la main, ou via une bibliothèque de streams par-dessus |
| Profilage et outillage | Spécialisés | JFR standard, thread dumps, débogueurs |
| Courbe d'apprentissage | Raide | Négligeable |
La recommandation pratique pour les nouveaux services JVM en 2026 : commencez avec les virtual threads et un modèle thread par requête, plus simple et qui apporte l'essentiel du bénéfice en concurrence. Tournez-vous vers une bibliothèque réactive quand vous avez besoin de backpressure entre composants ou du vocabulaire d'opérateurs de streaming, et pas simplement parce que vous devez gérer beaucoup de connexions. Le guide Java concurrence et executors détaille le modèle de threads. Restent quelques mises en garde : sur les versions plus anciennes, un virtual thread épinglé par un bloc synchronized qui conserve un verrou pendant un appel bloquant occupe toujours son porteur, donc préférez ReentrantLock dans les chemins critiques ; et mettre des virtual threads en pool va totalement à l'encontre de leur raison d'être.
Points clés à retenir
- Un système réactif est une propriété architecturale (disponible, résilient, élastique, orienté messages) ; la programmation réactive est une manière d'en implémenter certaines parties, pas la même chose.
- L'avantage concret des I/O non bloquantes est de découpler les requêtes en cours des threads, ce qui change la façon dont un serveur se dégrade sous forte concurrence, pas la vitesse d'une requête isolée.
- La backpressure est la caractéristique fondatrice de Reactive Streams : rien n'est émis au-delà de ce que l'abonné a demandé, et un buffer non borné n'est qu'une panne différée.
FluxetMonode Reactor sont paresseux, se composent de façon déclarative et exigent un ordonnancement réfléchi : tout appel bloquant doit être isolé surboundedElastic.- Le réactif est rentable pour les charges limitées par les I/O, en streaming ou à très grand nombre de connexions, et coûte très cher partout ailleurs, surtout quand une couche reste bloquante.
- Pour la plupart des nouveaux services JVM, les virtual threads offrent la montée en charge avec du code et des outils ordinaires ; choisissez le réactif quand vous avez spécifiquement besoin de backpressure ou d'opérateurs de flux.
Sources et lectures complémentaires
Questions fréquentes
- Le code réactif est-il plus rapide que le code bloquant ?
- Pas à l'échelle d'une requête : un appel isolé prend en général un peu plus de temps, à cause de l'ordonnancement et du surcoût des opérateurs. Ce qui change, c'est l'allure de la courbe sous concurrence : un serveur bloquant se dégrade brutalement dès que son pool de threads est épuisé, alors qu'un serveur non bloquant continue d'accepter des connexions avec une hausse de mémoire modeste. Si votre service traite quelques centaines de requêtes simultanées, vous ne verrez strictement aucun gain.
- Que se passe-t-il concrètement si j'appelle une API bloquante dans un pipeline réactif ?
- Vous monopolisez l'un des très rares threads de la boucle d'événements (en général un par cœur CPU) pendant toute la durée de l'appel, et toutes les autres requêtes affectées à ce thread attendent. Une poignée d'appels de ce genre suffit à figer tout le serveur. Si vous devez appeler du code bloquant, planifiez-le sur un scheduler bounded elastic avec subscribeOn ou publishOn pour qu'il s'exécute hors de la boucle d'événements.
- Les virtual threads rendent-ils la programmation réactive obsolète ?
- Ils lui retirent sa principale justification pour la plupart des applications. Les virtual threads offrent le comportement en charge des I/O non bloquantes tout en conservant du code séquentiel ordinaire, des stack traces exploitables et des try/catch. Ils n'apportent en revanche ni backpressure ni vocabulaire déclaratif d'opérateurs pour fusionner, bufferiser ou fenêtrer des flux : les charges de travail qui sont réellement des flux tirent donc toujours profit d'une bibliothèque réactive.
- Quelle différence entre un Flux et un Stream Java ?
- Un Stream est tiré de façon synchrone par le consommateur et se termine avec l'opération terminale : il ne peut donc pas représenter quelque chose qui produit des valeurs au fil du temps. Un Flux est poussé de façon asynchrone, peut être infini, porte la complétion et l'erreur comme des signaux à part entière et laisse l'abonné demander un nombre borné d'éléments. Le Stream modélise une collection ; le Flux modélise des événements qui arrivent.