Verhindern von Race Conditions mit SELECT FOR UPDATE in Webanwendungen
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Einleitung
In der schnelllebigen Welt der Webanwendungen interagieren oft mehrere Benutzer gleichzeitig mit denselben Daten. Stellen Sie sich eine E-Commerce-Website vor, auf der zwei Kunden versuchen, den letzten verfügbaren Artikel zu kaufen, oder eine Bankanwendung, bei der zwei Überweisungen versuchen, dasselbe Konto zu belasten. Ohne angemessene Schutzmaßnahmen können diese gleichzeitigen Vorgänge zu unerwünschten Ergebnissen wie falschen Inventurständen, Doppelbuchungen oder beschädigten Finanzaufzeichnungen führen. Dieses Phänomen, bekannt als Datenrennen oder Race Condition, tritt auf, wenn das Timing oder die Verflechtung von Operationen durch mehrere Threads oder Prozesse die Korrektheit einer Berechnung beeinträchtigt. Die Gewährleistung von Datenkonsistenz und -integrität in solchen Umgebungen ist von größter Bedeutung. Dieser Artikel befasst sich mit einem leistungsstarken Datenbankmechanismus, SELECT ... FOR UPDATE, und erklärt, wie er diese Nebenprobleme in Webanwendungen effektiv verhindert und zuverlässige Datentransaktionen gewährleistet.
Verständnis der Nebenläufigkeitskontrolle
Bevor wir uns mit SELECT ... FOR UPDATE befassen, ist es wichtig, einige Kernkonzepte von Datenbanken zu verstehen:
- Nebenläufigkeitskontrolle (Concurrency Control): Eine Reihe von Mechanismen, um sicherzustellen, dass mehrere Transaktionen gleichzeitig ausgeführt werden können, ohne sich gegenseitig zu stören und ohne die Integrität der Datenbank zu beeinträchtigen.
- Transaktion (Transaction): Eine einzelne logische Arbeitseinheit, die auf eine Datenbank zugreift und diese möglicherweise modifiziert. Transaktionen haben ACID-Eigenschaften: Atomarität, Konsistenz, Isolation und Dauerhaftigkeit.
- Isolationsebenen (Isolation Levels): Definieren, wie und wann die von einer Operation gemachten Änderungen für andere sichtbar werden. Übliche Ebenen sind Read Uncommitted, Read Committed, Repeatable Read und Serializable. Niedrigere Isolationsebenen bieten höhere Nebenläufigkeit, aber geringere Garantien für die Datenintegrität und umgekehrt.
- Sperren (Locking): Ein Mechanismus, der verwendet wird, um den Zugriff auf gemeinsam genutzte Ressourcen (wie Zeilen oder Tabellen) in einer Datenbank zu steuern. Wenn eine Ressource gesperrt ist, wird anderen Transaktionen der Zugriff oder die Änderung derselben verweigert, bis die Sperre aufgehoben wird.
- Datenrennen / Race Condition (Data Race / Race Condition): Eine Situation, in der das Endergebnis einer Berechnung von der nicht-deterministischen relativen Zeitgebung von Ereignissen abhängt, was oft zu falschen Ergebnissen führt.
- Dirty Read: Eine Transaktion liest Daten, die von einer anderen Transaktion geschrieben wurden, aber noch nicht committet wurden (und daher möglicherweise zurückgerollt werden).
- Lost Update: Zwei Transaktionen lesen dieselben Daten, modifizieren sie dann beide. Die Aktualisierung einer Transaktion überschreibt die andere, wodurch die erste Aktualisierung effektiv "verloren geht".
SELECT ... FOR UPDATE befasst sich hauptsächlich mit dem Problem der verlorenen Updates und trägt dazu bei, stärkere Isolationsgarantien zu erzielen, typischerweise auf der Ebene von Read Committed oder Repeatable Read, indem explizit Sperren erworben werden.
Wie SELECT FOR UPDATE funktioniert
SELECT ... FOR UPDATE ist eine SQL-Klausel, die, wenn sie an eine SELECT-Anweisung angehängt wird, eine exklusive (Schreib-)Sperre für die abgerufenen Zeilen erwirbt. Das bedeutet, dass:
- Andere Transaktionen können diese gesperrten Zeilen nicht ändern, bis die aktuelle Transaktion entweder committet oder zurückgerollt wird.
- Andere
SELECT ... FOR UPDATE-Anweisungen für dieselben Zeilen blockieren, bis die aktuelle Transaktion ihre Sperren freigibt. - Reguläre
SELECT-Anweisungen (ohneFOR UPDATE) können die gesperrten Zeilen möglicherweise immer noch lesen, abhängig von der Isolationsebene der Datenbank. Wenn die Isolationsebene jedochRepeatable ReadoderSerializableist, können selbst einfacheSELECTs blockieren oder einen konsistenten Snapshot sehen.
Dieser Sperrmechanismus verhindert verlorene Updates, indem er sicherstellt, dass eine Transaktion, sobald sie Daten mit der Absicht zu ändern liest, keine andere Transaktion diese Daten gleichzeitig ändern kann.
Praktische Anwendung in einer Webanwendung
Betrachten Sie ein E-Commerce-Szenario, in dem ein Benutzer einen Artikel kaufen möchte. Die Anwendung muss die Verfügbarkeit prüfen, den Lagerbestand reduzieren und eine Bestellung erstellen.
Ohne SELECT FOR UPDATE (Mögliche Race Condition):
Nehmen wir an, zwei Benutzer, Alice und Bob, versuchen gleichzeitig, den letzten verfügbaren Produkt A zu kaufen.
| Zeit | Alices Transaktion | Bobs Transaktion | Produkt A Lagerbestand |
|---|---|---|---|
| T1 | SELECT stock FROM products WHERE id = 1; (ergibt 1) | 1 | |
| T2 | SELECT stock FROM products WHERE id = 1; (ergibt 1) | 1 | |
| T3 | UPDATE products SET stock = 0 WHERE id = 1; | 0 | |
| T4 | COMMIT; | 0 | |
| T5 | UPDATE products SET stock = 0 WHERE id = 1; | 0 | |
| T6 | COMMIT; | 0 |
In diesem Szenario haben sowohl Alice als auch Bob den Artikel "erfolgreich" gekauft, aber der Lagerbestand wurde nur einmal reduziert. Dies ist ein klassisches Problem der verlorenen Updates.
Mit SELECT FOR UPDATE (Verhindert Race Condition):
Integrieren wir nun SELECT ... FOR UPDATE in den Kaufvorgang.
-- Alices Transaktion START TRANSACTION; SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- // Alices Anwendungslogik bestätigt Lagerbestand > 0 -- // ... UPDATE products SET stock = stock - 1 WHERE id = 1; INSERT INTO orders (product_id, user_id, quantity) VALUES (1, 'Alice', 1); COMMIT; -- Bobs Transaktion START TRANSACTION; SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- Diese SELECT-Anweisung wird blockieren, bis Alices Transaktion committet oder zurückgerollt wird. -- Sobald Alice committet, wird Bobs SELECT ausgeführt. -- Wenn der Lagerbestand jetzt 0 ist, wird Bobs Anwendungslogik feststellen, dass er nicht vorrätig ist. -- // Bobs Anwendungslogik bestätigt Lagerbestand > 0 (wird es nicht sein, wenn Alice ihn gekauft hat) -- // ... -- Wenn der Lagerbestand 0 war, wird Bobs Transaktion möglicherweise zurückgerollt oder gibt an, dass der Artikel nicht verfügbar ist. -- Wenn der Lagerbestand aus irgendeinem Grund immer noch > 0 war (z. B. ursprünglicher Lagerbestand > 1 und Alice hat 1 gekauft), -- würde Bob ihn trotzdem reduzieren. -- ... -- UPDATE products SET stock = stock - 1 WHERE id = 1; -- INSERT INTO orders (product_id, user_id, quantity) VALUES (1, 'Bob', 1); -- COMMIT;
Lassen Sie uns dies erneut mit zwei Benutzern nachverfolgen:
| Zeit | Alices Transaktion | Bobs Transaktion | Produkt A Lagerbestand | Sperren für Produkt A (id=1) |
|---|---|---|---|---|
| T1 | START TRANSACTION; | 1 | ||
| T2 | SELECT stock FROM products WHERE id = 1 FOR UPDATE; (ergibt 1) | 1 | Alice (exklusiv) | |
| T3 | START TRANSACTION; | 1 | Alice (exklusiv) | |
| T4 | SELECT stock FROM products WHERE id = 1 FOR UPDATE; | 1 | Alice (exklusiv), Bob blockiert | |
| T5 | UPDATE products SET stock = 0 WHERE id = 1; | 0 | Alice (exklusiv), Bob blockiert | |
| T6 | INSERT INTO orders ...; | 0 | Alice (exklusiv), Bob blockiert | |
| T7 | COMMIT; | 0 | Sperren freigegeben | |
| T8 | Bobs SELECT wird entsperrt und gibt Lagerbestand = 0 zurück | 0 | Bob (exklusiv) | |
| T9 | // Bobs Logik sieht Lagerbestand 0, stoppt den Kauf // ROLLBACK; (oder ähnliche Behandlung) | 0 | Sperren freigegeben |
In diesem aktualisierten Szenario, wenn Alice Produkt A sperrt, wird Bobs Versuch, dieselbe Zeile zu sperren, blockiert. Sobald Alice committet und ihre Sperre freigibt, wird Bobs SELECT ... FOR UPDATE fortgesetzt. An diesem Punkt sieht Bobs Abfrage den aktualisierten Lagerbestand (0), was korrekt darauf hinweist, dass der Artikel nicht mehr verfügbar ist. Dies verhindert, dass der Lagerbestand negativ wird oder eine Bestellung für einen nicht existierenden Artikel erstellt wird.
Implementierungsüberlegungen
- In Transaktionen einbetten:
SELECT ... FOR UPDATEist nur wirksam, wenn es innerhalb einer Datenbanktransaktion verwendet wird. Die Sperren werden gehalten, bis die Transaktion committet oder zurückgerollt wird. - Leistungsauswirkungen: Das Sperren von Zeilen kann zu Konflikten führen und die Nebenläufigkeit verringern. Wenn viele Transaktionen häufig mit
FOR UPDATEauf dieselben Zeilen zugreifen, kann dies zu Leistungshindernissen führen. Verwenden Sie es umsichtig bei kritischen Ressourcen. - Deadlocks: Wenn zwei Transaktionen versuchen, Sperren auf Ressourcen in unterschiedlicher Reihenfolge zu erwerben, können sie in einen Deadlock-Zustand geraten. Moderne Datenbanksysteme verfügen in der Regel über Mechanismen zur Deadlock-Erkennung und -Auflösung (z. B. das Zurückrollen einer der Transaktionen), aber es ist wichtig, die Transaktionslogik sorgfältig zu gestalten, um deren Auftreten zu minimieren.
- Datenbankdialekte: Die genaue Syntax und das Verhalten können zwischen Datenbanksystemen (z. B. PostgreSQL, MySQL, Oracle) leicht variieren. PostgreSQL bietet beispielsweise
FOR SHARE(gemeinsame Sperre),FOR NO KEY UPDATE,FOR SHARE SKIP LOCKEDundFOR UPDATE NOWAITfür eine feinere Steuerung. DieInnoDB-Engine von MySQL bietet ähnliche Funktionalitäten.
# Beispiel für die Verwendung von SQLAlchemy ORM (Python) für ein E-Commerce-Szenario from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base # Datenbank-Setup engine = create_engine('sqlite:///:memory:', echo=True) Base = declarative_base() class Product(Base): __tablename__ = 'products' id = Column(Integer, primary_key=True) name = Column(String, unique=True, nullable=False) stock = Column(Integer, nullable=False, default=0) def __repr__(self): return f"<Product(id={self.id}, name='{self.name}', stock={self.stock})>" Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) # Datenbank bevölkern session = Session() session.add(Product(name='Example Widget', stock=1)) session.commit() session.close() def purchase_product(product_id: int): session = Session() try: with session.begin(): # Startet eine Transaktion # Verwenden Sie with_for_update(), um FOR UPDATE anzuhängen product_to_purchase = session.query(Product).filter_by(id=product_id).with_for_update().one() print(f"Purchasing product {product_to_purchase.name} with current stock: {product_to_purchase.stock}") if product_to_purchase.stock > 0: product_to_purchase.stock -= 1 # In einer echten App würden Sie hier auch einen Bestelldatensatz erstellen print(f"Successfully purchased. New stock: {product_to_purchase.stock}") # session.commit() erfolgt automatisch mit dem Kontextmanager `session.begin()` return True else: print(f"Product {product_to_purchase.name} is out of stock.") # session.rollback() erfolgt automatisch, wenn eine Ausnahme auftritt return False except Exception as e: print(f"An error occurred during purchase: {e}") session.rollback() return False finally: session.close() # Simulieren gleichzeitiger Anfragen import threading results = [] threads = [] for i in range(2): # Alice und Bob thread = threading.Thread(target=lambda: results.append(purchase_product(1))) threads.append(thread) thread.start() for thread in threads: thread.join() print(f"\nFinal stock of product ID 1: {Session().query(Product).filter_by(id=1).one().stock}") print(f"Purchase results: {results}") # Erwartete Ausgabe (Reihenfolge der Druckausgaben kann aufgrund der Thread-Planung variieren, aber die Korrektheit ist garantiert): # Purchasing product Example Widget with current stock: 1 # Successfully purchased. New stock: 0 # Purchasing product Example Widget with current stock: 0 (Dieser Thread wartete auf den ersten Thread) # Product Example Widget is out of stock. # # Final stock of product ID 1: 0 # Purchase results: [True, False]
Im Python-Beispiel ist session.query(Product).filter_by(id=product_id).with_for_update().one() das SQLAlchemy-Äquivalent zu SELECT ... FOR UPDATE. Wenn der erste Thread dies ausführt, erwirbt er eineSchreibsperre. Der zweite Thread, der dieselbe Operation versucht, wird blockiert, bis die Transaktion des ersten Threads committet oder zurückgerollt wird. Dies stellt sicher, dass nur ein Kauf den Lagerbestand erfolgreich auf Null reduziert und die Race Condition verhindert wird.
Fazit
SELECT ... FOR UPDATE ist ein wichtiges Werkzeug zur Aufrechterhaltung der Datenkonsistenz in gleichzeitigen Webanwendungen. Durch den Erwerb exklusiver Sperren für Änderungen vorgesehene Zeilen verhindert es effektiv Datenrennen wie verlorene Updates und stellt sicher, dass kritische Operationen wie Bestandsverwaltung oder Finanztransaktionen zuverlässig bleiben. Obwohl es potenzielle Leistungsaspekte und das Risiko von Deadlocks mit sich bringt, ist seine umsichtige Anwendung innerhalb ordnungsgemäß gestalteter Transaktionen für robuste und vertrauenswürdige Webdienste unverzichtbar. Die Verwendung von SELECT ... FOR UPDATE ist ein grundlegender Schritt zum Aufbau skalierbarer und ausfallsicherer Anwendungen, bei denen die Datenintegrität nicht verhandelbar ist.