|
1 | 1 | # Transaktionen und Datenkonsistenz in Anwendungen |
2 | 2 |
|
3 | | -Dieser Block wird Transaktionsgrenzen aus Anwendungssicht behandeln. Themen sind `@Transactional`, Commit, Rollback, fachliche Invarianten, externe Seiteneffekte und Optimistic Locking. |
| 3 | +Ein Ticket wird in einer Anwendung selten als einzelne Tabellenzeile behandelt. Wenn ein neues Ticket entsteht, braucht es oft zusätzlich einen ersten Kommentar, einen Ereigniseintrag und später einen Statuswechsel. Aus Sicht der Benutzerin ist das ein einziger fachlicher Vorgang. Aus Sicht der Datenbank sind es mehrere `INSERT`- und `UPDATE`-Anweisungen. |
4 | 4 |
|
5 | | -Die inhaltliche Ausarbeitung folgt später. |
| 5 | +Genau an dieser Stelle werden Transaktionen im Anwendungscode wichtig. Eine Transaktion beantwortet nicht nur die Frage, ob PostgreSQL `COMMIT` oder `ROLLBACK` ausführt. Sie beantwortet vor allem die fachliche Frage: Welche Änderungen gehören so eng zusammen, dass sie nur gemeinsam dauerhaft gespeichert werden dürfen? |
| 6 | + |
| 7 | +In Block 2 hast du `db-2-app` als Starterprojekt kennengelernt. Die Anwendung konnte Tickets lesen und erstellen. Für Block 3 bleibt der normale Lernpfad gleich: Du startest beim Starter und entwickelst weiter. Falls du mitten im Modul einsteigst oder eine Lösung anschauen musst, kannst du einen Checkpoint erzeugen: |
| 8 | + |
| 9 | +```bash |
| 10 | +cd db-2-app |
| 11 | +./course-state create block-3-start ../work/db-2-app-block-3 |
| 12 | +./course-state create block-3-complete ../work/db-2-app-block-3-loesung |
| 13 | +``` |
| 14 | + |
| 15 | +Der Checkpoint ist eine normale Arbeitskopie. Er ersetzt nicht den Unterrichtsweg, sondern macht definierte Zustände reproduzierbar. |
| 16 | + |
| 17 | +:::{important} Lernziele |
| 18 | +Nach diesem Block kannst du: |
| 19 | + |
| 20 | +- eine fachliche Transaktionsgrenze im Service-Layer begründen. |
| 21 | +- `@Transactional` als Werkzeug für deklarative Transaktionen in Spring einordnen. |
| 22 | +- erklären, wann eine Service-Methode committet oder zurückgerollt wird. |
| 23 | +- Ticket, Kommentar und Event als gemeinsamen Datenbankvorgang modellieren. |
| 24 | +- Datenbanktransaktionen von externen Seiteneffekten wie E-Mail oder HTTP Calls abgrenzen. |
| 25 | +- Optimistic Locking als Grundidee für gleichzeitige Bearbeitung erklären. |
| 26 | +- einen fehlerhaften Transaktionsablauf im Code Review erkennen. |
| 27 | +::: |
| 28 | + |
| 29 | +## Warum Transaktionen in der Anwendung? |
| 30 | + |
| 31 | +PostgreSQL kennt Transaktionen seit DB-1: Änderungen werden entweder dauerhaft gemacht oder verworfen. In DB-2 interessiert uns, wo diese Entscheidung im Anwendungscode liegt. |
| 32 | + |
| 33 | +Betrachte einen einfachen Ticket-Workflow: |
| 34 | + |
| 35 | +1. Ein Ticket wird erstellt. |
| 36 | +2. Ein erster Kommentar wird gespeichert. |
| 37 | +3. Ein Event `ticket_created` wird in den Verlauf geschrieben. |
| 38 | + |
| 39 | +Wenn Schritt 2 oder 3 fehlschlägt, darf nicht einfach nur das Ticket übrig bleiben. Sonst zeigt die Anwendung ein Ticket ohne Verlauf oder ohne Startkommentar. Das ist technisch möglich, aber fachlich unvollständig. |
| 40 | + |
| 41 | +```{mermaid} |
| 42 | +flowchart LR |
| 43 | + Request["POST /api/tickets"] |
| 44 | + Ticket["tickets<br/>neue Zeile"] |
| 45 | + Comment["ticket_comments<br/>optional erster Kommentar"] |
| 46 | + Event["ticket_events<br/>ticket_created"] |
| 47 | + Commit["COMMIT"] |
| 48 | + Rollback["ROLLBACK bei Fehler"] |
| 49 | +
|
| 50 | + Request --> Ticket --> Comment --> Event --> Commit |
| 51 | + Ticket -. Fehler .-> Rollback |
| 52 | + Comment -. Fehler .-> Rollback |
| 53 | + Event -. Fehler .-> Rollback |
| 54 | +``` |
| 55 | + |
| 56 | +Eine gute Transaktionsgrenze schützt den fachlichen Zusammenhang. Sie ist deshalb meistens nicht die Repository-Methode und nicht der Controller, sondern die Service-Methode, die den Ablauf koordiniert. |
| 57 | + |
| 58 | +## `@Transactional` im Service-Layer |
| 59 | + |
| 60 | +Spring unterstützt deklarative Transaktionen mit `@Transactional`. Die Annotation wird typischerweise durch einen Spring-Proxy ausgewertet: Beim Eintritt in die Methode wird eine Transaktion geöffnet oder wiederverwendet, am Ende wird bei normalem Rücksprung committet und bei passenden Exceptions zurückgerollt {cite}`spring_framework_transaction_reference`. |
| 61 | + |
| 62 | +Im Kursprojekt liegt die Grenze bewusst im Service: |
| 63 | + |
| 64 | +```java |
| 65 | +@Transactional |
| 66 | +TicketResponse createTicket(CreateTicketRequest request) { |
| 67 | + TicketEntity savedTicket = persistTicketWorkflow(request); |
| 68 | + return ticketMapper.toResponse(savedTicket); |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +Die Schichten haben dabei unterschiedliche Rollen: |
| 73 | + |
| 74 | +| Schicht | Rolle im Transaktionskontext | |
| 75 | +| --- | --- | |
| 76 | +| Controller | HTTP-Daten entgegennehmen, validieren und an den Service delegieren | |
| 77 | +| Service | fachlichen Ablauf und Transaktionsgrenze festlegen | |
| 78 | +| Repository | einzelne Datenbankoperationen kapseln | |
| 79 | +| PostgreSQL | Constraints, Fremdschlüssel und dauerhafte Speicherung erzwingen | |
| 80 | + |
| 81 | +Der Service ist der passende Ort, weil er weiss, welche Schritte fachlich zusammengehören. Das Repository weiss nur, wie eine Entity gespeichert wird. Der Controller sollte nicht entscheiden, welche Datenbankänderungen atomar sein müssen. |
| 82 | + |
| 83 | +```{mermaid} |
| 84 | +sequenceDiagram |
| 85 | + autonumber |
| 86 | + participant C as TicketController |
| 87 | + participant S as TicketService |
| 88 | + participant T as tickets |
| 89 | + participant K as ticket_comments |
| 90 | + participant E as ticket_events |
| 91 | +
|
| 92 | + C->>S: createTicket(request) |
| 93 | + Note over S: @Transactional beginnt |
| 94 | + S->>T: Ticket speichern |
| 95 | + S->>K: Kommentar speichern |
| 96 | + S->>E: Event speichern |
| 97 | + Note over S: Methode endet ohne Fehler |
| 98 | + S-->>C: TicketResponse |
| 99 | + Note over S,E: COMMIT |
| 100 | +``` |
| 101 | + |
| 102 | +## Commit und Rollback lesen |
| 103 | + |
| 104 | +In Spring gilt als Grundregel: Eine `RuntimeException` oder ein `Error` löst bei `@Transactional` standardmässig ein Rollback aus. Checked Exceptions werden nicht automatisch gleich behandelt, wenn nichts anderes konfiguriert ist {cite}`spring_framework_transaction_reference`. |
| 105 | + |
| 106 | +Für den Unterricht reicht diese Faustregel: |
| 107 | + |
| 108 | +- Wenn die Methode normal endet, wird committet. |
| 109 | +- Wenn eine unbehandelte Runtime Exception aus der Methode herausläuft, wird zurückgerollt. |
| 110 | +- Wenn ein Fehler abgefangen und verschluckt wird, sieht Spring am Methodenende keinen Rollback-Grund. |
| 111 | + |
| 112 | +Das ist der Grund, warum Fehlerbehandlung nicht nur Stilfrage ist. Eine Service-Methode, die einen Fehler abfängt, loggt und trotzdem normal zurückkehrt, kann einen unvollständigen Vorgang committen. |
| 113 | + |
| 114 | +```java |
| 115 | +@Transactional |
| 116 | +void falsch() { |
| 117 | + ticketRepository.save(ticket); |
| 118 | + try { |
| 119 | + eventRepository.save(event); |
| 120 | + } catch (RuntimeException ex) { |
| 121 | + // Problem: Methode laeuft danach normal weiter. |
| 122 | + } |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +Besser ist, den Fehler entweder bewusst weiterzugeben oder eine fachliche Alternative zu implementieren, die keinen halben Zustand hinterlässt. |
| 127 | + |
| 128 | +## Fachliche Invarianten |
| 129 | + |
| 130 | +Eine Invariante ist eine Regel, die nach jedem erfolgreichen Vorgang gelten muss. Im Ticket-System können solche Regeln sein: |
| 131 | + |
| 132 | +- Ein neu erstelltes Ticket hat einen gültigen Status. |
| 133 | +- Ein Ticket mit Startkommentar hat einen Kommentar-Eintrag, der auf das Ticket zeigt. |
| 134 | +- Jeder Statuswechsel erzeugt ein Event. |
| 135 | +- Ein geschlossenes Ticket wird in diesem einfachen Modell nicht wieder geöffnet. |
| 136 | + |
| 137 | +Manche Regeln gehören in PostgreSQL: |
| 138 | + |
| 139 | +```sql |
| 140 | +ALTER TABLE app_starter.tickets |
| 141 | + ADD CONSTRAINT tickets_status_check |
| 142 | + CHECK (status IN ('open', 'waiting', 'closed')); |
| 143 | +``` |
| 144 | + |
| 145 | +Andere Regeln brauchen Service-Logik: |
| 146 | + |
| 147 | +```java |
| 148 | +private void assertStatusTransition(String oldStatus, String newStatus) { |
| 149 | + if ("closed".equals(oldStatus)) { |
| 150 | + throw new ResponseStatusException( |
| 151 | + HttpStatus.BAD_REQUEST, |
| 152 | + "Geschlossene Tickets werden nicht wieder geoeffnet" |
| 153 | + ); |
| 154 | + } |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +Beides ergänzt sich. PostgreSQL schützt zentrale Datenregeln unabhängig vom Schreibpfad. Der Service schützt fachliche Abläufe, die mehr Kontext brauchen als eine einzelne Spalte. |
| 159 | + |
| 160 | +## Externe Seiteneffekte |
| 161 | + |
| 162 | +Eine Datenbanktransaktion kann Datenbankänderungen zurückrollen. Sie kann aber keine bereits gesendete E-Mail zurückholen, keinen HTTP Call ungeschehen machen und keine Message aus einer externen Queue entfernen. |
| 163 | + |
| 164 | +Riskant ist deshalb ein Ablauf wie: |
| 165 | + |
| 166 | +```text |
| 167 | +Transaktion beginnt |
| 168 | + Ticket speichern |
| 169 | + E-Mail senden |
| 170 | + Event speichern schlaegt fehl |
| 171 | +Rollback |
| 172 | +``` |
| 173 | + |
| 174 | +Nach dem Rollback existiert das Ticket nicht, aber die E-Mail wurde schon verschickt. Das ist kein Fehler von PostgreSQL. Es ist eine falsch gewählte Grenze zwischen Datenbanktransaktion und Systemprozess. |
| 175 | + |
| 176 | +Für Block 3 genügt die Grundentscheidung: |
| 177 | + |
| 178 | +- Datenbankänderungen, die fachlich zusammengehören, liegen in einer kurzen Transaktion. |
| 179 | +- Externe Effekte werden bewusst danach ausgelöst oder in einem eigenen Muster behandelt. |
| 180 | +- Im Code Review wird geprüft, ob externe Effekte mitten in der Transaktion stehen. |
| 181 | + |
| 182 | +Wir vertiefen hier keine Event-Architektur. Entscheidend ist, dass du die Grenze erkennst. |
| 183 | + |
| 184 | +## Optimistic Locking |
| 185 | + |
| 186 | +Nebenläufigkeit wird sichtbar, wenn zwei Personen dasselbe Ticket bearbeiten. Beide lesen den Status `open`. Person A setzt auf `waiting`, Person B setzt fast gleichzeitig auf `closed`. Ohne Schutz kann die spätere Speicherung die frühere Änderung überschreiben. |
| 187 | + |
| 188 | +Optimistic Locking löst dieses Problem mit einer Versionsspalte. Die Entity erhält ein Feld mit `@Version`; Hibernate/JPA nutzt dieses Feld, um beim Speichern zu prüfen, ob der gelesene Stand noch aktuell ist {cite}`hibernate_orm_user_guide`. |
| 189 | + |
| 190 | +```java |
| 191 | +@Version |
| 192 | +@Column(name = "version") |
| 193 | +private Long version; |
| 194 | +``` |
| 195 | + |
| 196 | +Die Datenbankmigration ergänzt die passende Spalte: |
| 197 | + |
| 198 | +```sql |
| 199 | +ALTER TABLE app_starter.tickets |
| 200 | + ADD COLUMN version BIGINT NOT NULL DEFAULT 0; |
| 201 | +``` |
| 202 | + |
| 203 | +Die Idee ist bewusst einfach: |
| 204 | + |
| 205 | +```{mermaid} |
| 206 | +sequenceDiagram |
| 207 | + autonumber |
| 208 | + participant A as Supporter A |
| 209 | + participant B as Supporter B |
| 210 | + participant DB as PostgreSQL |
| 211 | +
|
| 212 | + A->>DB: Ticket lesen, version = 0 |
| 213 | + B->>DB: Ticket lesen, version = 0 |
| 214 | + A->>DB: Status waiting speichern, version 0 -> 1 |
| 215 | + B->>DB: Status closed mit version 0 speichern |
| 216 | + DB-->>B: Konflikt, Stand ist veraltet |
| 217 | +``` |
| 218 | + |
| 219 | +Optimistic Locking verhindert nicht, dass zwei Personen gleichzeitig arbeiten. Es verhindert, dass eine Anwendung unbemerkt auf veraltetem Stand speichert. Die fachliche Reaktion bleibt Aufgabe der Anwendung: neu laden, Konflikt melden oder Benutzerin entscheiden lassen. |
| 220 | + |
| 221 | +## Checkpoint-System für diesen Block |
| 222 | + |
| 223 | +Das Checkpoint-System ist bewusst nicht Git-basiert. Es verwendet normale Dateien in Overlays: |
| 224 | + |
| 225 | +```text |
| 226 | +db-2-app/course/ |
| 227 | +├── states.yml |
| 228 | +└── overlays/ |
| 229 | + ├── block-2-complete/ |
| 230 | + └── block-3-complete/ |
| 231 | +``` |
| 232 | + |
| 233 | +Die Zustände bauen aufeinander auf: |
| 234 | + |
| 235 | +| Zustand | Verwendung | |
| 236 | +| --- | --- | |
| 237 | +| `block-2-start` | Normaler Starter für Block 2 | |
| 238 | +| `block-2-complete` | Lösung der V2-Migration | |
| 239 | +| `block-3-start` | Einstieg in Block 3 | |
| 240 | +| `block-3-complete` | Lösung mit Transaktionsworkflow | |
| 241 | + |
| 242 | +Für den normalen Unterricht beginnst du im Starter. Für Quereinstieg oder Lösungseinsicht erzeugst du eine Kopie: |
| 243 | + |
| 244 | +```bash |
| 245 | +./course-state create block-3-start ../work/db-2-app-block-3 |
| 246 | +``` |
| 247 | + |
| 248 | +Dozierende können alle definierten Zustände prüfen: |
| 249 | + |
| 250 | +```bash |
| 251 | +./course-state validate --skip-slow |
| 252 | +``` |
| 253 | + |
| 254 | +Ohne `--skip-slow` werden auch Testcontainers-Tests ausgeführt, sofern Docker oder Podman verfügbar ist. |
| 255 | + |
| 256 | +## Review-Fragen für Block 3 |
| 257 | + |
| 258 | +Beim Lesen eines Transaktionsablaufs helfen diese Fragen: |
| 259 | + |
| 260 | +- Welcher fachliche Vorgang wird geschützt? |
| 261 | +- Beginnt und endet die Transaktion an der richtigen Stelle? |
| 262 | +- Welche Tabellenänderungen müssen gemeinsam committen? |
| 263 | +- Welche Exception würde ein Rollback auslösen? |
| 264 | +- Werden Fehler versehentlich abgefangen? |
| 265 | +- Gibt es externe Seiteneffekte innerhalb der Transaktion? |
| 266 | +- Braucht der Vorgang Schutz gegen gleichzeitige Bearbeitung? |
| 267 | + |
| 268 | +Eine gute Antwort nennt nicht nur `@Transactional`. Sie begründet, warum genau diese Grenze den fachlichen Zustand schützt. |
0 commit comments