1. Batching activé mais requêtes uniques
IDENTITY ne gêne plus les insertions, flush()/clear() maintiennent la taille du contexte sous contrôle — et voilà qu'un autre problème apparaît : le batch peut ne presque pas se former si le flush produit un flux SQL irrégulier. Le problème n'est donc plus dans le fait même de l'écriture de masse, mais dans la similarité des opérations qui parviennent au JDBC.
Imaginez que vous ayez activé hibernate.jdbc.batch_size, fait tout « comme dans les exemples », lancé l'import — et vous voyez dans les logs toujours les mêmes INSERT/UPDATE individuels, comme si le batching n'était même pas en marche. C'est une situation très fréquente : batch size définit la possibilité, mais le comportement réel dépend de la mesure dans laquelle votre flux SQL ressemble à une « production en série » plutôt qu'à un « entrepôt chaotique où tout est mélangé ».
Le point clé ici est que le JDBC batching fonctionne autour du PreparedStatement. En gros, le driver peut dire : « OK, j'ai la même requête SQL du type insert into product (...) values (?, ?, ?) — je l'exécute 50 fois d'un seul coup. Mais si votre flush produit une séquence INSERT product, puis INSERT category, puis à nouveau INSERT product, puis INSERT category, alors du point de vue du driver, ce sont deux PreparedStatement différents entre lesquels il doit constamment basculer. En conséquence, les lots sont soit petits (par exemple 1–2 opérations), soit se réduisent à des exécutions individuelles.
Et c'est là qu'intervient le sujet de la leçon d'aujourd'hui : hibernate.order_inserts et hibernate.order_updates. Ce ne sont pas « deux autres drapeaux magiques d'accélération », mais une tentative d'Hibernate de réorganiser les opérations d'écriture accumulées pour que les opérations SQL similaires soient placées les unes à côté des autres. Hibernate essaie donc de faire pour votre flux d'écriture ce que vous feriez vous-même si vous étiez une personne très patiente : mettre tous les papiers identiques dans une même pile, puis les apporter soigneusement à la base de données.
Pour éviter toute confusion : cela n'a rien à voir avec ORDER BY dans un SELECT. Nous ne trions pas les données que nous lisons. Nous trions les opérations d'écriture (INSERT/UPDATE) avant de les envoyer à la base de données lors du flush.
2. Flush et ActionQueue
Si l'on essaie de se représenter Hibernate comme une « personne », avant le flush il ressemble plutôt à un comptable : il note dans son carnet que vous avez créé une entité, modifié un champ, supprimé un élément de collection, mais il ne se précipite pas à chaque fois vers la base en criant « URGENT UPDATE !!! ». Mais au moment du flush, il se transforme en coursier : il sort son carnet et commence réellement à envoyer du SQL à la base. Et c'est à ce moment-là qu'on peut « ordonnancer » quelque chose.
Techniquement, à l'intérieur d'Hibernate, il existe une structure que l'on décrit généralement comme une file d'attente/ensemble de files d'actions — une sorte de « plan de travail pour le flush ». On y accumule séparément les opérations d'insert, de mise à jour, de suppression, les opérations sur les collections, etc. Dans les leçons sur le flush, nous avons déjà évoqué qu'Hibernate exécute ce plan non pas dans un ordre arbitraire, mais selon une logique définie pour ne pas casser les clés étrangères et l'intégrité.
hibernate.order_inserts et hibernate.order_updates n'interfèrent pas avec votre logique métier ni avec le dirty checking, mais bien avec la façon dont ce plan d'actions est ordonné. Votre code Java reste le même : vous appelez persist(), vous modifiez les champs d'entités managées, les cascades fonctionnent — tout comme avant. Seul l'ordre dans lequel Hibernate envoie les requêtes SQL pendant le flush change.
Il est utile de garder en tête ce schéma (simplifié mais fonctionnel) :
flowchart TD
%% Avant le flush, Hibernate ne fait qu'"accumuler" les actions, le SQL ne part généralement pas encore vers la base
A["Vous appelez persist()/modifiez managed-entity"] --> B["Hibernate accumule les actions dans la file d'attente du flush"]
%% Pendant le flush, Hibernate forme et exécute le plan d'écriture en base
B --> C["Le flush survient : AUTO ou manuel"]
C --> D{"order_inserts / order_updates activés ?"}
D -- non --> E["Exécution dans l'ordre naturel des files"]
D -- oui --> F["Tri des actions insert/update/collection pour un meilleur regroupement"]
E --> G["Le driver JDBC exécute le SQL"]
F --> G["Le driver JDBC exécute le SQL (plus de chances de batch)"]
Une conséquence importante : l'ordonnancement fonctionne à l'intérieur d'un flush spécifique. Si vous faites un flush trop souvent, Hibernate n'accumule tout simplement pas une quantité suffisante d'actions qu'il serait rentable de trier. Et inversement : si vous accumulez 100 000 actions dans un seul flush, vous obtiendrez à la fois le potentiel du batching et une potentielle douleur en termes de mémoire/temps de tri. L'ordonnancement n'est donc qu'un amplificateur d'un flux d'écriture bien construit, et non un substitut magique à flush()/clear().
3. hibernate.order_inserts : groupons les INSERT
Lorsque vous activez hibernate.order_inserts, Hibernate commence à ordonner les opérations d'insert par type d'entité et par valeur de clé primaire afin que le JDBC batching soit plus efficace. Par défaut, cette propriété vaut false. La documentation mentionne également un point important : l'ordre de tri prend en compte les dépendances de clés étrangères, c'est-à-dire qu'Hibernate n'« insérera pas les enfants avant les parents juste pour la beauté », ce qui risquerait de provoquer des erreurs de FK.
Avantage pour le batching
Le batching repose sur un mécanisme assez simple mais honnête : le driver découpe les lots aux limites du PreparedStatement. Pour Product et Category, le SQL sera différent, donc les PreparedStatement seront différents. Si vous insérez des produits et des catégories en alternance, c'est comme si vous obligiez un conducteur de bus à changer toutes les 10 mètres de bus en tramway et inversement — il y a bien un mouvement, mais ce n'est clairement pas optimal.
order_inserts simplifie les choses : « Mettons d'abord tous les produits, puis toutes les catégories (sans violer les dépendances FK) ». En conséquence, le PreparedStatement pour product peut accumuler un lot de, par exemple, 20 insertions, puis un lot pour category se prépare.
Mini-scénario dans notre Commerce Persistence Lab
Prenons un extrait de code très concret qui pourrait apparaître dans une importation de catalogue en laboratoire. Nous alternons persist() de différentes entités, car les données d'entrée arrivent « par paires » et la main a écrit une boucle unique :
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class CatalogMixedImportService {
private final EntityManager em;
public CatalogMixedImportService(EntityManager em) {
this.em = em; // EntityManager nécessaire pour que persist() accumule les actions dans le contexte de persistence
}
@Transactional
public void importMixed(List<Product> products, List<Category> categories) {
// Important : on suppose que les listes ont la même taille et que les éléments correspondent
for (int i = 0; i < products.size(); i++) {
// Au niveau du code, cela ressemble à "deux persist à la suite"
em.persist(products.get(i)); // sera INSERT dans la table product (lors du flush)
em.persist(categories.get(i)); // sera INSERT dans la table category (lors du flush)
// En raison de cette alternance, sans ordering, le SQL devient souvent "irrégulier" et le batching n'est pas optimal
}
// En réalité, le SQL ne partira pas ici, mais lors du flush/commit (s'il n'y a pas eu de flush forcé avant)
}
}
En Java, cela ressemble à « deux opérations identiques dans une boucle ». Mais au niveau SQL, Hibernate verra deux tables différentes, et sans ordering, le flux peut devenir « irrégulier » : insert product, insert category, insert product, insert category… Et le batch pour chaque table peut ne pas atteindre votre batch_size.
Avec hibernate.order_inserts activé, Hibernate a une chance, pendant le flush, de réordonner les opérations d'insert de manière à ce qu'elles se présentent par groupes de types d'entités (en tenant compte des dépendances).
Trace SQL (simplifiée)
Supposons que votre base de données voie approximativement ces requêtes (pour illustration) :
Sans ordonnancement :
-- Les requêtes alternent : le driver doit sauter entre différents PreparedStatement
insert into product (sku, name) values (?, ?);
insert into category (code, name) values (?, ?);
insert into product (sku, name) values (?, ?);
insert into category (code, name) values (?, ?);
Avec hibernate.order_inserts=true, Hibernate a une chance de transformer cela en quelque chose de plus proche de :
-- Les requêtes sont regroupées par table : meilleure chance qu'elles partent en un seul lot (batch)
insert into product (sku, name) values (?, ?);
insert into product (sku, name) values (?, ?);
insert into category (code, name) values (?, ?);
insert into category (code, name) values (?, ?);
Et voilà que le batching commence à ressembler à du batching : deux insert into product ... identiques peuvent être envoyés en un lot, puis deux insert into category ... identiques — un autre lot.
Un point « psychologique » important
Lorsque l'ordonnancement est activé, vous pouvez voir dans les logs que le SQL ne suit pas l'ordre de la boucle. C'est normal. Hibernate n'est pas obligé de conserver l'ordre SQL qui vous semble psychologiquement agréable ; il doit garantir l'intégrité des données. L'ordre du SQL fait partie de la stratégie d'exécution, et c'est justement ce que ces drapeaux permettent de configurer.
Si vous avez besoin que le SQL soit strictement dans l'ordre du code (généralement nécessaire uniquement pour des déclencheurs spécifiques ou des expériences de débogage), il est préférable de ne pas activer l'ordonnancement. Dans un flux backend normal, se fier à l'ordre des DML n'est pas une idée très fiable, car le flush lui-même peut déjà se produire plus tôt que prévu (par exemple, avant une requête).
4. hibernate.order_updates : groupons les UPDATE et les collections
hibernate.order_updates est très similaire dans son principe à order_inserts, mais il opère sur le flux des mises à jour et a une portée légèrement plus large. Par défaut, cette propriété est également désactivée (false). Lorsqu'elle est activée, Hibernate ordonne les opérations de mise à jour par type d'entité et par clé primaire, et ordonne également les opérations liées aux modifications de collections par rôle de collection et valeur de clé étrangère — toujours dans le but d'un JDBC batching plus efficace.
La documentation mentionne un autre détail subtil : un tel ordonnancement réduit également le risque de violation de clé unique lorsqu'un élément de collection « déménage » d'un parent à un autre, car Hibernate essaie d'exécuter les opérations de suppression/« removal » avant les opérations qui n'en contiennent pas. Ce n'est pas une « garantie en béton », mais une protection utile contre des conflits rares et très gênants.
Un flux de mise à jour irrégulier
En général, le flux d'insert est assez clair : vous créez de nouveaux objets. Mais le flux de mise à jour apparaît souvent « tout seul » : vous chargez un ensemble d'entités, modifiez des champs, Hibernate fait du dirty checking, et dans le flush s'accumule une multitude de UPDATE.
Si dans une même transaction vous modifiez plusieurs types d'entités (par exemple, des produits et des stocks, ou des commandes et des articles), les requêtes de mise à jour peuvent alterner, surtout si les modifications se produisent dans différentes parties du code, via des cascades, des helpers et autres « vie de projet ».
order_updates essaie de répartir ces modifications en « piles ordonnées » : d'abord toutes les mises à jour d'un type, puis d'un autre. Cela augmente la probabilité que le JDBC batching puisse réellement constituer des lots par PreparedStatement.
Un petit exemple du domaine : mise à jour de produits et de stocks dans une même transaction
Imaginons un scénario de service qui, pour une raison quelconque, met à jour à la fois Product et InventoryItem dans une même transaction. Ce n'est pas forcément « correct » du point de vue du domaine, mais dans un projet de laboratoire, de telles situations sont utiles pour observer le comportement d'Hibernate :
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@Service
public class ReindexService {
private final EntityManager em;
public ReindexService(EntityManager em) {
this.em = em; // Nécessaire pour que les entités soient managées dans le cadre de la transaction
}
@Transactional
public void touchProductAndInventory(Product product, InventoryItem item) {
// Important : product et item doivent être managés (chargés dans cette session ou mergés),
// sinon Hibernate pourrait ne pas faire le UPDATE attendu lors du flush.
product.setName(product.getName() + " *"); // sera UPDATE product (dirty checking sur flush)
item.setUpdatedAt(Instant.now()); // sera UPDATE inventory_item (dirty checking sur flush)
// Si de nombreuses paires sont traitées, sans order_updates, les UPDATE vont souvent sauter entre les tables,
// ce qui dégrade le batching.
}
}
Si cette méthode est appelée en boucle pour de nombreuses paires product+inventory, sans order_updates, le flush peut facilement produire une séquence de mises à jour où le SQL saute entre les tables, ce qui nuit au batching. En revanche, hibernate.order_updates=true offre une chance de regrouper les requêtes de mise à jour par type d'entité et PK.
Les collections et le rôle de collection
L'expression « rôle de collection » peut évoquer un jeu de rôle (« votre collection a le rôle de "mage" ou de "tank" ? »), mais dans Hibernate, c'est bien plus prosaïque. Le rôle de collection est essentiellement « quelle collection dans quelle entité » : par exemple, PurchaseOrder.items ou Customer.addresses. Même si les éléments de la collection sont des entités distinctes, Hibernate effectue un ensemble d'actions autour de la modification de la collection : ajouter un élément, supprimer un élément, modifier la relation, parfois mettre à jour la colonne FK.
order_updates peut ordonner ces actions pour réduire le chaos et augmenter les chances de batching, surtout là où la modification de collection génère beaucoup de SQL identique.
Ordonnancement des mises à jour et risque de deadlock
Dans les modules précédents (sur le verrouillage), nous avons évoqué que les deadlocks surviennent souvent lorsque deux transactions prennent des verrous sur des lignes dans un ordre différent : l'une met à jour id=10 puis id=20, l'autre fait l'inverse. Si les mises à jour sont ordonnées par PK, les deux transactions mettront plus souvent à jour les lignes dans le même ordre, réduisant ainsi le risque de blocage mutuel.
Mais attention à ne pas tomber dans la « pensée magique ». hibernate.order_updates est un réglage du flux d'écriture pour le batching et pour une séquence légèrement plus déterministe. Il ne remplace pas des limites de transaction correctes, ne corrige pas des stratégies de verrouillage inappropriées et ne transforme pas la concurrence en conte de fées à la fin heureuse.
5. Activer l'ordonnancement dans Spring Boot 4
En pratique, il suffit de deux propriétés Hibernate à côté du batching déjà activé. Si hibernate.jdbc.batch_size est toujours à 0, l'ordonnancement n'a rien à amplifier : on peut trier autant qu'on veut, le batch n'apparaîtra pas de lui-même.
spring:
jpa:
properties:
hibernate.order_inserts: true
hibernate.order_updates: true
Si vous définissez la taille du batch localement sur la Session, ces deux drapeaux restent utiles : ils affectent l'ordre des actions à l'intérieur du flush, et non la provenance de la taille du lot.
order_inserts et order_updates n'accélèrent pas le SQL dans le vide. Ils rendent simplement le flux d'écriture plus homogène à l'intérieur du flush, afin que le JDBC batching constitue plus souvent des lots complets sur les mêmes PreparedStatement.
Si votre flux d'insertion/mise à jour est déjà homogène, il n'y aura peut-être presque aucune différence. Mais dans un flux d'écriture mixte, ces deux lignes fournissent souvent cet effet : « le batching était activé, et maintenant il commence enfin à ressembler à du batching ».
6. Quand l'ordonnancement est-il perceptible et quand ne l'est-il pas ?
En général, on a tendance à vouloir activer ces drapeaux « au cas où ». C'est un désir humain, je ne juge pas — j'ai moi-même fait cela, puis j'ai géré les conséquences et écrit un rapport de bug dans ma tête. Il est donc utile de comprendre à l'avance où order_inserts/order_updates apportent un profit visible, et où vous allez probablement perdre du temps à paramétrer sans voir de différence.
L'observation est simple : l'ordonnancement est particulièrement notable lorsque, dans un même flush, vous avez beaucoup d'opérations d'écriture de différents types et que ces opérations arrivent initialement dans la file de manière « mélangée ». Cela peut être dû à l'alternance de persist() de différentes entités, aux cascades, à un graphe de modifications complexe, ou au fait que le code est « éparpillé » sur plusieurs méthodes sans se soucier de la préparation du batch.
Si votre scénario est du type « on prend 5000 Product et on leur modifie un champ dans une boucle », vous obtiendrez de toute façon une série de UPDATE product ... identiques, et l'ordonnancement peut n'ajouter rien : il n'y a rien à améliorer, le flux est déjà homogène.
Il est pratique de le résumer dans un petit tableau (sans religion, juste comme un repère d'ingénieur) :
| Scénario d'écriture dans un même flush | Ce qui se passe sans ordonnancement | Ce qui peut s'améliorer avec l'ordonnancement |
|---|---|---|
| INSERT massifs d'un seul type (Product uniquement) | Le SQL est déjà homogène | Généralement sans différence |
| Alternance de différents types (Product, Category, Assignment) | Le SQL saute entre les tables | Plus de chances de constituer des lots complets par type |
| UPDATE massifs de différents types dans une même transaction | Le flux de mise à jour est mélangé | Regroupement des UPDATE par type et PK, meilleur batching |
| flush() très fréquent (par exemple à chaque itération) | Le batching et l'ordonnancement n'ont pas le temps de se « déployer » | Effet minimal, car il n'y a rien à trier |
| GenerationType.IDENTITY sur des entités avec un scénario d'insert intense | Hibernate ne peut pas bien grouper les insert | L'ordonnancement ne résout pas le problème de fond |
Et encore un point important : l'ordonnancement est un travail supplémentaire (tri, réorganisation des files). Si vous activez ces drapeaux dans un scénario où le batching est quasiment impossible ou inutile, vous risquez d'obtenir « réglé, mais c'est même un peu plus lent ». Ce n'est pas un bug d'Hibernate, c'est un résultat honnête : vous avez ajouté des calculs sans pouvoir en tirer profit.
7. Erreurs typiques
Il est facile de tomber dans le piège de ces réglages en pensant « j'ai coché la case, ça va plus vite ». Il est donc utile d'anticiper quelques râteaux pour les éviter en théorie au moins une fois, plutôt que trois fois en production.
Erreur n°1 : activer l'ordonnancement mais oublier que le batching est désactivé.
Parfois, on trouve dans la configuration hibernate.order_inserts=true, alors que hibernate.jdbc.batch_size reste à 0. En conséquence, Hibernate trie consciencieusement les opérations d'insert/mise à jour, mais le mécanisme JDBC batching n'est pas activé, et vous ne verrez pas l'effet escompté. Pire, vous avez ajouté un tri sans en tirer profit. Le rappel est très simple : le batching ne s'active qu'avec un hibernate.jdbc.batch_size positif.
Erreur n°2 : s'attendre à ce que l'ordonnancement corrige le problème IDENTITY.
Si la stratégie de génération d'identifiant rend le batch insert impossible en principe (comme c'est le cas avec IDENTITY dans Hibernate), alors order_inserts ne deviendra pas une « solution de contournement ». Il ne transforme pas l'impossible en possible. Dans ces scénarios, vous devez d'abord résoudre le problème de la stratégie d'identifiant et de la forme du flux d'insert, et ensuite seulement considérer l'ordonnancement comme un réglage.
Erreur n°3 : faire un flush trop souvent et s'étonner que l'ordonnancement « ne fonctionne pas ».
L'ordonnancement opère à l'intérieur du flush, il a donc besoin que le flush dispose d'un nombre suffisant d'actions accumulées. Si vous appelez flush() à chaque itération (ou si le flush se déclenche automatiquement à cause de vos propres requêtes dans la transaction), il n'y a tout simplement rien à trier : la file contient trop peu d'opérations pour que le regroupement fasse une différence.
Erreur n°4 : confondre hibernate.order_inserts/hibernate.order_updates avec ORDER BY et les réglages de lecture.
C'est une confusion très fréquente : quelqu'un entend le mot « order » et cherche à savoir comment cela affecte le tri de la liste des produits dans l'admin. Pas du tout. C'est un paramètre d'ordre des DML pendant le flush. Pour la lecture, vous avez d'autres outils : les requêtes, JOIN FETCH, EntityGraph, les projections, etc. — mais cela relève d'une autre partie du cursus et d'un autre « axe du monde ».
Erreur n°5 : compter sur un ordre SQL spécifique comme faisant partie de la logique métier.
Avec l'ordonnancement activé, le SQL peut ne plus suivre l'ordre dans lequel vous avez appelé persist() ou modifié les champs. Mais même sans ordonnancement, l'ordre des DML pendant le flush peut dépendre des cascades, des dépendances et des règles internes d'Hibernate. Si vous avez une exigence métier du type « dans la base, l'enregistrement X doit absolument apparaître avant Y », c'est généralement le signe que vous mélangez des règles métier avec un détail d'infrastructure. Hibernate garantit la cohérence, et non un ordre des logs « agréable à l'œil ».
GO TO FULL VERSION