Design de Aplicativos

Como Implementamos Transações ACID Distribuídas de Vários Documentos no Couchbase

22 MIN DE LEITURA

Transações ACID são essenciais quando você tem requisitos rígidos de consistência de dados em sua aplicação. Os custos de execução de transações em sistemas distribuídos podem rapidamente criar gargalos em grande escala. Neste artigo, forneceremos uma visão geral de alguns dos desafios enfrentados pelos bancos de dados NoSQL e NewSQL. Em seguida, mergulharemos em como o Couchbase implementou um modelo de transação distribuída escalável, sem coordenação central e sem ponto único de falha. Além disso, também darei uma breve visão geral de como é o suporte a transações no N1QL no Couchbase 7.0.

Alguns detalhes menores foram omitidos por simplicidade.

 

Transações relacionais vs NewSQL vs NoSQL

Antes de começar a explicar como o Couchbase implementou suporte a transações, preciso primeiro explicar as características inerentes da atomicidade em bancos de dados relacionais e NoSQL (usando modelos de dados semiestruturados como JSON):

 

Atomicidade em SGBDR

Digamos que você precise salvar um novo usuário no banco de dados. Naturalmente, como ele possui muitas outras tabelas associadas, inserir um usuário também exigirá inserções em várias outras tabelas:

Transactions on Relational

Como o modelo relacional força você a armazenar tudo em “caixas” e dividir seus dados em pequenos pedaços, adicionar um novo usuário deve sempre ser executado dentro de um contexto transacional. Caso contrário, se uma de suas inserções falhar, seu usuário terminará salvo pela metade. Note como um SGBDR depende fortemente de transações, já que as aplicações são muito mais complexas do que quando o modelo relacional foi originalmente concebido na década de setenta.

Felizmente, como esses bancos de dados são projetados para rodar em um único nó, você pode usar um coordenador de transações central para confirmar os dados de uma só vez sem nenhum impacto no desempenho.

 

Atomicidade em NewSQL

Do lado do NewSQL (relacional distribuído), as coisas são um pouco mais complicadas. Como a maioria desses bancos de dados reutiliza o modelo relacional, os dados da sua entidade (ou raiz de agregação) tende a se espalhar por vários nós.

atomicity in newsql

Na imagem acima, se precisarmos carregar o usuário na memória, precisaríamos primeiro obter o usuário do Servidor 1, depois carregar a associação entre usuários e perfis no Servidor 2 e, finalmente, carregar o perfil de destino do Servidor 3. Esta operação simples exige que os dados viajem pelo menos duas vezes pela rede, o que acabará por limitar o seu desempenho de leitura. Em um cenário do mundo real, um usuário tem muito mais tabelas associadas a ele. É por isso que o modelo relacional distribuído ainda não é prático quando você precisa ler/escrever o mais rápido possível.

Você pode tentar minimizar os problemas acima limitando o tamanho do seu cluster, contando fortemente com índices para rastrear todos os relacionamentos, ou por meio de algumas técnicas de sharding para manter todos os dados relacionados no mesmo nó (o que é difícil de implementar na prática). As duas últimas abordagens, mesmo quando bem implementadas, consumirão recursos significativos do banco de dados para serem gerenciadas adequadamente.

As transações ACID em bancos de dados NewSQL exigem mais coordenação do que no NoSQL, já que os dados relacionados a uma entidade são divididos em várias tabelas que podem estar em nós diferentes. O modelo relacional, tal como o utilizamos hoje, requer transações para a maioria das gravações, atualizações e exclusões em cascata. A coordenação extra exigida pela arquitetura NewSQL tem o custo de reduzir a vazão para aplicações que exigem operações de baixa latência.

 

Atomicidade em Bancos de Dados de Documentos

O uso de dados semiestruturados como JSON pode reduzir drasticamente o número de junções entre nós (“cross-node joins”), entregando, portanto, melhor desempenho de leitura/escrita sem a necessidade de depender excessivamente de indexação. Esta foi uma das principais conclusões do Dynamo Paper (publicado pela primeira vez há ~13 anos) que foi o catalisador para criar os bancos de dados NoSQL como os conhecemos hoje.

Outra característica interessante de um modelo de dados semiestruturados é que ele é menos transacional, já que você pode colocar todos os dados do usuário em um único documento:

atomicity nosql

Como você pode ver na imagem acima, as preferências e os papéis do usuário podem facilmente caber dentro de um “Documento de Usuário”, portanto, não há necessidade de uma transação para inserir ou atualizar um usuário, já que a operação é atômica. Nós inserimos o documento ou toda a operação falha. O mesmo é válido para muitos outros casos de uso comuns: carrinhos de compras, produtos, estruturas em árvore e raízes de agregação Em geral.

Na maioria das aplicações que utilizam bancos de dados de documentos, 90% das operações transacionais se enquadram nessa categoria de documento único. Mas… e os outros 10%? Bem, para esses, precisaremos de suporte a transações com múltiplos documentos, que foi adicionado ao Couchbase a partir da versão 6.5 e é o foco principal deste artigo.

Aqui está uma apresentação sobre transações que foi apresentada no Couchbase Connect 2020. Matt Ingenthron oferece uma explicação de quando e por que você pode precisar de transações ACID multi-documento:

Assista à versão completa em https://www.youtube.com/watch?v=2fsZVe2cT3M&ab_channel=Couchbase

 

Transcrição do Vídeo

Clique para ler a transcrição completa.

Vamos falar sobre como isso é aplicado a um exemplo fictício, mas talvez um tanto realista, de um modelo de documento para um sistema Couchbase. Então, nós arrecadamos um dinheiro, e vamos construir um jogo de interpretação de papéis online para múltiplos jogadores em massa (um MMORPG), com jogadores e monstros. Portanto, precisamos de um modelo de dados. 

Nós vamos ter jogadores que lutam contra monstros e, com base nessa luta, vencendo ou perdendo, eles vão. Se eles vencerem, eles vão ganhar uma arma; se perderem, uh, eles perdem alguns pontos de vida para que ninguém possa morrer. Você sempre pode voltar à vida, você sempre pode encontrar outro dia. Mas seus jogadores lutam contra monstros, e nós construímos nossa versão 1.0. Ótimo! Ok, conseguimos nosso financiamento, construímos a 1.0.

O problema é que esquecemos de fazer a parte massivamente multiplayer. Não há jogo colaborativo. Eu não posso ter vários jogadores lutando contra o mesmo monstro. Então eu preciso consertar isso, certo? Então vamos lançar uma nova versão. Assim, os jogadores vão continuar lutando contra monstros e ganhando armas.

Então nós lançamos a nossa versão 2.0, e na versão 2.0 os jogadores podem lutar contra monstros juntos. Eu posso me coordenar com meus amigos, nós podemos ir encontrar um monstro, e podemos matar esse monstro. 

Mas nós deixamos um bug lá. É possível que vários jogadores dessem o golpe fatal e a razão pela qual isso é um problema é que os jogadores do jogo descobrem isso. Em vez de lutarem contra um monstro juntos até a morte, o que eles fazem é, neste mundo massivamente multiplayer, eles lutam contra ele até quase a morte, e então um grupo de jogadores se reúne, e todos dão o golpe fatal ou muitos dão o golpe fatal ao mesmo tempo. 

O problema é que eles ganham itens, ganham múltiplos itens, e como os itens têm raridade, e se você não tiver uma certa quantidade de raridade para um item, a jogabilidade não é muito interessante. Esse bug permitiu que muitos itens existissem no mundo, e os jogadores estão apenas gastando tempo hackeando o jogo, e então eles ficam entediados e saem. Precisamos manter a jogabilidade interessante.

Então vamos pensar sobre isso. Como podemos consertar isso? Acho que o que precisaremos fazer provavelmente é introduzir uma correção. Ainda vamos permitir jogadores e monstros, vários jogadores lutando contra um monstro. Mas o que vamos fazer é usar um daqueles truques que temos, vamos pegar CAS do Couchbase Operações.

Com o Operação CAS O que acontece é que agora vários jogadores estão lutando contra esse monstro, reduzindo seus pontos de vida até que cheguem a zero. Mas apenas um desses jogadores poderá desferir o golpe fatal nesse monstro e ganhar um item.

Então, a forma como isso funciona é que, se dois jogadores estão tentando desferir esse golpe fatal, o aplicativo servidor que está processando a solicitação vai tentar modificar o documento. Ele tem que pegar um pequeno pedaço de informação opaca que chamamos de CAS (que significa Check And Set), e, portanto, isso significa que se esse valor opaco não corresponder, os documentos já foram modificados e, então, você precisa tentar novamente essa operação. No cenário em que dois atores dentro do sistema estão tentando pegar esse documento ao mesmo tempo, o que queremos é que um tenha sucesso e outro falhe, e o CAS nos fornecerá isso de maneira muito eficiente.

Nós tiramos esse truque da cartola, introduzimos operações CAS, o bug é corrigido, a jogabilidade agora fica muito mais interessante e a versão 2.1 se sai muito bem, então isso é ótimo.

Então agora vamos tentar um, hã, queremos seguir em frente, queremos tornar as coisas mais interessantes. Imagine agora que eu introduza outro recurso: “Os jogadores ainda podem lutar contra monstros juntos, mas eles têm que fazer isso fora da cidade. Então você tem que estar do lado de fora da muralha da cidade onde os jogadores estão, e se você for para dentro da cidade, você fará comércio em um bazar”

Isso funciona muito bem no início, mas depois os jogadores descobrem alguma coisa.

Então imagine o jogador1, eu tenho que recuperar o documento para o jogador1. Depois tenho que recuperar o documento para o jogador2. Depois tenho que mover a espada do jogador1 para o jogador2, isso é muito fácil de fazer na lógica da aplicação, e então vou armazenar essa alteração de volta no sistema com a operação CAS, e então vou armazenar a outra alteração de volta, certo? Parece que vai ser ótimo. Exceto que há um bug.

O erro aqui é que meus jogadores podem iniciar uma troca e depois se desconectar, e então os itens que poderíamos querer que fossem raros não serão raros. Eles podem ser duplicados dentro do sistema.

Em jogos de interpretação de papéis online para milhares de jogadores, isso se chama bug de duplicação. Se você for ao Google e pesquisar por um “bug de duplicação”, encontrará vários cenários.

Aqui está um de apenas algumas semanas atrás, onde Final Fantasy Crystal Chronicles no Switch teve que ser corrigido por causa de um bug de duplicação. E depois houve outro apenas alguns dias antes disso, este é de um blog onde um blogueiro de jogos mostrava às pessoas como usar esse glitch de duplicação para recuperar... para conseguir itens adicionais dentro do jogo.

Então nós precisamos corrigir esse bug. Como vamos fazer isso? Bem, então nós vamos recorrer ao nosso arsenal de truques do Couchbase.

Vamos introduzir as transações do Couchbase. A dinâmica do jogo é quase exatamente a mesma. Mas o que vamos fazer com as transações do Couchbase? E este é o único slide onde vou falar um pouco sobre o código.

 

Transações ACID Distribuídas Multi-Documento no Couchbase

 

Agora que você entende como as transações se comportam em diferentes modelos de dados, é hora de mergulhar a fundo em como as implementamos no Couchbase e o que levou às nossas escolhas de design. Primeiro, vamos ver a sintaxe:

O exemplo em Java acima é o exemplo clássico de como transferir dinheiro entre dois clientes. Note que decidimos usar um função lambda para expressar a transação. O tratamento adequado de erros pode ser desafiador nesse cenário, e envolver sua transação com uma função anônima permite que o SDK Java do Couchbase faça esse trabalho para você (ou seja, tentar novamente se algo falhar).

Quando lançamos o suporte a transações pela primeira vez, tentávamos evitar a verbosidade. Era assim que costumava ser a sintaxe de transação de um concorrente:

Ultimamente, parece que lidar com transações dentro de funções lambda está se tornando a norma para bancos de dados NoSQL.

Para aqueles que esperavam que fosse semelhante à sintaxe relacional para transações (por exemplo, comandos SQL BEGIN/COMMIT/ROLLBACK), continuem lendo: vocês também podem executar transações através do N1QL! Agora, vamos tentar entender o que está acontecendo por trás dos panos.

 

Revisão de Arquitetura do Couchbase

Para aqueles que não estão familiarizados com a arquitetura do Couchbase, preciso explicar rapidamente 4 conceitos importantes antes de prosseguir:

  • O Couchbase é altamente escalável, você pode facilmente passar de 1 para 100 nós em um único cluster com esforço mínimo 
  • Os documentos JSON têm um espaço “Meta” chamado xAttr onde você pode armazenar metadados sobre seu documento.
  • Dentro de cada Bucket (semelhante a um schema em RDBMS), o Couchbase distribui automaticamente os dados em 1024 partições chamadas V-Bucks. O sharding é totalmente transparente para o desenvolvedor, e nós também cuidamos da estratégia de sharding. Nosso algoritmo de sharding (CRC32) essencialmente garante que os documentos serão distribuídos uniformemente entre esses vBuckets e nenhum re-sharding será necessário. Os vBuckets são distribuídos uniformemente entre os nós do seu cluster (por exemplo, se você tiver um cluster de 4 nós, cada nó contém 256 vBuckets).

  • The client’s SDK stores a copy of the cluster map, which is a hashmap of vBuckets and the node responsible for them. By hashing the document’s key, the SDK can find in which vBucket the document should be located. And thanks to the cluster map it can talk directly to the node responsible for the document during save/delete/update operations. 

bucket to server mapping

The design choices above allow Couchbase to have a masterless architecture (also referred to as master/master) instead of the traditional master/slave used in other NoSQL databases. There are a number of advantages of this kind of architecture, but the ones relevant for us now are the following:

  • The SDK saves “one network hop” during insert/update/delete operations as it knows where a given document is located (In the master/slave architecture you have to ask the master where the document is).
  • The database itself has no central coordinator, therefore, no single point of failure. In practice, the client acts indirectly as a lightweight coordinator, as it knows exactly which node in the cluster to talk to.

 

Distributed Transactions without a Central Coordinator

 

On Couchbase’s architecture, each client is responsible for the coordination of its own transactions. Naturally, everything is done under-the-hood on the SDK level. To put it simply, if you have 100 instances of your application running transactions, then you have potentially ~100 coordinators. These coordinators add next-to-no overhead to your application, and you will soon understand why.

If we reuse the money transfer example shown in our code example and assume that the 2 documents involved in this transaction live in two different nodes, from a 1,000-foot view the transaction follows these steps:

distribute transaction flow

  1. Each vBucket has a single document responsible for the transaction log called Active Transaction Record (ATR). The ATR can be easily identified by the  _txn:atr- id prefix. Before the first document mutation ( ctx.substituir(userA, userAContent) in this case) a new entry is added in the ATR in the same vBucket with the transaction id and the “Pending” status. Just one ATR is used per transaction.
  2. The transaction Id and the content of the first mutation, ctx.substituir(userA, userAContent), is staged in the xAttrs of the first document (“userA”).
  3. The transaction Id and the content of the second mutation, ctx.substituir(userB, conteúdoDoUsuárioB), is staged in the xAttrs of the second document “userB”.
  4. The transaction is marked as “Committed” in the ATR. We also leverage this call to update the list of document ids involved in the transaction.
  5. Document “userA” is unstaged (removed from xAttrs and replaces the document body)
  6. Document “userB” is unstaged (removed from xAttrs and replaces the document body)
  7. The transaction is marked as “Completed” and removed from the ATR

Note that this implementation is not limited by scopes, collections, or shards (vBuckets). In fact, you can even execute transactions across multiple buckets. As long as you have enough permissions,  any document inside your cluster can be part of a transaction.

At this point, I assume that you have many questions about all the potential failure scenarios. Let’s try to cover the most important topics here. Feel free to leave comments and I will try to update the article accordingly.

Handling Isolation – Monotonic Atomic View

Jepsen has a brilliant graph that explains the most important consistency models for databases:

Consistency Models

Couchbase has support for the Read Committed/Visão Atômica Monotônica” consistency models. But how good is that? Well, Read Committed is the default choice in Postgres, MySQL, MariaDB and many other databases out there; if you never changed that option, that’s what you are using right now.

Read Committed guarantees that your application can’t read uncommitted data, which is what you likely expect from your database, but the interesting part here is how the commit process actually happens. In relational databases, quite often, there is a coordination between the new versions of the rows changed in a transaction to take over their previous ones all at the same time. This is commonly referred to as write-point commit. In order for that to happen, Multiversion Concurrency Control(MVCC) is required. This is problematic because of all the baggage that comes with it, not to mention how expensive it gets (in terms of performance) to be implemented in an ACID distributed database where fast reads/writes are key.

Another disadvantage of write-point commits is that you might spend valuable time synchronizing your commit but … no other thread reads it right after, wasting all effort spent with synchronization. That is when Monotonic Atomic View(MAV) comes into play. It was first described in the Highly Available Transactions: Virtues and Limitations paper and had a great influence on our design.

With MAV we can provide an atomic commit at the read-point instead, which leads to a significant improvement in performance. Let’s see how it works in practice:

 

Repeatable Reads and Monotonic Atomic Views

In our transaction example, there is a fraction of time after Step 4 where we have set the transaction in the ATR as “Committed” but we haven’t unstaged the data of the documents involved in the transaction yet. So what happens if another client tries to read the data during this interval?

transaction failure scenario

Internally, if by any chance the SDK finds a document that has staged content in it, it will also read the ATR to get the transaction state. If the state is “Committed”, it will return the staged version instead. Boom! No need to synchronize writes if you can simply solve it WHEN it happens on read time. 

 

Durability in a Distributed Database

One of the most important jobs of a database is to ensure that what is written stays written. Even in the event of a node failure, no data should be lost. This is achieved in Couchbase through two features: Bucket Replicas and Durability in the SDK.

During your bucket creation you can configure how many replicas (backups) of each document you want (two is the most common choice). This option allows you to lose N number of nodes without implying any potential data loss.

Atomicity-new SQL

Couchbase is configured by default to always take the fastest approach, so as soon as your data arrives on the server, an acknowledgment will be sent back to the client saying that your write was successful and all data replication will be handled under the hood. However, if your server fails before it gets the chance to replicate the data (we are talking about microseconds to a few milliseconds as the replication is made memory-to-memory) you might naturally lose your change. This might be fine for some low-value data, but .. hey! This totally violates the “durability” in ACID. That is why we allow you to specify your durability requirements:

durabilty options

A MAJORITY (default option in the transaction’s library) in the code above means that the mutation must be replicated to (that is, held in the memory allocated to the bucket on) a majority of the Data Service nodes. The other options are: majorityAndPersistActive, e persistToMajority. Please refer to the official documentation on durability requirements to better understand how it works. This feature can also be used outside of a transaction in case you need to pessimistically guarantee that a document has been saved.

 

What happens if something fails during a transaction?

You can configure how long your transaction should last before it is rolled back. The default value is 15 seconds. Within this timeframe, if there are concurrency or node issues, we will use a combination of wait and retry until the transaction reaches this time.

If the client managing the transaction suddenly disconnects, it might leave some staged content on the document’s metadata. However, other clients trying to modify the same document can recognize that the staged content can be overwritten as it is part of a transaction that already expired.

Additionally, the transaction library will periodically run cleanups to remove non-active transactions from the ATRs to keep it as small as possible.

 

Distributed SQL Transactions with N1QL

N1QL is a query language that implements the SQL++ spec,  it is compatible with SQL92 but designed for structured and flexible JSON documents. Learn more in this interactive N1QL tutorial.

The distributed transaction solution that we discussed so far is great for application-level transactions. But, sometimes we need to run ad hoc data changes. Or, due to the number of documents involved in the operation, manipulating them in the application memory becomes an expensive operation (e.g. adding 10 credits to all users’ accounts). In Couchbase Server 7.0, you can run transactions through N1QL with virtually the same SQL syntax as most relational databases:

NoSQL Transactions Vs Relational Transactions

N1QL transactions have already been properly introduced at Couchbase Transactions with N1QL e “Use cases and Best Practices for Distributed Transactions through N1QL”, so I won’t deep dive on this topic. From a thousand-foot view, the transaction is managed by the query service. Since Couchbase is modular, you can increase your transaction throughput by scaling up or out your nodes running the query service.

You can use both the N1QL and lambda transactions together, but using the transaction library only is preferred whenever possible

 

Conclusion: The Best NoSQL for Transactions

Couchbase already has had support for atomic single document operations and Optimistic and Pessimistic Locking for a long time. Last year, we introduced fast transactional support regardless of buckets, collections, scopes, or shards. With Couchbase 7.0 you can even use the same traditional relational transaction syntax. The combination of all these features makes Couchbase the best NoSQL for transactions at scale.

Low Cost – Pay for what you use

The total transaction overhead is simply the number of document mutations + 3 (ATR marked as Pending, Committed, and Completed). For instance, running the money transfer example in a transactional context will cost you up to 5 additional calls to the database. 

The transaction library is a layer on top of the SDK, we added support for transactions with zero impact on performance (something that other players can’t easily claim). This could only be achieved thanks to our solid architecture.

 

No Central Transaction Manager or Central Coordinator 

Given Couchbase’s masterless architecture and the fact that clients are in charge of managing their own transactions, our implementation has no central coordination and no single point of failure. Even during a node failure, transactions that are not touching documents in the faulty server can still be completed successfully. In fact, with an appropriate max transaction time, even if you touch documents in a node that is failing, Couchbase’s Node Failover  can be fast enough to isolate the faulty server and promote a new node before your transaction expires

 

No Internal Global Clock and no MVCC

Some transaction implementations require the concept of a global clock, which can be expensive to maintain without dedicated hardware in a distributed environment. In some cases, it can even bring the database down if the clock skew is higher than ~250 milliseconds. 

With Multiversion Concurrency Control (MVCC) and global clocks, you can technically achieve higher levels of consistency. On the flip side, it will most likely impact the overall performance of the database. Couchbase supports the Read Committed consistency model, which is the same one that relational databases support by default.

 

Flexibility

You can use the durability options inside and outside of a transactional context, use optimistic and pessimistic locking to avoid potential concurrency issues, and use transactions in the SDK and/or via N1QL. There is a lot of flexibility for you to build any kind of application on top of Couchbase and fine-tune the performance according to your business needs.

 

What is next?

Here are a few links for you to learn more about what we just discussed and to get started with Couchbase:

Learn more about transactions https://www.couchbase.com/transactions
Java SDK transactions https://docs.couchbase.com/java-sdk/current/howtos/distributed-acid-transactions-from-the-sdk.html
.NET SDK transactions https://docs.couchbase.com/dotnet-sdk/current/howtos/distributed-acid-transactions-from-the-sdk.html
C++ SDK transactions https://docs.couchbase.com/cxx-txns/current/distributed-acid-transactions-from-the-sdk.html
N1QL Transactions https://www.couchbase.com/blog/couchbase-transactions-with-n1ql/
Download Couchbase https://www.couchbase.com/downloads
What’s New in Couchbase 7.0? https://docs.couchbase.com/server/7.0/introduction/whats-new.html

 

Compartilhe este artigo

Autor

Denis Rosa é um Developer Advocate para o Couchbase e mora em Munique – Alemanha. Ele tem uma sólida experiência como engenheiro de software e fala fluentemente Java, Python, Scala e Javascript. Denis gosta de escrever sobre busca, Big Data, IA, microsserviços e tudo mais que ajude desenvolvedores a criar um aplicativo bonito, mais rápido, estável e escalável.

Uma resposta

Deixe um comentário

Pronto para começar com o Couchbase Capella?

Começar a construir

Confira nosso portal para desenvolvedores para explorar o NoSQL, navegar por recursos e começar com tutoriais.

Use o Capella free

Coloque a mão na massa com o Couchbase em apenas alguns cliques. O Capella DBaaS é a maneira mais fácil e rápida de começar.

Entre em contato

Quer saber mais sobre as ofertas do Couchbase? Deixe-nos ajudar.