Sincronización de solicitud de cliente en Spring

Hoy le sugiero que analice una tarea práctica sobre la carrera de solicitudes de clientes que encontré en MaximTelecom al desarrollar el back-end para nuestra aplicación móvil MT_FREE.

Al inicio, la aplicación cliente envía asincrónicamente un "paquete" de solicitudes a la API. La aplicación tiene el identificador clientId, en función del cual es posible distinguir entre solicitudes de un cliente de otro. Para cada solicitud en el servidor, se ejecuta un código del formulario:

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

donde la entidad Cliente tiene un campo clientId, que debe ser único y tiene una restricción única en la base de datos para esto. Dado que en Spring cada solicitud ejecutará este código en un hilo separado, incluso si se trata de solicitudes de la misma aplicación cliente, aparecerá un error en el formulario:
violación de restricción de integridad: restricción única o violación de índice; Tabla UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENTE

El error ocurre por una razón obvia: 2 o más subprocesos con el mismo clientId reciben el cliente == entidad nula y comienzan a crearlo, después de lo cual obtienen un error al confirmar.

Desafío:


Es necesario sincronizar las solicitudes de un clientId para que solo la primera solicitud complete la creación de la entidad del Cliente, y el resto se bloqueará en el momento de la creación y reciba el objeto que ya creó.

Solución 1


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

Esta solución está funcionando, pero es muy costosa, ya que todas las solicitudes (hilos) que deben crearse están bloqueadas, incluso si crean un Cliente con un ID de cliente diferente y no compiten entre sí.

Tenga en cuenta que la combinación de sincronización con la anotación @Transactional

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

el mismo error ocurrirá nuevamente. La razón es que el monitor (sincronizado) se libera primero y el siguiente subproceso ingresa al área sincronizada, y solo después de eso, la transacción se confirma mediante el primer subproceso en el objeto proxy. Para resolver este problema es simple: necesita que el monitor se libere después de la confirmación, por lo tanto, se debe llamar a sincronizado anteriormente:

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

Decisión 2


Realmente me gustaría usar un diseño de la forma:

 synchronized (clientId) 

pero el problema es que se creará un nuevo objeto clientId para cada solicitud, incluso si sus valores son equivalentes, por lo tanto, la sincronización no se puede realizar de esta manera. Para resolver el problema con diferentes objetos clientId, debe usar el grupo:

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

Esta solución utiliza el conjunto de cadenas de Java, respectivamente, las solicitudes con el clientId equivalente, al llamar a clientId.intern (), recibirán el mismo objeto. Desafortunadamente, en la práctica, esta solución no es aplicable, ya que es imposible administrar el clientId "podrido", que tarde o temprano conducirá a OutOfMemory.

Decisión 3


Para usar ReentrantLock, necesita un grupo de la forma:

 private final ConcurrentMap<String, ReentrantLock> locks; 

y luego:

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

El único problema es la gestión de clientId "obsoleto", que se puede resolver utilizando la implementación no estándar de ConcurrentMap, que ya admite caducar, por ejemplo, tome guaya Cache:

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

Decisión 4


Las soluciones anteriores sincronizan las solicitudes dentro de una sola instancia. ¿Qué hacer si su servicio está girando en N nodos y las solicitudes pueden ir a diferentes al mismo tiempo? Para esta situación, usar la biblioteca Redisson es perfecto como solución:

  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 biblioteca resuelve el problema de los bloqueos distribuidos usando redis como repositorio.

Conclusión


La decisión de aplicar ciertamente depende de la escala del problema: las soluciones 1-3 son bastante adecuadas para servicios pequeños de instancia única, la solución 4 está dirigida a servicios distribuidos. También vale la pena señalar por separado que resolver este problema utilizando Redisson o análogos (por ejemplo, Zookeeper clásico) es, por supuesto, un caso especial, ya que están diseñados para una gama mucho más amplia de tareas para sistemas distribuidos.

En nuestro caso, nos decidimos por la solución 4, ya que nuestro servicio está distribuido y la integración de Redisson fue la más fácil en comparación con los análogos.

Amigos, sugieran en los comentarios sus opciones para resolver este problema, ¡estaré muy feliz!
El código fuente de los ejemplos está disponible en GitHub .

Por cierto, estamos constantemente expandiendo el personal de desarrollo, las vacantes relevantes se pueden encontrar en nuestra página de carrera .

UPD 1. Solución de lectores 1


Esta solución propone no sincronizar solicitudes, pero en caso de un error de la forma:
violación de restricción de integridad: restricción única o violación de índice; Tabla UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENTE

debe ser procesado y retirado
 client = clientRepository.findByClientId(clientId); 

o hacerlo a través de reintento de primavera:
 @Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000)) @Transactional public Client getOrCreateUser(String clientId) 

(gracias a Throwable por un ejemplo )
En este caso, habrá consultas "adicionales" a la base de datos, pero en la práctica la creación de la entidad del Cliente no ocurrirá con frecuencia, y si la sincronización es necesaria solo para resolver el problema de inserción en la base de datos, entonces se puede prescindir de esta solución.

UPD 2. Solución de lectores 2


Esta solución propone realizar la sincronización a través de la sesión:
 HttpSession session = request.getSession(false); if (session != null) { Object mutex = WebUtils.getSessionMutex(session); synchronized (mutex) { ... } } 

Esta solución funcionará para servicios de instancia única, pero será necesario resolver el problema para que todas las solicitudes de un cliente a la API se lleven a cabo dentro de la misma sesión.

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


All Articles