Sincronização de solicitação de cliente no Spring

Hoje, sugiro que você analise uma tarefa prática sobre a corrida de solicitações de clientes que encontrei no MaximTelecom ao desenvolver o back-end para nosso aplicativo móvel MT_FREE.

Na inicialização, o aplicativo cliente envia assincronamente um "pacote" de solicitações para a API. O aplicativo possui o identificador clientId, com base no qual é possível distinguir entre solicitações de um cliente de outro. Para cada solicitação no servidor, um código do formulário é executado:

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

onde a entidade Cliente possui um campo clientId, que deve ser exclusivo e ter uma restrição exclusiva no banco de dados para isso. Como no Spring cada solicitação executará esse código em um thread separado, mesmo que sejam solicitações do mesmo aplicativo cliente, um erro do formulário será exibido:
violação de restrição de integridade: restrição exclusiva ou violação de índice; Tabela UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENT

O erro ocorre por um motivo óbvio: 2 ou mais threads com o mesmo clientId recebem o cliente == entidade nula e começam a criá-lo, após o que recebem um erro ao confirmar.

Desafio:


É necessário sincronizar solicitações de um clientId para que apenas a primeira solicitação conclua a criação da entidade Cliente, e o restante seja bloqueado no momento da criação e receba o objeto que já criou.

Solução 1


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

Essa solução está funcionando, mas é muito cara, pois todas as solicitações (threads) que precisam ser criadas são bloqueadas, mesmo que elas criem Client com clientId diferente e não concorram entre si.

Observe que a combinação de sincronização com a anotação @Transactional

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

o mesmo erro ocorrerá novamente. O motivo é que o monitor (sincronizado) é liberado primeiro e o próximo encadeamento entra na área sincronizada e somente depois que a transação é confirmada pelo primeiro encadeamento no objeto proxy. Para resolver este problema é simples - você precisa que o monitor seja liberado após a confirmação, portanto, sincronizado deve ser chamado acima:

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

Decisão 2


Eu realmente gostaria de usar um design do formulário:

 synchronized (clientId) 

mas o problema é que um novo objeto clientId será criado para cada solicitação, mesmo que seus valores sejam equivalentes; portanto, a sincronização não pode ser executada dessa maneira. Para resolver o problema com diferentes objetos clientId, você precisa usar o 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)); } } } 

Esta solução usa o pool de strings java, respectivamente, solicitações com o clientId equivalente, chamando clientId.intern (), receberão o mesmo objeto. Infelizmente, na prática, esta solução não é aplicável, pois é impossível gerenciar o clientId "rotting", que mais cedo ou mais tarde levará ao OutOfMemory.

Decisão 3


Para usar o ReentrantLock, você precisa de um pool do formulário:

 private final ConcurrentMap<String, ReentrantLock> locks; 

e então:

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

O único problema é o gerenciamento do clientId "obsoleto", que pode ser resolvido usando a implementação não-padrão do ConcurrentMap, que já suporta expirar, por exemplo, tomar cache da goiaba:

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

Decisão 4


As soluções acima sincronizam solicitações em uma única instância. O que fazer se seu serviço estiver girando em N nós e as solicitações puderem ser diferentes ao mesmo tempo? Para essa situação, o uso da biblioteca Redisson é perfeito como solução:

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

A biblioteca resolve o problema de bloqueios distribuídos usando redis como repositório.

Conclusão


A decisão de aplicar certamente depende da escala do problema: as soluções 1 a 3 são bastante adequadas para pequenos serviços de instância única, a solução 4 visa serviços distribuídos. Também é importante observar separadamente que resolver esse problema usando Redisson ou análogos (por exemplo, o Zookeeper clássico) é, obviamente, um caso especial, pois eles são projetados para uma gama muito maior de tarefas para sistemas distribuídos.

No nosso caso, optamos pela solução 4, pois nosso serviço é distribuído e a integração Redisson foi a mais fácil em comparação com os análogos.

Amigos, sugiram nos comentários suas opções para resolver este problema, ficarei muito feliz!
O código fonte dos exemplos está disponível no GitHub .

By the way, estamos constantemente expandindo a equipe de desenvolvimento, vagas relevantes podem ser encontradas em nossa página de carreira .

UPD 1. Solução dos leitores 1


Esta solução propõe não sincronizar solicitações, mas no caso de um erro do formulário:
violação de restrição de integridade: restrição exclusiva ou violação de índice; Tabela UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENT

deve ser processado e recuperado
 client = clientRepository.findByClientId(clientId); 

ou faça isso por meio de nova tentativa de mola:
 @Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000)) @Transactional public Client getOrCreateUser(String clientId) 

(graças a Throwable, por exemplo )
Nesse caso, haverá consultas "extras" no banco de dados, mas, na prática, a criação da entidade Cliente não ocorrerá com frequência, e se a sincronização for necessária apenas para resolver o problema de inserção no banco de dados, essa solução poderá ser dispensada.

UPD 2. Solução dos leitores 2


Esta solução propõe fazer a sincronização através da sessão:
 HttpSession session = request.getSession(false); if (session != null) { Object mutex = WebUtils.getSessionMutex(session); synchronized (mutex) { ... } } 

Essa solução funcionará para serviços de instância única, mas será necessário resolver o problema para que todas as solicitações de um cliente para a API sejam realizadas na mesma sessão.

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


All Articles