stateDiagram-v2
[*] --> Ativa
Ativa --> ParcialmenteConfirmada: ultima operacao executada
Ativa --> Falha: erro ou rollback
ParcialmenteConfirmada --> Confirmada: gravacao bem sucedida
ParcialmenteConfirmada --> Falha: falha na gravacao
Falha --> Abortada: reverte mudancas
Confirmada --> [*]
Abortada --> [*]
Transações e controle de concorrência
Vou te conduzir por um dos temas que mais separa quem decorou comandos SQL de quem realmente entende como um banco de dados funciona por dentro: transações e controle de concorrência. Esse assunto cai em prova porque é onde a teoria encontra a engenharia. Um SGBD relacional não atende um usuário de cada vez, em fila — ele atende centenas ou milhares de sessões ao mesmo tempo, todas lendo e escrevendo as mesmas tabelas. Sem um mecanismo rigoroso para coordenar esse acesso simultâneo, o banco rapidamente entregaria dados inconsistentes, somas erradas, saldos negativos onde não deveria haver. Repare comigo ao longo do módulo como cada conceito aqui existe para responder a uma pergunta prática: como deixar muitas transações trabalharem juntas sem que uma corrompa o trabalho da outra. Vou retomar as propriedades ACID que você viu lá no módulo 02, mas agora sob a ótica da execução concorrente, e vou te mostrar os mecanismos concretos — bloqueios, 2PL, MVCC — que os SGBDs usam para entregar essas garantias.
O que é uma transação
Começo pela unidade básica de todo esse raciocínio. Uma transação é uma sequência de uma ou mais operações de banco de dados que o sistema trata como uma única unidade lógica de trabalho, indivisível do ponto de vista lógico. O exemplo clássico, e que eu quero que você guarde, é a transferência bancária: debitar cem reais da conta A e creditar cem reais na conta B. São duas operações de escrita distintas, mas elas formam uma só transação, porque não faz sentido que apenas uma aconteça. Se o sistema cair entre o débito e o crédito, o dinheiro simplesmente evaporaria. A transação existe justamente para que essas duas escritas sejam tratadas como um pacote: ou as duas acontecem, ou nenhuma acontece.
Esse “ou tudo ou nada” é a primeira das propriedades ACID. Vou aproveitar para revisá-las todas, agora pensando especificamente em execução concorrente, que é o foco deste módulo.
Propriedades ACID em revisão. A Atomicidade garante que a transação é indivisível: todas as suas operações são confirmadas, ou todas são desfeitas. A Consistência garante que a transação leva o banco de um estado válido a outro estado válido, respeitando todas as restrições de integridade. O Isolamento garante que transações concorrentes não interfiram umas nas outras — o resultado de executá-las simultaneamente deve ser equivalente a executá-las em alguma ordem serial. A Durabilidade garante que, uma vez confirmada, a transação sobrevive a quedas de energia e falhas de sistema, pois suas mudanças foram gravadas de forma permanente.
Das quatro letras, a que mais nos interessa aqui é o I, de Isolamento. Atomicidade e Durabilidade falam sobre falhas e recuperação; Consistência é o objetivo global. Mas é o Isolamento que diz respeito diretamente à convivência entre transações simultâneas. E você vai perceber, ao longo deste módulo, que isolamento perfeito tem um custo: quanto mais isolada uma transação está das outras, menos paralelismo o sistema consegue extrair, e mais lento ele fica. Todo o tema de controle de concorrência é, no fundo, a busca por um equilíbrio inteligente entre correção e desempenho.
O ciclo de vida de uma transação
Antes de pensarmos em várias transações ao mesmo tempo, quero que você visualize o que acontece com uma transação isolada, do nascimento ao fim. O SGBD modela isso como uma máquina de estados. Uma transação nasce ativa, executando suas operações. Quando termina sua última operação, ela passa a um estado parcialmente confirmada — fez tudo o que precisava, mas o sistema ainda não gravou definitivamente as mudanças em disco. Se essa gravação final dá certo, ela vai para confirmada (committed) e suas mudanças tornam-se permanentes. Se, em qualquer momento, algo impede a continuação normal — uma violação de restrição, uma falha de hardware, um ROLLBACK explícito —, a transação entra em falha e em seguida é abortada, com o banco revertendo tudo o que ela havia feito até ali.
Repare na assimetria deste diagrama: de confirmada não há volta. Esse é o ponto sem retorno garantido pela Durabilidade. Por isso o COMMIT é uma decisão tão séria — uma vez dado, o sistema promete que aquele trabalho está gravado para sempre. Já de qualquer estado anterior à confirmação, ainda é possível abortar e voltar atrás de forma limpa, graças à Atomicidade.
Controlando transações em SQL
Na prática, você delimita uma transação com comandos explícitos. O padrão SQL e o PostgreSQL me dão BEGIN para abrir, COMMIT para confirmar e ROLLBACK para abortar. Vou te mostrar a transferência bancária que descrevi, agora em código.
Se entre os dois UPDATE algo desse errado — digamos que a conta de destino nem existisse —, eu daria ROLLBACK e o débito da primeira conta seria desfeito como se nunca tivesse acontecido. É a Atomicidade trabalhando para mim.
Existe ainda um recurso intermediário que vale conhecer: o savepoint. Ele permite marcar um ponto dentro da transação e, mais tarde, reverter parcialmente até ali sem abortar a transação inteira. Pensa nele como um “checkpoint” dentro da sua unidade de trabalho.
BEGIN;
UPDATE estoque SET quantidade = quantidade - 5 WHERE produto_id = 10;
SAVEPOINT antes_do_brinde;
INSERT INTO itens_pedido (pedido_id, produto_id, brinde) VALUES (99, 77, true);
-- Mudei de ideia sobre o brinde, mas quero manter a baixa de estoque:
ROLLBACK TO SAVEPOINT antes_do_brinde;
COMMIT;Nesse exemplo, o ROLLBACK TO SAVEPOINT desfaz apenas o INSERT do brinde, preservando o UPDATE de estoque que veio antes. Ao final, o COMMIT grava o que sobrou. É um nível de controle fino que aparece em sistemas mais elaborados e que os examinadores gostam de cobrar para separar quem conhece o assunto a fundo.
Os problemas da concorrência
Agora chegamos ao coração do módulo. Imagine duas transações rodando ao mesmo tempo sobre os mesmos dados, sem nenhum controle. O que pode dar errado? A teoria de bancos de dados catalogou quatro fenômenos clássicos, e eu quero que você saiba reconhecer cada um por nome, porque os níveis de isolamento que veremos a seguir são definidos exatamente em termos de quais desses fenômenos eles impedem.
Perda de atualização (lost update). Duas transações leem o mesmo valor, ambas o modificam com base no que leram, e a segunda escrita sobrescreve a primeira como se ela nunca tivesse existido. Exemplo: T1 e T2 leem um estoque de 10 unidades; T1 vende 3 e grava 7; T2, que também tinha lido 10, vende 2 e grava 8. A venda de T1 evaporou — o estoque correto seria 5.
Leitura suja (dirty read). Uma transação lê um dado que outra transação modificou mas ainda não confirmou. Se essa segunda transação acabar dando ROLLBACK, a primeira terá tomado decisões com base em um valor que oficialmente nunca existiu. Você leu “lixo” que depois foi descartado.
Leitura não repetível (non-repeatable read). Uma transação lê uma linha, e mais tarde, dentro da mesma transação, lê a mesma linha de novo e encontra um valor diferente — porque outra transação a alterou e confirmou no meio do caminho. A mesma pergunta, feita duas vezes, devolveu respostas diferentes.
Leitura fantasma (phantom read). Parecida com a anterior, mas em vez de uma linha individual mudar, é o conjunto de linhas que satisfaz uma condição que muda. A transação roda um SELECT ... WHERE preco > 100, recebe cinco linhas; outra transação insere uma sexta linha que também satisfaz a condição e confirma; a primeira transação reexecuta o mesmo SELECT e agora vê seis linhas. Surgiu um “fantasma”.
Quero que você perceba a sutileza entre os dois últimos. A leitura não repetível trata de uma linha que já existia mudando de valor. A leitura fantasma trata de linhas novas aparecendo (ou desaparecendo) dentro do escopo de uma condição de busca. Essa distinção é exatamente o que diferencia dois níveis de isolamento, como você verá adiante. É um ponto que separa as bancas mais exigentes.
Níveis de isolamento
O padrão SQL reconhece que isolamento total custa caro em desempenho. Então, em vez de obrigar todo mundo a pagar o preço máximo, ele oferece quatro níveis de isolamento, cada um permitindo ou proibindo certos fenômenos. Você escolhe o nível conforme o quanto sua aplicação tolera anomalias em troca de mais paralelismo. Vou apresentá-los do mais frouxo ao mais rígido.
No READ UNCOMMITTED, o mais permissivo, a transação pode até ler dados não confirmados de outras — ou seja, ele permite leituras sujas. É o nível de menor isolamento e, na prática, raramente desejável. O READ COMMITTED já garante que você só lê dados confirmados, eliminando leituras sujas, mas ainda permite que valores mudem entre duas leituras suas. O REPEATABLE READ vai além e garante que, se você leu uma linha, ela permanecerá com o mesmo valor até o fim da sua transação, eliminando as leituras não repetíveis. Por fim, o SERIALIZABLE, o nível mais alto, garante que o resultado da execução concorrente seja equivalente a alguma execução serial das transações — como se elas tivessem rodado uma após a outra, em fila. Ele previne todos os fenômenos.
Vou consolidar isso na tabela que você precisa ter na ponta da língua para a prova.
| Nível de isolamento | Leitura suja | Leitura não repetível | Leitura fantasma |
|---|---|---|---|
| READ UNCOMMITTED | Permitida | Permitida | Permitida |
| READ COMMITTED | Previne | Permitida | Permitida |
| REPEATABLE READ | Previne | Previne | Permitida* |
| SERIALIZABLE | Previne | Previne | Previne |
Atenção a uma pegadinha de prova. A tabela acima reflete a definição do padrão SQL, em que o REPEATABLE READ ainda permitiria leituras fantasma. Mas a implementação real varia entre SGBDs. No PostgreSQL, por causa do MVCC que vou explicar adiante, o nível REPEATABLE READ na prática também previne fantasmas, porque a transação enxerga um instantâneo congelado do banco. Por isso marquei a célula com um asterisco. Saiba distinguir o que o padrão exige do que um produto específico entrega — bancas costumam explorar exatamente essa diferença.
Na prática, eu defino o nível de isolamento assim:
A maioria dos sistemas adota READ COMMITTED como nível padrão, e o PostgreSQL é um deles. É o ponto de equilíbrio que atende bem a grande maioria das aplicações: você nunca lê lixo não confirmado, mas paga pouco em desempenho. Quando o seu caso de uso exige garantias mais fortes — relatórios financeiros que precisam de uma fotografia consistente do banco, por exemplo —, você sobe para REPEATABLE READ ou SERIALIZABLE de forma deliberada, ciente do custo.
Mecanismos de controle de concorrência
Definimos o que cada nível garante. Agora vou te mostrar como os SGBDs entregam essas garantias por baixo dos panos. Existem duas grandes famílias de estratégia, e entender a diferença entre elas é o que eu considero o salto de maturidade neste tema.
Controle pessimista: bloqueios
A abordagem pessimista parte do pressuposto de que conflitos vão acontecer, então previne-os de antemão impedindo o acesso concorrente. O instrumento principal é o bloqueio (lock). Antes de acessar um dado, a transação adquire um cadeado sobre ele; enquanto o segura, outras transações que precisem de um cadeado conflitante ficam esperando.
Há dois tipos fundamentais. O bloqueio compartilhado (shared lock), usado para leitura, permite que várias transações leiam o mesmo dado simultaneamente — afinal, leituras não conflitam entre si. O bloqueio exclusivo (exclusive lock), usado para escrita, impede qualquer outro acesso, de leitura ou escrita, enquanto a transação modifica o dado. A regra de compatibilidade é simples: vários compartilhados convivem, mas um exclusivo exige solidão.
graph TD
A[Transacao quer acessar dado] --> B{Tipo de acesso}
B -->|Leitura| C[Pede bloqueio compartilhado]
B -->|Escrita| D[Pede bloqueio exclusivo]
C --> E{Existe bloqueio exclusivo ativo?}
E -->|Nao| F[Concede e le]
E -->|Sim| G[Espera liberacao]
D --> H{Existe qualquer bloqueio ativo?}
H -->|Nao| I[Concede e escreve]
H -->|Sim| G
No PostgreSQL, eu peço um bloqueio explícito de leitura-para-atualização com SELECT ... FOR UPDATE. Isso é típico do padrão “leia, decida, escreva”, em que preciso garantir que ninguém altere a linha entre minha leitura e minha escrita — exatamente o que evita a perda de atualização.
Mas não basta colocar e tirar cadeados a esmo; é preciso uma disciplina sobre quando adquiri-los e liberá-los, para garantir a serializabilidade. Essa disciplina é o bloqueio em duas fases (2PL, two-phase locking). A regra dele é elegante: a transação tem uma fase de crescimento, em que só adquire bloqueios e nunca libera, e depois uma fase de encolhimento, em que só libera e nunca adquire. O pico, o momento em que ela detém o maior número de cadeados, marca a fronteira entre as duas fases.
graph LR
A[Inicio] --> B[Fase de crescimento: so adquire locks]
B --> C[Ponto de bloqueio: pico de locks]
C --> D[Fase de encolhimento: so libera locks]
D --> E[Fim]
Por que duas fases? O 2PL garante a serializabilidade dos escalonamentos. Uma variante muito usada na prática é o 2PL estrito, no qual a transação só libera seus bloqueios exclusivos no momento do COMMIT ou ROLLBACK. Isso evita que outra transação leia um dado que ainda poderia ser revertido, prevenindo as leituras sujas e simplificando a recuperação. Quase todo SGBD que usa bloqueios na prática usa alguma forma de 2PL estrito.
Controle otimista
A abordagem otimista parte do pressuposto oposto: conflitos são raros, então não vale a pena pagar o custo de bloquear tudo o tempo todo. A transação trabalha livremente, sem cadeados, e só no momento do COMMIT o sistema verifica se houve conflito com alguma transação concorrente. Se houve, uma das transações é abortada e precisa tentar de novo. É uma aposta: quando os conflitos realmente são raros, você ganha muito em desempenho ao evitar a contenção de bloqueios; quando são frequentes, você paga em retrabalho com abortos e retentativas.
| Critério | Controle pessimista (locks) | Controle otimista |
|---|---|---|
| Pressuposto | Conflitos são prováveis | Conflitos são raros |
| Momento da verificação | Antes de acessar o dado | No momento do commit |
| Custo principal | Espera por bloqueios, deadlocks | Abortos e retentativas |
| Cenário ideal | Muita disputa pelos mesmos dados | Pouca disputa, muita leitura |
MVCC: o caminho do PostgreSQL
Há ainda uma terceira via, que combina o melhor das anteriores e domina os SGBDs modernos: o controle de concorrência multiversão (MVCC, multiversion concurrency control). A ideia central é genial em sua simplicidade. Em vez de bloquear um dado durante a escrita, o sistema mantém múltiplas versões de cada linha. Quando uma transação atualiza uma linha, ela não sobrescreve a versão antiga: cria uma versão nova, e a antiga continua existindo para quem precisar dela.
A consequência é maravilhosa: leitores nunca bloqueiam escritores, e escritores nunca bloqueiam leitores. Uma transação que está lendo enxerga uma versão consistente da linha — aquela que era válida no instante em que sua consulta ou sua transação começou — enquanto outra transação modifica a linha em paralelo, criando uma versão mais nova. Cada transação vê um snapshot coerente do banco, e ninguém precisa esperar pelo cadeado do outro só para ler.
graph TD
A[Linha conta_id=1, saldo=500] -->|T2 atualiza para 400| B[Nova versao saldo=400, visivel apos commit de T2]
A -->|T1 ja em andamento le| C[T1 enxerga ainda saldo=500 do seu snapshot]
B --> D[Transacoes novas leem saldo=400]
É exatamente assim que o PostgreSQL funciona. Cada linha carrega marcadores internos indicando quais transações a criaram e quais a tornaram obsoleta, e o sistema usa essa informação para decidir qual versão cada transação deve enxergar. Isso explica por que, no PostgreSQL, o nível REPEATABLE READ entrega uma fotografia tão estável: a transação simplesmente continua lendo as versões que existiam quando ela começou, ignorando tudo o que foi confirmado depois. O preço do MVCC é que versões antigas vão se acumulando e precisam ser limpas periodicamente — no PostgreSQL, esse trabalho de faxina é feito por um processo chamado VACUUM. É um detalhe de implementação que vale você conhecer, porque mostra que toda escolha de arquitetura tem um custo em algum lugar.
Deadlocks
Chegamos ao problema mais elegante e mais temido do controle pessimista. Um deadlock (impasse) acontece quando duas ou mais transações ficam presas esperando, cada uma, por um recurso que a outra detém — um abraço mortal em que ninguém consegue avançar. Deixa eu te mostrar o cenário canônico. A transação T1 trava a linha A e quer travar a linha B. A transação T2, ao mesmo tempo, travou a linha B e agora quer travar a linha A. T1 espera por B, que está com T2; T2 espera por A, que está com T1. Nenhuma das duas vai liberar o que tem antes de conseguir o que falta, e o que falta nunca vai chegar. Estão em deadlock.
-- Sessao 1 (T1) -- Sessao 2 (T2)
BEGIN; BEGIN;
UPDATE contas SET saldo=... UPDATE contas SET saldo=...
WHERE conta_id=1; WHERE conta_id=2;
-- (T1 travou a linha 1) -- (T2 travou a linha 2)
UPDATE contas SET saldo=... UPDATE contas SET saldo=...
WHERE conta_id=2; WHERE conta_id=1;
-- T1 espera pela linha 2 -- T2 espera pela linha 1
-- >>> DEADLOCK <<<A forma mais clara de visualizar um deadlock é com um grafo de espera (wait-for graph), em que cada nó é uma transação e cada seta significa “está esperando por”. Quando esse grafo contém um ciclo, há um deadlock. É assim, aliás, que o próprio SGBD detecta o problema.
graph LR
T1 -->|espera linha B detida por| T2
T2 -->|espera linha A detida por| T1
Repare no ciclo: T1 aponta para T2, que aponta de volta para T1. Esse circuito fechado é a assinatura inequívoca de um impasse.
Detecção e resolução. SGBDs modernos, incluindo o PostgreSQL, executam periodicamente um detector que monta o grafo de espera e procura ciclos. Encontrando um, o sistema escolhe uma das transações do ciclo como vítima e a aborta automaticamente, liberando seus bloqueios. As demais transações, então, conseguem prosseguir. A aplicação que teve sua transação escolhida recebe um erro de deadlock e deve, tipicamente, tentar a operação de novo. Ou seja: o deadlock não trava o banco para sempre; ele é detectado e quebrado, ao custo de sacrificar uma transação.
Além de detectar e resolver, é melhor ainda prevenir. As três medidas práticas mais eficazes você precisa saber. A primeira, e mais poderosa, é ordenar consistentemente o acesso aos recursos: se todas as transações sempre travarem a linha de menor id antes da de maior id, o ciclo do grafo de espera se torna impossível de formar. No exemplo acima, o deadlock só ocorreu porque T1 pegou a linha 1 primeiro e T2 pegou a linha 2 primeiro; se ambas seguissem a ordem crescente, T2 esperaria pela linha 1 logo de início e nunca chegaria a travar a linha 2 fora de ordem. A segunda medida é usar timeouts: se uma transação espera demais por um bloqueio, ela desiste e aborta por conta própria. A terceira é manter as transações curtas, segurando bloqueios pelo menor tempo possível, o que reduz a janela em que impasses podem surgir.
Síntese para a prova
O que mais cai e você não pode errar. Uma transação é a unidade lógica indivisível de trabalho, governada pelas propriedades ACID; neste contexto, o Isolamento é a propriedade central, sempre em tensão com o desempenho. Decore os quatro fenômenos de concorrência — perda de atualização, leitura suja, leitura não repetível e leitura fantasma — e saiba distinguir os dois últimos: linha existente que muda de valor versus conjunto de linhas que ganha ou perde membros. Domine a tabela dos quatro níveis de isolamento e exatamente quais fenômenos cada um previne: READ UNCOMMITTED não previne nada, READ COMMITTED corta a leitura suja, REPEATABLE READ corta também a não repetível, e SERIALIZABLE corta tudo. Lembre que READ COMMITTED é o padrão usual, inclusive no PostgreSQL. Nos mecanismos, separe a abordagem pessimista (bloqueios compartilhados e exclusivos, disciplinados pelo 2PL e seu 2PL estrito que só solta locks no commit) da otimista (sem locks, verifica conflito só no commit), e entenda o MVCC como a terceira via que mantém múltiplas versões para que leitores e escritores nunca se bloqueiem — o caminho do PostgreSQL, com seu VACUUM limpando versões antigas. Em SQL, saiba usar BEGIN, COMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTION ISOLATION LEVEL e SELECT ... FOR UPDATE. Por fim, o deadlock: surge de um ciclo no grafo de espera, é detectado automaticamente pelo SGBD, resolvido pelo aborto de uma transação vítima, e prevenido sobretudo pela ordenação consistente de acesso aos recursos.
Se você entendeu por que isolamento e desempenho puxam para lados opostos, e por que o MVCC foi a saída engenhosa que a maioria dos bancos modernos encontrou para esse dilema, você dominou o essencial deste módulo. Esse equilíbrio entre correção e velocidade é o tipo de raciocínio que as melhores provas adoram cobrar — e que faz de você alguém que entende o banco por dentro, não apenas alguém que escreve consultas.