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 banco de dadosum programa que guarda informações de forma organizada, como um arquivo digital gigante. 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;Três inimigos dos seus dados
| Inimigo | Exemplo | Quem nos defende |
|---|---|---|
| Uma falha no meio do trabalho | O servidor perde energia depois do débito e antes do crédito | Atomicidade e durabilidade, construídas com o log |
| Muitos usuários ao mesmo tempo | Dois compradores levam o último item no mesmo milissegundo | Isolamento, construído com bloqueios ou versões |
| Muitos computadores | A loja e o banco são dois sistemas diferentes | Two-phase commit, ou saga e outbox |
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.
| Letra | Promessa | Em palavras simples |
|---|---|---|
| Atomicidade | Todas as operações têm efeito, ou nenhuma tem | Nunca existe trabalho pela metade |
| Consistência | No momento do COMMIT as regras de integridade são respeitadas | O banco vai de um estado válido para outro estado válido |
| Isolamento | Transações que rodam juntas não atrapalham umas às outras | Cada uma trabalha como se estivesse sozinha (na forma mais forte) |
| Durabilidade | Um resultado confirmado não se perde | Depois 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 logum caderno em que o banco anota cada mudança que faz 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 autocommitum modo em que cada comando SQL é uma transaçãozinha própria, confirmada na hora 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'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");
});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
| Anomalia | O 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 skew | Duas 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).
| Nível (padrão SQL) | Dirty read | Non-repeatable read | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | possível | possível | possível |
| READ COMMITTED | impossível | possível | possível |
| REPEATABLE READ | impossível | impossível | possível |
| SERIALIZABLE | impossível | impossível | impossível |
O PostgreSQL é mais rígido que o padrão
- O nível padrão é
READ COMMITTED(configuraçãodefault_transaction_isolation). READ UNCOMMITTEDé aceito, mas funciona exatamente comoREAD 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)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;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ãoO 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.
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
| Bloqueio | Usado para | Junto com S | Junto com X |
|---|---|---|---|
| S (shared, compartilhado) | leitura | compatível | precisa esperar |
| X (exclusive, exclusivo) | escrita | precisa esperar | precisa 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)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
| Abordagem | Ideia | O preço | Bom quando |
|---|---|---|---|
| Pessimista | Espera um conflito. Bloqueia primeiro e faz os outros esperarem | Esperar numa fila | Conflitos são comuns |
| Otimista | Espera que não haja conflito. Trabalha livre, confere na hora de escrever e rejeita quem chegou atrasado | Trabalho desperdiçado e novas tentativas | Conflitos 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 antesDetalhes que importam no PostgreSQL
- Bloqueios de linha só fazem fila para quem também quer um bloqueio de linha:
UPDATE,DELETE,SELECT FOR UPDATEeFOR SHARE. UmSELECTcomum é atendido por versões e não é bloqueado. Por isso oFOR UPDATEsó 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, ouSERIALIZABLEcom 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 UPDATEos 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.
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 | 9Uma 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ãoO 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.
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 = deadlockO 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
ROLLBACKe rode a transação de novo desde oBEGIN, 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.
| Esquema | A mais velha pede um bloqueio da mais nova | A mais nova pede um bloqueio da mais velha |
|---|---|---|
| Wait-die | A mais velha espera | A mais nova morre (é abortada) |
| Wound-wait | A mais velha fere a mais nova (a aborta) | A mais nova espera |
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 logAs 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
| Falha | O que se perde | Como recuperar |
|---|---|---|
| Queda do sistema (energia, processo) | Só a memória. O disco está bem | Reaplicar o log desde o último checkpoint |
| Falha de mídia (disco destruído) | Os arquivos de dados | Restaurar 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.
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)- 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';PREPARE TRANSACTIONdeixa a transação pronta para confirmar e durável. Exigemax_prepared_transactionsmaior 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
VACUUMnão consegue limpar versões antigas e as tabelas incham. - Quem usa o recurso precisa monitorar a view
pg_prepared_xacts.
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 brokerum correio de mensagens: serviços deixam mensagens nele e outros serviços as retiram. 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 brokerInverter 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;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.
| Passo | Serviço | Transação local | Compensação |
|---|---|---|---|
| 1 | pedidos | Cria o pedido como PENDING e emite OrderCreated | Cancelar o pedido |
| 2 | pagamento | Cobra o cartão, emite PaymentCompleted ou PaymentFailed | Reembolsar (se um passo posterior falhar) |
| 3 | pedidos | Em PaymentCompleted marca PAID. Em PaymentFailed marca CANCELLED | não precisa |
Coreografia e orquestração
| Estilo | Quem conhece o fluxo | Bom | Ruim |
|---|---|---|---|
| Coreografia | Ninguém. Cada serviço escuta eventos e publica novos | Os serviços ficam desacoplados | O fluxo fica espalhado, difícil de ver |
| Orquestração | Um orquestrador central envia comandos a cada serviço | Fácil de seguir e depurar | Um 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.
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.
| Comando | Idempotente? | Por quê |
|---|---|---|
UPDATE accounts SET balance = 100 WHERE id = 1 | Sim | Uma atribuição absoluta: 'o saldo é 100' |
UPDATE accounts SET balance = balance - 10 WHERE id = 1 | Não | Relativa: cada execução subtrai de novo |
INSERT INTO orders (...) VALUES (...) | Não | Cada execução cria mais uma linha |
Garantias de entrega
| Garantia | O que pode acontecer | Quando o ack é enviado |
|---|---|---|
| At-most-once (no máximo uma vez) | Nunca há duplicata, mas uma falha pode perder a mensagem | Antes de processar |
| At-least-once (pelo menos uma vez) | Nada se perde, mas uma mensagem pode chegar duas vezes | Depois 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 ackTeorema 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
| Letra | Propriedade | Em palavras simples |
|---|---|---|
| Consistency (consistência) | Toda leitura enxerga a escrita mais recente | Os clientes não percebem que existem várias cópias |
| Availability (disponibilidade) | Todo pedido a um nó que funciona recebe resposta | Sem erro e sem espera infinita |
| Partition tolerance (tolerância a partições) | O sistema continua funcionando quando a rede separa grupos de nós | Os 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 depoisO 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=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
| Modelo | Promessa | Exemplo |
|---|---|---|
| Consistência eventual | Uma 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 estado | Um contador de rede social que atualiza alguns segundos depois |
| Garantias de sessão | Valem para um cliente por vez. Read-your-writes: você nunca deixa de ver as suas próprias mudanças | Depois de mudar o nome do perfil, você vê o nome novo |
| Linearizabilidade | Uma 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
| Propriedade | Serializabilidade | Linearizabilidade |
|---|---|---|
| Tipo de garantia | Isolamento: transações concorrentes equivalem a alguma ordem serial | Recência: as operações respeitam o tempo real |
| Trata de | Grupos de operações (transações) | Uma única operação em um único objeto |
| Respeita o relógio? | Não, a ordem pode ser qualquer | Sim |
Juntando tudo
Cada ferramenta deste resumo existe para responder a uma pergunta: 'o que pode dar errado e quem impede?'. Aqui está o mapa.
| Problema | Ferramenta | Onde funciona |
|---|---|---|
| Queda no meio de uma transação | Write-ahead log, redo e undo | Um banco |
| Ler trabalho inacabado dos outros | Níveis de isolamento | Um banco |
| Dois escritores na mesma linha | Bloqueios (pessimista) ou checagem de versão (otimista) | Um banco |
| Leitores e escritores se bloqueando | MVCC | Um banco |
| Espera circular | Detecção e nova tentativa, ou uma ordem global de bloqueios | Um banco |
| Vários bancos precisam concordar | Two-phase commit | Poucos sistemas confiáveis, com risco de bloqueio |
| Serviços e brokers que não entram numa transação | Saga e outbox | Entre serviços |
| Pedidos repetidos ou reentregues | Chaves de idempotência e deduplicação | Entre serviços |
| Cópias dos dados | Quóruns e um modelo de consistência escolhido | Sistemas replicados |
Erros comuns
- Achar que só
BEGIN ... COMMITimpede o overselling. NoREAD COMMITTEDuma leitura comum não reserva nada. - Achar que
READ UNCOMMITTEDmostra dirty reads no PostgreSQL. Ele se comporta comoREAD COMMITTED. - Ignorar o erro
40001ou40P01. Eles significam 'repita a transação inteira'. - Ignorar o número de linhas afetadas no controle otimista.
- Usar o cliente global em vez do
txdentro 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.