Pular para o conteúdo
Quiz
← Início

Transações

100 questões

Certas
0
Erradas
0
Não respondidas
100
Dificuldade
Número de questões

100 questões neste filtro

Resumo teórico

Imagine que você dá 10 moedas do seu cofrinho para um amigo. Duas coisas precisam acontecer: o seu cofrinho perde 10 moedas e o cofrinho do seu amigo ganha 10 moedas. E se a luz acabar bem no meio? Suas moedas sumiriam e seu amigo não receberia nada. Ninguém quer isso. Uma transação é um jeito de dizer: 'faça todos estes passos juntos, ou não faça nenhum'.

As transações vivem dentro de um . Toda vez que você paga numa loja online, reserva um lugar no cinema ou manda dinheiro pelo celular, uma transação protege os dados. Elas resolvem três problemas de uma vez: uma falha no meio do trabalho, muitas pessoas mexendo nos mesmos dados ao mesmo tempo e vários computadores que precisam concordar.

Neste resumo você vai aprender: quais são as quatro promessas do ACID, o que pode dar errado quando muita gente divide os mesmos dados (as anomalias) e como os níveis de isolamento controlam isso, como os bloqueios e as várias versões de uma linha (MVCC) mantêm a ordem, como os deadlocks acontecem e como sair deles, como o log deixa o banco sobreviver a uma queda, como vários bancos podem fazer commit juntos (two-phase commit), o que fazer quando não dá (saga e outbox), por que repetir um pedido precisa ser seguro (idempotência) e o que o teorema CAP diz sobre cópias dos dados. No final você estará pronto para o quiz.

O panorama: o que é uma transação?

Uma transação é um grupo de operações que o banco trata como uma única unidade de trabalho. Você a começa com BEGIN. E a termina de apenas duas formas: COMMIT (guarda tudo) ou ROLLBACK (joga tudo fora, como se nada tivesse acontecido).

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;
Uma transferência de dinheiro: duas escritas que precisam ficar juntas.

Três inimigos dos seus dados

InimigoExemploQuem nos defende
Uma falha no meio do trabalhoO servidor perde energia depois do débito e antes do créditoAtomicidade e durabilidade, construídas com o log
Muitos usuários ao mesmo tempoDois compradores levam o último item no mesmo milissegundoIsolamento, construído com bloqueios ou versões
Muitos computadoresA loja e o banco são dois sistemas diferentesTwo-phase commit, ou saga e outbox
Assista ao vídeo: Transactions: myths, surprises and opportunities (Martin Kleppmann, Strange Loop), em inglês↑ Voltar ao sumário

ACID: as quatro promessas

ACID é uma palavra feita com quatro primeiras letras. Cada letra é uma promessa que a transação faz. Vamos conhecê-las pensando na transferência entre cofrinhos.

LetraPromessaEm palavras simples
AtomicidadeTodas as operações têm efeito, ou nenhuma temNunca existe trabalho pela metade
ConsistênciaNo momento do COMMIT as regras de integridade são respeitadasO banco vai de um estado válido para outro estado válido
IsolamentoTransações que rodam juntas não atrapalham umas às outrasCada uma trabalha como se estivesse sozinha (na forma mais forte)
DurabilidadeUm resultado confirmado não se perdeDepois do 'COMMIT feito', uma queda não desfaz

Atomicidade: tudo ou nada

Se o servidor cair depois do débito e antes do COMMIT, o banco age como se a transação nunca tivesse existido. Ao reiniciar, ele usa o seu para desfazer o trabalho incompleto. O comando ROLLBACK (sem savepoint) pede a mesma coisa de propósito.

A atomicidade só cobre o que está dentro de uma transação. No não existe BEGIN, então um débito e um crédito enviados como dois comandos são duas transações separadas. Se o programa morrer entre eles, o débito fica e o crédito se perde. Para manter duas escritas juntas, envolva-as em BEGIN e COMMIT.

Savepoints e erros dentro de uma transação

Um savepoint é um marcador de página dentro da transação. O ROLLBACK TO SAVEPOINT desfaz só o trabalho depois do marcador e mantém a transação aberta.

BEGIN;
INSERT INTO logs (msg) VALUES ('a');
SAVEPOINT s1;
INSERT INTO logs (msg) VALUES ('b');
ROLLBACK TO SAVEPOINT s1;   -- desfaz só o 'b'
INSERT INTO logs (msg) VALUES ('c');
COMMIT;                     -- guarda 'a' e 'c'
Depois do COMMIT a tabela tem as linhas a e c.

No PostgreSQL, quando um comando falha dentro de uma transação (por exemplo, uma chave duplicada), a transação inteira entra em estado abortado. Todo comando seguinte é recusado com 'current transaction is aborted', e até um COMMIT apenas desfaz tudo. Se você espera um erro e quer continuar, coloque um savepoint antes do comando arriscado.

Consistência: as regras continuam verdadeiras

Consistência no ACID quer dizer: quando a transação termina, o banco respeita as suas regras de integridade (chaves primárias, CHECK, chaves estrangeiras). Durante a transação as regras podem ser quebradas por um instante, mas não no COMMIT. É a única letra do ACID compartilhada entre o banco e o programa. O banco só conhece as regras que foram declaradas. Se a regra 'a soma de todos os saldos nunca muda' não foi declarada, e uma transação com bug debita 100 e credita 90, o banco confirma sem reclamar. As outras três letras existem para que um programa correto não seja atrapalhado.

Durabilidade: confirmado quer dizer guardado

Quando o banco diz 'COMMIT feito', a informação necessária para refazer a transação já está em armazenamento persistente (normalmente no log). Cada COMMIT custa uma escrita em disco que precisa chegar de verdade ao disco. O PostgreSQL tem uma configuração, synchronous_commit = off, que responde antes dessa escrita. Fica mais rápido, mas você aceita uma pequena janela em que as últimas transações podem se perder numa queda. Atomicidade e consistência não são afetadas, porque o log continua sendo escrito na ordem certa.

Uma armadilha com ORMs

Uma transação pertence a uma conexão. Com um ORM como o Prisma, o objeto recebido pelo callback (tx) é o único que fala por essa conexão.

await prisma.$transaction(async (tx) => {
	const order = await tx.order.create({ data: { total: 100 } });
	await prisma.payment.create({ data: { orderId: order.id } }); // BUG: cliente global
	throw new Error("stock check failed");
});
O pagamento usa o cliente global, então roda em outra conexão e é confirmado sozinho. O erro desfaz o pedido, mas não o pagamento.
↑ Voltar ao sumário

Níveis de isolamento e anomalias

Imagine dois cozinheiros que dividem o mesmo cartão de receita. Um risca um número enquanto o outro está lendo. Se eles não combinarem regras, o bolo dá errado. Isolamento é o conjunto de regras que decide quanto duas transações que rodam ao mesmo tempo podem enxergar do trabalho uma da outra. Um erro causado por essa mistura se chama anomalia.

As cinco anomalias

AnomaliaO que dá errado
Dirty read (leitura suja)Você lê uma mudança que outra transação não confirmou. Se ela der rollback, você usou um valor que oficialmente nunca existiu
Non-repeatable read (leitura não repetível)Você lê a mesma linha duas vezes e recebe dois valores, porque outra transação confirmou uma mudança no meio
Phantom read (leitura fantasma)Você faz a mesma busca duas vezes e o conjunto de linhas muda, porque outra transação inseriu ou apagou uma linha que combina
Lost update (atualização perdida)Duas transações leem o mesmo valor, cada uma calcula um valor novo no programa e o escreve. A última escrita apaga a outra
Write skewDuas transações leem o mesmo conjunto, decidem e escrevem em linhas diferentes. Cada escrita está certa sozinha, mas juntas quebram uma regra

O padrão SQL só nomeia as três primeiras (dirty read, non-repeatable read e phantom). Lost update e write skew ficaram fora da tabela dele. Por isso um banco pode seguir a letra de um nível e mesmo assim não ser serializável.

Os quatro níveis do padrão SQL

O padrão define cada nível pela lista de fenômenos que ele proíbe. Do mais fraco ao mais forte: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE. Um nível mais alto permite menos anomalias e em geral custa mais (espera ou transações abortadas).

'Possível' é uma permissão, não uma obrigação. Um banco pode ser mais rígido.
Nível (padrão SQL)Dirty readNon-repeatable readPhantom
READ UNCOMMITTEDpossívelpossívelpossível
READ COMMITTEDimpossívelpossívelpossível
REPEATABLE READimpossívelimpossívelpossível
SERIALIZABLEimpossívelimpossívelimpossível

O PostgreSQL é mais rígido que o padrão

  • O nível padrão é READ COMMITTED (configuração default_transaction_isolation).
  • READ UNCOMMITTED é aceito, mas funciona exatamente como READ COMMITTED. Uma dirty read nunca acontece, em nenhum nível.
  • REPEATABLE READ é snapshot isolation: a transação enxerga o banco como ele estava no seu primeiro comando. Então também não há phantoms, embora o padrão os permitisse.
  • SERIALIZABLE é serializable snapshot isolation (SSI): snapshots, mais o acompanhamento de quem leu e quem escreveu o quê.

A grande diferença entre os dois primeiros níveis reais é quanto tempo a foto dura. No READ COMMITTED cada comando tira um snapshot novo, então dois SELECT count(*) na mesma transação podem dar respostas diferentes. No REPEATABLE READ um único snapshot serve a transação inteira.

Lost update, passo a passo

tempo  Sessão A                      Sessão B
1      SELECT stock -> 10
2                                    SELECT stock -> 10
3      UPDATE SET stock = 9; COMMIT
4                                    UPDATE SET stock = 9; COMMIT

estoque final = 9   (duas vendas, deveria ser 8)
Ler, calcular no programa, escrever: a última escrita vence e uma venda some.

Isso é possível no READ COMMITTED. No REPEATABLE READ do PostgreSQL, se uma transação tenta mudar uma linha que outra transação mudou e confirmou depois do seu snapshot, ela é abortada com o SQLSTATE 40001 (um código que significa 'não foi possível serializar o acesso'). Não é um bug: é o contrato. O programa deve repetir a transação inteira.

Existe também um truque que evita o problema sem um nível alto. Um único comando como o abaixo faz a verificação e a escrita dentro do banco. Se outra transação segura a linha, o comando espera; quando ela confirma, o WHERE é verificado de novo sobre a nova versão da linha.

UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
Seguro no READ COMMITTED do PostgreSQL: verificar e escrever é um passo só.

Write skew: os médicos de plantão

regra: pelo menos um médico de plantão

tempo  Ana                           Beto
1      count on_call -> 2
2                                    count on_call -> 2
3      UPDATE Ana  on_call = false
4                                    UPDATE Beto on_call = false
5      COMMIT                        COMMIT

resultado: 0 médicos de plantão
Linhas diferentes foram escritas, então não há conflito de escrita com escrita.

O REPEATABLE READ não percebe isso, porque só confere duas escritas na mesma linha. O SERIALIZABLE do PostgreSQL vigia as dependências entre leituras e escritas e aborta uma das duas com 40001. É o único nível que impede o write skew, e de novo o programa precisa repetir.

↑ Voltar ao sumário

Bloqueios: duas fases, pessimista e otimista

Há dois grandes jeitos de impedir que transações se pisem: bloquear os dados para os outros esperarem, ou deixar todo mundo trabalhar e conferir no final. Vamos começar pelos bloqueios.

Bloqueios compartilhados e exclusivos

Quem pede um bloqueio incompatível espera.
BloqueioUsado paraJunto com SJunto com X
S (shared, compartilhado)leituracompatívelprecisa esperar
X (exclusive, exclusivo)escritaprecisa esperarprecisa esperar

Two-phase locking (2PL)

A regra: a vida de uma transação tem uma fase de crescimento, em que ela só adquire bloqueios, e uma fase de encolhimento, em que ela só os libera. Depois da primeira liberação, nenhum bloqueio novo pode ser adquirido. Se toda transação seguir essa regra, a execução concorrente equivale a alguma ordem serial: ela é serializável. Mas o 2PL não evita deadlocks.

bloqueios
segurados
   ^
   |        /\
   |       /  \
   |      /    \
   |     /      \
   |    /        \
   +---+----------+--------> tempo
    crescimento  encolhimento
    (só          (só
    adquire)     libera)
O formato do two-phase locking.

No 2PL básico, um bloqueio de escrita pode ser liberado antes do fim, o que expõe dados ainda não confirmados. O 2PL estrito mantém os bloqueios exclusivos até o COMMIT ou o ROLLBACK. Isso elimina o problema de outras transações dependerem de dados que ainda podem ser desfeitos.

Pessimista e otimista

AbordagemIdeiaO preçoBom quando
PessimistaEspera um conflito. Bloqueia primeiro e faz os outros esperaremEsperar numa filaConflitos são comuns
OtimistaEspera que não haja conflito. Trabalha livre, confere na hora de escrever e rejeita quem chegou atrasadoTrabalho desperdiçado e novas tentativasConflitos são raros

Pessimista em SQL é o SELECT ... FOR UPDATE: a transação lê as linhas e as reserva no mesmo comando, o que fecha a janela entre ler e escrever, onde nascem as lost updates.

O controle otimista costuma usar uma coluna de versão. Você lê a linha e a versão sem bloquear. Ao escrever, exige que a versão ainda seja a mesma e a aumenta.

UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 7;
-- UPDATE 0   <- alguém escreveu antes
Zero linhas afetadas significa conflito. O banco não levanta nenhum erro.

Detalhes que importam no PostgreSQL

  • Bloqueios de linha só fazem fila para quem também quer um bloqueio de linha: UPDATE, DELETE, SELECT FOR UPDATE e FOR SHARE. Um SELECT comum é atendido por versões e não é bloqueado. Por isso o FOR UPDATE só protege se toda transação concorrente também o usar.
  • Envolver leitura, verificação e escrita numa transação não basta no READ COMMITTED, porque uma leitura comum não reserva a linha. É assim que um checkout vende mais estoque do que existe. Três correções: FOR UPDATE, uma coluna de versão, ou SERIALIZABLE com nova tentativa.
  • Com 200 compradores disputando 10 unidades numa só linha, o controle otimista desperdiça trabalho (uma escrita vence por rodada, o resto refaz), enquanto o FOR UPDATE os faz esperar numa fila. No laboratório de overselling deste repositório, o código ingênuo criou de 150 a 180 pedidos para 10 unidades, e toda correção criou exatamente 10.
  • Bloqueios de linha não impedem phantoms. Um phantom é uma linha nova que passa a combinar com a consulta, e ela ainda não existe para ser bloqueada. Para impedi-lo é preciso bloquear a própria condição: predicate locks, bloqueios de intervalo de índice (next-key) ou a tabela inteira.
↑ Voltar ao sumário

MVCC: várias versões de uma linha

Bloqueios fazem leitores e escritores esperarem uns pelos outros. O controle de concorrência multiversão (MVCC) tem uma ideia mais esperta: uma escrita nunca destrói o valor antigo. Ela adiciona uma nova versão da linha. Cada transação enxerga a versão que combina com o seu próprio momento no tempo.

Por que o MVCC é bom

Com bloqueio puro, uma leitura longa segura as escritas e uma escrita segura as leituras. Com MVCC, as leituras são atendidas por versões já confirmadas, então leitores não bloqueiam escritores e escritores não bloqueiam leitores. Mas o conflito entre duas escritas na mesma linha continua: a segunda espera a primeira. Depois, conforme o nível, ela segue sobre a nova versão ou é abortada.

Snapshots

Um snapshot é apenas uma regra de visibilidade: diz quais transações contam como já confirmadas para quem lê. Para cada linha, o leitor recebe a versão mais nova criada por uma dessas transações. Nenhuma cópia física é feita. O snapshot também lembra quem estava rodando quando foi tirado: essas transações são tratadas como não confirmadas para sempre, mesmo que confirmem um instante depois, e o mesmo vale para as que começam depois.

Como o PostgreSQL guarda versões

O PostgreSQL guarda todas as versões na própria tabela. Cada versão tem duas colunas escondidas: xmin, a transação que a criou, e xmax, a transação que a expirou (apagou ou substituiu). Um UPDATE na verdade é: marcar a versão atual como expirada e inserir uma nova.

SELECT ctid, xmin, stock FROM products WHERE id = 1;
--  (0,1) | 740 | 10
UPDATE products SET stock = 9 WHERE id = 1;
SELECT ctid, xmin, stock FROM products WHERE id = 1;
--  (0,2) | 752 | 9
Depois do UPDATE a linha tem um novo xmin e uma nova posição física (ctid): é uma nova versão.

Uma versão é visível para um snapshot quando a transação em xmin está confirmada para ele e a transação em xmax não está. Se o xmax pertence a uma transação ainda em andamento (ou abortada), a versão continua visível. É assim que um DELETE ou UPDATE pendente fica invisível para os outros.

versão da linha: xmin = 100  xmax = 105  stock = 10

transação 100: CONFIRMADA
transação 105: EM ANDAMENTO
transação 110: executa SELECT agora

100 confirmada          -> criada: OK
105 ainda não confirmou -> não expirou para a 110
=> a transação 110 VÊ esta versão
Uma mudança pendente de outra transação não esconde a versão antiga.

O custo: versões mortas e o VACUUM

Cada UPDATE ou DELETE deixa uma versão morta para trás. O VACUUM (normalmente disparado pelo autovacuum) encontra versões que nenhum snapshot ativo consegue ver e libera o espaço delas. Uma versão só pode ser descartada quando ninguém precisa dela. Então uma transação deixada aberta por horas no REPEATABLE READ, sem fazer nada, prende o 'ponto mais antigo necessário' no passado: toda versão criada desde então é mantida. As tabelas e os índices crescem (bloat) e as consultas ficam mais lentas.

Snapshot isolation e seu limite

A snapshot isolation aplica uma regra: entre transações concorrentes que escrevem a mesma linha, só uma pode confirmar. Isso impede a lost update. Mas ela não sabe o que cada transação leu, então não consegue perceber o write skew. Detectar o write skew exige acompanhar as leituras, e é isso que o SERIALIZABLE do PostgreSQL acrescenta.

↑ Voltar ao sumário

Deadlocks

Duas crianças, dois brinquedos. A Ana segura o carrinho e quer a boneca. O Beto segura a boneca e quer o carrinho. Nenhum solta o seu antes de ganhar o outro, então esperam para sempre. Isso é um deadlock: um grupo de transações, cada uma segurando um bloqueio e pedindo um bloqueio que pertence a outra do grupo, formando um ciclo. A espera nunca termina sozinha.

T1 segura o bloqueio A, quer o B
T2 segura o bloqueio B, quer o A

   grafo de espera (wait-for):

   +----+   espera   +----+
   | T1 | ----------> | T2 |
   |    | <---------- |    |
   +----+   espera   +----+

   um ciclo = deadlock
Cada nó é uma transação. Uma seta T1 para T2 significa 'T1 espera um bloqueio que T2 segura'.

O grafo de espera (wait-for graph) é a ferramenta clássica para detectar deadlocks. Enquanto ele não tem ciclo, sempre há alguém que não espera ninguém e consegue terminar. Um ciclo significa espera circular. A cura é escolher uma vítima no ciclo e abortá-la.

O que o PostgreSQL faz

O PostgreSQL não previne deadlocks, ele os detecta. Quando uma espera por bloqueio passa de deadlock_timeout (1 segundo por padrão), o servidor procura um ciclo. Se encontra, aborta uma transação com o erro 40P01 (deadlock detected). O rollback da vítima libera os bloqueios dela e as outras seguem.

Como lidar com deadlocks na sua aplicação

  • Tentar de novo. A vítima é abortada por inteiro, não só o comando que estava esperando. O erro 40P01 é transitório. Capture-o, faça ROLLBACK e rode a transação de novo desde o BEGIN, com um número limitado de tentativas.
  • Bloquear sempre na mesma ordem. Um ciclo só se forma quando as transações pedem os mesmos bloqueios em ordens diferentes. Se toda transferência sempre bloqueia primeiro a conta de menor id, a segunda transação para no primeiro bloqueio e não segura nada de que a primeira precise. É a prevenção mais simples.

Prevenção pela idade: wait-die e wound-wait

Outro jeito é dar a cada transação um timestamp (a sua idade) quando ela começa e permitir esperar em uma direção só. Como todas as esperas vão para o mesmo lado na linha das idades, um ciclo é impossível.

Uma transação reiniciada mantém o timestamp original, então um dia ela vira a mais velha e não passa fome para sempre.
EsquemaA mais velha pede um bloqueio da mais novaA mais nova pede um bloqueio da mais velha
Wait-dieA mais velha esperaA mais nova morre (é abortada)
Wound-waitA mais velha fere a mais nova (a aborta)A mais nova espera
↑ Voltar ao sumário

Log e recuperação: o write-ahead log

Como um banco pode prometer atomicidade e durabilidade se a energia pode acabar a qualquer momento? A resposta é um caderno chamado log. Antes de mudar qualquer coisa importante, o banco anota o que pretende fazer.

A regra do write-ahead log (WAL)

Write-ahead logging: o registro de log de uma mudança precisa chegar ao armazenamento estável (o disco) antes de a página de dados alterada ser escrita. E no COMMIT, o registro de commit precisa estar no disco antes de o servidor dizer ao cliente 'COMMIT feito'. O ponto de commit de uma transação é o momento em que o seu registro de commit está seguro. Dali em diante o resultado é garantido, mesmo que nenhuma página de dados tenha sido escrita ainda.

Por que escrever o log e não as páginas?

Uma transação pode mudar poucos bytes em muitas páginas diferentes. Escrever todas essas páginas no commit significa muitas escritas aleatórias de páginas inteiras. O log transforma isso em uma escrita sequencial só com as mudanças, o que é rápido. As páginas vão para o disco depois, em segundo plano.

Checkpoints

Sem ajuda, depois de uma queda o banco teria de reler o log inteiro. Um checkpoint escreve no disco as páginas que foram alteradas na memória e marca esse ponto no log. Depois de uma queda, só o log depois do último checkpoint é reaplicado. O log mais antigo pode ser reciclado.

O que a recuperação faz depois de uma queda

checkpoint        queda
    |                |
----+----------------+------> tempo
    T1 confirmou     T2 ainda estava ativa

    páginas da T1 podem NÃO estar no disco -> REDO pelo log
    páginas da T2 PODEM estar no disco     -> UNDO pelo log
Dois problemas opostos depois de uma queda, um log para resolver os dois.

As transações se dividem em vencedoras (têm um registro de commit no log) e perdedoras (não têm). O redo reaplica as vencedoras que podem não ter chegado às páginas. O undo remove os efeitos das perdedoras que chegaram. Uma transação que tinha mudado três linhas e não tinha feito COMMIT é desfeita por completo. Assim a atomicidade sobrevive a uma queda.

Queda do sistema contra falha de mídia

O log deve ficar num dispositivo diferente do dos dados. O roll forward é a base da recuperação para um ponto no tempo (point-in-time recovery).
FalhaO que se perdeComo recuperar
Queda do sistema (energia, processo)Só a memória. O disco está bemReaplicar o log desde o último checkpoint
Falha de mídia (disco destruído)Os arquivos de dadosRestaurar o último backup completo e reaplicar o log arquivado desde então (roll forward)

ARIES em poucas linhas

  • Três fases: análise (descobrir o que estava acontecendo na queda), redo, undo.
  • Repetir a história: o redo reaplica o log inteiro, de vencedoras e perdedoras, até o banco ficar idêntico ao instante da queda. Só então o undo percorre o log para trás, desfazendo as perdedoras e escrevendo cada passo como um registro de compensação (CLR). Assim a recuperação continua correta mesmo se for interrompida e recomeçar.
  • LSN: cada registro de log tem um número de sequência (log sequence number), e cada página guarda o LSN da última mudança que recebeu. No redo, um registro só é aplicado se o seu LSN for maior que o da página. Rodar a recuperação duas vezes não aplica nada duas vezes: o redo é idempotente.

Páginas rasgadas e full-page writes

Um registro de log comum diz 'mude este trecho em relação ao conteúdo antigo', então supõe que a página no disco está intacta. Se a queda acontece no meio da escrita de uma página, ela fica parte nova e parte velha (uma torn page, página rasgada) e o redo incremental não funciona. No PostgreSQL, com full_page_writes = on, a primeira mudança numa página depois de um checkpoint escreve a imagem inteira da página no WAL. Isso dá à recuperação um ponto de partida intacto.

↑ Voltar ao sumário

Transações distribuídas e two-phase commit

Dentro de um banco, o log local dá a atomicidade. Mas e se uma transação muda dados em dois bancos diferentes? Cada um poderia confirmar ou falhar por conta própria, e você terminaria com um débito num lugar e nenhum crédito no outro. O two-phase commit (2PC, commit em duas fases) é um protocolo que faz todos concordarem em um só resultado: todos confirmam ou todos abortam.

As duas fases

Coordenador            Participantes A, B, C
    |  PREPARE?  ------>  
    |  <------ sim/não
    |      (fase 1: votação)
    |
    |  todos sim -> COMMIT  ---->
    |  algum não -> ABORT   ---->
    |      (fase 2: decisão)
O coordenador conduz o protocolo. Cada participante tem direito a veto.
  • Fase 1, prepare. O coordenador pergunta a cada participante se ele consegue confirmar. Cada um se prepara e vota sim ou não.
  • Fase 2, decisão. Se todos votaram sim, o coordenador manda todos confirmarem. Se algum votou não (ou não respondeu a tempo), manda todos abortarem.
  • A decisão é unânime: um único 'não' faz tudo abortar, porque só cada participante sabe se a sua parte pode ser confirmada (uma restrição pode ter falhado, por exemplo).

O que significa um 'sim'

Antes de votar, o participante pode abortar quando quiser. Depois de votar sim, ele fez uma promessa: 'eu consigo confirmar, aconteça o que acontecer'. Ele não pode mais confirmar nem abortar sozinho, precisa esperar o coordenador. Para manter a promessa mesmo que caia logo depois, o participante deve forçar a escrita das mudanças e de um registro de prepare no seu log antes de responder sim. Quando volta, ele encontra a transação preparada e pergunta ao coordenador o que foi decidido.

O grande problema: o bloqueio

O 2PC é um protocolo bloqueante. Se todos os participantes votaram sim e o coordenador cai antes de enviar a decisão, os participantes ficam em dúvida (in doubt). Eles não podem escolher sozinhos, então esperam o coordenador voltar e ler a decisão do seu log. Enquanto isso, os dados deles ficam bloqueados. Essa janela de incerteza, com bloqueios mantidos, é o principal custo do 2PC.

Peças do 2PC no PostgreSQL

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
PREPARE TRANSACTION 'tx42';   -- durável, a sessão fica livre
-- depois, de qualquer sessão:
COMMIT PREPARED 'tx42';       -- ou: ROLLBACK PREPARED 'tx42';
O PostgreSQL oferece as peças. Um coordenador externo executa o protocolo.
  • PREPARE TRANSACTION deixa a transação pronta para confirmar e durável. Exige max_prepared_transactions maior que zero (o padrão é zero).
  • Se o coordenador nunca voltar, a transação preparada órfã continua segurando os bloqueios de linha. Ela também continua 'em andamento', então o VACUUM não consegue limpar versões antigas e as tabelas incham.
  • Quem usa o recurso precisa monitorar a view pg_prepared_xacts.
↑ Voltar ao sumário

Saga e outbox

O two-phase commit acopla todos os participantes: se um está fora do ar, ninguém confirma, e a maioria dos brokers de mensagens nem participa dele. Sistemas modernos com muitos serviços pequenos (microsserviços) costumam abrir mão da transação única e construir a segurança com peças menores. As duas peças famosas são a saga e o outbox.

O bug da escrita dupla (dual write)

Seu serviço precisa salvar um pedido no seu banco e anunciar OrderCreated a um . São dois sistemas diferentes e nenhuma transação em volta dos dois. Isso é uma dual write.

await db.insert(orders).values(order);          // 1: COMMIT no banco
// <- queda aqui
await broker.publish("order.created", order);   // 2: publica no broker
Se o processo morrer entre as linhas, o pedido existe e o evento não. O serviço de pagamento nunca fica sabendo.

Inverter a ordem não resolve. Publique primeiro e caia, e um evento anuncia um pedido que nunca existiu, o que costuma ser pior. Nenhuma ordem de duas escritas resolve a falta de atomicidade entre dois sistemas.

O transactional outbox

O truque: transformar duas escritas em dois sistemas em uma escrita em um sistema. O evento vira uma linha numa tabela outbox, inserida na mesma transação local do pedido. O banco passa a garantir 'os dois ou nenhum'.

BEGIN;
INSERT INTO orders (id, total) VALUES (1, 100);
INSERT INTO outbox (event_id, routing_key, payload) VALUES (...);
COMMIT;
Pedido e evento são salvos juntos ou não são salvos.

Um laço separado, o relay, lê as linhas pendentes, publica cada uma no broker, espera a confirmação e marca a linha como enviada. Uma queda só atrasa o evento, não consegue perdê-lo. Mas o relay pode morrer depois de publicar e antes de marcar a linha, e então publica o mesmo evento de novo. Por isso o outbox dá entrega at-least-once (pelo menos uma vez), não exactly-once. O consumidor precisa, portanto, ser idempotente (próximas seções).

A saga

Uma saga divide uma operação de negócio em transações locais, uma por serviço, encadeadas por mensagens. Cada passo realmente confirma no seu banco, então não dá para desfazê-lo com ROLLBACK. Se um passo posterior falha, rodam transações compensatórias: novas operações de negócio inversas, como cancelar, reembolsar ou devolver ao estoque. Elas desfazem o efeito pelo significado e deixam o histórico visível.

Se o cartão é recusado, a falha viaja como mensagem e o serviço de pedidos cancela o pedido. No fim os dois bancos concordam.
PassoServiçoTransação localCompensação
1pedidosCria o pedido como PENDING e emite OrderCreatedCancelar o pedido
2pagamentoCobra o cartão, emite PaymentCompleted ou PaymentFailedReembolsar (se um passo posterior falhar)
3pedidosEm PaymentCompleted marca PAID. Em PaymentFailed marca CANCELLEDnão precisa

Coreografia e orquestração

EstiloQuem conhece o fluxoBomRuim
CoreografiaNinguém. Cada serviço escuta eventos e publica novosOs serviços ficam desacopladosO fluxo fica espalhado, difícil de ver
OrquestraçãoUm orquestrador central envia comandos a cada serviçoFácil de seguir e depurarUm ponto central para manter

O que a saga abre mão: o isolamento

Comparada a uma transação 2PC, a saga perde o isolamento da operação como um todo. Não há bloqueio global, então os efeitos do passo 1 ficam visíveis antes de a saga terminar, e alguém pode agir sobre um estado que será compensado. As defesas são escolhas de projeto: mostrar um estado intermediário explícito como PENDING (um bloqueio semântico), reler antes de agir e escolher com cuidado a ordem dos passos.

↑ Voltar ao sumário

Idempotência e efeitos exactly-once

Você aperta o botão do elevador. Parece que nada acontece, então aperta de novo, e de novo. O elevador vem uma vez só. Apertar o botão muitas vezes tem o mesmo resultado que apertar uma. Isso é idempotência: uma operação é idempotente se fazê-la uma, duas ou dez vezes deixa o sistema no mesmo estado.

Por que precisamos dela: as novas tentativas

Numa rede, quando uma resposta não chega, você não sabe se o pedido se perdeu ou se funcionou e foi a resposta que se perdeu. O seguro é tentar de novo. Isso só é seguro se a operação for idempotente. Até a recuperação do banco depende disso: redo e undo precisam ser repetíveis.

Uma operação não idempotente pode virar idempotente se for ligada a um id único e recusar uma segunda execução com o mesmo id.
ComandoIdempotente?Por quê
UPDATE accounts SET balance = 100 WHERE id = 1SimUma atribuição absoluta: 'o saldo é 100'
UPDATE accounts SET balance = balance - 10 WHERE id = 1NãoRelativa: cada execução subtrai de novo
INSERT INTO orders (...) VALUES (...)NãoCada execução cria mais uma linha

Garantias de entrega

Um ack é o receptor dizendo ao broker 'recebi'. Se ele não chega, o broker envia a mensagem de novo.
GarantiaO que pode acontecerQuando o ack é enviado
At-most-once (no máximo uma vez)Nunca há duplicata, mas uma falha pode perder a mensagemAntes de processar
At-least-once (pelo menos uma vez)Nada se perde, mas uma mensagem pode chegar duas vezesDepois de processar
Exactly-once (exatamente uma vez)Difícil ou impossível em geral (veja abaixo)-

O momento do ack define a garantia. Se o consumidor confirma assim que recebe e depois cai durante o processamento, a mensagem se perdeu (at-most-once). Se confirma depois de processar, uma queda entre o processamento e o ack causa reentrega (at-least-once).

Por que a entrega exactly-once é impossível em geral

Quando a confirmação não chega, quem enviou não sabe dizer se a mensagem se perdeu ou se foi processada e a resposta se perdeu. Reenviar dá at-least-once. Não reenviar dá at-most-once. Não existe uma terceira opção numa rede não confiável. O que é possível é um efeito exactly-once: entrega at-least-once combinada com processamento idempotente.

Duas ferramentas

Chave de idempotência numa API. O cliente gera um valor único, por exemplo no cabeçalho Idempotency-Key, e envia o mesmo em toda tentativa da mesma operação. O servidor guarda a chave junto com o resultado. Uma repetição recebe o resultado guardado e a cobrança acontece uma só vez.

Deduplicação num consumidor. O consumidor guarda o id de cada mensagem numa tabela processed_messages com uma chave única, na mesma transação local do efeito. Assim 'eu processei' e 'o efeito aconteceu' são uma única decisão atômica.

INSERT INTO processed_messages (message_id) VALUES ($1)
ON CONFLICT (message_id) DO NOTHING;
-- 1 linha inserida: primeira vez, faça o trabalho
-- 0 linhas: já vista, pule e só dê o ack
A chave única deixa o banco decidir quem chegou primeiro.
↑ Voltar ao sumário

Teorema CAP e modelos de consistência

Para serem rápidos e seguros contra falhas, os grandes sistemas guardam cópias (réplicas) dos dados em vários computadores chamados nós. As cópias trazem uma pergunta nova: se eu escrevo em um nó, quando os outros nós mostram o valor novo? A resposta depende do modelo de consistência que o sistema oferece.

O teorema CAP

LetraPropriedadeEm palavras simples
Consistency (consistência)Toda leitura enxerga a escrita mais recenteOs clientes não percebem que existem várias cópias
Availability (disponibilidade)Todo pedido a um nó que funciona recebe respostaSem erro e sem espera infinita
Partition tolerance (tolerância a partições)O sistema continua funcionando quando a rede separa grupos de nósOs nós não conseguem conversar por um tempo

O lema 'escolha dois de três' engana. Falhas de rede acontecem querendo você ou não, então não dá para abrir mão da tolerância a partições num sistema distribuído real. O teorema diz só isto: enquanto uma partição dura, é preciso escolher entre responder com dados possivelmente velhos (disponibilidade) ou recusar pedidos (consistência).

   [Nó 1] ---- X ---- [Nó 3]
       \                  /
        \                /
         +----- [Nó 2] -+

partição de rede: o Nó 3 está isolado

sistema CP: o Nó 3 (minoria) responde com erro
sistema AP: todos respondem, os conflitos são resolvidos depois
Um sistema CP aceita operações só do lado que tem a maioria (o quórum).

O PACELC amplia a ideia: se há uma Partição, escolha Disponibilidade (Availability) ou Consistência; Senão (Else, sem partição), escolha Latência ou Consistência. Mesmo num dia bom, ser mais consistente custa tempo de espera.

Quóruns: R + W > N

Considere N cópias. Uma escrita espera W confirmações e uma leitura consulta R nós. Se R + W > N, qualquer conjunto de R nós se sobrepõe a qualquer conjunto de W nós, então toda leitura ouve pelo menos um nó que tem a última escrita confirmada. Se R + W é N ou menos, uma leitura pode cair só em nós que nunca viram a escrita.

R + W para N = 5 nós (precisa ser maior que 5 para haver sobreposição)
R=1, W=1
2 R + W
R=2, W=2
4 R + W
R=2, W=3
5 R + W
R=3, W=3
6 R + W
R=1, W=5
6 R + W

Modelos de consistência, do fraco ao forte

ModeloPromessaExemplo
Consistência eventualUma escrita vai a uma réplica e se espalha em segundo plano. Réplicas diferentes podem responder diferente por um tempo. Se as escritas param, todas convergem para o mesmo estadoUm contador de rede social que atualiza alguns segundos depois
Garantias de sessãoValem para um cliente por vez. Read-your-writes: você nunca deixa de ver as suas próprias mudançasDepois de mudar o nome do perfil, você vê o nome novo
LinearizabilidadeUma garantia de recência: cada operação parece acontecer num instante entre o seu início e o seu fim, então uma leitura depois de uma escrita sempre a vêUm serviço de locks ou uma eleição de líder

O read-your-writes costuma ser feito lendo do primário logo depois de escrever, ou lendo só de réplicas que já aplicaram a posição da sua última escrita.

Linearizabilidade contra serializabilidade

Ter as duas costuma ser chamado de serializabilidade estrita (strict serializability).
PropriedadeSerializabilidadeLinearizabilidade
Tipo de garantiaIsolamento: transações concorrentes equivalem a alguma ordem serialRecência: as operações respeitam o tempo real
Trata deGrupos de operações (transações)Uma única operação em um único objeto
Respeita o relógio?Não, a ordem pode ser qualquerSim
↑ Voltar ao sumário

Juntando tudo

Cada ferramenta deste resumo existe para responder a uma pergunta: 'o que pode dar errado e quem impede?'. Aqui está o mapa.

ProblemaFerramentaOnde funciona
Queda no meio de uma transaçãoWrite-ahead log, redo e undoUm banco
Ler trabalho inacabado dos outrosNíveis de isolamentoUm banco
Dois escritores na mesma linhaBloqueios (pessimista) ou checagem de versão (otimista)Um banco
Leitores e escritores se bloqueandoMVCCUm banco
Espera circularDetecção e nova tentativa, ou uma ordem global de bloqueiosUm banco
Vários bancos precisam concordarTwo-phase commitPoucos sistemas confiáveis, com risco de bloqueio
Serviços e brokers que não entram numa transaçãoSaga e outboxEntre serviços
Pedidos repetidos ou reentreguesChaves de idempotência e deduplicaçãoEntre serviços
Cópias dos dadosQuóruns e um modelo de consistência escolhidoSistemas replicados

Erros comuns

  • Achar que só BEGIN ... COMMIT impede o overselling. No READ COMMITTED uma leitura comum não reserva nada.
  • Achar que READ UNCOMMITTED mostra dirty reads no PostgreSQL. Ele se comporta como READ COMMITTED.
  • Ignorar o erro 40001 ou 40P01. Eles significam 'repita a transação inteira'.
  • Ignorar o número de linhas afetadas no controle otimista.
  • Usar o cliente global em vez do tx dentro de uma transação de ORM.
  • Publicar num broker e confirmar num banco como dois passos separados.
  • Deduplicar mensagens com SELECT e depois INSERT, sem chave única.
  • Misturar o C do ACID com o C do CAP.
↑ Voltar ao sumário