Transações ACID multidocumento no MongoDB com Go
Avalie esse Início rápido
Os últimos meses foram uma aventura quando se trata de começar a usar o MongoDB usando a linguagem de programação Go (Golang). Exploramos tudo, desde operações de criação, recuperação, atualização e exclusão (CRUD) até a modelagem de dados e a alteração de fluxos. Para dar um final sólido a esta série, analisaremos um requisito popular de que muitas organizações precisam, e esse requisito são as transações.
Então, por que você deseja transações?
Existem algumas situações em que você pode precisar de atomicidade de leituras e gravações em vários documentos dentro de uma única coleção ou várias coleções. Isso nem sempre é uma necessidade, mas em alguns casos pode ser.
Veja o exemplo a seguir.
Digamos que você queira criar documentos em uma collection que dependam de documentos em outra collection existente. Ou digamos que você tem regras de validação de esquema em vigor em sua collection. No cenário de que você está tentando criar documentos e o documento relacionado não existe ou suas regras de validação de esquema falham, você não deseja que a operação continue. Em vez disso, você provavelmente gostaria de reverter para antes de acontecer.
Existem outras razões pelas quais você pode usar transações, mas você pode usar sua mente para elas.
Neste tutorial, vamos ver o que é necessário para usar transações com Golang e MongoDB. Nosso exemplo dependerá mais da passagem de regras de validação de esquema, mas isso não é uma limitação.
Como continuamos o mesmo tema ao longo da série, acho que seria uma boa ideia relembrar o modelo de dados que usaremos para este exemplo.
Nos últimos tutoriais, exploramos o trabalho com possíveis dados de podcast em várias collections. Por exemplo, nosso modelo de dados Go se parece com o seguinte:
1 type Episode struct { 2 ID primitive.ObjectID `bson:"_id,omitempty"` 3 Podcast primitive.ObjectID `bson:"podcast,omitempty"` 4 Title string `bson:"title,omitempty"` 5 Description string `bson:"description,omitempty"` 6 Duration int32 `bson:"duration,omitempty"` 7 }
Os campos na estrutura de dados são mapeados para campos de documento MongoDB por meio das anotações BSON. Você pode aprender mais sobre como usar essas anotações no tutorial anterior que escrevi sobre o assunto.
Embora tenhamos outras collections, vamos nos concentrar estritamente na collection
episodes
para este exemplo.Em vez de criar um código complicado para este exemplo para demonstrar operações que falham ou devem ser revertidas, Go a validação do esquema para forçar a falha de algumas operações. Vamos supor que nenhum episódio deve ter menos de dois minutos de duração, caso contrário, não é válido. Em vez de implementar isso, podemos usar recursos incorporados ao MongoDB.
Pegue a seguinte lógica de validação de esquema:
1 { 2 "$jsonSchema": { 3 "additionalProperties": true, 4 "properties": { 5 "duration": { 6 "bsonType": "int", 7 "minimum": 2 8 } 9 } 10 } 11 }
A lógica acima seria aplicada usando o MongoDB CLI ou com o Compass, mas estamos basicamente dizendo que nosso esquema para a coleção
episodes
pode conter quaisquer campos em um documento, mas o campoduration
deve ser um número inteiro e deve ser pelo menos dois. Nossa validação de esquema poderia ser mais complexa? Com certeza, mas neste exemplo estamos buscando a simplicidade. Se você quiser saber mais sobre validação de esquema, confira este incrível tutorial sobre o assunto.Agora que sabemos o esquema e o que causará uma falha, podemos começar a implementar um código de transação que confirmará ou reverterá as alterações.
Antes de nos aprofundarmos no início de uma sessão para nossas operações e confirmações de transações, vamos estabelecer um ponto base em nosso projeto. Vamos presumir que seu projeto tenha o seguinte código boilerplate MongoDB com Go:
1 package main 2 3 import ( 4 "context" 5 "fmt" 6 "os" 7 8 "go.mongodb.org/mongo-driver/bson/primitive" 9 "go.mongodb.org/mongo-driver/mongo" 10 "go.mongodb.org/mongo-driver/mongo/options" 11 ) 12 13 // Episode represents the schema for the "Episodes" collection 14 type Episode struct { 15 ID primitive.ObjectID `bson:"_id,omitempty"` 16 Podcast primitive.ObjectID `bson:"podcast,omitempty"` 17 Title string `bson:"title,omitempty"` 18 Description string `bson:"description,omitempty"` 19 Duration int32 `bson:"duration,omitempty"` 20 } 21 22 func main() { 23 client, err := mongo.Connect(context.TODO(), options.Client().ApplyURI(os.Getenv("ATLAS_URI"))) 24 if err != nil { 25 panic(err) 26 } 27 defer client.Disconnect(context.TODO()) 28 29 database := client.Database("quickstart") 30 episodesCollection := database.Collection("episodes") 31 32 database.RunCommand(context.TODO(), bson.D{{"create", "episodes"}}) 33 }
A coleta deve existir antes de trabalhar com transações. Ao usar o
RunCommand
, se a collection já existir, um erro será retornado. Neste exemplo, o erro não é importante para nós, pois só queremos que a collection exista, mesmo que isso implique criá-la.Agora, vamos presumir que você incluiu corretamente o driver MongoDB Go, conforme visto em um tutorial anterior intituladoComo se conectar ao cluster MongoDB com Go.
O objetivo aqui será tentar inserir um documento que esteja em conformidade com a validação do nosso esquema e um documento que não o faça, para que tenhamos uma confirmação que não aconteça.
1 // ... 2 3 func main() { 4 // ... 5 6 wc := writeconcern.New(writeconcern.WMajority()) 7 rc := readconcern.Snapshot() 8 txnOpts := options.Transaction().SetWriteConcern(wc).SetReadConcern(rc) 9 10 session, err := client.StartSession() 11 if err != nil { 12 panic(err) 13 } 14 defer session.EndSession(context.Background()) 15 16 err = mongo.WithSession(context.Background(), session, func(sessionContext mongo.SessionContext) error { 17 if err = session.StartTransaction(txnOpts); err != nil { 18 return err 19 } 20 result, err := episodesCollection.InsertOne( 21 sessionContext, 22 Episode{ 23 Title: "A Transaction Episode for the Ages", 24 Duration: 15, 25 }, 26 ) 27 if err != nil { 28 return err 29 } 30 fmt.Println(result.InsertedID) 31 result, err = episodesCollection.InsertOne( 32 sessionContext, 33 Episode{ 34 Title: "Transactions for All", 35 Duration: 1, 36 }, 37 ) 38 if err != nil { 39 return err 40 } 41 if err = session.CommitTransaction(sessionContext); err != nil { 42 return err 43 } 44 fmt.Println(result.InsertedID) 45 return nil 46 }) 47 if err != nil { 48 if abortErr := session.AbortTransaction(context.Background()); abortErr != nil { 49 panic(abortErr) 50 } 51 panic(err) 52 } 53 }
No código acima, passamos a definir as read e write concerns que nos darão o nível desejado de isolamento em nossa transação. Para saber mais sobre as preocupações de leitura e escrita disponíveis, consulte a documentação.
Depois de definir as opções de transação, iniciamos uma sessão que encapsulará tudo o que queremos fazer com a atomicidade. Depois, iniciamos uma transação que usaremos para confirmar tudo na sessão.
Um
Session
representa uma sessão lógica do MongoDB e pode ser usado para habilitar a consistência causal para um grupo de operações ou para executar operações em uma ACID transaction. Mais informações sobre como elas funcionam no Go podem ser encontradas na documentação.Dentro da sessão, estamos fazendo duas operações
InsertOne
. O primeiro seria bem-sucedido porque não viola nenhuma de nossas regras de validação de esquema. Ele até imprimirá um ID de objeto quando terminar. No entanto, a segunda operação falhará porque leva menos de dois minutos. O CommitTransaction
nunca será bem-sucedido devido ao erro que a segunda operação criou. Quando a funçãoWithSession
retorna o erro que criamos, a transação é anulada usando a funçãoAbortTransaction
. Por esse motivo, nenhuma das operaçõesInsertOne
aparecerá no banco de dados.Iniciar e confirmar transações de dentro de uma sessão lógica não é a única maneira de trabalhar com transações ACID usando Golang e MongoDB. Em vez disso, podemos usar o que pode ser considerado uma API de transações mais conveniente.
Faça os seguintes ajustes em nosso código:
1 // ... 2 3 func main() { 4 // ... 5 6 wc := writeconcern.New(writeconcern.WMajority()) 7 rc := readconcern.Snapshot() 8 txnOpts := options.Transaction().SetWriteConcern(wc).SetReadConcern(rc) 9 10 session, err := client.StartSession() 11 if err != nil { 12 panic(err) 13 } 14 defer session.EndSession(context.Background()) 15 16 callback := func(sessionContext mongo.SessionContext) (interface{}, error) { 17 result, err := episodesCollection.InsertOne( 18 sessionContext, 19 Episode{ 20 Title: "A Transaction Episode for the Ages", 21 Duration: 15, 22 }, 23 ) 24 if err != nil { 25 return nil, err 26 } 27 result, err = episodesCollection.InsertOne( 28 sessionContext, 29 Episode{ 30 Title: "Transactions for All", 31 Duration: 2, 32 }, 33 ) 34 if err != nil { 35 return nil, err 36 } 37 return result, err 38 } 39 40 _, err = session.WithTransaction(context.Background(), callback, txnOpts) 41 if err != nil { 42 panic(err) 43 } 44 }
Em vez de usar
WithSession
, agora estamos usando WithTransaction
, que lida com o início de uma transação, a execução de algum código de aplicativo e, em seguida, a confirmação ou o cancelamento da transação com base no sucesso desse código de aplicativo. Não apenas isso, mas novas tentativas podem ocorrer para erros específicos se determinadas operações falharem.Você acabou de ver como usar transações com o driver Go do MongoDB. Embora neste exemplo tenhamos usado a validação de esquema para determinar se uma operação de confirmação é bem-sucedida ou falha, você pode facilmente aplicar sua própria lógica de aplicativo dentro do escopo da sessão.
Se você quiser acompanhar outros tutoriais da série Introdução ao Golang, veja alguns abaixo:
Como as transações encerram esta série de tutoriais, certifique-se de ficar atento a mais tutoriais que se concentrem em tópicos mais específicos e interessantes que apliquem tudo o que foi ensinado durante os primeiros passos.