1. Bulk-Operationen und Mutation Query
Wenn man Hibernate aus der Perspektive eines Neulings betrachtet, scheint es immer ungefähr gleich zu funktionieren: Man bekommt ein Objekt, ändert Felder, Hibernate denkt sich etwas und sendet dann SQL. Bulk-Operationen durchbrechen dieses gewohnte Bild – und das ist normal. Ihre Idee ist es, keine tausenden Java-Objekte im Speicher zu erzeugen, wenn sich eine Geschäftsregel mit einem Satz ausdrücken lässt wie „aktualisiere alle Zeilen, die die Bedingung erfüllen“.
In Hibernate/JPA sind Bulk update und Bulk delete Abfragen, die Daten ändern, statt Entitäten zurückzugeben. Es ist nützlich, sofort den Begriff festzulegen: Mutation Query (Mutationsabfrage). Im Wesentlichen ist es ein „ORM-Wrapper“ um SQL UPDATE/DELETE, nur schreiben wir es in JPQL/HQL (und verwenden Entitäts- und Feldnamen) statt Tabellen- und Spaltennamen.
Das wichtigste praktische Detail: Eine Mutation Query gibt keine Liste von Objekten zurück. Sie gibt die Anzahl der betroffenen Zeilen zurück. Und das tut sie über die Methode executeUpdate().
Um keine Verwirrung zu stiften, halten wir eine kleine Tabelle fest:
| Was wir tun | Art der Abfrage | Was wird zurückgegeben | Hauptidee |
|---|---|---|---|
| Daten lesen | select ... | Objekte/Projektionen | „Bring mir das Ergebnis“ |
| Daten massenhaft ändern | update ... / delete ... | int (wie viele Zeilen) | „Führe die Änderung in der DB durch, ohne Entitäten zu laden“ |
Ein Minibeispiel für ein Bulk update im JPA-Stil (über EntityManager). Beachten Sie: Hier gibt es kein getResultList() – wir lesen nichts.
// Erstellen einer Mutation Query: Sie ändert Zeilen in der DB und gibt nichts "als Entitäten" zurück
int updated = entityManager.createQuery("""
update Product p
set p.status = :newStatus
where p.status = :oldStatus
""")
// Parameter werden sicher übergeben: ohne String-Konkatenation
.setParameter("newStatus", ProductStatus.HIDDEN)
.setParameter("oldStatus", ProductStatus.ACTIVE)
// executeUpdate() gibt die Anzahl der betroffenen Zeilen zurück
.executeUpdate();
// Wichtig: updated ist NICHT "wie viele Objekte im Speicher aktualisiert wurden", sondern wie viele Zeilen in der DB geändert wurden
System.out.println("updated = " + updated); // updated = 42
2. executeUpdate() und Statistik
Auf der Gefühlsebene eines Anfängers kann man executeUpdate() leicht mit so etwas wie „mach ein save, nur schnell“ verwechseln. Aber executeUpdate() ist kein Speichern eines Objekts und auch kein „Anwenden von Änderungen an Managed-Entitäten“. Es ist einfach ein Befehl: „führe update/delete in der DB aus“ und gib die Anzahl der Zeilen zurück, die von der Abfrage betroffen sind.
Das hat sowohl Vor- als auch Nachteile. Der Vorteil ist, dass Sie einen sehr ehrlichen Indikator erhalten: Haben Sie überhaupt etwas betroffen? Der Nachteil ist, dass executeUpdate() Ihnen keine „fertig aktualisierten Objekte“ liefert und auch nicht versucht, das, was bereits im Persistence Context liegt, zu „heilen“.
Hier bietet sich eine alltägliche Analogie an. Stellen Sie sich vor, der Persistence Context sei Ihr „Notizzettel auf Papier“. Sie haben einmal notiert: „Produkt #10 ACTIVE“. Dann gehen Sie zur Datenbank und sagen: „Mach Produkt #10 HIDDEN“. Die Datenbank hat sich geändert. Aber der Zettel auf dem Tisch hat sich nicht von selbst aktualisiert. executeUpdate() wird Ihnen sagen: „Ja, ich habe 1 Zeile geändert.“ Aber es wird nicht hingehen und Ihren Zettel korrigieren.
Auf SQL-Ebene sieht das maximal geradlinig aus. Beispielsweise wird ein Bulk-Update des Status oft in etwa so zu einer Abfrage (die genaue Form hängt vom Mapping des Enums und den Spaltennamen ab, aber der Sinn ist klar):
-- Massenaktualisierung von Zeilen: Ändern des Spaltenwerts ohne Laden der Entitäten in den Speicher
update product
set status = 'HIDDEN'
where status = 'ACTIVE';
Und das ist ein kritisch wichtiger Gedanke für das Folgende: Bulk bedeutet „zur DB gehen und Zeilen ändern“. Und Persistence Context bedeutet „Speicher und verwaltete Objekte“. Bulk ändert Zeilen, nicht Objekte.
createMutationQuery() in Hibernate
Die JPA-API ist historisch so aufgebaut, dass Sie ein update/delete über das normale entityManager.createQuery(...) erstellen können. Hibernate (ab Version 6 und in 7.2) fügt eine ausdrucksstärkere Möglichkeit hinzu: createMutationQuery(). Das ist keine „andere Magie“, sondern einfach eine Möglichkeit, den Code lesbarer zu machen: Sie sagen sich und Ihren Kollegen explizit: „Das ist eine Abfrage, die Daten ändert“.
Dazu muss man die Hibernate-Session aus dem EntityManager „herausholen“:
import org.hibernate.Session;
// unwrap wird benötigt, um die native Hibernate-API über den JPA EntityManager zu erhalten
Session session = entityManager.unwrap(Session.class);
// Mutation Query: löscht Zeilen nach Bedingung und gibt deren Anzahl zurück
int deleted = session.createMutationQuery("""
delete from InventorySnapshot s
where s.snapshotDate < :border
""")
// Den Datumsgrenzwert übergeben wir als Bind-Parameter
.setParameter("border", borderDate)
// Führt die Löschung direkt in der DB aus
.executeUpdate();
// deleted ist die Anzahl der gelöschten Zeilen, nicht „gelöschter Objekte im Speicher“
System.out.println("deleted = " + deleted); // deleted = 12000
Warum ist das in einem Übungsprojekt nützlich (und oft auch in einem realen Projekt), selbst wenn die JPA-Version auch funktioniert? Weil es weniger „Ratespiele“ im Code gibt. Sie lesen die Abfrage nicht mit den Augen und überlegen „ist das ein select oder ein update?“. Die Methode sagt es einem selbst.
Und noch eine Kleinigkeit: Wenn Sie createMutationQuery() sehen, sollte bei Ihnen automatisch ein Lämpchen angehen: „Jetzt kommt ein Bulk. Also sind stale-Objekte im Persistence Context möglich.“ Das ist wie ein Verkehrsschild: „Achtung, jetzt kommt eine andere Physik.“
3. Bulk umgeht das Managed-Modell von Hibernate
Jetzt kommt der Schlüsselmoment dieser Lektion. Eine Bulk-Operation beschleunigt nicht den normalen Managed-Flow. Sie umgeht ihn.
Um den Unterschied zu spüren, ist es nützlich, zwei „innere Szenarien“ zu vergleichen – als würden wir die Ausführung unter dem Mikroskop betrachten.
flowchart TD
A[SELECT entities] --> B[entities become managed]
B --> C[change fields in Java]
C --> D[dirty checking at flush]
D --> E[generated UPDATE per entity]
Szenario B – Bulk update/delete:
flowchart TD
A[createMutationQuery] --> B[executeUpdate]
B --> C["SQL UPDATE/DELETE directly in DB"]
C --> D[persistence context stays as it was]
Beachten Sie, was im Bulk-Zweig nicht vorkommt: keine Materialisierung von Entitäten im Speicher, kein First-Level-Cache für diesen Satz, keine Snapshots für Dirty Checking, kein Durchlaufen jeder Entity. Genau das bringt den Geschwindigkeitsvorteil.
Wenn wir zusammenfassen, „was genau umgangen wird“, ergibt sich folgende Tabelle:
| Hibernate-Mechanik | Im Managed-Flow (Entity-Loop) | Bei Bulk update/delete |
|---|---|---|
| Laden von Entitäten in den Speicher | Ja | Nein |
| Persistence Context (Identity Map) | Wird aktiv genutzt | Wird nicht automatisch aktualisiert |
| Dirty Checking (Snapshots, Vergleich) | Ja | Nein |
| Entity Callbacks/Listener (@PreUpdate usw.) | Normalerweise ja (wenn ein Entity-Update stattfindet) | Normalerweise nein (Entität nicht geladen) |
| Cascade / orphanRemoval | Kann je nach Operation ausgelöst werden | Wird nicht automatisch angewendet |
| Envers (Revisionsaudit) | Funktioniert normalerweise auf Entity-Ereignissen | Sieht Bulk normalerweise nicht direkt |
| Rückgabe des Ergebnisses | Managed Objekte | int – Anzahl der Zeilen |
Diese Tabelle ist kein Grund, Bulk zu fürchten. Sie ist eine Anleitung, wie man denkt: Bulk ist ein Werkzeug, das Geschwindigkeit um den Preis erkauft, dass Sie die komfortable Managed-Realität verlassen.
4. Stale Persistence Context
Nach der obigen Tabelle möchte man sagen: „Okay, verstanden: Bulk ist schneller. Wo ist der Haken?“ Der Haken ist, dass Hibernate keine Telepathie hat. Wenn Sie bereits ein Product als Managed-Objekt geladen haben und dann ein Bulk-Update auf die Produkttabelle durchführen, muss Hibernate (und kann es zuverlässig nicht) Ihren gesamten Persistence Context durchgehen und alle bereits geladenen Objekte aktualisieren.
So taucht der Begriff des Tages auf: Stale Persistence Context – „veralteter Kontext“. Das ist eine Situation, in der die Datenbank bereits geändert wurde, aber in der aktuellen Sitzung (im Persistence Context) noch Managed-Objekte mit altem Zustand liegen.
Das anschaulichste Beispiel: Wir lesen ein Produkt, führen dann ein Bulk-Update des Status durch und schauen uns dann das Feld des alten Objekts an:
// 1) Entität laden – jetzt ist sie managed und befindet sich im Persistence Context
Product product = entityManager.find(Product.class, productId);
// 2) Bulk Update durchführen: Zeile in der DB ändern, aber das bereits geladene product-Objekt NICHT aktualisieren
entityManager.createQuery("""
update Product p
set p.status = :newStatus
where p.id = :id
""")
.setParameter("newStatus", ProductStatus.HIDDEN)
.setParameter("id", productId)
.executeUpdate();
// 3) Feld aus dem Objekt im Speicher lesen: Es kann alt (stale) bleiben
System.out.println(product.getStatus()); // ACTIVE (stale)
Wenn Sie jetzt denken „das kann doch nicht sein, ich habe es doch aktualisiert!“, dann gratuliere ich: Sie haben genau das „erste echte Gefühl“ von Stale Persistence Context erwischt. Und das ist kein Bug und kein „Hibernate spinnt schon wieder“. Es ist eine logische Konsequenz: Der Bulk-Query hat Zeilen in der DB aktualisiert, aber product ist bereits ein fertiges Managed-Objekt im Speicher.
Es gibt einen zweiten kniffligen Punkt, der oft noch mehr überrascht. Wenn Sie nach dem Bulk erneut find() mit derselben id aufrufen, gibt Hibernate oft dasselbe Objekt aus dem First-Level-Cache (Identity Map) zurück, anstatt in die DB nach frischen Daten zu gehen.
// Erstes Lesen: Das Objekt gelangt in den Persistence Context
Product p1 = entityManager.find(Product.class, productId);
// Bulk Update ändert die Zeile in der DB, aber p1 wird im Speicher nicht automatisch geändert
entityManager.createQuery("""
update Product p
set p.status = :newStatus
where p.id = :id
""")
.setParameter("newStatus", ProductStatus.HIDDEN)
.setParameter("id", productId)
.executeUpdate();
// Zweites Lesen derselben id: Hibernate kann dasselbe Managed-Objekt aus der Identity Map zurückgeben
Product p2 = entityManager.find(Product.class, productId);
// Es ist dasselbe Objekt per Referenz, daher zeigen sich die Änderungen "in der DB" nicht von selbst
System.out.println(p1 == p2); // true
System.out.println(p2.getStatus()); // ACTIVE (stale)
Sie können also zweimal „aus der Datenbank“ lesen (wie es Ihnen erscheint), aber tatsächlich lesen Sie beim zweiten Mal „aus dem Speicher“. Denn innerhalb einer Transaktion hält Hibernate den Vertrag „eine Zeile = ein Managed-Objekt“ getreulich ein. Er hat auch nicht versprochen, dass dieses Objekt nach Bulk SQL automatisch aktualisiert wird.
Genau deshalb erfordern Bulk-Operationen fast immer Disziplin: „Wenn ich einen Bulk gemacht habe – muss ich überlegen, wie ich weiter mit dem Persistence Context umgehe“. Jetzt geht es vor allem darum, den Grund zu erkennen. Sobald klar ist, woher der Stale-Zustand kommt, stellt sich die nächste praktische Frage von selbst: wo vor einem Bulk flush() nötig ist, wann clear() hilft und wann refresh() angebracht ist.
5. Bulk delete und Speicher
Bulk delete ist im Allgemeinen noch ehrlicher als update: Es löscht einfach Zeilen nach Bedingung. Es ist das ideale Werkzeug für Aufgaben wie „historische Daten bereinigen“, die in unserem Projekt hervorragend auf InventorySnapshot passen.
Beispiel einer Bulk-Deletion „alles älter als 90 Tage“:
// Löschen von Zeilen direkt in der DB, ohne Entitäten InventorySnapshot in den Speicher zu laden
int deleted = entityManager.createQuery("""
delete from InventorySnapshot s
where s.snapshotDate < :border
""")
// Die Grenze "älter als 90 Tage" wird auf Anwendungsseite berechnet
.setParameter("border", LocalDate.now().minusDays(90))
.executeUpdate();
// deleted ist die Anzahl der gelöschten Zeilen
System.out.println("deleted = " + deleted); // deleted = 10500
Und hier taucht eine sehr unangenehme Falle für den Verstand auf: Wenn Sie vor dem Bulk Delete einen InventorySnapshot als Managed-Entity geladen haben, verschwindet dieses Objekt nach dem Bulk Delete nicht aus dem Speicher. Hibernate rennt nicht durch alle Ihre Referenzen und sagt: „Hey, diese Zeile gibt es nicht mehr“.
Stark vereinfachte Demonstration der Idee:
// Entität laden: Sie wird managed
InventorySnapshot snap = entityManager.find(InventorySnapshot.class, snapshotId);
// Bulk Delete löscht Zeilen in der DB, aber "räumt" nicht bereits managed-Objekte aus dem Persistence Context
entityManager.createQuery("""
delete from InventorySnapshot s
where s.snapshotDate < :border
""")
.setParameter("border", LocalDate.now().minusDays(90))
.executeUpdate();
// contains() zeigt: Das Objekt befindet sich immer noch unter der Verwaltung des Persistence Context
System.out.println(entityManager.contains(snap)); // true (immer noch managed)
Ja, das sieht seltsam aus, aber die Logik ist dieselbe: Bulk Delete löscht Zeilen direkt in der DB, und der Persistence Context erfährt das nicht automatisch. Im Speicher liegt immer noch ein Objekt, das „sozusagen existiert“. Bis Sie versuchen, etwas damit zu tun, was eine Synchronisation mit der DB erfordert.
Und das kann unangenehme Folgen haben. Zum Beispiel könnten Sie versehentlich auf Assoziationen zugreifen (falls vorhanden), Felder ändern, und beim Flush würde Hibernate versuchen, ein UPDATE durchzuführen … und die DB sagt: „Es gibt nichts zu aktualisieren, die Zeile existiert nicht“. Das führt zu Ausnahmen wie „stale state“ auf der Aktualisierungsebene.
Bulk Delete ist gut, aber es gibt eine „Nüchternheitsregel“: Wenn Sie Daten per Bulk löschen, tun Sie nicht so, als hätten Sie in der aktuellen Transaktion noch „normale“ Managed-Objekte dieser Zeilen.
6. Bulk in Spring Data JPA
In einem realen Projekt (und auch in unserem Laborprojekt) werden Bulk-Operationen oft in Repository-Methoden „verpackt“. Das ist praktisch: Der Service ruft die verständliche Methode bulkHideActiveProducts() auf, und innen drin liegt ein JPQL-Update.
Aber es ist wichtig zu bedenken: Der Wrapper ändert die Physik nicht. Ein über Spring Data JPA aufgerufenes Bulk update/delete arbeitet genauso direkt mit der DB und synchronisiert den Persistence Context nicht automatisch.
Ein Minibeispiel eines Repository (stark vereinfacht, damit Sie die Form sehen):
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
interface ProductRepository extends Repository<Product, Long> {
// @Modifying teilt Spring Data mit: Dies ist kein SELECT, sondern ein update/delete, executeUpdate() muss aufgerufen werden
// In echtem Code wird meist auch eine Transaktion benötigt (z.B. auf Service- oder Repository-Ebene)
@Modifying
@Query("""
update Product p
set p.status = :newStatus
where p.status = :oldStatus
""")
// Rückgabe int: wie viele Zeilen tatsächlich in der DB aktualisiert wurden
int bulkUpdateStatus(ProductStatus oldStatus, ProductStatus newStatus);
}
Fast alles, was wir oben besprochen haben, bleibt gültig: Die Methode gibt ein int zurück (wie viele Zeilen aktualisiert wurden), aber keine aktualisierten Entitäten, räumt nicht den Speicher für Sie auf und aktualisiert keine bereits geladenen Managed-Objekte.
Daher sind alle Warnungen im Sinne von „nach einem Bulk muss man über den Persistence Context nachdenken“ – das ist keine Bürokratie und keine „Besserwisserei“. Es ist ein Versuch, Sie davor zu bewahren, dass Sie glauben, mit aktuellen Daten zu arbeiten, aber in Wirklichkeit in der Vergangenheit leben (etwa wie ein Entwickler, der kein Git Pull gemacht hat, aber schon überzeugt ist, dass „alles aktuell“ ist).
7. Typische Fehler bei der Arbeit mit Bulk update/delete
Fehler Nr. 1: Erwarten, dass Bulk Update bereits geladene Managed-Entitäten aktualisiert.
Das ist die klassischste Falle. Sie sehen executeUpdate(), sehen, dass es 1 zurückgegeben hat, und Ihr Gehirn nimmt automatisch an, dass „das Objekt auch aktualisiert wurde“. Falsch: Bulk ändert Zeilen in der DB, aber die Managed-Objekte im Persistence Context bleiben wie gehabt. Daher kann das Lesen eines Feldes einer zuvor gefundenen Entität nach einem Bulk den alten Wert anzeigen – und das ist erwartbar.
Fehler Nr. 2: Ein Bulk über dieselben Daten durchführen, die Sie bereits im aktuellen Flow gelesen haben, und die Geschäftslogik „als ob nichts gewesen wäre“ fortsetzen.
Am gefährlichsten wird Bulk nicht an sich, sondern in Kombination mit dem gewohnten Stil „zuerst den Graph geladen, dann etwas massenhaft geändert, dann mit denselben Referenzen weitergearbeitet“. So erzeugen Sie leicht einen Stale Persistence Context, und dann stützen sich alle Bedingungsprüfungen, Berechnungen und Entscheidungen auf veraltete Daten, obwohl die Datenbank bereits anders aussieht.
Fehler Nr. 3: executeUpdate() als Bestätigung der Synchronisation des In-Memory-Zustands interpretieren.
executeUpdate() bestätigt nur eines: „In der DB wurden N Zeilen geändert.“ Es bestätigt nicht, dass Hibernate „weiß“, welche Objekte im Speicher nun veraltet sind. Das ist wie ein Kassenzettel: Er beweist, dass ein Kauf stattgefunden hat, aber nicht, dass Ihr Kühlschrank sich selbst mit Lebensmitteln aufgefüllt hat.
Fehler Nr. 4: Auf Kaskaden, Callbacks/Listener, Envers und andere „Serviceeffekte“ des ORM zählen.
Bulk ist ein Modus „mache SQL direkt“. Normalerweise lädt er keine Entitäten, ruft keine Domänenmethoden auf, startet kein @PreUpdate, führt kein Cascade durch und wird oft nicht im Audit erfasst (z.B. Envers). Wenn Ihre Geschäftslogik oder infrastrukturelle Effekte im Entity-Lifecycle „leben“, kann Bulk sie umgehen. Im Übungsprojekt bemühen wir uns bewusst, wichtige Geschäftslogik in Services zu halten, damit solche Überraschungen nicht unsichtbar bleiben.
Fehler Nr. 5: Bulk als „schnelle Variante einer remove()-Schleife“ betrachten und Datenbankbeschränkungen ignorieren.
Bulk delete wird genau so weit ausgeführt, wie es das Schema erlaubt: Fremdschlüssel, Constraints, Integritätsregeln. Hibernate wird Ihnen hier nicht mit Kaskaden „helfen“, wenn Sie einfach Zeilen mit einem Befehl gelöscht haben. Wenn die Tabelle InventorySnapshot sicher bereinigt werden kann – großartig. Wenn Sie auf diese Weise Entitäten löschen würden, auf die jemand verweist, könnte die DB zu Recht sagen: „geht nicht“.
GO TO FULL VERSION