Synchronisierung von Clientanforderungen im Frühjahr

Heute schlage ich Ihnen vor, eine praktische Aufgabe über das Rennen der Kundenanforderungen zu analysieren, auf die ich in MaximTelecom bei der Entwicklung des Backends für unsere mobile Anwendung MT_FREE gestoßen bin.

Beim Start sendet die Clientanwendung asynchron ein "Paket" von Anforderungen an die API. Die Anwendung verfügt über die clientId-ID, anhand derer Anforderungen von einem Client von einem anderen unterschieden werden können. Für jede Anforderung auf dem Server ein Code des Formulars:

//      Client client = clientRepository.findByClientId(clientId); //      if(client == null){ client = clientRepository.save(new Client(clientId)); } //    

Dabei verfügt die Client-Entität über ein clientId-Feld, das eindeutig sein muss und dafür eine eindeutige Einschränkung in der Datenbank aufweist. Da im Frühjahr jede Anforderung diesen Code in einem separaten Thread ausführt, wird ein Fehler des Formulars angezeigt, auch wenn es sich um Anforderungen derselben Clientanwendung handelt:
Verletzung der Integritätsbeschränkung: Verletzung der eindeutigen Einschränkung oder des Index; Tabelle UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENT

Der Fehler tritt aus einem offensichtlichen Grund auf: 2 oder mehr Threads mit derselben clientId empfangen die Entität client == null und beginnen mit der Erstellung. Danach wird beim Festschreiben ein Fehler angezeigt.

Herausforderung:


Es ist erforderlich, Anforderungen von einer Client-ID zu synchronisieren, damit nur die erste Anforderung die Erstellung der Client-Entität abschließt und der Rest zum Zeitpunkt der Erstellung blockiert wird und das bereits erstellte Objekt empfängt.

Lösung 1


  //      if(client == null){ //   synchronized (this){ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } } 

Diese Lösung funktioniert, ist aber sehr teuer, da alle Anforderungen (Threads), die erstellt werden müssen, blockiert werden, auch wenn sie einen Client mit einer anderen clientId erstellen und nicht miteinander konkurrieren.

Bitte beachten Sie, dass die Kombination aus Synchronisation mit @ Transactional-Annotation

 @Transactional public synchronized Client getOrCreateUser(String clientId){ //      Client client = clientRepository.findByClientId(clientId); //      if(client == null){ client = clientRepository.save(new Client(clientId)); } return client; } 

Der gleiche Fehler tritt erneut auf. Der Grund dafür ist, dass der Monitor (synchronisiert) zuerst freigegeben wird und der nächste Thread den synchronisierten Bereich betritt. Erst danach wird die Transaktion vom ersten Thread im Proxy-Objekt festgeschrieben. Die Lösung dieses Problems ist einfach: Sie müssen den Monitor nach dem Festschreiben freigeben. Daher muss oben synchronisiert aufgerufen werden:

  synchronized (this){ client = clientService.getOrCreateUser(clientId); } 

Entscheidung 2


Ich würde wirklich gerne ein Design des Formulars verwenden:

 synchronized (clientId) 

Das Problem ist jedoch, dass für jede Anforderung ein neues clientId-Objekt erstellt wird, auch wenn ihre Werte äquivalent sind. Daher kann die Synchronisierung nicht auf diese Weise durchgeführt werden. Um das Problem mit verschiedenen clientId-Objekten zu lösen, müssen Sie den Pool verwenden:

 Client client = clientRepository.findByClientId(clientId); //      if(client == null){ //   synchronized (clientId.intern()){ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } } 

Diese Lösung verwendet den Java-String-Pool. Anforderungen mit der entsprechenden clientId erhalten durch Aufrufen von clientId.intern () dasselbe Objekt. Leider ist diese Lösung in der Praxis nicht anwendbar, da es unmöglich ist, die "verrottende" clientId zu verwalten, was früher oder später zu OutOfMemory führen wird.

Entscheidung 3


Um ReentrantLock verwenden zu können, benötigen Sie einen Pool des Formulars:

 private final ConcurrentMap<String, ReentrantLock> locks; 

und dann:

 Client client = clientRepository.findByClientId(clientId); //      if(client == null){ //   ReentrantLock lock = locks.computeIfAbsent(clientId, (k) -> new ReentrantLock()); lock.lock(); try{ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } finally { //   lock.unlock(); } } 

Das einzige Problem ist die Verwaltung der "veralteten" clientId. Sie kann mithilfe der nicht standardmäßigen Implementierung von ConcurrentMap gelöst werden, die bereits das Ablaufen unterstützt. Nehmen Sie beispielsweise den Guaven-Cache:

 locks = CacheBuilder.newBuilder() .concurrencyLevel(4) .expireAfterWrite(Duration.ofMinutes(1)) .<String, ReentrantLock>build().asMap(); 

Entscheidung 4


Die oben genannten Lösungen synchronisieren Anforderungen innerhalb einer einzelnen Instanz. Was tun, wenn sich Ihr Dienst auf N Knoten dreht und Anforderungen gleichzeitig an andere gesendet werden können? In dieser Situation ist die Verwendung der Redisson- Bibliothek als Lösung perfekt:

  Client client = clientRepository.findByClientId(clientId); //      if(client == null){ //   RLock lock = redissonClient.getFairLock(clientId); lock.lock(); try{ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } finally { //   lock.unlock(); } } 

Die Bibliothek löst das Problem der verteilten Sperren mithilfe von redis als Repository.

Fazit


Welche Entscheidung für eine Anwendung getroffen wird, hängt sicherlich vom Ausmaß des Problems ab: Die Lösungen 1 bis 3 eignen sich gut für kleine Dienste mit einer Instanz, Lösung 4 richtet sich an verteilte Dienste. Es ist auch erwähnenswert, dass die Lösung dieses Problems mit Redisson oder Analoga (z. B. klassischer Zookeeper) natürlich ein Sonderfall ist, da sie für einen viel größeren Aufgabenbereich für verteilte Systeme ausgelegt sind.

In unserem Fall haben wir uns für Lösung 4 entschieden, da unser Service verteilt ist und die Redisson-Integration im Vergleich zu Analoga am einfachsten war.

Freunde, schlagen Sie in den Kommentaren Ihre Möglichkeiten zur Lösung dieses Problems vor, ich werde mich sehr freuen!
Der Quellcode für die Beispiele ist auf GitHub verfügbar.

Übrigens erweitern wir ständig das Entwicklungspersonal, relevante Stellen finden Sie auf unserer Karriereseite .

UPD 1. Lösung von Lesern 1


Diese Lösung schlägt vor, Anforderungen nicht zu synchronisieren, sondern im Falle eines Fehlers des Formulars:
Verletzung der Integritätsbeschränkung: Verletzung der eindeutigen Einschränkung oder des Index; Tabelle UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENT

muss verarbeitet und zurückgerufen werden
 client = clientRepository.findByClientId(clientId); 

oder machen Sie es durch Spring-Retry:
 @Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000)) @Transactional public Client getOrCreateUser(String clientId) 

(danke an Throwable für ein Beispiel )
In diesem Fall gibt es "zusätzliche" Abfragen an die Datenbank, aber in der Praxis wird die Erstellung der Client-Entität nicht häufig durchgeführt. Wenn eine Synchronisierung nur erforderlich ist, um das Problem des Einfügens in die Datenbank zu lösen, kann auf diese Lösung verzichtet werden.

UPD 2. Lösung von Lesern 2


Diese Lösung schlägt vor, die Synchronisierung über die Sitzung durchzuführen:
 HttpSession session = request.getSession(false); if (session != null) { Object mutex = WebUtils.getSessionMutex(session); synchronized (mutex) { ... } } 

Diese Lösung funktioniert für Dienste mit einer Instanz, das Problem muss jedoch behoben werden, damit alle Anforderungen von einem Client an die API in derselben Sitzung ausgeführt werden.

Source: https://habr.com/ru/post/de434714/


All Articles