Spring WebFlux : Programmer en Réactif pour 10x Plus de Scalabilité
La programmation réactive transforme radicalement l'architecture des applications Spring Boot. Avec Spring WebFlux, vous passez d'un modèle bloquant thread-per-request à une architecture non-bloquante event-driven capable de gérer 10 000+ requêtes simultanées avec le même matériel.
En 2026, les applications modernes nécessitent une scalabilité extrême : microservices distribuant des millions de requêtes, APIs consommant des flux temps-réel, systèmes IoT ingérant des données continues. Spring WebFlux, basé sur Project Reactor et Netty, offre cette capacité en exploitant pleinement les E/S non-bloquantes et la backpressure.
Cet article technique vous guide à travers l'architecture réactive Spring WebFlux, de la théorie aux implémentations production, en couvrant Mono/Flux, R2DBC, la migration depuis Spring MVC, et le déploiement Kubernetes optimisé.
$2
Spring MVC : Thread-Per-Request et Limites
Spring MVC traditionnel utilise un modèle thread-per-request sur un serveur Tomcat embarqué :
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
@Autowired
private RestTemplate restTemplate;
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// 🔴 Thread bloqué pendant la requête DB (50-100ms)
User user = userRepository.findById(id).orElseThrow();
// 🔴 Thread bloqué pendant l'appel HTTP externe (200-500ms)
String enrichedData = restTemplate.getForObject(
"https://api.external.com/users/" + id,
String.class
);
Problème : Chaque requête monopolise 1 thread pendant toute sa durée (300-600ms ici). Avec un pool Tomcat de 200 threads, vous êtes limité à 200 requêtes simultanées. Au-delà, les nouvelles requêtes attendent dans une file.
Conséquences en production :
MétriqueSpring MVC (Tomcat)Impact ---------------------------------------- Threads max200 (défaut)Hard limit sur concurrence Requêtes simultanées~200Rejet après saturation Temps réponse P95300-1500msDégradation sous charge Mémoire par thread1 MB (stack)200 MB minimum CPU idle60-80%Threads bloqués en attente I/O
Spring WebFlux : Event Loop Non-Bloquant
WebFlux inverse le paradigm avec Reactor et Netty :
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private ReactiveUserRepository userRepository;
@Autowired
private WebClient webClient;
Architecture réactive : Le thread Netty ne bloque jamais. Pendant l'attente DB ou HTTP, il traite d'autres requêtes. Quand la réponse arrive, un callback réveille le flux.
Résultats production :
MétriqueSpring WebFlux (Netty)Amélioration ------------------------------------------------ Threads event loop8 (= nb CPU cores)-96% threads Requêtes simultanées10 000++50x capacité Temps réponse P9550-200ms-70% latence Mémoire totale20 MB (event loops)-90% mémoire CPU utilisation90-95%Optimisation maximale
Cas d'usage idéaux :
- APIs haute concurrence (> 1000 req/s)
- Microservices avec nombreux appels externes
- Streaming temps-réel (SSE, WebSocket)
- Intégrations réactives bout-en-bout
$2
Spring WebFlux s'appuie sur Project Reactor, une implémentation des Reactive Streams (spécification JVM standard). Deux types principaux : Mono (0-1 élément) et Flux (0-N éléments).
Mono : Opération Asynchrone 0 ou 1 Résultat
Mono représente une promesse d'obtenir 0 ou 1 valeur dans le futur :
// Mono vide (aucun résultat)
Mono empty = Mono.empty();
// Mono avec valeur
Mono hello = Mono.just("Hello WebFlux");
// Mono depuis opération asynchrone
Mono user = userRepository.findById(42L);
// Transformation (lazy, pas exécuté tant qu'on ne subscribe pas)
Mono upperName = user
.map(u -> u.getName().toUpperCase())
.defaultIfEmpty("ANONYMOUS");
Opérateurs courants :
// map() : Transformation synchrone
Mono length = Mono.just("reactive")
.map(String::length); // 8
// flatMap() : Transformation asynchrone (retourne Mono/Flux)
Mono orderWithUser = orderRepository.findById(orderId)
.flatMap(order ->
userRepository.findById(order.getUserId())
.map(user -> {
order.setUser(user);
return order;
})
);
// filter() : Garde seulement si condition vraie
Mono activeUser = userRepository.findById(id)
.filter(u -> u.isActive())
.switchIfEmpty(Mono.error(new UserInactiveException()));
Flux : Stream Asynchrone 0 à N Éléments
Flux représente un flux de 0 à N valeurs arrivant au fil du temps :
// Flux depuis collection
Flux tags = Flux.just("java", "spring", "reactive");
// Flux depuis range
Flux numbers = Flux.range(1, 100);
// Flux depuis repository
Flux users = userRepository.findAll();
// Transformation et filtrage
Flux activeEmails = users
.filter(u -> u.isActive())
.map(User::getEmail)
.distinct();
Opérateurs avancés :
// flatMap() : Transformer chaque élément en Flux/Mono
Flux allOrders = userRepository.findAll()
.flatMap(user -> orderRepository.findByUserId(user.getId()));
// concatMap() : Comme flatMap mais préserve l'ordre
Flux orderedProducts = Flux.just(1L, 2L, 3L)
.concatMap(id -> productRepository.findById(id));
// buffer() : Regroupe en lots
Flux> batches = users.buffer(50); // Lots de 50
// window() : Découpe en sous-flux
Flux> windows = users.window(Duration.ofSeconds(1));
Backpressure : Gestion du Débit
La backpressure est le mécanisme qui permet à un consommateur lent de contrôler le débit d'un producteur rapide, évitant les OutOfMemoryError.
// Stratégie ERROR : Erreur si trop rapide
Flux errorStrategy = dataSource
.onBackpressureError();
// Stratégie DROP : Ignore les éléments en trop
Flux dropStrategy = dataSource
.onBackpressureDrop();
// Stratégie BUFFER : Met en tampon (avec limite)
Flux bufferStrategy = dataSource
.onBackpressureBuffer(1000);
// Stratégie LATEST : Garde seulement le dernier
Flux latestStrategy = dataSource
.onBackpressureLatest();
Exemple concret : API publiant des événements temps-réel vers des clients WebSocket avec débit variable.
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux streamEvents() {
return eventRepository.findAll()
.delayElements(Duration.ofMillis(100))
.onBackpressureBuffer(500) // Buffer 500 événements max
.doOnNext(event -> log.info("Sending event: {}", event.getId()));
}
$2
Étape 1 : Dépendances Maven
org.springframework.boot
spring-boot-starter-webflux
org.springframework.boot
spring-boot-starter-data-r2dbc
org.postgresql
r2dbc-postgresql
io.r2dbc
r2dbc-pool
Étape 2 : Configuration R2DBC
application.yml :
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/mydb
username: postgres
password: secret
pool:
initial-size: 10
max-size: 50
max-idle-time: 30m
validation-query: SELECT 1
webflux:
base-path: /api
server:
port: 8080
netty:
connection-timeout: 10s
idle-timeout: 60s
Étape 3 : Entity et Repository Réactifs
User Entity :
@Table("users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
private Long id;
@Column("username")
private String username;
@Column("email")
private String email;
@Column("active")
private Boolean active;
Repository réactif :
@Repository
public interface UserRepository extends ReactiveCrudRepository {
// Méthodes dérivées (implémentées automatiquement)
Mono findByEmail(String email);
Flux findByActiveTrue();
@Query("SELECT * FROM users WHERE username LIKE :pattern")
Flux searchByUsername(String pattern);
Étape 4 : Controller Réactif
Avant (Spring MVC) :
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public List getAll() {
return userService.findAll(); // Bloquant
}
@GetMapping("/{id}")
public User getById(@PathVariable Long id) {
return userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
Après (Spring WebFlux) :
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public Flux getAll() {
return userService.findAll(); // Non-bloquant
}
@GetMapping("/{id}")
public Mono getById(@PathVariable Long id) {
return userService.findById(id)
.switchIfEmpty(Mono.error(new UserNotFoundException(id)));
}
@PostMapping
public Mono create(@Valid @RequestBody Mono userMono) {
return userMono.flatMap(userService::save);
}
@PutMapping("/{id}")
public Mono update(@PathVariable Long id,
@Valid @RequestBody Mono userMono) {
return userMono.flatMap(user -> {
user.setId(id);
return userService.update(user);
});
}
Étape 5 : Service Layer Réactif
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private WebClient webClient;
public Flux findAll() {
return userRepository.findAll()
.switchIfEmpty(Flux.empty());
}
public Mono findById(Long id) {
return userRepository.findById(id);
}
public Mono save(User user) {
user.setCreatedAt(LocalDateTime.now());
user.setActive(true);
return userRepository.save(user);
}
public Mono update(User user) {
return userRepository.findById(user.getId())
.flatMap(existing -> {
existing.setUsername(user.getUsername());
existing.setEmail(user.getEmail());
return userRepository.save(existing);
});
}
public Mono deleteById(Long id) {
return userRepository.deleteById(id);
}
Étape 6 : Gestion d'Erreurs Globale
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity handleUserNotFound(UserNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity handleValidation(ValidationException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.badRequest().body(error);
}
$2
Configuration Avancée R2DBC
@Configuration
@EnableR2dbcRepositories
public class R2dbcConfig extends AbstractR2dbcConfiguration {
@Value("${spring.r2dbc.url}")
private String url;
@Value("${spring.r2dbc.username}")
private String username;
@Value("${spring.r2dbc.password}")
private String password;
@Bean
public ConnectionFactory connectionFactory() {
ConnectionFactoryOptions options = ConnectionFactoryOptions.builder()
.option(DRIVER, "postgresql")
.option(HOST, "localhost")
.option(PORT, 5432)
.option(USER, username)
.option(PASSWORD, password)
.option(DATABASE, "mydb")
.option(Option.valueOf("schema"), "public")
.option(CONNECT_TIMEOUT, Duration.ofSeconds(10))
.option(Option.valueOf("lock_timeout"), Duration.ofSeconds(30))
.build();
// Pool de connexions
ConnectionPoolConfiguration poolConfig = ConnectionPoolConfiguration.builder()
.connectionFactory(new PostgresqlConnectionFactory(options))
.initialSize(10)
.maxSize(50)
.maxIdleTime(Duration.ofMinutes(30))
.maxAcquireTime(Duration.ofSeconds(5))
.validationQuery("SELECT 1")
.build();
return new ConnectionPool(poolConfig);
}
Transactions Réactives
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private ReactiveTransactionManager transactionManager;
@Transactional
public Mono createOrder(Order order) {
// Transaction réactive automatique
return orderRepository.save(order)
.flatMap(savedOrder ->
// Mise à jour stock
inventoryRepository.findById(order.getProductId())
.flatMap(inventory -> {
if (inventory.getQuantity() < order.getQuantity()) {
return Mono.error(new InsufficientStockException());
}
inventory.setQuantity(inventory.getQuantity() - order.getQuantity());
return inventoryRepository.save(inventory);
})
.thenReturn(savedOrder)
);
// Si erreur, rollback automatique
}
// Transaction programmatique pour contrôle fin
public Mono createOrderManual(Order order) {
TransactionalOperator transactionalOperator =
TransactionalOperator.create(transactionManager);
Requêtes Custom avec DatabaseClient
@Repository
public class CustomUserRepository {
@Autowired
private DatabaseClient databaseClient;
public Flux findActiveUsersByRole(String role) {
return databaseClient.sql(
"SELECT u.* FROM users u " +
"JOIN user_roles ur ON u.id = ur.user_id " +
"WHERE u.active = true AND ur.role = :role"
)
.bind("role", role)
.map((row, metadata) -> {
User user = new User();
user.setId(row.get("id", Long.class));
user.setUsername(row.get("username", String.class));
user.setEmail(row.get("email", String.class));
return user;
})
.all();
}
$2
@RestController
@RequestMapping("/api/events")
public class EventStreamController {
@Autowired
private EventService eventService;
// SSE : Stream d'événements infini
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux> streamEvents() {
return eventService.getEventStream()
.map(event -> ServerSentEvent.builder()
.id(String.valueOf(event.getId()))
.event("event-update")
.data(event)
.retry(Duration.ofSeconds(5))
.build()
);
}
// Flux avec intervalle
@GetMapping(value = "/metrics", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux streamMetrics() {
return Flux.interval(Duration.ofSeconds(1))
.map(tick -> eventService.getCurrentMetrics())
.doOnCancel(() -> log.info("Client disconnected"));
}
}
@Service
public class EventService {
private final Sinks.Many eventSink = Sinks.many()
.multicast()
.onBackpressureBuffer(1000);
public Flux getEventStream() {
return eventSink.asFlux();
}
$2
Dockerfile Multi-Stage avec GraalVM Native (Optionnel)
# Build stage
FROM ghcr.io/graalvm/native-image:ol8-java17 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN ./mvnw package -Pnative -DskipTests
Deployment Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: webflux-api
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: webflux-api
template:
metadata:
labels:
app: webflux-api
spec:
containers:
- name: api
image: myregistry.io/webflux-api:1.0.0
ports:
- containerPort: 8080
protocol: TCP
resources:
requests:
memory: "256Mi" # WebFlux = très peu de RAM
cpu: "500m"
limits:
memory: "512Mi"
cpu: "1000m"
env:
- name: SPRING_PROFILES_ACTIVE
value: "production"
- name: SPRING_R2DBC_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: SPRING_R2DBC_USERNAME
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: SPRING_R2DBC_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: webflux-api
namespace: production
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 8080
protocol: TCP
selector:
app: webflux-api
Autoscaling HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: webflux-api-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: webflux-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
$2
Scénario de Test
Setup :
- API REST avec endpoint
/api/usersfaisant :
- 1 requête DB (50ms latence simulée) - 1 appel HTTP externe (200ms latence)
- Load test : Apache Bench (ab) avec concurrence croissante
- Environnement : VM 4 vCPU, 8 GB RAM
Configuration Spring MVC :
server:
tomcat:
threads:
max: 200
min-spare: 10
Configuration Spring WebFlux :
server:
netty:
event-loop-threads: 4 # = nb CPU cores
Résultats Benchmarks
MétriqueSpring MVCSpring WebFluxAmélioration ---------------------------------------------------- Concurrence 100 req Requêtes/sec380 req/s950 req/s+150% Temps réponse P95280ms120ms-57% Mémoire utilisée450 MB180 MB-60% Concurrence 500 req Requêtes/sec420 req/s2100 req/s+400% Temps réponse P951200ms250ms-79% Mémoire utilisée680 MB220 MB-68% Concurrence 1000 req Requêtes/sec190 req/s3800 req/s+1900% Temps réponse P955200ms320ms-94% Mémoire utilisée950 MB280 MB-71% Erreurs (timeout)15%0%✅
Analyse :
- Spring MVC s'effondre à 1000 requêtes simultanées (saturation pool threads)
- Spring WebFlux maintient des performances linéaires jusqu'à 10 000+ requêtes
- Consommation mémoire 3-4x inférieure avec WebFlux
- Latence P95 5-10x meilleure sous forte charge
Monitoring Production
@Configuration
public class MetricsConfig {
Métriques Micrometer disponibles :
http.server.requests: Latence endpointsreactor.netty.eventloop.pending.tasks: File d'attente event loopr2dbc.pool.acquired: Connexions DB utiliséesjvm.memory.used: Mémoire heap/non-heap
Dashboard Grafana :
# Throughput API
rate(http_server_requests_seconds_count[5m])
# Latence P95
histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))
$2
✅ À Faire
1. Toujours retourner Mono/Flux des controllers et services 2. Utiliser flatMap() pour opérations asynchrones imbriquées 3. Configurer timeouts sur tous les appels externes 4. Implémenter backpressure pour flux haute fréquence 5. Logger avec doOnNext/doOnError pour déboguer pipelines réactifs 6. Utiliser WebClient (pas RestTemplate) pour appels HTTP 7. Tester avec StepVerifier (Reactor Test)
❌ À Éviter
1. Bloquer le thread avec .block() ou .subscribe() dans le pipeline
// ❌ MAUVAIS
public Mono getUser(Long id) {
User user = userRepository.findById(id).block(); // BLOQUE !
return Mono.just(user);
}
2. Utiliser repositories JDBC avec WebFlux (bloquants) 3. Oublier la gestion d'erreurs (toujours .onErrorResume()) 4. Créer trop de subscriptions (mémoire leak) 5. Ignorer la backpressure sur flux infinis
Tests Unitaires avec StepVerifier
@SpringBootTest
class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private UserRepository userRepository;
@Test
void testFindById() {
User expected = new User(1L, "john", "john@test.com", true, null);
when(userRepository.findById(1L)).thenReturn(Mono.just(expected));
StepVerifier.create(userService.findById(1L))
.expectNext(expected)
.verifyComplete();
}
@Test
void testFindByIdNotFound() {
when(userRepository.findById(99L)).thenReturn(Mono.empty());
StepVerifier.create(userService.findById(99L))
.expectNextCount(0)
.verifyComplete();
}
@Test
void testFindAllWithFilter() {
Flux users = Flux.just(
new User(1L, "john", "john@test.com", true, null),
new User(2L, "jane", "jane@test.com", false, null)
);
when(userRepository.findAll()).thenReturn(users);
$2
Spring WebFlux apporte une révolution architecturale pour les applications nécessitant haute scalabilité et faible latence. Les gains sont spectaculaires : 10x plus de throughput, 3x moins de mémoire, latence divisée par 5 sous forte charge.
Adoptez WebFlux si :
- Votre API gère > 1000 requêtes/seconde
- Vous avez de nombreux appels externes (autres APIs, bases de données multiples)
- Vous construisez du streaming temps-réel (SSE, WebSocket)
- Vous visez une architecture réactive bout-en-bout
Restez sur Spring MVC si :
- Application CRUD simple avec charge modérée
- Stack JDBC existante difficile à migrer
- Équipe peu familière avec la programmation réactive
- Pas de contraintes de scalabilité extrême
La migration nécessite un changement de paradigme (penser asynchrone, composer des pipelines), mais les bénéfices en production sont indéniables pour les architectures distribuées modernes.
Pour approfondir l'écosystème Spring, consultez nos guides sur déployer Spring Boot sur Kubernetes pour l'orchestration complète, et Spring Boot + GraalVM Native pour combiner réactivité et compilation native (démarrage < 0.1s).
WebFlux + R2DBC + Kubernetes + GraalVM Native = La stack ultime pour microservices haute performance en 2026.