Im zweiten Teil unserer Serie ging’s um Caching-Header – und im Pro-Tipp zu starken vs. schwachen ETags haben wir kurz angeteasert, dass strong ETags noch einen zweiten Job haben: Sie schützen dich beim Schreiben vor Lost Updates. Genau darum geht’s heute.
Kennst du das – zwei Leute bearbeiten fast zeitgleich denselben Datensatz über eine API. Admin A öffnet einen Produktdatensatz, ändert den Preis und speichert. Kurz davor hat Admin B denselben Datensatz geöffnet, einen anderen Preis eingetragen und ebenfalls gespeichert. Wer gewinnt? Bei einem naiven PUT-Endpunkt: wer zuletzt speichert. Admin A’s Änderung landet in der Datenbank, überschreibt B’s Änderung komplett – und niemand bekommt einen Fehler, eine Warnung oder auch nur eine Zeile im Log. B’s Arbeit ist einfach weg.
Das ist das klassische Lost-Update-Problem, und HTTP hat dafür seit Jahrzehnten einen eingebauten Mechanismus: Conditional Requests mit If-Match und If-Unmodified-Since. In diesem Artikel schauen wir uns an, warum ein naiver Schreibzugriff dieses Problem hat, wie du es mit HTTP-Preconditions sauber löst, wie sich das zu klassischem optimistischem Locking über JPA @Version verhält – und wie du es in Spring Boot konkret umsetzt.
Das Problem: Lost Updates
Stell dir den Produkt-Endpunkt aus den letzten beiden Artikeln vor, diesmal beim Schreiben statt beim Lesen. Ein ganz gewöhnlicher PUT-Endpunkt, wie ihn vermutlich die meisten CRUD-APIs haben:
// Anti-Pattern: Der zweite Request gewinnt einfach, ohne dass jemand etwas merkt
@PutMapping("/products/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@RequestBody ProductUpdateRequest updateRequest) {
Product updated = productService.update(id, updateRequest);
return ResponseEntity.ok(updated);
}
Funktional ist daran nichts falsch – der Endpunkt tut genau das, was er soll. Das Problem zeigt sich erst, sobald zwei Clients ihn fast gleichzeitig aufrufen:
- Admin A lädt das Produkt: Preis 100 €.
- Admin B lädt dasselbe Produkt, ebenfalls Preis 100 €.
- Admin B ändert den Preis auf 80 € und speichert. Server-Stand: 80 €.
- Admin A hat B’s Änderung nie gesehen, ändert seinen (veralteten) Stand auf 90 € und speichert. Server-Stand: 90 €.
B’s Preisänderung ist komplett verschwunden – nicht weil sie fehlerhaft war, sondern weil A’s Schreibzugriff blind auf einem veralteten Datenstand aufsetzte und ihn einfach überschrieben hat. Kein Server-Fehler, kein Exception-Log, kein HTTP-Statuscode, der irgendwas andeutet. Der Request von A sieht aus wie jeder andere erfolgreiche PUT – und genau das macht Lost Updates so tückisch: Sie fallen nicht durch Fehler auf, sondern durch stillen Datenverlust, den du erst bemerkst, wenn sich jemand beschwert, dass seine Änderung “einfach weg” ist.
Der Server hat dabei technisch alles richtig gemacht, was er wusste. Das eigentliche Problem: Er wusste gar nicht, dass A auf einem alten Stand arbeitete, weil der Request diese Information nie mitgeliefert hat. Genau diese Lücke schließen Conditional Writes.
Die Lösung: Conditional Writes mit If-Match
HTTP löst das Lost-Update-Problem mit demselben Baustein, den wir im letzten Artikel für Caching kennengelernt haben: dem ETag. Nur diesmal nicht als Cache-Validierung, sondern als Precondition – eine Bedingung, die der Client an seinen Schreibzugriff knüpft: “Führe diesen PUT nur aus, wenn die Ressource seit meinem letzten Lesen unverändert ist.”
Der Ablauf sieht fast identisch aus wie bei der Caching-Validierung aus Teil 2, nur mit vertauschten Rollen:
- Client liest die Ressource per
GET, bekommtETag: "42"zurück. - Client möchte die Ressource ändern und schickt
PUTmitIf-Match: "42". - Server vergleicht: Ist das aktuelle ETag der Ressource noch
"42"? Dann führt er die Änderung aus und liefert ein neues ETag zurück. Ist das ETag inzwischen ein anderes – weil jemand anders zwischenzeitlich gespeichert hat – lehnt der Server die Änderung ab mit 412 Precondition Failed, ganz ohne irgendetwas zu schreiben.
Angewendet auf unser Beispiel von eben: A’s PUT würde If-Match: "<ETag von A's ursprünglichem GET>" mitschicken. Weil B zwischenzeitlich gespeichert und damit das ETag verändert hat, bekommt A ein 412 zurück – statt B’s Änderung stillschweigend zu überschreiben. A sieht den Fehler, kann den aktuellen Stand neu laden und seine Änderung bewusst neu anwenden.
Wichtig, und hier schließt sich der Kreis zum Pro-Tipp aus Teil 2: Für If-Match verlangt die Spezifikation zwingend ein strong ETag. Ein weak ETag (W/"42") ist für Preconditions nicht zulässig, weil es nur semantische Gleichheit garantiert – für eine Concurrency-Prüfung reicht das nicht, du brauchst Byte-für-Byte-Gewissheit, dass sich wirklich nichts verändert hat.
Hast du kein Versionsfeld für ein ETag zur Hand, aber einen updatedAt-Zeitstempel, gibt es das Pendant zu Last-Modified aus Teil 2: If-Unmodified-Since. Der Server lehnt den Schreibzugriff ab, wenn die Ressource nach dem angegebenen Zeitpunkt verändert wurde:
PUT /products/42 HTTP/1.1
If-Unmodified-Since: Fri, 10 Jul 2026 08:00:00 GMT
Content-Type: application/json
{ "price": 90 }
Genau wie beim Lesen gilt: If-Unmodified-Since ist weniger präzise (Sekundengenauigkeit), dafür brauchst du kein zusätzliches Versionsfeld. If-Match ist die genauere, aber auch aufwendigere Variante.
If-Match | If-Unmodified-Since | |
|---|---|---|
| Basis | ETag (strong) | Zeitstempel |
| Präzision | exakt | sekundengenau |
| Erfordert | Versions- oder Hash-Feld | updatedAt-Zeitstempel |
| Bei Verstoß | 412 Precondition Failed | 412 Precondition Failed |
Ein Punkt, der oft übersehen wird: Ohne If-Match-Header überhaupt zu senden, greift der Schutz nicht – der Server hat dann schlicht keine Bedingung zu prüfen, und der PUT läuft normal durch. Die Precondition ist optional aus Sicht des Servers, es sei denn, du erzwingst sie explizit. Wie das geht, schauen wir uns gleich als Pro-Tipp an.
Conditional Writes in der Praxis: Spring Boot
Die gute Nachricht vorweg: Anders als bei QUERY aus Teil 1 brauchst du hier keinen Workaround. Spring unterstützt Conditional Writes über denselben Mechanismus, den wir in Teil 2 für Caching genutzt haben – WebRequest.checkNotModified(). Der Unterschied steckt in der HTTP-Methode: Bei GET/HEAD prüft die Methode If-None-Match und setzt bei Übereinstimmung 304. Bei allen anderen Methoden – also auch PUT – prüft sie If-Match und setzt bei Nicht-Übereinstimmung 412, statt selbst etwas zu ändern.
@PutMapping("/products/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@RequestBody ProductUpdateRequest updateRequest,
WebRequest request) {
ProductMeta meta = productService.findMeta(id); // günstig: nur id, version
String etag = "\"" + meta.version() + "\"";
if (request.checkNotModified(etag)) {
// Kein oder veraltetes If-Match -> Spring hat bereits
// 412 Precondition Failed gesetzt, nichts weiter zu tun
return null;
}
Product updated = productService.update(id, updateRequest, meta.version());
return ResponseEntity.ok()
.eTag("\"" + updated.version() + "\"")
.body(updated);
}
Der Trick ist derselbe wie beim Lesen in Teil 2: findMeta() lädt nur das Versionsfeld, nicht die komplette Entity. Die Precondition wird geprüft, bevor überhaupt eine teure Update-Operation angestoßen wird – schlägt sie fehl, hast du weder unnötig Datenbank-Last erzeugt noch versehentlich etwas geschrieben.
Die Lücke, die HTTP-Checks allein nicht schließen
Zwischen dem checkNotModified()-Aufruf und dem eigentlichen productService.update() liegt ein kleines Zeitfenster. Rein theoretisch kann in genau diesem Fenster ein zweiter Request dieselbe Ressource ändern – dann würde dein update() trotzdem auf einem inzwischen veralteten Stand schreiben, obwohl die HTTP-Precondition davor grünes Licht gegeben hat. Der HTTP-Check reduziert das Lost-Update-Risiko drastisch, schließt es aber nicht zu hundert Prozent, weil zwischen Prüfung und Schreiben kein atomarer Zusammenhang besteht.
Deshalb lohnt sich eine zweite Sicherung auf Persistenzebene – klassisches optimistisches Locking über JPA:
@Entity
public class Product {
@Id
private Long id;
private String name;
private BigDecimal price;
@Version
private long version;
// Getter, Setter
}
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public Product update(Long id, ProductUpdateRequest updateRequest, long expectedVersion) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(updateRequest.name());
product.setPrice(updateRequest.price());
// Hibernate vergleicht die geladene @Version beim Flush automatisch
// gegen den aktuellen DB-Stand und wirft bei Abweichung
// ObjectOptimisticLockingFailureException
return productRepository.save(product);
}
}
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<Void> handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
Damit hast du zwei Ebenen, die zusammenspielen statt sich zu duplizieren: Der If-Match-Check an der API-Grenze fängt den Normalfall günstig ab, bevor überhaupt eine Transaktion startet – und dokumentiert das Concurrency-Verhalten explizit im HTTP-Vertrag deiner API, sichtbar für jeden Client. @Version auf Datenbankebene fängt das seltene Race im verbleibenden Zeitfenster ab, als letzte Instanz, die auch dann noch korrekt ist, wenn zwei Requests wirklich exakt gleichzeitig ankommen. Das eine ersetzt das andere nicht – es ist Defense in Depth.
Pro-Tipps / Warnungen
Tipp:
If-Matchschützt nicht nur vor Lost Updates beim Ändern, sondern auch beim Anlegen. Willst du mitPUTeine Ressource nur erstellen und garantiert keine bestehende überschreiben, sendest duIf-None-Match: *. Das sagt dem Server: “Führe diesen Request nur aus, wenn unter dieser URI noch gar nichts existiert.”@PutMapping("/products/{id}") public ResponseEntity<Product> createProduct( @PathVariable Long id, @RequestBody ProductCreateRequest createRequest, @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) { if (!"*".equals(ifNoneMatch)) { return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build(); } if (productService.exists(id)) { return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build(); } Product created = productService.create(id, createRequest); return ResponseEntity.status(HttpStatus.CREATED).eTag("\"" + created.version() + "\"").body(created); }Zwei parallele “Erstellen”-Requests auf dieselbe ID können sich damit nicht mehr gegenseitig überschreiben – genau dasselbe Prinzip wie bei
If-Match, nur umgekehrt.
Warnung: Ohne
If-Match-Header überhaupt zu senden, greift der Schutz gar nicht – die Precondition ist aus Serversicht optional, solange du sie nicht aktiv erzwingst. Willst du sicherstellen, dass niemand “vergisst”, eine Bedingung mitzuschicken, antworte auf fehlende Preconditions explizit mit 428 Precondition Required (RFC 6585):@RequestHeader(value = "If-Match", required = false) String ifMatch // ... if (ifMatch == null) { return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build(); }So macht der Server aus einer optionalen Absicherung einen verpflichtenden Teil des API-Vertrags – Clients, die das ignorieren, scheitern von Anfang an klar statt später still Daten zu überschreiben.
Warnung: Verwechsle 412 nicht mit 409 Conflict. 412 Precondition Failed heißt: eine vom Client explizit mitgeschickte Bedingung (
If-Match,If-Unmodified-Since) trifft nicht zu – rein technisch, unabhängig vom fachlichen Inhalt der Änderung. 409 Conflict heißt: der Request kollidiert fachlich mit dem aktuellen Zustand der Ressource, unabhängig davon, ob überhaupt eine Precondition gesetzt wurde – etwa ein doppelter, eigentlich eindeutiger Produktname. Ein Client, der beide pauschal als “irgendein 4xx-Fehler” behandelt, verliert genau die Information, die er bräuchte, um zu entscheiden: neu laden und erneut versuchen (412) oder dem Nutzer einen fachlichen Fehler zeigen (409).
Fazit
Lost Updates sind wie die anderen Probleme dieser Serie: kein Bug, der crasht, sondern einer, der einfach klaglos falsche Ergebnisse produziert. If-Match und If-Unmodified-Since lösen das, indem sie eine Information explizit machen, die bei einem naiven PUT komplett fehlt – auf welchem Stand der Client eigentlich glaubt zu arbeiten. Der Server kann diese Annahme dann prüfen, statt blind zu vertrauen.
Wie du gesehen hast, lohnt sich dabei ein zweistufiger Ansatz: der HTTP-Check an der API-Grenze als schnelle, für Clients sichtbare Absicherung – und @Version auf Persistenzebene als Sicherheitsnetz für das kleine verbleibende Zeitfenster, das der HTTP-Check allein nicht schließen kann. Keine der beiden Ebenen ersetzt die andere, zusammen ergeben sie eine Absicherung, die tatsächlich hält.
Damit haben wir in dieser Serie den Bogen von einer komplett neuen HTTP-Methode über Caching bis zu Concurrency Control gespannt – drei Bausteine, die im API-Alltag oft nur oberflächlich genutzt werden, obwohl gerade in den Details (weak vs. strong ETag, no-cache vs. no-store, 412 vs. 409) der eigentliche Unterschied zwischen “funktioniert meistens” und “funktioniert wirklich” steckt.
If-Match schützt dich beim Ändern bestehender Ressourcen – aber was ist mit POST, wo es noch gar keine Ressource und damit kein ETag gibt? Genau dieses verwandte Problem, doppelte Bestellungen durch Netzwerk-Retries statt überschriebene Preisänderungen, schauen wir uns im nächsten Teil an: Idempotency Keys.
