1. „Unsichtbares UPDATE“: Woher kommt es?
Ein Transient-Objekt speichert sich nicht selbst, ein detached wird nicht mehr verfolgt, und ein managed lebt innerhalb des aktuellen persistence context. Das bedeutet, die wichtigste praktische Frage hier ist sehr bodenständig: Was genau macht Hibernate, während die Entität managed ist, und warum erscheint manchmal ein UPDATE ohne explizites save()?
Wenn Sie nach gewöhnlichen CRUD-Lektionen zu Spring Data JPA kommen, möchte das Gehirn automatisch eine einfache Regel: „Du willst speichern – ruf save() auf“. Das ist ein praktischer Reflex, aber in JPA beginnt er schnell zu schaden. Denn JPA ist keine Sammlung einzelner unabhängiger SQL-Befehle, sondern ein Mechanismus, der eine unit of work verwaltet und die Entitäten zwischen Lesen und Commit „in der Hand halten“ kann.
Daher sieht eine typische Szene so aus: Sie haben im Service ein Product geladen, seinen Namen geändert, kein save() aufgerufen, und plötzlich erscheint in den SQL-Logs ein update product .... Und Sie sitzen da, wie nach einem Zaubertrick, und verdächtigen Hibernate der Hexerei (dabei arbeitet es nur).
Der wichtigste Gedanke der Vorlesung: In JPA ist „Speichern“ nicht immer „die Methode save() aufrufen“. Manchmal ist „Speichern“ einfach „ein managed-Objekt innerhalb einer Transaktion ändern“, und die ORM versteht selbst, was als nächstes zu tun ist.
Dirty Checking einfach erklärt: Hibernate als Buchhalter mit Notizblock
Dirty Checking ist ein Mechanismus, der es der ORM ermöglicht zu bemerken, dass sich eine managed-Entität geändert hat, und ein UPDATE vorzubereiten. Es klingt bedrohlich, aber die Idee ist sehr bodenständig: Wenn Hibernate ein Objekt geladen hat, merkt es sich, „wie es war“. Später, kurz vor der Synchronisation mit der Datenbank, schaut es, „wie es geworden ist“. Wenn es sich unterscheidet, ist das Objekt „dirty“ (sozusagen „beschmutzt“ von Änderungen) und muss aktualisiert werden.
Wichtig, nicht zu verwechseln: Dirty Checking bedeutet nicht, dass das UPDATE sofort nach setName(...) in die Datenbank geht. Dirty Checking ist für die Erkennung von Änderungen zuständig. Wann genau das SQL abgeschickt wird, ist eine Frage der Synchronisation des Kontexts mit der DB: Dirty Checking findet nur die Änderung, ist aber nicht verpflichtet, das SQL in derselben Sekunde zu senden.
Für uns reicht es jetzt, folgende Ereigniskette im Kopf zu behalten: Innerhalb der Transaktion wird die Entität managed → Sie ändern ein Feld → die ORM stellt fest, dass das Objekt „dirty“ ist → später sendet die ORM das UPDATE.
2. Aktualisierung einer managed-Entität ohne save() am Beispiel Product
In unserem Projekt shop-data-jpa (Mini-Shop: Katalog, Bestände, Bestellungen) lebt die Entität Product im Katalog. Und das lehrreichste Szenario ist, ein Produkt umzubenennen oder den Preis zu ändern. Lassen Sie uns eine Service-Methode erstellen, die den Namen des Produkts ohne save() ändert.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
// Repository für den Zugriff auf Product in der DB
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional // Wichtig: Innerhalb der Transaktion wird die Entität managed
public void renameProduct(long productId, String newName) {
// Laden der Entität aus der DB: Dies ist eine managed-Instanz im Persistenzkontext
Product product = productRepository.findById(productId).orElseThrow();
// Ändern des Feldes: Hibernate bemerkt die Änderung durch Dirty Checking
product.setName(newName);
}
}
Wenn SQL-Logs aktiviert sind, werden Sie in etwa Folgendes sehen: zuerst ein select ... from product where id=? und gegen Ende der Transaktion erscheint ein update product set name=? where id=?. Und das ist normal: Die Entität war managed, Sie haben das Feld geändert, die ORM hat die Änderung bemerkt und später mit der DB synchronisiert.
Um die Spannung endgültig zu nehmen, ist es nützlich zu klären, „warum das so geht“. Innerhalb von @Transactional befindet sich das Product im persistence context. Das ist genau der Fall, in dem das Objekt für die ORM wirklich „lebendig“ ist: Hibernate kennt seine id, weiß, dass es aus der DB geladen wurde, und kann Änderungen verfolgen.
Und nun ein Gegenbeispiel, das die Erwartungen eines Anfängers zerstört: Wenn keine Transaktion vorhanden ist, haben Sie so gut wie garantiert kein langlebiges managed-Objekt, und das Dirty Checking wird einfach nicht rechtzeitig ausgelöst.
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void renameProductNoTx(long productId, String newName) {
// Die Entität wird nur im Rahmen der kurzen Repository-Operation managed sein (falls überhaupt)
Product product = productRepository.findById(productId).orElseThrow();
// Höchstwahrscheinlich ist das Produkt hier bereits detached (Kontext beendet)
product.setName(newName); // in der DB wird sich höchstwahrscheinlich nichts ändern
}
}
Warum „höchstwahrscheinlich“? Weil das Repository möglicherweise eine eigene kurze Transaktion für die Dauer von findById() öffnen kann, aber nach Verlassen der Repository-Methode endet der Kontext und das Objekt wird detached. Und ein detached-Objekt kann man beliebig ändern – es ist nur ein Java-Objekt, die ORM verfolgt es nicht mehr.
Das ist keine „Besonderheit von Spring“, sondern eine direkte Konsequenz des Modells: Dirty Checking funktioniert für managed-Entitäten innerhalb eines lebendigen Kontexts.
3. Die Rolle von save() in Spring Data JPA
An dieser Stelle möchte man dramatisch ausrufen: „Also, wird save() nicht mehr benötigt?!“ Doch. Es hat nur eine andere Rolle als der „UPDATE-Button zur Datenbank“. save() wird benötigt, um ein Objekt in die Persistenzwelt einzuführen oder Daten zurück in die managed-Welt zu bringen, wenn Sie sich nicht mehr in der Situation „managed geladen → geändert → commit“ befinden.
Um ganz ehrlich und erwachsen zu sprechen (aber immer noch junior-freundlich): save() in Spring Data JPA hat zwei Hauptverhaltenszweige. Für ein neues Objekt (transient) führt save() zur persist-Semantik: Die ORM beginnt, das Objekt als Teil des persistence context zu betrachten und bereitet ein INSERT vor. Für ein vorhandenes Objekt, das nicht mehr managed ist, führt save() normalerweise zur merge-Semantik: Der Zustand des Objekts wird in die managed-Instanz des aktuellen Kontexts übertragen.
Das heißt, save() ist im Sinne eine Operation über den Zustand des Objekts relativ zum Kontext und nicht ein Befehl „schreibe sofort in die DB“.
Überprüfen wir das am Beispiel der Produkterstellung. Ein neues Product ist ein transient-Objekt. Niemand verfolgt es. Hier wird save() wirklich benötigt.
import java.math.BigDecimal;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public long createProduct(String sku, String name) {
// Neues Objekt: transient, ORM verfolgt es noch nicht
Product product = new Product();
product.setSku(sku);
product.setName(name);
product.setPrice(BigDecimal.valueOf(199.90));
// save(...) führt das Objekt in den Persistenzkontext ein (persist-Semantik) und bereitet INSERT vor
Product saved = productRepository.save(product);
// Normalerweise wird die ID nach persist/insert (und/oder flush) verfügbar, aber im Code liest es sich so
return saved.getId();
}
In diesem Szenario ist save() kein „optionaler Stil“, sondern ein entscheidender Schritt: Ohne ihn bleibt das Objekt einfach ein Java-Objekt.
4. save() vs. Dirty Checking: Szenarienvergleich
Es ist sehr einfach, so zu denken: „Entweder ich mache save() oder ich mache Dirty Checking, ich muss die richtige Religion wählen“. In der Praxis sind das nicht zwei Religionen, sondern zwei Werkzeuge für verschiedene Situationen. Dirty Checking betrifft managed-Objekte innerhalb einer Transaktion. save() betrifft „das Objekt in die Persistenzwelt einschließen“ oder „eine nicht-managed-Situation richtig behandeln“.
Unten ist eine kleine Tabelle, damit das Gehirn nicht versucht, sich das als eine Reihe von Zaubersprüchen zu merken.
| Szenario | Zustand des Objekts | Normaler Code-Stil | Was in die DB geht (sinngemäß) |
|---|---|---|---|
| Erstellung eines neuen Produkts | transient → wird managed | Objekt erstellen → save() | INSERT |
| Änderung eines vorhandenen Produkts innerhalb einer Transaktion | managed | findById() → Felder ändern | UPDATE durch Dirty Checking |
| Änderung eines Objekts, das bereits aus dem Kontext „gefallen“ ist | detached | entweder neu laden und ändern, oder save() | UPDATE, aber bereits durch merge-Semantik |
Jetzt festigen wir mit Mini-Beispielen.
Korrektes Update durch Dirty Checking (üblicher Servicestil)
import java.math.BigDecimal;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changePrice(long productId, BigDecimal newPrice) {
// Laden der managed-Entität innerhalb der Transaktion
Product product = productRepository.findById(productId).orElseThrow();
// Nur das benötigte Feld ändern: Das ist der normale "update" in JPA
product.setPrice(newPrice); // managed + dirty checking
}
Dies ist die ruhigste und vorhersagbarste Art von Updates: Sie sehen explizit, dass Sie mit der aktuellen Entität aus der DB arbeiten, und ändern nur die benötigten Felder.
save() für ein neues Objekt (sonst bleibt es „nur ein Objekt“)
import org.springframework.transaction.annotation.Transactional;
@Transactional
public long createCategory(String code, String name) {
// Neues Objekt: transient, ohne save gelangt es nicht in den Persistenzkontext
Category category = new Category();
category.setCode(code);
category.setName(name);
// save(...) bereitet INSERT vor und ermöglicht das Abrufen der ID
Category saved = categoryRepository.save(category);
return saved.getId();
}
Hier hilft Dirty Checking nicht, weil es nichts zu „checken“ gibt: Das Objekt wurde noch nicht aus der DB geladen und war nicht managed.
save() für ein nicht-managed-Objekt: funktioniert, aber man kann sich leicht ins Bein schießen
Hier ist ein Beispiel, das häufig vorkommt (vor allem, wenn jemand versucht, „eine Entität wie ein DTO zu aktualisieren“). Angenommen, irgendwo weiter oben im Stack wurde Ihnen ein Product-Objekt übergeben, das bereits geladen war, aber jetzt detached ist. Sie ändern das Feld und rufen save() auf.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameDetachedProduct(Product detachedProduct, String newName) {
// detachedProduct wird derzeit nicht vom ORM verfolgt (es ist nur ein Objekt)
detachedProduct.setName(newName);
// Unter der Haube wird merge-Semantik verwendet: Der Zustand wird in die managed-Instanz eingefügt
// Wichtig: detachedProduct selbst wird dabei nicht magisch managed
productRepository.save(detachedProduct);
}
Dieser Code kann funktionieren, birgt aber das Risiko „versteckter Komplexität“: Die merge-Semantik hat ihre Nuancen, und die wichtigste ist, dass nach save() / merge die managed-Instanz wichtig ist, nicht der alte detached-Referenz. Daher ist es für unseren Kurs und unser Projekt sicherer, den „Hauptstil“ als find → modify innerhalb einer Transaktion zu betrachten.
5. save() nach jedem Setter: eine schlechte Angewohnheit
Bei Anfängern entsteht oft ein Ritual: „Feld geändert – sofort save()“. Es scheint logisch: „Ich will es doch speichern.“ Aber in JPA macht dieses Ritual den Code normalerweise schlechter und das Verständnis der Mechanik schwächer. Es ist, als ob Sie ständig den Knopf „Dokument speichern“ in Google Docs drücken, das ohnehin alles selbst speichert. Nicht unbedingt schädlich … aber sehr hektisch.
Das Problem sind nicht nur die überflüssigen Zeilen. Das Problem ist, dass dieser Stil den wahren Grund für das UPDATE maskiert. In Wirklichkeit erfolgt das Update, weil das Objekt managed ist und das Dirty Checking die Änderung gesehen hat, nicht weil Sie „magisch um Speicherung gebeten haben“. Wenn Sie immer save() schreiben, verlieren Sie das wichtige ingenieurtechnische Gefühl: „Ist die Entität jetzt managed oder bereits detached?“
Ein weiterer unangenehmer Punkt: Wenn ein Team sich an „save nach jedem Setter“ gewöhnt, beginnt es, diesen Stil auch dorthin zu tragen, wo er wirklich gefährlich ist. Zum Beispiel an Stellen, wo ein teilweise gefülltes Objekt „aus der Außenwelt“ in save() gelangt. Dort wird save() von einem harmlosen Ritual zu einem echten Buggenerator (Felder überschrieben, Verbindung verloren, null geschrieben, wo es nicht gewünscht war).
Und ja, es ist auch nur Rauschen. Code sollte einen Gedanken ausdrücken. Der Gedanke „Produkt umbenennen“ ist renameProduct(id, newName), nicht „Produkt umbenennen, und für alle Fälle noch dreimal save() aufrufen, falls es hungrig ist“.
6. Normaler Stil von Update-Methoden in shop-data-jpa
Um das richtige Muster speziell im Rahmen unseres Projekts zu festigen, formulieren wir ein „Grundgerüst“ für einen Service-Update-Use-Case. Es sieht fast immer so aus: Der Service öffnet eine Transaktion, lädt die Entität (oder mehrere Entitäten), prüft Geschäftsinvarianten, ändert Felder und ist dann fertig. Wenn die Entitäten managed waren, erledigt das Dirty Checking den Rest.
Zum Beispiel erstellen wir eine saubere Methode, die den Status eines Produkts umschaltet (sagen wir ACTIVE / ARCHIVED). Es ist egal, wie genau das Enum heißt – wichtig ist der Stil.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void archiveProduct(long productId) {
// Laden des Produkts als managed-Entität
Product product = productRepository.findById(productId).orElseThrow();
// Prüfen der Geschäftsbedingung vor Zustandsänderung
if (product.getStatus() == ProductStatus.ARCHIVED) {
return; // bereits archiviert, nichts zu tun
}
// Status ändern: UPDATE wird durch Dirty Checking erstellt
product.setStatus(ProductStatus.ARCHIVED);
}
Beachten Sie: Wir veranstalten kein „save()-Ritual“. Wir behalten das richtige Modell im Kopf: product ist jetzt managed, also wird die Änderung bemerkt.
Und nun ein Beispiel, das sehr verlockend ist, aber als Standard schlecht ist: Eine Entität aktualisieren, indem man ein „neues Objekt mit id“ erstellt und save() aufruft. Manchmal macht man das, wenn man ein „partielles Update“ möchte, aber in Wirklichkeit ähnelt es eher einer „partiellen Katastrophe“, weil Sie leicht Felder überschreiben, die Sie nicht übergeben haben.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void badPartialUpdate(Product incomingProduct) {
// incomingProduct kann unvollständig sein: ohne category, ohne price usw.
// save(...) in dieser Form kann leicht zu Datenüberschreibung durch merge führen
productRepository.save(incomingProduct); // Risiko, Daten zu überschreiben
}
Im Rahmen des Kurses halten wir die Grenze ein: entity ist kein DTO, und Service-Updates sollten besser über „aus der DB laden und das Nötige ändern“ erfolgen. Dies ist der sicherste und vorhersagbarste Stil, der gut mit Dirty Checking harmoniert und das Geschehen in den SQL-Protokollen erklärt.
7. Typische Fehler bei save() und Dirty Checking
An dieser Stelle atmet man normalerweise erleichtert auf: „Okay, Dirty Checking ist keine Magie, sondern ein Mechanismus.“ Aber dann beginnt die Praxis, und dort lauern Fehler nicht aus der Kategorie „Ich habe die Definition nicht verstanden“, sondern aus der Kategorie „Ich habe Code geschrieben, er compiliert sogar, verhält sich aber seltsam“. Unten sind die häufigsten Hürden, die man am Klang erkennen sollte, wie ein Systemadministrator einen Festplattenausfall am Seufzer des Servers erkennt.
Fehler Nr. 1: Erwarten, dass Dirty Checking ohne Transaktion funktioniert.
Wenn die Entität geladen wurde und sofort aus dem persistence context „gefallen“ ist, ist sie nicht mehr managed, und Änderungen an Feldern werden nur zu Änderungen an einem Java-Objekt. Normalerweise sieht das aus wie eine Methode „ohne @Transactional“, in der Sie findById() und set...() aufrufen, und in der DB ist Stille. Behoben wird das nicht durch Schamanentum, sondern durch eine korrekte Transaktionsgrenze im Service.
Fehler Nr. 2: Save() nach jeder Änderung einer managed-Entität aufrufen.
Der Code wird meistens „funktionieren“, aber es erzeugt ein falsches Modell: als ob das Update wegen save() passiert. Später übertragen Sie diesen Stil auf detached-Objekte, partielle Updates, komplexe Graphen – und erhalten plötzlich unerwartete Effekte. Ein guter Reflex hier ist ein anderer: Zuerst fragen „Ist die Entität managed?“, und erst dann entscheiden „Ist save() nötig?“
Fehler Nr. 3: Versuchen, eine Entität über ein „eingehendes Objekt“ zu aktualisieren, als ob die Entity ein DTO wäre.
Wenn Sie ein Objekt „von außen“ akzeptieren und save(incoming) aufrufen, vertrauen Sie ihm faktisch, ein vollständiger und korrekter Snapshot des Zustands zu sein. In Wirklichkeit ist es fast nie ein solcher Snapshot. Infolgedessen können Sie Felder mit null überschreiben, Verbindungen verlieren, den Status zurücksetzen und dann lange suchen, wer die Daten gestohlen hat (Spoiler: niemand, Sie selbst).
Fehler Nr. 4: Denken, dass save() bedeutet „SQL ist jetzt sofort abgeschickt“.
In JPA ist save() nicht „führe UPDATE sofort aus“, sondern „sorge dafür, dass die ORM das Objekt richtig verwaltet“. Der Zeitpunkt, wann SQL tatsächlich an die DB gesendet wird, hängt mit der Synchronisation des Kontexts (flush) und dem Abschluss der Transaktion (commit) zusammen. Wenn Sie Ihre Logik um die Idee herum aufbauen „nach save() liegt in der DB bereits alles fest“, werden Sie früher oder später auf ein Verhalten stoßen, das wie ein Bug aussieht, obwohl es nur ein anderes Ausführungsmodell ist.
Fehler Nr. 5: Daten in @Transactional(readOnly = true) ändern und hoffen, dass es „vielleicht gespeichert wird“.
Eine Read-Only-Transaktion ist ein Signal der Absicht und manchmal eine Optimierung, aber kein Ort zum Ändern von Daten. Manchmal können Änderungen „versehentlich“ geschrieben werden, manchmal „versehentlich“ nicht, und am Ende erhalten Sie Code, der vom Glück lebt. In unserem Projekt ist es besser, eine einfache Regel zu befolgen: Read-Only für Lesevorgänge, normale Transaktion für Befehle.
GO TO FULL VERSION