Observação importante: As transações ACID de vários documentos agora estão disponíveis no Couchbase. Veja: Transações ACID para aplicativos NoSQL para obter mais informações!
As transações com vários documentos não foram abordadas na postagem anterior desta série: Propriedades ACID e Couchbase. Essa postagem do blog abordou os blocos de construção do ACID que o Couchbase suporta para o único documento. Nesta postagem do blog, usaremos essa base para criar algo como uma transação atômica e distribuída de vários documentos.
Isenção de responsabilidade: o código nesta postagem do blog não é recomendado para produção. Trata-se de um exemplo simplificado que pode O Couchbase pode ser útil para você no estado em que se encontra, mas precisará ser aprimorado e polido antes de estar pronto para produção. A intenção é dar a você uma ideia do que seria necessário para aquelas situações (esperamos que raras) em que você precisa de transações de vários documentos com o Couchbase.
Uma breve recapitulação
Na parte 1, vimos que as propriedades ACID estão de fato disponíveis no Couchbase no nível de documento único. Para casos de uso em que os documentos podem armazenar dados juntos de forma desnormalizada, isso é adequado. Em alguns casos, a desnormalização em um único documento por si só não é suficiente para atender aos requisitos. Para esse pequeno número de casos de uso, talvez você queira considerar o exemplo desta postagem do blog.
Uma nota de advertência: esta postagem do blog é uma início ponto para você. Seu caso de uso, suas necessidades técnicas, os recursos do Couchbase Server e os casos extremos com os quais você se importa variam. Atualmente, não existe uma abordagem única para todos os casos.
Exemplo de transações com vários documentos
Vamos nos concentrar em uma operação simples para manter o código simples. Para casos mais avançados, você pode usar esse código como base e, possivelmente, genérico e adaptá-lo como achar melhor.
Digamos que estejamos trabalhando em um jogo. Esse jogo envolve a criação e a administração de fazendas (parece loucura, eu sei). Suponha que, nesse jogo, você tenha um celeiro que contém um certo número de galinhas. Seu amigo também tem um celeiro com um certo número de galinhas. Em algum momento, você pode querer transferir algumas galinhas do seu galpão para o galpão de um amigo.
Nesse caso, a normalização de dados provavelmente não ajudará. Porque:
- Um único documento contendo todos os celeiros simplesmente não funcionará em um jogo de tamanho significativo.
- Não faz sentido que seu documento do celeiro contenha o documento do celeiro de seu amigo (ou vice-versa).
- O restante da lógica do jogo funciona bem com a atomicidade de um único documento: só a transferência de frango é que é complicada.
Para começar, tudo o que temos são dois documentos de "celeiro" (Grant Barn e Miller Barn):
Esse método que usaremos para transferir galinhas é chamado de "commit de duas fases". Há um total de seis etapas. O código-fonte completo está disponível no GitHub.
Depois de tirar todas as capturas de tela e escrever os exemplos de código, ocorreu-me que as galinhas vivem em galinheiros, não em celeiros? Mas me acompanhe nessa.
0) Documento de transação
A primeira etapa é criar um documento de transação. Esse é um documento que manterá o controle da transação de vários documentos e a estado da transação. Criei um C# Enum
com os possíveis estados usados na transação. Isso será um número quando armazenado no Couchbase, mas você pode usar cadeias de caracteres ou outra representação, se desejar.
1 2 3 4 5 6 7 8 9 |
público enum Estados de transação { Inicial = 0, Pendente = 1, Comprometido = 2, Feito = 3, Cancelamento = 4, Cancelado = 5 } |
Ela começará em um estado "Inicial". Para essa transação, temos um galpão de "origem", um galpão de "destino" e um certo número de galinhas para transferir.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var transação = _bucket.Upsert(novo Documento<Registro de transação> { Id = transactionDocumentKey, Conteúdo = novo Registro de transação { SourceId = fonte.Id, Fonte = fonte.Valor, DestinationId = destino.Id, Destino = destino.Valor, Valor = amountToTransfer, Estado = Estados de transação.Inicial } }); |
Vamos dar uma olhada nos dados novamente. Agora há três documentos. A transação é nova; os documentos do celeiro são os mesmos do início.
1) Mudar para pendente
Em seguida, vamos colocar o documento da transação em um estado "pendente". Veremos mais adiante por que o "estado" de uma transação é importante.
1 |
var transCas = UpdateWithCas<Registro de transação>(transação.Id, x => x.Estado = Estados de transação.Pendente, transação.Documento.Cas); |
Trapaceei um pouco aqui, pois estou usando um UpdateWithCas
função. Farei isso muitas vezes, pois atualizar um documento usando uma operação Cas pode ser um pouco verboso no .NET. Por isso, criei uma pequena função auxiliar:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
privado ulong UpdateWithCas<T>(string documentId, Ação<T> ato, ulong? cas = nulo) { var documento = _bucket.Obter<T>(documentId); var conteúdo = documento.Valor; ato(conteúdo); var resultado = _bucket.Substituir(novo Documento<T> { Cas = cas ?? documento.Cas, Id = documento.Id, Conteúdo = conteúdo }); // OBSERVAÇÃO: poderia colocar retr(ies) aqui // ou lançar uma exceção quando os valores de cas não corresponderem retorno resultado.Documento.Cas; } |
Esse é um método auxiliar importante. Ele usa bloqueio otimista para atualizar um documento, mas não faz nenhuma nova tentativa ou tratamento de erros.
Voltemos aos dados. Ainda temos três documentos, mas o documento de transação "state" foi atualizado.
2) Alterar os documentos
Em seguida, realizaremos de fato as mutações necessárias nos documentos do celeiro. Subtraindo uma galinha do celeiro de origem e adicionando uma galinha ao celeiro de destino. Ao mesmo tempo, vamos "marcar" esses documentos do celeiro com o ID do documento da transação. Mais uma vez, você verá por que isso é importante mais tarde. Também armazenarei os valores Cas dessas mutações, pois eles serão necessários mais tarde ao fazer outras alterações nesses documentos.
1 2 3 |
var fonteCas = UpdateWithCas<Celeiro>(fonte.Id, x => { x.Galinhas -= amountToTransfer; x.Transação = transação.Id; }); var destCas = UpdateWithCas<Celeiro>(destino.Id, x => { x.Galinhas += amountToTransfer; x.Transação = transação.Id; }); |
Nesse ponto, o código moveu uma galinha entre celeiros. Observe também a "etiqueta" de transação nos galpões.
3) Mudar para comprometido
Até agora, tudo bem. As mutações foram concluídas; é hora de marcar a transação como "confirmada".
1 |
transCas = UpdateWithCas<Registro de transação>(transação.Id, x => x.Estado = Estados de transação.Comprometido, transCas); |
A única coisa que mudou foi o "estado" da transação.
4) Remover as tags de transação
Agora que a transação de vários documentos está em um estado "confirmado", os celeiros não precisam mais saber que fazem parte de uma transação. Remova essas "tags" dos celeiros.
1 2 |
UpdateWithCas<Celeiro>(fonte.Id, x => { x.Transação = nulo; }, fonteCas); UpdateWithCas<Celeiro>(destino.Id, x => { x.Transação = nulo; }, destCas); |
Agora os celeiros estão livres da transação.
5) A transação está concluída
A última etapa é alterar o estado da transação para "concluído".
1 |
UpdateWithCas<Registro de transação>(transação.Id, x => x.Estado = Estados de transação.Feito, transCas); |
Se chegamos até aqui, então a transação com vários documentos está concluída. Os galpões têm o número correto de galinhas após a transferência.
Reversão: e se algo der errado?
É perfeitamente possível que algo dê errado durante transações com vários documentos. Esse é o objetivo de uma transação, na verdade. Todas as operações acontecem, ou não acontecem.
Coloquei o código das etapas 1 a 5 acima em um único bloco try/catch. Uma exceção pode ocorrer em qualquer lugar do caminho, mas vamos nos concentrar em dois pontos críticos.
Exceção durante "pending" (pendente) - Como devemos lidar se ocorrer um erro bem no meio da etapa 2? Ou seja, DEPOIS de uma galinha ser subtraída do celeiro de origem, mas ANTES de uma galinha ser adicionada ao celeiro de destino. Se não tratássemos dessa situação, uma galinha desapareceria no éter e os jogadores do jogo ficariam furiosos!
Exceção após a transação ser "confirmada" - A transação tem um estado de "confirmada", mas ocorre um erro antes que as tags de transação não estejam mais nos celeiros. Se não tratássemos disso, poderia parecer para outros processos que os celeiros ainda estão dentro de uma transação. A primeiro a transferência de frangos seria bem-sucedida, mas nenhum outro frango poderia ser transferido.
O código pode lidar com esses problemas dentro do captura
bloco. É aqui que o "estado" da transação entra em jogo (bem como as "tags" da transação).
Exceção durante "pending" (pendente)
Essa é a situação que faria com que as galinhas fossem perdidas e deixaria nossos jogadores irritados. O objetivo é substituir as galinhas perdidas e fazer com que os celeiros voltem ao estado em que estavam antes da transação.
Vamos supor que isso aconteça bem no meio. Para este exemplo, temos uma nova transação: transferir 1 galinha do celeiro Burrows (12 galinhas) para o celeiro White (13 galinhas).
Ocorreu um erro bem no meio. O celeiro de origem tem uma galinha a menos, mas o celeiro de destino não a recebeu.
Aqui estão as três etapas para a recuperação:
1) Cancelar transação
Altere o estado da transação para "cancelando". Mais tarde, mudaremos para "cancelada".
1 |
UpdateWithCas<Registro de transação>(transação.Id, x => x.Estado = Estados de transação.Cancelamento, registro de transação.Cas); |
A única coisa que mudou até agora foi o documento de transação:
2) Reverter alterações
Em seguida, precisamos reverter o estado dos celeiros de volta ao que eram antes. Observe que isso é necessário SOMENTE se o celeiro tiver uma etiqueta de transação. Se não tiver uma etiqueta, sabemos que já está em seu estado pré-transação. Se houver uma etiqueta, remova-a.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
UpdateWithCas<Celeiro>(fonte.Id, x => { se (x.Transação != nulo) { x.Galinhas += registro de transação.Valor.Valor; x.Transação = nulo; } }); UpdateWithCas<Celeiro>(destino.Id, x => { se (x.Transação != nulo) { x.Galinhas -= registro de transação.Valor.Valor; x.Transação = nulo; } }); |
Agora os celeiros voltaram a ser o que eram antes.
3) Transação cancelada
A última coisa a fazer é definir a transação como "cancelada".
1 |
UpdateWithCas<Registro de transação>(transação.Id, x => x.Estado = Estados de transação.Cancelado); |
E agora, a transação foi "cancelada".
Isso preserva o número total de galinhas no jogo. Nesse ponto, você ainda precisa lidar com o erro que causou a necessidade de uma reversão. Você pode tentar novamente, notificar os jogadores, registrar um erro ou todas as opções acima.
Exceção durante o "commit"
A seguir, vamos analisar outro caso: as alterações nos celeiros foram concluídas, mas as tags de transação ainda não foram removidas. Supondo que a lógica do jogo se preocupe com essas tags, talvez não seja possível realizar futuras transações com vários documentos.
A mesma lógica de reversão também lida com essa situação.
Problemas e casos extremos
Esse exemplo simplificado pode ser o ideal para o seu aplicativo, mas há muitos casos extremos a serem considerados.
E se o processo morrer no meio do caminho? Isso significa que o código não chega nem mesmo ao captura
bloqueio. Talvez seja necessário verificar se há transações de vários documentos incompletas na inicialização do aplicativo e executar a recuperação nesse ponto. Ou, possivelmente, ter um processo diferente de watchdog que procure transações incompletas de vários documentos.
E se houver uma leitura durante a transação? Suponha que eu "pegue" os celeiros logo entre suas atualizações. Essa será uma leitura "suja", o que pode ser problemático.
Em que estado está tudo o que resta? De quem é a responsabilidade de concluir/reverter transações pendentes de vários documentos?
O que acontece se o mesmo documento fizer parte de duas transações com vários documentos simultaneamente? Você precisará criar uma lógica para evitar que isso aconteça.
O exemplo contém todo o estado para a reversão. Mas se você quiser mais tipos de transação (talvez queira transferir vacas)? Você precisaria de um identificador de tipo de transação ou precisaria generalizar o código de transação para que pudesse abstrair o "valor" usado nos exemplos acima e, em vez disso, especificar a versão atualizada do documento.
Outros casos extremos. O que acontece se houver um nó em seu cluster que falhe no meio da transação? O que acontece se você não conseguir obter os bloqueios desejados? Por quanto tempo você continua tentando novamente? Como você identifica uma transação com falha (timeouts)? Há muitos e muitos casos extremos com os quais lidar. Você deve testar exaustivamente todas as condições que espera encontrar na produção. E, no final, talvez você queira considerar algum tipo de estratégia de atenuação. Se você detectar um problema ou encontrar um bug, poderá dar alguns frangos de graça para todas as partes envolvidas depois de corrigir o bug.
Outras opções
Nossa equipe de engenharia tem feito experimentos com Transações RAMP no lado do cliente. RAMP (Read Atomic Multi-Partition) é uma forma de garantir a visibilidade atômica em bancos de dados distribuídos. Para obter mais informações, consulte RAMP facilitado por Jon Haddad ou Visibilidade atômica dimensionável com transações RAMP por Peter Bailis.
O exemplo mais maduro criado para o Couchbase é o do Graham Pople usando o Java SDK. Esta também não é uma biblioteca pronta para produção. No entanto, Graham está fazendo algumas coisas interessantes com transações de vários documentos no lado do cliente. Fique ligado!
Outra opção é o software livre Biblioteca NDescribe por Iain Cartledge (que é um Campeão da comunidade Couchbase).
Por fim, dê uma olhada no Padrão Sagaque é especialmente útil para transações de vários documentos entre microsserviços.
Conclusão
Esta postagem do blog falou sobre como usar as primitivas ACID disponíveis no Couchbase para criar um tipo de transação atômica de vários documentos para um banco de dados distribuído. Isso ainda não é um substituto completamente sólido para o ACID, mas é suficiente para o que a grande maioria dos aplicativos modernos baseados em microsserviços precisa. Para a pequena porcentagem de casos de uso que precisam de garantias transacionais adicionais, o Couchbase continuará a inovar ainda mais.
Agradecemos a Mike Goldsmith, Graham Pople e Shivani Gupta, que ajudaram a revisar esta publicação do blog.
Se você estiver ansioso para aproveitar os benefícios de um banco de dados distribuído como o Couchbase, mas ainda tiver dúvidas sobre as transações com vários documentos, entre em contato conosco! Você pode fazer perguntas na seção Fóruns do Couchbase ou pode entrar em contato comigo pelo telefone Twitter @mgroves.
Um dos pontos fortes dos microsserviços é enviar eventos durante as gravações para um tópico e, em seguida, fazer com que outros, como exibições, consumam o evento do tópico. Para garantir que o evento seja enviado ao tópico, uma solução simples, mas eficaz, é gravar o evento no banco de dados (digamos, em uma tabela chamada EventsToBeSent) junto com a raiz agregada, por exemplo, e ter um trabalhador que pesquise essa tabela e envie os eventos (em ordem) para o tópico (Kafka, por exemplo).
Você entende imediatamente que é quase impossível armazenar os eventos no AR, portanto, eles precisam ser armazenados isoladamente. Conseguir isso com o Couchbase, fora da caixa, parece ser impossível, e usar sua abordagem parece ser extremamente baixo.
Outra solução possível é armazenar o evento dentro do documento AR e fazer com que o pesquisador pesquise todos os ARs com SELECT flat_array(ar.events) FROM bucket ar where type in ("AR1", "AR2", "AR3") WHERE count(ar.events) > 0 sort by ar.timeStamp. Mas há três problemas principais com essa abordagem: 1) As consultas são inconsistentes no Couchbase (a menos que você use um truque para obter consistência forte), 2) O desempenho dessa consulta em comparação com o antigo e comprovado RDBMS, 3) O desempenho da remoção dos eventos dos ARs. Em um RDBMS, basta fazer um simples UPDATE EventsToBeSent e SET e.IsSent = true WHERE e.Id in (,,,).