Ihre Programme sind nicht Single-Threaded
Andere Sprachen: English Español Français 日本語 한국어 Português 中文
Beim Schreiben von Code neigen wir dazu, synchron zu denken. Wir schreiben eine Anweisung nach der anderen, und die Laufzeit führt sie der Reihe nach aus. Dieses mentale Modell funktioniert hervorragend für Skripte und Kommandozeilen-Tools, kann aber beim Arbeiten mit Frameworks und Serveranwendungen gefährlich irreführend sein.
Selbst wenn Sie nie explizit Threads erstellen oder Nebenläufigkeitsprimitive verwenden, führt Ihre Webanwendung mit ziemlicher Sicherheit Code parallel aus. Das Framework erledigt das für Sie, was praktisch ist, solange Sie das im Hinterkopf behalten und das Threading-Modell und die Bausteine, die das Framework bereitstellt, korrekt verwenden.
In diesem Artikel werden wir genau die Art von Bug betrachten, die auftreten kann, wenn man fälschlicherweise einem Single-Threaded-Mentalmodell in scheinbar synchronem Code folgt. Wir werden die Ursache finden, sie mit dem Debugger reproduzieren und mehrere mögliche Lösungen betrachten.
Das Problem
Stellen Sie sich vor, Sie haben eine Spring Boot-Anwendung, die in Entwicklung und Produktion einwandfrei läuft. Lange Zeit hat alles reibungslos funktioniert, aber dann meldet das Buchhaltungsteam ein ernstes Problem: Mehrere Rechnungen haben die gleiche Nummer. Das ist schlecht. Rechnungsnummern müssen aus rechtlichen und buchhalterischen Gründen eindeutig sein. Die Duplikate sind nur einmal aufgetreten, und Sie können das Problem lokal nicht reproduzieren.
Um diese Übung interessanter zu gestalten, können Sie versuchen, das Problem selbst zu finden, bevor Sie weiterlesen. Das Projekt ist auf GitHub verfügbar.
Sie überprüfen die Datenbank und sehen unterschiedliche Datensätze mit identischen Rechnungsnummern. Die Konfiguration sieht in Ordnung aus, dennoch stimmt offensichtlich etwas nicht. Indem Sie den Rechnungserstellungsfluss verfolgen, finden Sie den Service, der für die Generierung von Rechnungsnummern verantwortlich ist.
So sieht der Code aus:
@Service
public class InvoiceService {
private InvoiceNumberGenerator generator;
public Invoice createInvoice(Order order) {
if (generator == null) {
generator = createGenerator();
}
String invoiceNumber = generator.nextNumber();
return new Invoice(invoiceNumber, order);
}
private InvoiceNumberGenerator createGenerator() {
// Reads last used number from database, initializes counter
int lastNumber = invoiceRepository.findMaxInvoiceNumber();
return new InvoiceNumberGenerator(lastNumber);
}
}
So würden Sie diesen Code nicht schreiben. Können Sie das Problem erkennen?
Was ist schiefgelaufen?
Das mentale Modell des Autors war wahrscheinlich: “Spring verwaltet meine Beans, also bin ich sicher.”
Und das stimmt: Spring handhabt tatsächlich den Bean-Lebenszyklus und stellt sicher,
dass InvoiceService einmal erstellt und ordnungsgemäß geteilt wird.
Aber Spring weiß nichts über Felder, die Sie selbst initialisieren.
Das generator -Feld liegt in Ihrer Verantwortung, und hier lauert das Problem.
Die Abfolge der Ereignisse
Spring Beans sind standardmäßig Singletons.
Das bedeutet, dass es nur eine Instanz von InvoiceService gibt, die von allen Anfragen geteilt wird.
Wenn mehrere HTTP-Anfragen gleichzeitig eingehen,
werden sie von verschiedenen Threads behandelt, die alle auf dieselbe Service-Instanz zugreifen.
Lassen Sie uns nun verfolgen, was passiert, wenn der Generator noch nicht initialisiert wurde
und zwei Threads ungefähr gleichzeitig createInvoice() aufrufen:
- Thread A prüft
generator == null, wastrueergibt - Thread A betritt den
if-Block und beginnt mit der Erstellung des Generators - Thread B prüft
generator == null, was immer nochtrueist (A hat den Generator noch nicht erstellt) - Thread B betritt ebenfalls den
if-Block - Beide Threads fragen die Datenbank nach
findMaxInvoiceNumber()ab und erhalten denselben Wert, zum Beispiel1000 - Beide Threads erstellen ihren eigenen
InvoiceNumberGenerator, der bei1000beginnt - Beide rufen
nextNumber()auf und erhalten1001 - Zwei Rechnungen mit derselben Nummer werden erstellt
Die verzögerte Initialisierung ist nicht thread-sicher, und bei gleichzeitigem Zugriff können beide Threads dieselbe “letzte Rechnungsnummer” aus der Datenbank lesen und dieselbe nächste Nummer generieren.
Mit dem Debugger reproduzieren
Der problematische Code, den wir gerade gefunden haben, ist einfach, sodass Sie die Abfolge der Ereignisse vielleicht schon durch bloßes Ansehen verstehen können. In komplexeren Fällen möchten Sie den Bug möglicherweise Schritt für Schritt reproduzieren, um genau zu verstehen, was passiert. Manchmal möchten Sie auch einfach das unglückliche Timing simulieren und testen, ob das Programm unter Last nicht zusammenbricht.
Wir werden dafür den Debugger von IntelliJ IDEA verwenden. Er kann einzelne Threads steuern – genau das, was wir brauchen.
Setzen Sie zunächst einen Breakpoint in der Zeile generator = createGenerator(); ,
klicken Sie mit der rechten Maustaste darauf und setzen Sie Suspend
auf Thread.
Dies weist den Debugger an, nur den Thread anzuhalten, der den Breakpoint erreicht hat, und nicht alle.
Führen Sie die Anwendung im Debug-Modus aus und senden Sie zwei Anfragen von verschiedenen Terminals mit dem folgenden curl-Befehl:
curl -X POST http://localhost:8080/api/diagnostics/create-order
Eine der Anfragen wird den Breakpoint erreichen: das ist erwartet. Wenn die zweite Anfrage ebenfalls den Breakpoint erreicht, haben Sie gerade bewiesen, dass mehrere Threads gleichzeitig in den kritischen Abschnitt eintreten können – die Definition des vorliegenden Bugs.
Sie können den Threads-Tab untersuchen, um zu sehen, dass beide Threads an derselben Stelle warten:
Beide Threads haben die null -Prüfung passiert,
und wenn Sie sie freigeben, werden sie ihre eigenen Generator-Instanzen erstellen.
Alternativ können Sie einen nicht-anhaltenden Logging-Breakpoint mit einer kleinen Verzögerung verwenden, um eine langsamere Ausführung zu simulieren:
Diese Art von Breakpoint ist für Debug-Logging konzipiert, kann aber wie wir bereits wissen für so ziemlich jeden Nebeneffekt verwendet werden. Mit dieser künstlichen Verzögerung müssen wir die Anwendung nicht anhalten, da die Anfragen wahrscheinlich ohnehin kollidieren werden.
Das Problem beheben
Es gibt mehrere Möglichkeiten, das Problem zu beheben, abhängig von Ihren Anforderungen:
Option 1: Die Datenbank verwenden
Für Rechnungsnummern speziell ist die richtige Lösung, die Datenbank die Eindeutigkeit handhaben zu lassen:
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public Invoice createInvoice(Order order) {
// Database generates the number via sequence or auto-increment
return invoiceRepository.save(new Invoice(order));
}
}
Dies eliminiert den In-Memory-Zähler vollständig. Die Datenbank garantiert Eindeutigkeit auch über mehrere Anwendungsinstanzen hinweg.
Option 2: Eager Initialisierung
Wenn Sie einen In-Memory-Generator benötigen, machen Sie ihn zu einer Spring Bean:
@Component
public class InvoiceNumberGenerator {
private final AtomicInteger counter;
public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
int startingNumber = (maxNumber != null) ? maxNumber : 0;
this.counter = new AtomicInteger(startingNumber);
}
public String nextNumber() {
int next = counter.incrementAndGet();
return String.format("INV-%05d", next);
}
}
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberGenerator generator;
public InvoiceService(InvoiceRepository invoiceRepository,
InvoiceNumberGenerator generator) {
this.invoiceRepository = invoiceRepository;
this.generator = generator;
}
public Invoice createInvoice(Order order) {
String invoiceNumber = generator.nextNumber();
Invoice invoice = new Invoice(invoiceNumber, order);
return invoiceRepository.save(invoice);
}
}
Dies ist der Standardansatz in Spring. Singleton-Beans werden beim Start erstellt und sicher über alle Anfragen hinweg geteilt.
Option 3: @Lazy verwenden
Wenn Sie wirklich verzögerte Initialisierung benötigen
(zum Beispiel, wenn die Erstellung einer Bean aufwendig ist, weil sie Daten lädt oder Verbindungen aufbaut),
können Sie @Lazy verwenden, damit Spring dies sicher handhabt:
@Component @Lazy
public class InvoiceNumberGenerator {
private final AtomicInteger counter;
public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
int startingNumber = (maxNumber != null) ? maxNumber : 0;
this.counter = new AtomicInteger(startingNumber);
}
public String nextNumber() {
int next = counter.incrementAndGet();
return String.format("INV-%05d", next);
}
}
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
private final InvoiceNumberGenerator generator;
public InvoiceService(InvoiceRepository invoiceRepository,
@Lazy InvoiceNumberGenerator generator) {
this.invoiceRepository = invoiceRepository;
this.generator = generator;
}
public Invoice createInvoice(Order order) {
String invoiceNumber = generator.nextNumber();
Invoice invoice = new Invoice(invoiceNumber, order);
return invoiceRepository.save(invoice);
}
}
Als Faustregel gilt: Wenn Sie sich dabei ertappen, verzögerte Initialisierung, Caching, Synchronisation oder ähnliche Muster manuell zu implementieren, prüfen Sie, ob das Framework dies bereits bereitstellt. Es selbst zu tun bedeutet oft, gegen das Framework zu kämpfen und Wartungsaufwand sowie subtile Bugs einzuführen.
Zusammenfassung
Auch wenn wir nicht explizit Threads erstellen, laufen unsere Anwendungen oft nebenläufig. Dieses Muster ist nicht einzigartig für Spring oder sogar die Webentwicklung. Überall dort, wo Sie ein implizites Threading-Modell haben, sind Sie gefährdet: Web-Frameworks, UI-Toolkits und sogar Standard-Bibliotheks-APIs verwalten alle Threads im Hintergrund.
Wenn Sie das nächste Mal intermittierendes, unerklärliches Verhalten in Ihrer Anwendung sehen, fragen Sie sich: Könnte dies ein Threading-Problem sein? Meistens lautet die Antwort ja.
Frohes Debuggen!