Lidando com concorrência em MongoDB

Esses dias, estava eu assistindo uma palestra sobre como desenhar uma solução escalável de filas e jobs assíncronos utilizando apenas o PostgreSQL, me interessei, pois como pode um banco de dados relacional lidar com tanto contexto? Como isso seria possível no meu contexto? E fui tentar entender o problema e também as possíveis resoluções com MongoDB.

O problema da escala

Quando temos uma aplicação que precisa processar dados com muitos recursos e funcionar com alta disponibilidade precisamos dimensionar e redimensionar nossas máquinas, hoje em dia uma prática que tem sido amplamente adotada por muitos benefícios é a réplica do mesmo programa dividido entre máquinas com um volume menor e com contexto efêmero, a isso chamamos de escala horizontal. Entretanto, uma vez que meu programa agora tem duas, tres, quatro, cem réplicas eu posso ter todas essas entrando em race condition onde cada réplica disputa e atualiza o mesmo registro e toda a minha escala e desempenho vão por água abaixo, assim como, várias réplicas da mesma aplicação escrevendo sequencialmente o mesmo documento.

Como podemos resolver isso

Para resumir bem a história, a resolução está em travar LOCK a linha (row-level locking) para que ninguém possa mais escrever sobre ela, isso nos resolve muita coisa porém não nos ajuda em nada a lidar com a performance. E para solucionar o problema de performance precisamos fazer com que quem esteja consultando pule os documentos que contém LOCK usando algo como SKIP LOCK e podendo agir em concorrência a suas outras réplicas.

Acontece que MongoDB não tem um mecanismo de LOCK para linhas feito a mão como em PostgreSQL como também não tem SKIP LOCK, ao invés disso, ele oferece apenas operações atômicas que podem ser utilizadas para o mesmo objetivo de implementação porém com seus trade-offs e cenários. Vamos falar mais sobre as possibilidades

Método findAndModify

Esse método do MongoDB permite que você faça uma query e modifique o mesmo em uma operação, garantindo que nenhuma outra operação naquele documento acontecerá no mesmo momento em que essa modificação foi requisitada. Ele é útil porém no nosso contexto onde queremos eliminar a race condition entre as réplicas e também permitir a concorrência entre instâncias ele não se aplica.

Utilizando flags

Nessa abordagem estamos falando de algo que vai para o lado "faça você mesmo", onde você pode compor essa ideia e também juntar a outras, para isso você precisa dizer ao documento que ele tem um dono, utilizando de uma flag para identificar quem está operando um LOCK artificial, por exemplo, você tem uma flag lockedBy e você utiliza dela para filtrar quem não tenha ou consultar quem está realizando esse mesmo LOCK.

Para esse cenário você até contempla a concorrência entre as instancias, uma vez que, sempre que você filtrar por lockedBy = null os documentos estarão sem ter quem os processe, entretanto, essa implementação não contempla a resolução da race condition pois ela pode e deve disparar eventos simultâneos que disputem entre si o "primeiro lock".

Optimistic Concurrency Control

Um método/conceito sobre concorrência, também conhecido como optimistic locking que é amplamente usado, que nos ajuda a entender como ter uma solução robusta e de baixo custo. Funciona assim, uma vez lido um documento você guarda a data de ultima atualização dele, passe as suas tarefas com o documento, caso na hora de dar o update dessas tarefas sua data de atualização tenha sido modificada, você não poderá enviar esse update, de maneira prática basta você passar essa mesma data como filtro.

db.collection.update(
  { _id: myId, updatedAt: myLastUpdatedAt },
  { $set: { ...fields } }
);

Essa abordagem não usaria de LOCK efetivo para nenhum documento porém se certificaria que a race condition não afetará o estado do seu banco de dados pois nunca dois inserts para o mesmo documento serão feitos ao mesmo tempo, nesse cenário não lidamos com concorrência, tudo que foi entendido como LOCK é um conflito que deve ser descartado.

Distributed Locking

Uma outra alternativa possível, é também fazer o LOCK via acionamento por instância, implementando uma coleção específica para receber os inserts que determinam que um processo começou a rodar em uma instância, portanto, esse mesmo processo não estará disponível para rodar em nenhuma outra, pois todas as vezes que um novo processo iniciar, você aguardará uma resposta da coleção de locks. Algo que é de fato uma solução bem mais robusta e completa que as outras, mas ao mesmo tempo você pode perder em aproveitamento das suas máquinas. Os riscos de race condition, se tornam baixos uma vez que isso não ocorre mais no documento em questão, mas sim na coleção de locks, já a concorrência acontece por instâncias, isso pode ser bom já que cada parte fica com sua responsabilidade entretanto pode sobrecarregar umas mais do que as outras e ter gargalos mesmo com a aplicação bem escalada horizontalmente.

Qual é a melhor?

Com todos esses recursos apontados a minha conclusão é que não existe melhor, existem várias abordagens, com riscos e defeitos mas também benefícios. Confiabilidade, performance, custo, tudo deve ser posto na balança na hora de lidar com controle de concorrência utilizando MongoDB, ressaltando que nesse contexto, bancos relacionais dão um show a parte com row-level locking nativo.