trilha

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:

  1. SERIALIZABLE do 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 usar SERIALIZABLE exige lógica de retry no app. Ver Strong Consistency.
  2. REPEATABLE READ do 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.
  3. O Oracle não tem REPEATABLE READ. Onde outro banco usaria esse nível, o padrão Oracle é SELECT ... FOR UPDATE explí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
Transação curta é a única política que funciona nos três

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


Parte de MOC Comparações · Isolamento de transações (PostgreSQL)

Buscar

Busca por título, seção e texto das notas