Introdução às transações ACID multidocumento em Python
Avalie esse Início rápido
Transações multidocumento chegaram ao MongoDB 4.0 em 2018 junho. O MongoDB sempre foi transacional em torno de atualizações em um único documento. Agora, com transações ACID de vários documentos, podemos encapsular um conjunto de operações de banco de dados dentro de uma chamada de transação de início e confirmação. Isso garante que, mesmo com inserções e/ou atualizações ocorrendo em várias collections e/ou bancos de dados, a visualização externa dos dados atenda às restrições do ACID.
Para demonstrar transações na natureza, usamos um exemplo de aplicativo trivial que emula uma reserva de voo para um aplicativo de companhia aérea on-line. Nesta reserva simplificada precisamos realizar três operações:
- Alocar um assento no
seat_collection
- Pague pelo assento no
payment_collection
- Atualizar a contagem de assentos alocados e vendas no
audit_collection
Para este aplicativo, usaremos três collection separadas para esses documentos, conforme detalhado acima. O código em
transactions_main.py
atualiza essas collection em série, a menos que o --usetxns argument
seja usado. Em seguida, envolvemos o conjunto completo de operações dentro de uma ACID transaction. O código em transactions_main.py
é compilado diretamente usando o driver MongoDB Python (PyMongo 3.7.1).O objetivo deste código é demonstrar aos desenvolvedores de Python como é fácil converter o código existente para transações, se necessário, ou portar sistemas antigos baseados em SQL.
gitignore
: padrão do Github .gitignore para Python.LICENSE
: 2 do Apache. Licença 0 (Github padrão).Makefile
: Makefile com alvos para operações padrão.transaction_main.py
: executa um conjunto de gravações com e sem transações. Execute pythontransactions_main.py -h
para obter ajuda.transactions_retry.py
: o arquivo que contém as funções de repetição das transações.watch_transactions.py
: Use um MongoDB change stream para observar as collection à medida que elas mudam quando transactions_main.py estiver em execução.kill_primary.py
: inicia um conjunto de réplicas do MongoDB (na porta 7100) e elimina o primário regularmente. Isso é usado para emular uma eleição acontecendo no meio de uma transação.featurecompatibility.py
: verifique e/ou defina a compatibilidade de recursos para o banco de dados (ele precisa ser definido como "4.0" para transações).
Você pode clonar este repositório e trabalhar ao nosso lado durante esta publicação no blog (registre qualquer problema na aba Problemas no Github).
O Makefile descreve as operações necessárias para configurar o ambiente de teste.
Todos os programas neste exemplo usam um intervalo de portas que começa em 27100 para garantir que esse exemplo não entre em conflito com uma instalação MongoDB existente.
Para configurar o ambiente, você pode executar as etapas a seguir manualmente. As pessoas que têm
make
podem acelerar a instalação usando o comandomake install
.1 $ cd pymongo-transactions 2 $ virtualenv -p python3 venv 3 $ source venv/bin/activate
1 pip install --upgrade pymongo
mtools é uma coleção de scripts auxiliares para analisar, filtrar e visualizar arquivos de log do MongoDB (mongod, mongos). O mtools também inclui o
mlaunch
, um utilitário para configurar rapidamente ambientes de teste complexos do MongoDB em uma máquina local. Para esta demonstração, usaremos apenas o programamilaunch.1 pip install mtools
1 pip install psutil
O programa
mlaunch
nos fornece um comando simples para iniciar um conjunto de réplicas do MongoDB, pois as transações só são suportadas em um conjunto de réplicas.Inicie um conjunto de réplicas cujo nome seja txntest. Consulte a meta de criação
make init_server
para obter detalhes:1 mlaunch init --port 27100 --replicaset --name "txntest"
Há um
Makefile
com alvos para todas essas operações. Para aqueles que estão em plataformas sem acesso ao Make, deve ser fácil recortar e colar os comandos dos alvos e executá-los na linha de comando.Executando o
Makefile
:1 $ cd pymongo-transactions 2 $ make
Você precisará ter MongoDB 4.0 no seu caminho. Existem outras metas de conveniência para iniciar os programas de demonstração:
make notxns
: inicie o cliente de transações sem usar transactions.make usetxns
: iniciar o cliente de transações com transações habilitadas.make watch_seats
: veja a mudança da coleção de assentos.make watch_payments
: observe a mudança na cobrança de pagamentos.
O exemplo de transações consiste em dois programas python.
transaction_main.py
,watch_transactions.py
.
1 $ python transaction_main.py -h 2 usage: transaction_main.py [-h] [--host HOST] [--usetxns] [--delay DELAY] 3 [--iterations ITERATIONS] 4 [--randdelay RANDDELAY RANDDELAY] 5 6 optional arguments: 7 -h, --help show this help message and exit 8 --host HOST MongoDB URI [default: mongodb://localhost:27100,localh 9 ost:27101,localhost:27102/?replicaSet=txntest&retryWri 10 tes=true] 11 --usetxns Use transactions [default: False] 12 --delay DELAY Delay between two insertion events [default: 1.0] 13 --iterations ITERATIONS 14 Run N iterations. O means run forever 15 --randdelay RANDDELAY RANDDELAY 16 Create a delay set randomly between the two bounds 17 [default: None]
Você pode optar por usar
--delay
ou --randdelay
. Se você usar ambos, o parâmetro --delay terá precedência. O parâmetro--randdelay
cria um atraso aleatório entre um limite inferior e um limite superior que será adicionado entre cada evento de inserção.O programa
transactions_main.py
sabe que deve usar o conjunto de réplicastxntest e o intervalo de portas padrão correto.Para executar o programa sem transações, você pode executá-lo sem argumentos:
1 $ python transaction_main.py 2 using collection: SEATSDB.seats 3 using collection: PAYMENTSDB.payments 4 using collection: AUDITDB.audit 5 Using a fixed delay of 1.0 6 7 1. Booking seat: '1A' 8 1. Sleeping: 1.000 9 1. Paying 330 for seat '1A' 10 2. Booking seat: '2A' 11 2. Sleeping: 1.000 12 2. Paying 450 for seat '2A' 13 3. Booking seat: '3A' 14 3. Sleeping: 1.000 15 3. Paying 490 for seat '3A' 16 4. Booking seat: '4A' 17 4. Sleeping: 1.000
O programa executa uma função chamada
book_seat()
que reserva um assento em um avião adicionando documentos a três coleções. Primeiro, ele adiciona a alocação de assentos ao seats_collection
, depois adiciona um pagamento ao payments_collection
, finalmente atualiza uma contagem de auditoria no audit_collection
. (Este é um processo de reserva muito simplificado usado apenas para ilustração).O padrão é executar o programa sem usar transações. Para usar transações, temos que adicionar o sinalizador de linha de comando
--usetxns
. Execute isto para testar se você está executando MongoDB 4.0 e que a featureCompatibilitycorreta está configurada (deve ser definida como 4.0). Se você instalar o MongoDB 4.0 em um diretório/data
existente contendo 3.6 bancos de dados, então featureCompatibility será definido como 3.6 por padrão e as transações não estarão disponíveis.Observação: se você receber o seguinte erro ao executar o python
transaction_main.py --usetxns
, isso significa que está selecionando uma versão mais antiga do pymongo (anterior a 3.7.x) para a qual não há suporte para transações multidocumento.1 Traceback (most recent call last): 2 File "transaction_main.py", line 175, in 3 total_delay = total_delay + run_transaction_with_retry( booking_functor, session) 4 File "/Users/jdrumgoole/GIT/pymongo-transactions/transaction_retry.py", line 52, in run_transaction_with_retry 5 with session.start_transaction(): 6 AttributeError: 'ClientSession' object has no attribute 'start_transaction'
Para realmente ver o efeito das transações, precisamos observar o que está ocorrendo dentro das collection
SEATSDB.seats
e PAYMENTSDB.payments
.Podemos fazer isso com
watch_transactions.py
. Este roteiro usa MongoDB change stream para ver o que está acontecer dentro de uma collection em tempo real. Precisamos executar dois deles em paralelo, então é melhor alinhá-los lado a lado.Aqui está o programa
watch_transactions.py
:1 $ python watch_transactions.py -h 2 usage: watch_transactions.py [-h] [--host HOST] [--collection COLLECTION] 3 4 optional arguments: 5 -h, --help show this help message and exit 6 --host HOST mongodb URI for connecting to server [default: 7 mongodb://localhost:27100/?replicaSet=txntest] 8 --collection COLLECTION 9 Watch [default: 10 PYTHON_TXNS_EXAMPLE.seats_collection]
Precisamos assistir a cada coleção para que em duas Windows de terminal separadas inicie o observador.
Janela 1:
1 $ python watch_transactions.py --watch seats 2 Watching: seats 3 ...
Janela 2:
1 $ python watch_transactions.py --watch payments 2 Watching: payments 3 ...
Vamos executar o código sem transações primeiro. Se você examinar o código
transaction_main.py
, verá uma função book_seats
.1 def book_seat(seats, payments, audit, seat_no, delay_range, session=None): 2 ''' 3 Run two inserts in sequence. 4 If session is not None we are in a transaction 5 6 :param seats: seats collection 7 :param payments: payments collection 8 :param seat_no: the number of the seat to be booked (defaults to row A) 9 :param delay_range: A tuple indicating a random delay between two ranges or a single float fixed delay 10 :param session: Session object required by a MongoDB transaction 11 :return: the delay_period for this transaction 12 ''' 13 price = random.randrange(200, 500, 10) 14 if type(delay_range) == tuple: 15 delay_period = random.uniform(delay_range[0], delay_range[1]) 16 else: 17 delay_period = delay_range 18 19 # Book Seat 20 seat_str = "{}A".format(seat_no) 21 print(count( i, "Booking seat: '{}'".format(seat_str))) 22 seats.insert_one({"flight_no" : "EI178", 23 "seat" : seat_str, 24 "date" : datetime.datetime.utcnow()}, 25 session=session) 26 print(count( seat_no, "Sleeping: {:02.3f}".format(delay_period))) 27 #pay for seat 28 time.sleep(delay_period) 29 payments.insert_one({"flight_no" : "EI178", 30 "seat" : seat_str, 31 "date" : datetime.datetime.utcnow(), 32 "price" : price}, 33 session=session) 34 audit.update_one({ "audit" : "seats"}, { "$inc" : { "count" : 1}}, upsert=True) 35 print(count(seat_no, "Paying {} for seat '{}'".format(price, seat_str))) 36 37 return delay_period
Este programa emula uma reserva de companhia aérea muito simplificada com um assento sendo alocado e depois pago. Estes são frequentemente separados por um período de tempo razoável (por exemplo, alocação de assentos versus validação externa de cartão de crédito e verificação antifraude) e emulamos isso inserindo um atraso. O padrão é 1 segundo.
Agora, com os dois scripts
watch_transactions.py
em execução, paraseats_collection
e payments_collection
, podemos executar transactions_main.py
da seguinte forma:1 $ python transaction_main.py
A primeira execução é sem transações habilitadas.
A janela inferior mostra
transactions_main.py
em execução. Na parte superior esquerda, estamos observando as inserções na coleção de assentos. Na parte superior direita, estamos observando as inserções na coleção de pagamentos.Podemos ver que a janela de pagamentos fica atrasada em relação à janela de assentos, pois os observadores só atualizam quando a inserção é concluída. Assim, os assentos vendidos não podem ser facilmente conciliados com os pagamentos correspondentes. Se após o terceiro assento ter sido reservado nós CTRL-C o programa, podemos ver que o programa sai antes de escrever o pagamento. Isso é refletido no Change Streams da collection de pagamentos, que mostra apenas os pagamentos para as assentos 1A e 2A, em comparação com as alocações de assentos para 1A, 2A e 3A.
Se quisermos que os pagamentos e assentos sejam instantaneamente reconciliáveis e consistentes, devemos executar as inserções em uma transação.
Agora vamos executar o mesmo sistema com
--usetxns
habilitado.1 $ python transaction_main.py --usetxns
Executamos exatamente a mesma configuração, mas agora definimos
--usetxns
.Observe agora como os change streams estão travados e são atualizados em paralelo. Isso ocorre porque todas as atualizações só se tornam visíveis quando a transação é confirmada. Observe como abortamos a terceira transação pressionando CRTL-C. Agora, nem o assento nem o pagamento aparecem nos fluxos de alteração, ao contrário do primeiro exemplo em que o assento passou.
É nesse ponto que as transações se destacam em um mundo em que tudo ou nada é a palavra de ordem. Nunca queremos manter os assentos alocados, a menos que eles sejam pagos.
Em um conjunto de réplicas do MongoDB, todas as gravações são direcionadas ao nó primário. Se o nó primário falhar ou ficar inacessível (por exemplo, devido a uma partição de rede), as gravações em andamento poderão falhar. Em um cenário não transacional, o driver se recuperará de uma única falha e tentará a gravação novamente. Em uma transação com vários documentos, devemos recuperar e tentar novamente no caso desses tipos de falhas transitórias. Esse código está encapsulado em
transaction_retry.py
. Tentamos novamente a transação e repetimos a confirmação para lidar com cenários em que a primária falha na transação e/ou na operação de confirmação .1 def commit_with_retry(session): 2 while True: 3 try: 4 # Commit uses write concern set at transaction start. 5 session.commit_transaction() 6 print("Transaction committed.") 7 break 8 except (pymongo.errors.ConnectionFailure, pymongo.errors.OperationFailure) as exc: 9 # Can retry commit 10 if exc.has_error_label("UnknownTransactionCommitResult"): 11 print("UnknownTransactionCommitResult, retrying " 12 "commit operation ...") 13 continue 14 else: 15 print("Error during commit ...") 16 raise 17 18 def run_transaction_with_retry(functor, session): 19 assert (isinstance(functor, Transaction_Functor)) 20 while True: 21 try: 22 with session.start_transaction(): 23 result=functor(session) # performs transaction 24 commit_with_retry(session) 25 break 26 except (pymongo.errors.ConnectionFailure, pymongo.errors.OperationFailure) as exc: 27 # If transient error, retry the whole transaction 28 if exc.has_error_label("TransientTransactionError"): 29 print("TransientTransactionError, retrying " 30 "transaction ...") 31 continue 32 else: 33 raise 34 35 return result
Para observar o que acontece durante as eleições, podemos usar o roteiro
kill_primary.py
. Este script iniciará um conjunto de réplicas e matará continuamente o primário.1 $ make kill_primary 2 . venv/bin/activate && python kill_primary.py 3 no nodes started. 4 Current electionTimeoutMillis: 500 5 1. (Re)starting replica-set 6 no nodes started. 7 1. Getting list of mongod processes 8 Process list written to mlaunch.procs 9 1. Getting replica set status 10 1. Killing primary node: 31029 11 1. Sleeping: 1.0 12 2. (Re)starting replica-set 13 launching: "/usr/local/mongodb/bin/mongod" on port 27101 14 2. Getting list of mongod processes 15 Process list written to mlaunch.procs 16 2. Getting replica set status 17 2. Killing primary node: 31045 18 2. Sleeping: 1.0 19 3. (Re)starting replica-set 20 launching: "/usr/local/mongodb/bin/mongod" on port 27102 21 3. Getting list of mongod processes 22 Process list written to mlaunch.procs 23 3. Getting replica set status 24 3. Killing primary node: 31137 25 3. Sleeping: 1.0
kill_primary.py
redefine electionTimeOutMillis para 500ms do padrão de 10000ms (10 segundos). Isso permite que as eleições sejam resolvidas mais rapidamente para os fins deste teste, pois estamos executando tudo localmente.Quando
kill_primary.py
estiver em execução, podemos iniciar transactions_main.py
novamente usando o argumento--usetxns
.1 $ make usetxns 2 . venv/bin/activate && python transaction_main.py --usetxns 3 Forcing collection creation (you can't create collections inside a txn) 4 Collections created 5 using collection: PYTHON_TXNS_EXAMPLE.seats 6 using collection: PYTHON_TXNS_EXAMPLE.payments 7 using collection: PYTHON_TXNS_EXAMPLE.audit 8 Using a fixed delay of 1.0 9 Using transactions 10 11 1. Booking seat: '1A' 12 1. Sleeping: 1.000 13 1. Paying 440 for seat '1A' 14 Transaction committed. 15 2. Booking seat: '2A' 16 2. Sleeping: 1.000 17 2. Paying 330 for seat '2A' 18 Transaction committed. 19 3. Booking seat: '3A' 20 3. Sleeping: 1.000 21 TransientTransactionError, retrying transaction ... 22 3. Booking seat: '3A' 23 3. Sleeping: 1.000 24 3. Paying 240 for seat '3A' 25 Transaction committed. 26 4. Booking seat: '4A' 27 4. Sleeping: 1.000 28 4. Paying 410 for seat '4A' 29 Transaction committed. 30 5. Booking seat: '5A' 31 5. Sleeping: 1.000 32 5. Paying 260 for seat '5A' 33 Transaction committed. 34 6. Booking seat: '6A' 35 6. Sleeping: 1.000 36 TransientTransactionError, retrying transaction ... 37 6. Booking seat: '6A' 38 6. Sleeping: 1.000 39 6. Paying 380 for seat '6A' 40 Transaction committed. 41 ...
Como você pode ver durante as eleições, a transação será abortada e deve ser repetida. Se você olhar para o código
transaction_rety.py
, verá como isso acontece. Se uma operação de gravação encontrar um erro, ela lançará uma das seguintes exceções:Dentro dessas exceções, haverá um rótulo chamado TransientTransactionError. Este rótulo pode ser detectado usando a função
has_error_label(label)
que está disponível no pymongo 3.7.x. Erros transitórios podem ser recuperados e o código de repetição em transactions_retry.py
tem código que tenta novamente tanto para escrita quanto para commit (veja acima).As transações multidocumento são a peça final do quebra-cabeça para desenvolvedores SQL que têm evitado experimentar o MongoDB. As transações ACID facilitam o trabalho do programador e oferecem às equipes que estão migrando de um esquema SQL existente um caminho de transição muito mais consistente e conveniente.
Como a maioria das migrações envolve uma mudança de estruturas de dados altamente normalizadas para documentos JSON aninhados mais naturais e flexíveis, seria de se esperar que o número de transações multidocumento necessárias fosse menor em um aplicativo MongoDB construído corretamente. Mas onde as transações de vários documentos são necessárias, os programadores agora podem incluí-las usando uma sintaxe muito semelhante ao SQL.
Com transações ACID no MongoDB 4.0 agora ele pode ser a primeira opção para uma variedade ainda mais ampla de casos de uso de aplicativos.
Se você ainda não configurou seu cluster gratuito no MongoDB Atlas, agora é um ótimo momento para fazer isso. Você tem todas as instruções nesta publicação no blog.