Tecnologias/05 - Comparações6 min
MVCC e Isolamento na Prática
Conceito
6. Transações e ACID define os níveis de isolamento no papel. Esta nota é sobre o que os três bancos fazem de fato — porque nenhum implementa exatamente o que o padrão descreve, e as diferenças aparecem como bug intermitente sob carga.
O que MVCC resolve
Sem MVCC, leitura e escrita competem pelo mesmo lock: um relatório longo trava as escritas, uma escrita trava os relatórios. MVCC (Multi-Version Concurrency Control) resolve dando a cada transação uma visão consistente do passado:
Cada transação lê a versão dos dados que era válida no instante em que sua visão foi criada. Escritores criam versões novas; leitores continuam vendo a antiga.
Daí o slogan: leitores não bloqueiam escritores, escritores não bloqueiam leitores. O custo é guardar as versões antigas em algum lugar — e é aí que os três divergem:
| Onde guarda a versão antiga | Preço | |
|---|---|---|
| PostgreSQL | na própria tabela (tupla morta) | VACUUM, bloat, tabela e índices crescendo |
| MySQL | undo log (segmentos de rollback) | history list crescendo; rollback lento |
| Oracle | undo tablespace | ORA-01555 snapshot too old |
Isolamento: o que cada um faz de verdade
| Nível | PostgreSQL | MySQL (InnoDB) | Oracle |
|---|---|---|---|
READ UNCOMMITTED |
aceita a sintaxe, comporta-se como READ COMMITTED |
implementa (permite dirty read) | não existe |
READ COMMITTED |
padrão | disponível | padrão |
REPEATABLE READ |
snapshot da transação, sem gap lock | padrão, com gap locks | não existe (use SERIALIZABLE ou read-only) |
SERIALIZABLE |
SSI de verdade (detecta e aborta com 40001) |
via next-key locks | snapshot isolation (pode dar ORA-08177) |
Três observações que valem mais que a tabela:
SERIALIZABLEdo Postgres é o mais forte dos três. O SSI (Serializable Snapshot Isolation) realmente detecta anomalias de serialização e aborta a transação — então usarSERIALIZABLEexige lógica de retry no app. Ver Strong Consistency.REPEATABLE READdo MySQL é o padrão e traz gap locks. Locks em intervalos de índice, para evitar leitura fantasma. Eles produzem deadlocks que não existem no Postgres com carga equivalente.- O Oracle não tem
REPEATABLE READ. Onde outro banco usaria esse nível, o padrão Oracle éSELECT ... FOR UPDATEexplícito ou transação read-only.
As anomalias, com nome e sintoma
| Anomalia | O que acontece | Quem previne |
|---|---|---|
| Dirty read | lê dado não confirmado | todos, no padrão (só MySQL permite se você pedir) |
| Non-repeatable read | ler duas vezes dá valores diferentes | REPEATABLE READ+ |
| Phantom read | uma nova linha aparece na mesma faixa | SERIALIZABLE (ou gap lock no MySQL) |
| Lost update | dois UPDATE sobre o mesmo valor, um se perde |
nenhum nível resolve com READ COMMITTED |
| Write skew | duas transações válidas separadas, inválidas juntas | só SERIALIZABLE (SSI no Postgres) |
O lost update é o que mais aparece em código real e o menos discutido:
A: SELECT saldo → 100 B: SELECT saldo → 100
A: saldo = 100 - 30 = 70 B: saldo = 100 - 50 = 50
A: UPDATE saldo = 70 B: UPDATE saldo = 50 ← os 30 sumiram
E a correção não é "aumentar o isolamento". São três padrões concretos, na seção seguinte.
Trade-offs
Isolamento mais forte custa concorrência. SERIALIZABLE no Postgres significa
transações abortando com 40001 sob contenção — e um app que não faz retry passa a
mostrar erro ao usuário. SERIALIZABLE no MySQL significa mais locks, mais espera, mais
deadlock.
Isolamento mais fraco custa correção. READ COMMITTED não protege contra lost
update, e o bug não aparece em teste: ele precisa de duas requisições no mesmo
milissegundo. Ele aparece na Black Friday.
A mesma aplicação, dois bancos, dois comportamentos. Este é o ponto prático da nota:
migrar de MySQL para Postgres muda o isolamento padrão de REPEATABLE READ para
READ COMMITTED sem você mudar uma linha. Relatórios que somavam certo passam a
somar valores de instantes diferentes.
O trade-off central: MVCC não elimina a necessidade de pensar em concorrência — ele troca "esperar no lock" por "trabalhar sobre um passado". Quem não decidir explicitamente qual passado quer ler vai receber o padrão do banco, que muda de banco para banco.
Exemplo prático
Os três padrões que resolvem lost update
1. Bloqueio pessimista — trave a linha antes de ler:
BEGIN;
SELECT saldo FROM contas WHERE id = 1 FOR UPDATE; -- B espera aqui
UPDATE contas SET saldo = saldo - 30 WHERE id = 1;
COMMIT;
Funciona nos três. Simples, correto e serializa a linha quente — ruim se muita gente disputa o mesmo registro.
2. Bloqueio otimista — versione a linha:
UPDATE contas SET saldo = 70, versao = versao + 1
WHERE id = 1 AND versao = 5;
-- se afetou 0 linhas, alguém passou na frente: releia e tente de novo
Nenhum lock, escala melhor, e transfere o retry para o app. É o modelo de quase todo ORM
(@Version) e o que se usa em API HTTP com ETag/If-Match.
3. Deixe o banco calcular — a solução mais simples e a mais esquecida:
UPDATE contas SET saldo = saldo - 30 WHERE id = 1 AND saldo >= 30;
Não há "ler, decidir, escrever": há uma operação atômica com a condição dentro. Se afetou 0 linhas, não havia saldo. Quando dá para expressar assim, é sempre a melhor opção. Ver Idempotent Operations.
Fila de trabalho: SKIP LOCKED nos três
SELECT id FROM jobs WHERE status = 'pendente'
ORDER BY id FOR UPDATE SKIP LOCKED
FETCH FIRST 10 ROWS ONLY; -- Postgres/Oracle; no MySQL use LIMIT 10
Suportado por Postgres 9.5+, MySQL 8.0+ e Oracle 11g+. É a base de Competing Consumers sem broker (1. PostgreSQL).
Como isso falha em produção
| Sintoma | Causa |
|---|---|
| Deadlock só no MySQL | gap locks do REPEATABLE READ em UPDATE ... WHERE faixa |
40001 could not serialize access |
SERIALIZABLE no Postgres sem retry no app |
ORA-01555 snapshot too old |
transação longa no Oracle além da retenção de undo |
| Tabela de 10 GB com 200 MB de dado | bloat no Postgres: VACUUM bloqueado por transação antiga |
| Relatório com totais que não fecham | duas queries em READ COMMITTED, dois instantes diferentes |
| Rollback demorando mais que a transação | MySQL/Oracle desfazendo pelo undo |
| Contador "somando errado" | lost update com READ COMMITTED — nenhum dos três protege |
VACUUM bloqueado, snapshot too old, deadlock, lock em fila e history list longa
têm a mesma causa raiz: transação aberta por tempo demais. Nunca abra transação
antes de uma chamada HTTP, e nunca deixe uma transação esperando entrada do usuário.
Aprofundamento aplicado
Write skew: duas linhas certas, uma regra violada
Dois médicos estão de plantão e deve permanecer pelo menos um. Cada transação lê os dois, vê dois disponíveis e retira um médico diferente. Se o isolamento permitir as duas decisões sobre o snapshot antigo, ambas podem confirmar deixando zero médicos.
Não houve duas escritas na mesma linha. Um conflito somente por registro atualizado não captura a regra “deve existir alguém no conjunto”. Opções incluem serialização com retries, uma linha comum para arbitrar a decisão ou modelagem que imponha a invariante. O comportamento exato depende do banco e do nível configurado. Referência: anomalias e serialização.
Exercício: desenhe a sequência ler A/B, retirar A, retirar B e confirmar. Resultado esperado: explicar por que proteger apenas a atualização de cada médico é insuficiente e qual ponto de coordenação sua solução acrescenta.
Uma transação curta reduz duração da disputa, mas não corrige sozinha o algoritmo. Da mesma forma, um deadlock pode ocorrer em operações curtas se a ordem de aquisição de recursos for conflitante.
Relacionado
- 6. Transações e ACID
- 1. PostgreSQL vs MySQL vs Oracle
- 1. PostgreSQL
- 1. MySQL
- 1. Oracle Database
- Strong Consistency
- Idempotent Operations
Parte de MOC Comparações · Isolamento de transações (PostgreSQL)