🎯 OBJECTIF
Comprendre comment :
IDENTITY casse le batching — SEQUENCE le permetpooled vs pooled-lo) gèrent la génération d'IDs en multi-podsorder_inserts / order_updates regroupent les statements par table pour maximiser le batchingpersist() garantit le batching — save() ne le garantit pas (risque de merge() + SELECT)@Version complique le batching des UPDATE mais pas des INSERT🧠 MODÈLE MENTAL
Le coût d'un INSERT ne vient pas principalement de l'écriture disque — c'est le round-trip JDBC qui coûte : connexion, parsing SQL côté DB, latence réseau. Avec 1000 INSERT sans batching, c'est 1000 allers-retours. Avec batch_size=50, c'est 20 executeBatch() — soit 50× moins de round-trips.
Pour que le batching fonctionne, Hibernate utilise PreparedStatement.addBatch() + executeBatch(). Condition absolue : les statements SQL doivent être strictement identiques (même colonnes, seules les valeurs changent). Dès qu'ils alternent (INSERT stock, INSERT blocking, INSERT stock...), le batch est cassé. C'est le rôle de order_inserts=true : regrouper tous les INSERT par table.
La stratégie d'ID est le frein principal : avec IDENTITY, Hibernate doit faire l'INSERT immédiatement pour récupérer l'ID généré par la DB — impossible d'accumuler. Avec SEQUENCE, Hibernate obtient les IDs à l'avance et peut remplir son buffer tranquillement.
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 50 # ✓ taille du lot JDBC
order_inserts: true # ✓ regroupe les INSERT par table
order_updates: true # ✓ regroupe les UPDATE par table
batch_versioned_data: true # ✓ permet le batching sur entités @Versionyamlbatch_size ≠ flush. batch_size ≠ commit. C'est uniquement la taille du lot envoyé via executeBatch().
// ❌ IDENTITY — casse le batching
@GeneratedValue(strategy = GenerationType.IDENTITY)
// → la DB génère l'ID à l'INSERT
// → Hibernate doit faire l'INSERT immédiatement pour récupérer l'ID
// → impossible d'accumuler des statements → 1 INSERT par entité
// ✓ SEQUENCE — permet le batching
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "stock_seq"
)
@SequenceGenerator(
name = "stock_seq",
sequenceName = "stock_id_seq",
allocationSize = 50 // ✓ aligner sur batch_size
)
// → Hibernate obtient 50 IDs d'un coup depuis la séquence
// → accumule 50 INSERT → exécute en un seul executeBatch()java🚨 IDENTITY = zéro batching, même avec batch_size configuré
Avec IDENTITY, hibernate.jdbc.batch_size=50 est ignoré sur les INSERT. Aucun log d'avertissement. Le batching est silencieusement désactivé. Toujours utiliser SEQUENCE ou UUID pour les entités avec gros volumes d'insertion.
PostgreSQL et Hibernate ont des rôles séparés dans la génération d'IDs. PostgreSQL garantit l'unicité globale via nextval(). Hibernate décide comment transformer ces valeurs en IDs applicatifs via un optimizer.
none — 1 INSERT = 1 nextval(). Lent mais sans configuration. allocationSize=1.
pooled (recommandé) — activé automatiquement si allocationSize > 1. Hibernate interprète nextval() comme une borne interne et calcule lui-même les blocs. Ne nécessite pas de changer INCREMENT BY sur la séquence. Safe en multi-pods : chaque pod reçoit une valeur unique, Hibernate en déduit un bloc distinct non chevauchant.
pooled-lo (avancé) — nextval() est interprété comme le début du bloc. Requiert que INCREMENT BY de la séquence soit aligné sur allocationSize. Mal configuré = conflits d'IDs garantis.
// ❌ pooled-lo mal configuré — conflit garanti en multi-pods
// allocationSize=50, INCREMENT BY=1
// POD1: nextval()=76 → IDs 76..125
// POD2: nextval()=77 → IDs 77..126 ← chevauchement !
// ✓ pooled-lo bien configuré
// allocationSize=50, INCREMENT BY=50
// POD1: nextval()=76 → IDs 76..125
// POD2: nextval()=126 → IDs 126..175 ← blocs distincts
// ✓ pooled (standard) — INCREMENT BY=1, allocationSize=50
// POD1: nextval()=76 → bloc A calculé par Hibernate
// POD2: nextval()=77 → bloc B calculé par Hibernate ← toujours distinctsjava@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_gen")
@SequenceGenerator(
name = "order_gen",
sequenceName = "order_seq",
allocationSize = 50 // optimizer pooled automatique
)
private Long id;java-- PostgreSQL
CREATE SEQUENCE order_seq
INCREMENT BY 1 -- ✓ avec pooled, pas besoin d'aligner
CACHE 50 -- optimisation interne DB (aucun impact sur les IDs)
START WITH 1;sql| Optimizer | allocationSize | INCREMENT BY | Multi-pods | Complexité | Recommandé |
|---|---|---|---|---|---|
none |
1 | 1 | ✅ | ✅ Simple | ❌ Lent |
pooled |
50 | 1 | ✅ | ✅ Simple | ✅ OUI |
pooled-lo |
50 | 50 | ✅ si aligné | ⚠️ Complexe | ⚠️ Rare |
pooled-lo |
50 | 1 | ❌ CONFLITS | ❌ Dangereux | ❌ NON |
🔑 CACHE PostgreSQL ≠ allocationSize Hibernate
CACHE est une optimisation interne PostgreSQL (pré-alloue des valeurs en mémoire pour réduire les WAL writes) — sans impact fonctionnel sur les IDs générés. allocationSize contrôle le batching Hibernate. Ce sont deux mécanismes orthogonaux.
Sans order_inserts, Hibernate peut interleaver les statements :
-- ❌ Sans order_inserts — batch impossible
INSERT INTO stock (...)
INSERT INTO blocking (...) -- type différent → executeBatch() forcé
INSERT INTO stock (...) -- nouveau batch repart de zéro
INSERT INTO task (...) -- encore un flush prématurésql-- ✓ Avec order_inserts=true — batch optimal
INSERT INTO stock (...) -- batch 1 : tous les stocks
INSERT INTO stock (...)
INSERT INTO stock (...) -- → 1 executeBatch() pour tous les stocks
INSERT INTO blocking (...) -- batch 2 : tous les blockings
INSERT INTO blocking (...) -- → 1 executeBatch() pour tous les blockingssql// ✓ PATTERN PROD — batching garanti
@Transactional
public void insertBulk(List<Blocking> blockings) {
int i = 0;
for (Blocking b : blockings) {
entityManager.persist(b); // ✓ NEW → INSERT pur, aucun SELECT
if (++i % 50 == 0) {
entityManager.flush(); // ✓ envoie le batch
entityManager.clear(); // ✓ libère le L1
}
}
}java// ⚠️ RISQUÉ — save() peut déclencher merge() + SELECT
@Transactional
public void saveAll(List<Blocking> blockings) {
for (Blocking b : blockings) {
repository.save(b); // ← si b a un ID déjà défini → merge() → SELECT + UPDATE
} // ← batching dégradé ou cassé
}java| Méthode | Comportement | Batching |
|---|---|---|
entityManager.persist() |
INSERT pur, aucun SELECT | ✅ Optimal |
repository.save() (entité isNew()) |
persist() |
✅ OK |
repository.save() (ID déjà défini) |
merge() → SELECT + UPDATE |
⚠️ Dégradé |
repository.saveAll() |
pas de flush intermédiaire → L1 gonfle | ❌ Risque OOM |
@Version complique les UPDATE mais pas les INSERT :
-- UPDATE avec @Version — structure identique → batchable si batch_versioned_data=true
UPDATE stock SET quantity = ?, version = version + 1 WHERE id = ? AND version = ?sqlAvec hibernate.batch_versioned_data=true : Hibernate exécute le batch des UPDATE, vérifie les rowCount après, et lève OptimisticLockException si un UPDATE n'a touché 0 ligne.
🔑 Batching = transactions plus courtes = moins d'OptimisticLockException
Un batching efficace réduit la durée des transactions. Moins de temps en transaction = fenêtre de conflit réduite = moins de OptimisticLockException.
# ✓ Toutes ces propriétés ensemble pour un batching efficace
hibernate:
jdbc.batch_size: 50
order_inserts: true
order_updates: true
batch_versioned_data: true # si entités @Version
# ✓ Stratégie d'ID
@GeneratedValue(strategy = SEQUENCE) # ou UUID
allocationSize: 50 # aligner sur batch_size
# Sequence PostgreSQL : INCREMENT BY 1, CACHE 50
# ✓ Pattern d'insertion
entityManager.persist(entity) # pas save()
flush() + clear() tous les 50 # libérer le L1
# ✓ Une seule transaction
@Transactional # pas de save() dans une boucle non-transactionnelleyaml⚡ TL;DR — chaque concept en une ligne
batch_size
✓ Réduit les allers-retours JDBC en regroupant les statements identiques — 1000 INSERT en 20 executeBatch() avec batch_size=50.
⚠ Inefficace si les statements ne sont pas identiques (tables interleaved) — nécessite order_inserts=true pour fonctionner.
IDENTITY vs SEQUENCE
✓ SEQUENCE + allocationSize permet à Hibernate d'obtenir les IDs à l'avance et d'accumuler les INSERT en mémoire.
⚠ IDENTITY oblige un INSERT immédiat par entité pour récupérer l'ID — désactive silencieusement le batching.
pooled vs pooled-lo
✓ pooled (défaut avec allocationSize > 1) est safe en multi-pods sans configuration de séquence particulière.
⚠ pooled-lo exige que INCREMENT BY de la séquence soit aligné sur allocationSize — mal aligné = conflits d'IDs.
persist() vs save()
✓ entityManager.persist() garantit un INSERT pur, sans SELECT, avec batching optimal.
⚠ repository.save() peut déclencher merge() + SELECT si l'entité a un ID déjà défini — batching dégradé ou cassé.
@Version + batching
✓ Activer batch_versioned_data=true pour batcher les UPDATE sur entités versionnées.
⚠ Sans ce flag, le batching est silencieusement désactivé pour tous les UPDATE sur entités @Version.
🎓 À retenir
order_inserts est aussi important que batch_size — sans regroupement par table, les INSERT de types différents alternent et cassent le batch. Les deux propriétés sont indissociables pour un batching réel.saveAll() ne fait pas de flush intermédiaire — sur 100 000 entités, le Persistence Context gonfle jusqu'à OOM avant que le moindre SQL ne soit envoyé. Toujours utiliser persist() + flush() + clear() par lots explicites.CACHE PostgreSQL et allocationSize Hibernate sont deux mécanismes orthogonaux — CACHE optimise les lectures de séquence côté DB (réduit les WAL writes), allocationSize contrôle les appels à nextval() depuis Hibernate. On peut avoir les deux sans conflit, mais ils ne se substituent pas l'un l'autre.