Spring WebFlux : Programmer en Réactif pour 10x Plus de Scalabilité

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/users faisant :

- 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 endpoints
  • reactor.netty.eventloop.pending.tasks : File d'attente event loop
  • r2dbc.pool.acquired : Connexions DB utilisées
  • jvm.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.