Synchronisation des demandes client au printemps

Aujourd'hui, je vous propose d'analyser une tâche pratique sur la race des demandes des clients que j'ai rencontrée dans MaximTelecom lors du développement du back-end de notre application mobile MT_FREE.

Au démarrage, l'application cliente envoie de manière asynchrone un "paquet" de requêtes à l'API. L'application a l'identifiant clientId, sur la base duquel il est possible de distinguer les demandes d'un client d'un autre. Pour chaque requête sur le serveur, un code du formulaire est exécuté:

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

où l'entité Client a un champ clientId, qui doit être unique et a une contrainte unique dans la base de données pour cela. Puisqu'au printemps, chaque requête exécutera ce code dans un thread séparé, même s'il s'agit de requêtes de la même application client, une erreur de formulaire apparaîtra:
violation de contrainte d'intégrité: contrainte unique ou violation d'index; UK_BFJDOY2DPUSSYLQ7G1S3S1TN8 table: CLIENT

L'erreur se produit pour une raison évidente: 2 threads ou plus avec le même clientId reçoivent l'entité client == null et commencent à la créer, après quoi ils obtiennent une erreur lors de la validation.

Défi:


Il est nécessaire de synchroniser les demandes d'un clientId afin que seule la première demande termine la création de l'entité Client, et que les autres soient bloquées au moment de la création et reçoivent l'objet qu'elle a déjà créé.

Solution 1


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

Cette solution fonctionne, mais est très coûteuse, car toutes les demandes (threads) à créer sont bloquées, même si elles créent un client avec un clientId différent et ne se font pas concurrence.

Veuillez noter que la combinaison de la synchronisation avec l'annotation @Transactional

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

la même erreur se reproduira. La raison en est que le moniteur (synchronisé) est libéré en premier et le thread suivant entre dans la zone synchronisée, et seulement après que la transaction est validée par le premier thread dans l'objet proxy. Pour résoudre ce problème est simple - vous devez libérer le moniteur après la validation, par conséquent, synchronized doit être appelé ci-dessus:

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

Décision 2


J'aimerais vraiment utiliser un design du formulaire:

 synchronized (clientId) 

mais le problème est qu'un nouvel objet clientId sera créé pour chaque demande, même si leurs valeurs sont équivalentes, par conséquent, la synchronisation ne peut pas être effectuée de cette manière. Afin de résoudre le problème avec différents objets clientId, vous devez utiliser le pool:

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

Cette solution utilise le pool de chaînes Java, respectivement, les demandes avec l'équivalent clientId, en appelant clientId.intern (), recevront le même objet. Malheureusement, dans la pratique, cette solution n'est pas applicable, car il est impossible de gérer le clientId «en décomposition», ce qui conduira tôt ou tard à OutOfMemory.

Décision 3


Pour utiliser ReentrantLock, vous avez besoin d'un pool du formulaire:

 private final ConcurrentMap<String, ReentrantLock> locks; 

puis:

 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(); } } 

Le seul problème est la gestion de clientId «périmé», il peut être résolu en utilisant l'implémentation non standard de ConcurrentMap, qui prend déjà en charge expire, par exemple, prendre le cache de goyave:

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

Décision 4


Les solutions ci-dessus synchronisent les demandes au sein d'une seule instance. Que faire si votre service tourne sur N nœuds et que les demandes peuvent être différentes en même temps? Pour cette situation, l'utilisation de la bibliothèque Redisson est parfaite comme solution:

  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(); } } 

La bibliothèque résout le problème des verrous distribués en utilisant redis comme référentiel.

Conclusion


La décision à appliquer dépend certainement de l'ampleur du problème: les solutions 1 à 3 conviennent parfaitement aux petits services à instance unique, la solution 4 vise les services distribués. Il convient également de noter séparément que la résolution de ce problème en utilisant Redisson ou des analogues (par exemple, Zookeeper classique) est, bien sûr, un cas particulier, car ils sont conçus pour une gamme de tâches beaucoup plus large pour les systèmes distribués.

Dans notre cas, nous avons opté pour la solution 4, car notre service est distribué et l'intégration de Redisson a été la plus simple par rapport aux analogues.

Amis, suggérez dans les commentaires vos options pour résoudre ce problème, je serai très heureux!
Le code source des exemples est disponible sur GitHub .

Soit dit en passant, nous élargissons constamment le personnel de développement, les offres d'emploi pertinentes peuvent être trouvées sur notre page carrière .

UPD 1. Solution des lecteurs 1


Cette solution propose de ne pas synchroniser les requêtes, mais en cas d'erreur de forme:
violation de contrainte d'intégrité: contrainte unique ou violation d'index; UK_BFJDOY2DPUSSYLQ7G1S3S1TN8 table: CLIENT

doit être traité et rappelé
 client = clientRepository.findByClientId(clientId); 

ou faites-le au printemps:
 @Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000)) @Transactional public Client getOrCreateUser(String clientId) 

(merci à Throwable pour un exemple )
Dans ce cas, il y aura des requêtes "supplémentaires" dans la base de données, mais dans la pratique, la création de l'entité Client ne se produira pas souvent, et si la synchronisation n'est nécessaire que pour résoudre le problème de l'insertion dans la base de données, alors cette solution peut être supprimée.

UPD 2. Solution des lecteurs 2


Cette solution propose de faire la synchronisation à travers la session:
 HttpSession session = request.getSession(false); if (session != null) { Object mutex = WebUtils.getSessionMutex(session); synchronized (mutex) { ... } } 

Cette solution fonctionnera pour les services à instance unique, mais il sera nécessaire de résoudre le problème afin que toutes les demandes d'un client à l'API soient exécutées dans la même session.

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


All Articles