Optimistic Locking ist ein Konzept zur Vermeidung gleichzeitiger Änderungen an denselben Daten. Genauer gesagt geht es nicht darum, gleichzeitige Änderungen zu verhindern (das wäre Pessimistic Locking), sondern darum, solche gleichzeitig auftretenden Änderungen zu erlauben, zu erkennen und eine gleichzeitige Modifikation zu verhindern.

Es gibt einige Artikel zu diesem Thema, zum Beispiel: Optimistic Locking in JPA [1], Optimistic Locking with JPA and Hibernate [2], Testing Optimistic Locking Handling with Spring Boot and JPA [3].

Üblicherweise behandeln Artikel nur das Problem gleichzeitiger Datenbanktransaktionen: Zwei oder mehr Transaktionen laden denselben Zustand einer Entität, ändern ihn dann und speichern ihn wieder in der Datenbank, was dazu führt, dass die zuletzt committete Transaktion die Änderungen der vorherigen überschreibt.

Es gibt jedoch ein zweites Problem, das in Artikel [3] sehr gut beschrieben wird: gleichzeitige lange Konversationen. Typischerweise laden zwei oder mehr Benutzer denselben Zustand einer Entität in ihre lokale Benutzeroberfläche, ändern einige Felder und senden ihren Zustand zurück an den Server. Ohne Optimistic Locking überschreibt der Benutzer, der zuletzt speichert, die Änderungen der vorherigen Benutzer, ohne zu wissen, dass die Daten zwischenzeitlich von anderen geändert wurden.

Normalerweise besteht die vorgeschlagene Lösung darin, einfach ein @Version-Attribut zur Entität hinzuzufügen. Aber typischerweise funktioniert das für das Problem gleichzeitiger langer Konversationen nicht. Um zu verstehen warum, schauen wir uns zunächst ein Beispiel mit dem @Version-Attribut an, wie vorgeschlagen.

Der Code ist in Kotlin geschrieben und auf GitHub verfügbar [4].

Entity-Mapping

Um eine Entität für Optimistic Locking vorzubereiten, muss ein Versionsattribut zur Datenbanktabelle und dem Entity-Mapping hinzugefügt werden:

@Entity 
class Person( 
    @Id @Type(type = "uuid-char") var id: UUID, 
    var name: String, 
    var address: String 
) { 
    @Version 
    var version: Long? = null 
}

TIPP: Wenn ein anwendungsgenerierter Schlüssel vorhanden ist – z. B. wie die UUID oder ein anderer Business-Key aus dem obigen Beispiel – empfehle ich, einen Nullable-Typ für das Versionsfeld zu verwenden (Wrapper-Typ Long statt primitives long in Java). Das spart eine zusätzliche Abfrage beim Einfügen oder Aktualisieren, da Spring Data dann das Versionsfeld verwendet, um zu bestimmen, ob es sich um eine neue zu speichernde Entität handelt (Version ist null – also ist ein Insert erforderlich) oder um eine bestehende, die aktualisiert werden soll. Wenn weder die ID noch die Version ein Nullable-Typ ist (und das Interface org.springframework.data.domain.Persistable in der Entität auch nicht implementiert ist), wird vor dem Insert oder Update eine zusätzliche SELECT-Abfrage ausgeführt, um zu bestimmen, ob ein Insert oder Update erforderlich ist.

Durch das Hinzufügen dieses @Version-Feldes werden die Update- und auch die Delete-Abfragen um and version = ? in der WHERE-Klausel erweitert, anstatt nur die ID zu verwenden:

update person set address=?, name=?, version=? where id=? and version=?

Zu beachten ist, dass eine programmatische Änderung der Version einer Entität während einer Transaktion nicht möglich ist. Die Version kann programmatisch gesetzt werden, ohne dass eine Ausnahme geworfen wird, aber die manuell gesetzte Version wird ignoriert (außer wenn eine losgelöste Entität vorliegt und diese dann dem Persistence Context angehängt wird).

Das Testen der Funktionalität ist nur möglich, indem mehrere Threads dieselbe Transaktion gleichzeitig aufrufen. Im GitHub-Projekt gibt es ein Unit-Test-Beispiel, ähnlich dem aus Artikel [3]:

@Test
fun `concurrent database transactions`(output: CapturedOutput) {
    // given
    val createDto = PersonDto(UUID.fromString("a426dbe8-e711-45cc-a2f7-5651dc2ea124"), "John Doe", "Doe Street 1")
    val createdDto = personApi.createPerson(createDto)
    // when
    val executor = Executors.newFixedThreadPool(5)
    for (i in 1..5) {
        val updateDto = PersonDto(createdDto.id, "${createdDto.name} update $i", "${createdDto.address} update $i", 0)
        executor.execute {
            try {
                personApi.updatePerson(updateDto)
            } catch (_: Exception) {
                // ignore optimistic lock exceptions
            }
        }
    }
    executor.shutdown()
    executor.awaitTermination(10, TimeUnit.SECONDS)
    // then
    assertThat(personApi.getPerson(createDto.id).version).isEqualTo(1)
    verify(exactly = 5) { personService.updatePerson(any()) }
    assertThat(output.all).contains("ObjectOptimisticLockingFailureException: " +
            "Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; " +
            "statement executed: update person set address=?, name=?, version=? where id=? and version=?")
}

Behandlung gleichzeitiger langer Konversationen

Das versehentliche Überschreiben von Änderungen von Benutzer B, die gespeichert wurden, während das Eingabeformular von Benutzer A noch bearbeitet und dann gespeichert wurde, wird von Hibernate nicht out-of-the-box unterstützt.

Die Lösung besteht darin, die Version im Service-Code manuell zu überprüfen und eine Ausnahme zu werfen, wenn eine gleichzeitige Änderung erkannt wird:

override fun updatePerson(personDto: PersonDto): PersonDto {
    val person = getOrThrow(personDto.id).also {
        if (it.version != personDto.version) {
            throw OptimisticLockException(it)
        }
        it.name = personDto.name
        it.address = personDto.address
    }
    return saveAndMap(person)
}
private fun getOrThrow(id: UUID) = personRepository.findByIdOrNull(id) ?: throw EntityNotFoundException()
private fun map(person: Person) = PersonDto(person.id, person.name, person.address, person.version)
// flush is necessary to get new version for dto mapping
private fun saveAndMap(person: Person) = map(personRepository.saveAndFlush(person))

Einen Unit-Test für dieses Szenario zu schreiben ist ganz einfach – einfach aufeinanderfolgende Änderungen mit derselben Version der Entität versuchen:

@Test
fun `concurrent long conversations`() {
    // given
    val createDto =
        PersonDto(UUID.fromString("a426dbe8-e711-45cc-a2f7-5651dc2ea124"), "John Doe", "Doe Street 1")
    val createdDto = personApi.createPerson(createDto)
    // when
    val updateDto =
        PersonDto(createdDto.id, "${createdDto.name} update", "${createdDto.address} update", 0)
    personApi.updatePerson(updateDto)
    // then
    assertThatThrownBy { personApi.updatePerson(updateDto) } // version is 1 in the meantime!
        .hasMessageContaining("${HttpStatus.UNPROCESSABLE_ENTITY.value()}")
}

Der API-Controller wandelt die OptimisticLockException in HTTP-Status 422 UNPROCESSABLE_ENTITY um, der hier im Unit-Test abgefangen wird.

Anstatt die Version im Service-Code manuell zu überprüfen, könnte auch ein benutzerdefinierter Hibernate-FlushEntityEventListener implementiert werden, um die manuell gesetzte Version der Entität mit der Version aus der Datenbank vor dem Flushen zu vergleichen. In beiden Fällen ist etwas manuelle Arbeit erforderlich – ich bevorzuge die Service-Lösung, da sie weniger hacky und expliziter ist.

Alternative Lösung ohne @Version

Anstatt das @Version-Feld zu verwenden, könnte @DynamicUpdate mit der @OptimisticLock-Annotation eingesetzt werden:

@Entity 
@DynamicUpdate 
@OptimisticLocking(type = OptimisticLockType.ALL) 
class Person ( 
    @Id @Type(type = "uuid-char") var id: UUID, 
    var name: String, 
    var address: String 
)

Das würde zu einer Update- und Delete-Abfrage führen, die alle Felder in der WHERE-Klausel verwendet:

update person set address=?, name=?, version=? where id=? and name=? and address=?

Eine detaillierte Beschreibung findet sich im Artikel „How to prevent OptimisticLockException with Hibernate versionless optimistic locking” [5].

Das Problem gleichzeitiger langer Konversationen durch @DynamicUpdate zu lösen, ist jedoch nur möglich, wenn die Benutzeroberfläche alle vorherigen Feldwerte zurück an den Server sendet, die dann programmatisch mit allen alten Feldwerten verglichen werden, anstatt das einzelne Versionsfeld im Service-Code zu vergleichen, wie oben gezeigt.

Fazit

Die Implementierung von Optimistic Locking mit Hibernate und Spring Data JPA ist recht einfach. Das Hinzufügen eines @Version-Feldes zu den Entitäten erledigt den Großteil der Arbeit. Wenn gleichzeitige Aktualisierungen derselben Entitätsversion unterstützt werden müssen – z. B. wenn mehrere Benutzer gleichzeitig dieselben Daten in einer Benutzeroberfläche verwenden –, muss eine einfache If-Prüfung vor Änderungen an den Daten durchgeführt werden.

Der Beispielcode mit einer laufenden REST-Schnittstelle für manuelle Tests und Unit-Tests findet sich auf GitHub [4].

Viel Spaß beim Abfangen und Behandeln von Optimistic-Lock-Ausnahmen! 🙂

[1] Optimistic Locking in JPA

[2] Optimistic Locking with JPA and Hibernate

[3] Testing Optimistic Locking Handling with Spring Boot and JPA

[4] GitHub cloudflightio/hibernate-optimistic-locking-spring-boot

[5] How to prevent OptimisticLockException with Hibernate versionless optimistic locking