Dia 8 — Transações e concorrência

Informatica · Conteudo · publicado em 30/09/2026
Dia 8 de 15

Transações e concorrência

Aula 1

O problema da concorrência

Onde o dinheiro desaparece

Duas requisições chegam ao servidor no mesmo instante. As duas leem o saldo da

mesma conta, as duas subtraem 10, as duas gravam. O saldo final é 90, e não 80:

10 reais que ninguém sacou ficaram na conta sem que ninguém os pedisse. O

nome disso é lost update, ou atualização perdida.

A condição de corrida — race condition, em inglês — é o nome geral do

defeito: o resultado depende da ordem em que o mundo executou duas coisas, e

não da ordem em que o código foi escrito. A leitura e escrita que produzem o

saldo errado são uma condição de corrida concreta: entre o instante em que a

primeira requisição lê 100.00 e o instante em que ela grava 90.00, cabe a

segunda requisição inteira.

O que produz o erro não é o banco aceitar as duas escritas. É o código escrever

uma conta que não era a conta que ele leu.

const [lida] = await conexao.query('SELECT saldo FROM tb_d08a1_conta WHERE id = ?', [1]);
// <- o mundo continua aqui: outra requisição grava 90.00
const [gravada] = await conexao.query(
  'UPDATE tb_d08a1_conta SET saldo = ? WHERE id = ?', [Number(lida[0].saldo) - 10, 1]);

SELECT e UPDATE são dois comandos, e o MySQL não os junta. Cada um é

atômico sozinho: o SELECT devolve 100.00 inteiro, o UPDATE grava 90.00

inteiro. Nenhum dos dois está errado — e o resultado, somado, está.

O que falta entre os dois não é lock: é ocupação da linha. A correção

desta aula não usa bloqueio nem espera — ela escreve um comando só, e a

atualização atômica que esse comando faz resolve a corrida sem que a segunda

requisição pare um instante. A aula 2 troca essa folga por um bloqueio de

verdade, e paga por ele em latência.

A tabela do exemplo

CREATE TABLE IF NOT EXISTS tb_d08a1_conta (
  id             INT PRIMARY KEY,
  nm_titular     VARCHAR(40) NOT NULL,
  saldo          DECIMAL(10,2) NOT NULL DEFAULT 0,
  dt_atualizacao DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                 ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

O nome da tabela leva d08a1 de propósito, e não um tb_conta bonito. Cada

aula do dia tem a sua, e isso não é vaidade: um exemplo que depende do estado

que outro apaga reprova sem ter defeito próprio nenhum.

O estado inicial também é posto com INSERT ... ON DUPLICATE KEY UPDATE, e

não com DELETE seguido de INSERT. A diferença aparece quando dois

terminais rodam o mesmo dia ao mesmo tempo — o DELETE apagaria a linha que o

outro processo está lendo, o SELECT voltaria com zero linha e o exemplo morreria

com Cannot read properties of undefined.

dt_atualizacao com ON UPDATE é a coluna que serve de versão da linha: o

banco troca o valor sozinho a cada gravação, e o código pode exigir que ele ainda

seja o que foi lido antes de escrever. O (6) é o microssegundo, e ele é o que

torna a comparação confiável.

Por que o defeito escapa do teste

O lost update só aparece quando as duas leituras acontecem antes de qualquer

gravação. Quem manda nessa ordem é o agendador do Node e o estado interno do

servidor MySQL, não o seu código. Rodar o exemplo várias vezes costuma dar

90, 90, 80, 90, 80, 90… — e o teste passa na sua máquina e falha na do cliente.

Por isso o exemplo de hoje não promete um resultado fixo. Ele mede o que

aconteceu e compara com o esperado:

  • affectedRows de cada requisição: 1 e 1 significa que as duas gravaram;
  • o saldo final, confrontado com o saldo que a conta deveria ter.

Se na rodada em que você rodou o saldo ficou em 80, o defeito não se manifestou

e o exemplo diz isso em voz alta, em vez de mentir sobre a própria execução.

Duas linhas da saída que mudam a cada execução

A página mostra a saída real do exemplo, e duas linhas dela **são diferentes na

sua máquina**:

  • A versão lida, no formato 2026-09-30 12:10:52.418239, é o instante em que

a linha foi gravada de verdade. Muda a cada rodada, como a porta de um servidor

listen(0).

  • Qual requisição venceu no lock otimista. As duas leem a mesma versão, e a

primeira a gravar leva affectedRows 1; qual delas chega primeiro depende do

agendador. O saldo final é 90 nos dois casos, e é esse número que importa.

Tudo o mais sai idêntico. Se você rodar e a sua requisição A levar 1 onde a

página tem 0, o exemplo está correto e o escalonamento é que trocou de lado.

O conserto que não usa lock: um UPDATE só

A porta se fecha quando a conta deixa de ser calculada em JavaScript. A

subtração vai dentro do comando, e o filtro vai junto — é o UPDATE com

condição, e a subtração que ele faz é a atualização atômica: a conta não é

calculada fora e gravada depois, ela é alterada no mesmo instante em que o

banco a lê.

const [r] = await conexao.query(
  'UPDATE tb_d08a1_conta SET saldo = saldo - ? WHERE id = ? AND saldo >= ?',
  [10, 1, 10]);

if (r.affectedRows === 0) throw new Error('saldo insuficiente');

O banco lê o saldo, compara com 10 e escreve, no mesmo instante. Não existe

intervalo entre a leitura e a gravação em que outra requisição possa entrar, porque

não são dois comandos: é um só.

E o WHERE com a condição é o que fecha a outra metade da porta. Sem

AND saldo >= ?, um saque de 500 numa conta de 80 deixaria -420, porque o

saldo = saldo - 500 executa sem consultar nada.

affectedRows é a resposta do banco

affectedRowsSignificadoO que a rota responde
1a linha casou e foi gravada200, com o saldo novo
0o filtro não casou, nada foi tocado409 ou 422, com "saldo insuficiente"

O 0 não é erro de SQL e não lança exceção: é uma resposta normal que o código

precisa ler. Uma aplicação que só trata exceção e ignora o affectedRows deixa

passar o saque que o banco recusou, porque para ela "não houve erro".

E quando o UPDATE só não basta

Esse caminho resolve o saldo. Ele não segura um grupo de operações, não

impede duas leituras de decidirem sobre os mesmos dados ao mesmo tempo, e não

impede que uma leitura veja um estado intermediário de outra transação. Quem

precisa disso é a aula 2, com transação e SELECT ... FOR UPDATE.

A porta que a aula 2 fecha é a mesma, com o nome que ela usa: é a transação

com lock quando envolve o SELECT ... FOR UPDATE, e é a atualização atômica

com o UPDATE com condição quando a conta muda sem sair do lugar. Os dois

respondem à mesma pergunta — a conta que eu gravei é a conta que eu li? — e a

diferença é o que acontece com a segunda requisição enquanto a primeira não

terminou: no UPDATE com condição ela não espera, e na transação com lock ela

espera.

Lock otimista: a versão no WHERE

O outro jeito de fechar a porta sem travar ninguém é exigir que a linha **não

tenha mudado** desde a leitura. É o lock otimista, e ele usa a coluna

dt_atualizacao como contador de versão.

const FORMATO = '%Y-%m-%d %H:%i:%s.%f';

const [lida] = await conexao.query(
  'SELECT DATE_FORMAT(dt_atualizacao, ?) AS versao FROM tb_d08a1_conta WHERE id = ?',
  [FORMATO, 1]);

const [gravada] = await conexao.query(
  'UPDATE tb_d08a1_conta SET saldo = saldo - ?, dt_atualizacao = NOW(6) ' +
  'WHERE id = ? AND DATE_FORMAT(dt_atualizacao, ?) = ?',
  [10, 1, FORMATO, lida[0].versao]);

affectedRows 0 agora significa outra coisa: **alguém mexeu na linha entre a minha

leitura e a minha escrita**. O conflito é detectado em vez de silenciado, e a

aplicação decide — normalmente relendo e tentando de novo. O custo é zero de

espera: nenhuma linha fica travada, nenhuma requisição fica na fila.

Por que DATE_FORMAT e não o valor cru

O mysql2 converte DATETIME em Date do JavaScript, e o Date guarda

milissegundos. A coluna tem microssegundos. Ler o valor e comparar com a

coluna devolve affectedRows 0 sempre — inclusive sem nenhuma concorrência, o que

faz o código parecer quebrado na primeira execução. DATE_FORMAT traz a coluna

como texto com as seis casas, e o texto volta e volta igual.

A alternativa é uma coluna inteira versao incrementada no UPDATE, que é

simples e evita essa conversão. O dt_atualizacao existe para quando o sistema

precisa saber quando, e não só se a linha mudou.

As duas portas comparadas

UPDATE com condiçãoversão no WHERE
o que impedesaque acima do saldoqualquer escrita concorrente
o que não impededuas leituras decidindo juntasnada — detecta e devolve
esperanenhumanenhuma
o que o affectedRows 0 diz"não deu""mudou, refaça"
custouma coluna a menosdt_atualizacao precisa ser precisa

Nenhuma das duas trava uma linha. Quem trava, espera e por isso paga em latência

é o SELECT ... FOR UPDATE, assunto da aula 2.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 8: o problema da concorrencia.
//
// Duas requisicoes mexem na MESMA conta ao mesmo tempo. Cada uma le o saldo,
// faz a conta em JavaScript e grava o resultado. Entre a leitura e a gravacao
// cabe a outra requisicao, e o dinheiro some do saldo sem ir para lugar
// nenhum: e o lost update, tambem chamado de atualizacao perdida.
//
// O exemplo mede tres coisas na tabela `tb_d08a1_conta`:
//
//   1. o defeito        — `SELECT` e `UPDATE` como dois comandos separados
//   2. o conserto sem lock — um `UPDATE` so, que faz a conta no proprio banco
//   3. o conserto com versionamento — a coluna `dt_atualizacao` como versao
//
// O nome da tabela leva `d08a1` de proposito, e nao um `tb_conta` bonito.
//
// Medido: com as duas aulas do dia na MESMA tabela, rodar as duas ao mesmo
// tempo falhou em 12 de 12 pares. A aula 2 faz `DELETE FROM tb_conta` e
// `INSERT` no comeco; a aula 1 estava lendo essa linha no meio do caminho e o
// `SELECT` voltava com zero linha, derrubando o exemplo com
// `Cannot read properties of undefined (reading 'saldo')`.
//
// Isso e falha REAL, nao teorica: `validar.py` roda os dois exemplos do dia, e
// um exemplo que depende do estado que o outro apaga reprova sem ter defeito
// proprio. A regra do material e `tb_<dia>a<numero>_<assunto>`, e ela existe
// por exatamente este motivo.
//
// Aqui nao tem transacao nem `SELECT ... FOR UPDATE`: esta e a aula do
// problema, antes de qualquer solucao que trave linha. O lock pessimista e a
// aula 2.
//
// Cada requisicao abre a conexao DELA, que e o que o `pool` faz em producao.
// A conexao do observador e a terceira, separada: e quem mede o saldo final.

const { createConnection } = require('mysql2/promise');

// O tempo que a requisicao leva entre o `SELECT` e o `UPDATE`. Na vida real
// esse intervalo existe sem ninguem pedir: validar os dados, chamar outro
// servico, esperar o usuario confirmar. O exemplo deixa a janela explicita e
// curta porque, sem ela, as duas requisicoes as vezes nao se cruzam — e um
// defeito que so aparece de vez em quando e a pior forma de erro para
// estudar.
const JANELA_MS = 10;

const espera = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const ID_CONTA = 1;
const SALDO_INICIAL = 100.00;
const DOIS_SAQUES = 10.00;

// O SQL do defeito, escrito do jeito que a maioria escreve: le o saldo...
const SQL_LE_SALDO =
  'SELECT saldo FROM tb_d08a1_conta WHERE id = ?';

// ...e depois grava um numero que o JavaScript calculou fora do banco.
const SQL_GRAVA_SALDO =
  'UPDATE tb_d08a1_conta SET saldo = ? WHERE id = ?';

// O SQL do conserto: a conta fica DENTRO do `UPDATE`, e o filtro
// `AND saldo >= ?` tambem. O banco le, compara e escreve no mesmo comando, sem
// intervalo pelo meio em que outra requisicao possa entrar.
const SQL_SACA_COM_CONDICAO =
  'UPDATE tb_d08a1_conta SET saldo = saldo - ? WHERE id = ? AND saldo >= ?';

// A versao da linha. `DATETIME(6)` volta em JavaScript como um `Date`, e o
// `Date` perde a microsegunda: comparar o valor lido com a coluna devolve
// `affectedRows` 0 sempre, mesmo sem concorrencia nenhuma. `DATE_FORMAT`
// transforma a coluna em texto com as 6 casas, e o texto volta e volta igual.
const FORMATO_VERSAO = '%Y-%m-%d %H:%i:%s.%f';

const SQL_LE_VERSAO =
  'SELECT DATE_FORMAT(dt_atualizacao, ?) AS versao FROM tb_d08a1_conta WHERE id = ?';

const SQL_GRAVA_COM_VERSAO =
  'UPDATE tb_d08a1_conta SET saldo = saldo - ?, dt_atualizacao = NOW(6) ' +
  'WHERE id = ? AND DATE_FORMAT(dt_atualizacao, ?) = ?';

// ===========================================================================
// 1. o defeito: ler, calcular fora, gravar
// ===========================================================================

// Funcao que faz o saque do jeito que trava a fila: `SELECT`, conta em
// JavaScript, `UPDATE`. Devolve o que leu e quantas linhas gravou.
async function sacarDoJeitoErrado(conexao, valor) {
  const [lida] = await conexao.query(SQL_LE_SALDO, [ID_CONTA]);

  // A janela em que a outra requisicao passa por cima. E o que acontece na
  // vida real sem nenhuma linha de codigo para isso.
  await espera(JANELA_MS);

  const novoSaldo = Number(lida[0].saldo) - valor;
  const [gravada] = await conexao.query(SQL_GRAVA_SALDO, [novoSaldo, ID_CONTA]);

  // O valor gravado sai com duas casas, como o `DECIMAL` do banco: `90` lido
  // como numero impresso e `90.00`, e o aluno compara os dois com o saldo da
  // pagina. O texto do `DECIMAL` ja vem assim do banco.
  return {
    leu: lida[0].saldo,
    gravou: gravada.affectedRows,
    gravando: Number(novoSaldo).toFixed(2),
  };
}

// ===========================================================================
// 2. o conserto sem lock: um `UPDATE` so
// ===========================================================================

// Funcao que faz o mesmo saque sem nunca ler o saldo em JavaScript. O `WHERE`
// carrega a condicao, e o `affectedRows` da a resposta: 1 gravou, 0 recusou.
async function sacarComCondicao(conexao, valor) {
  const [r] = await conexao.query(SQL_SACA_COM_CONDICAO, [valor, ID_CONTA, valor]);
  return { gravou: r.affectedRows };
}

// ===========================================================================
// 3. o conserto com versionamento (lock otimista)
// ===========================================================================

// Funcao que le a versao da linha e a exige no `UPDATE`. Quem gravar com uma
// versao velha nao casa com o filtro e recebe `affectedRows` 0: e o conflito
// detectado, e nao silencio. Nenhuma linha fica travada em nenhum momento.
async function sacarComVersao(conexao, valor) {
  const [lida] = await conexao.query(SQL_LE_VERSAO, [FORMATO_VERSAO, ID_CONTA]);

  await espera(JANELA_MS);

  const [gravada] = await conexao.query(
    SQL_GRAVA_COM_VERSAO, [valor, ID_CONTA, FORMATO_VERSAO, lida[0].versao]);

  return { versao: lida[0].versao, gravou: gravada.affectedRows };
}

// ===========================================================================
// o programa
// ===========================================================================

// Uma requisicao nova: e o que cada request real do servidor recebe do `pool`.
function novaConexao() {
  return createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
  });
}

const texto = (n) => Number(n).toFixed(2);

async function main() {
  const observador = await novaConexao();
  const reqA = await novaConexao();
  const reqB = await novaConexao();
  const reqC = await novaConexao();

  try {
    const [versao] = await observador.query('SELECT VERSION() AS versao');
    console.log('Banco em uso:', versao[0].versao);

    // `CREATE TABLE IF NOT EXISTS`: rodar duas vezes da o mesmo resultado.
    await observador.query(`
      CREATE TABLE IF NOT EXISTS tb_d08a1_conta (
        id             INT PRIMARY KEY,
        nm_titular     VARCHAR(40) NOT NULL,
        saldo          DECIMAL(10,2) NOT NULL DEFAULT 0,
        dt_atualizacao DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                       ON UPDATE CURRENT_TIMESTAMP(6)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // O estado inicial e posto AQUI, no comeco: e o que torna a rodada
    // independente da anterior, sem depender de `DROP TABLE`.
    //
    // `INSERT ... ON DUPLICATE KEY UPDATE` em vez de `DELETE` + `INSERT`: o
    // `DELETE` apagava a linha que outra execucao do MESMO exemplo podia estar
    // lendo, o `SELECT` voltava zero linha e o `linha[0]` quebrava. Assim a
    // linha id 1 sempre existe, mesmo com duas rodadas cruzadas.
    await observador.query(`
      INSERT INTO tb_d08a1_conta (id, nm_titular, saldo)
      VALUES (?, ?, ?)
      ON DUPLICATE KEY UPDATE nm_titular = VALUES(nm_titular), saldo = VALUES(saldo)
    `, [ID_CONTA, 'ana', SALDO_INICIAL]);

    const saldoAgora = async () => {
      const [linha] = await observador.query(
        'SELECT saldo FROM tb_d08a1_conta WHERE id = ?', [ID_CONTA]);
      return linha[0].saldo;
    };

    const zeraSaldo = async () => {
      await observador.query(
        'UPDATE tb_d08a1_conta SET saldo = ? WHERE id = ?', [SALDO_INICIAL, ID_CONTA]);
    };

    const certo = texto(SALDO_INICIAL - DOIS_SAQUES * 2);

    // ---------------------------------------------------------- 1. o defeito
    console.log('\n=== 1. o defeito: ler, calcular fora, gravar ===');
    console.log('saldo da conta da ana:', texto(SALDO_INICIAL));
    console.log('duas requisicoes de 10.00 chegam ao mesmo tempo, cada uma com a');
    console.log('sua conexao. As duas leem antes de qualquer gravacao:');

    const [r1, r2] = await Promise.all([
      sacarDoJeitoErrado(reqA, DOIS_SAQUES),
      sacarDoJeitoErrado(reqB, DOIS_SAQUES),
    ]);

    console.log('  requisicao A leu', r1.leu, '-> gravou', r1.gravando,
      '(affectedRows', r1.gravou + ')');
    console.log('  requisicao B leu', r2.leu, '-> gravou', r2.gravando,
      '(affectedRows', r2.gravou + ')');

    const depoisDoDefeito = await saldoAgora();
    console.log('saldo final da conta:', depoisDoDefeito);
    console.log('o esperado era', certo, '— duas retiradas de 10.00');

    // A conclusao vem do numero medido e nao de um numero escrito no exemplo:
    // se o agendamento desta vez nao cruzou as duas requisicoes, a linha
    // abaixo diz isso em vez de mentir sobre o que aconteceu.
    if (Number(depoisDoDefeito) > Number(certo)) {
      const perdido = texto(Number(depoisDoDefeito) - Number(certo));
      console.log('MEDIDO: sobrou', depoisDoDefeito, 'em vez de', certo,
        '—', perdido, 'ficaram na conta sem ninguem ter pedido.');
      console.log('As duas gravacoes venceram: a segunda escreveu por cima da');
      console.log('primeira, e o trabalho da primeira foi apagado.');
    } else {
      console.log('MEDIDO: o saldo ficou em', depoisDoDefeito,
        '— nesta volta as duas gravacoes entraram.');
      console.log('O defeito depende do agendamento entre as duas requisicoes, e');
      console.log('por isso ele escapa do teste: passa na maquina do autor e falha');
      console.log('na do cliente.');
    }

    // ------------------------------------------------- 2. o conserto sem lock
    console.log('\n=== 2. o conserto: um `UPDATE` so, com a condicao dentro ===');
    await zeraSaldo();
    console.log('saldo da conta da ana voltou para', texto(SALDO_INICIAL));

    const [c1, c2] = await Promise.all([
      sacarComCondicao(reqA, DOIS_SAQUES),
      sacarComCondicao(reqB, DOIS_SAQUES),
    ]);

    console.log('  requisicao A -> affectedRows', c1.gravou);
    console.log('  requisicao B -> affectedRows', c2.gravou);
    const depoisDoConserto = await saldoAgora();
    console.log('saldo final da conta:', depoisDoConserto);
    console.log('as duas retiradas entraram, e nenhuma linha ficou travada.');
    console.log('A conta `saldo = saldo - 10` e o filtro `AND saldo >= 10` estao');
    console.log('no comando: quem decidiu foi o banco, no instante do `UPDATE`, e');
    console.log('nao o JavaScript com o valor que ele leu ha milissegundos atras.');

    // A recusa e a mesma resposta do sucesso, mudando o numero: e o que a
    // aplicacao decide com base.
    console.log('\nmesma consulta, agora pedindo 500.00 de uma conta que tem',
      depoisDoConserto + ':');
    const [recusa] = await reqC.query(SQL_SACA_COM_CONDICAO,
      [500.00, ID_CONTA, 500.00]);
    console.log('  affectedRows:', recusa.affectedRows);
    console.log('  saldo:', await saldoAgora());
    console.log('`affectedRows` 0 e a resposta do banco para "nao deu": o filtro');
    console.log('`AND saldo >= ?` nao casou e a linha nao foi tocada.');

    // ------------------------------------------------ 3. lock otimista/versao
    console.log('\n=== 3. o lock otimista: a versao da linha no `WHERE` ===');
    await zeraSaldo();
    console.log('saldo da conta da ana voltou para', texto(SALDO_INICIAL));

    const [v1, v2] = await Promise.all([
      sacarComVersao(reqA, DOIS_SAQUES),
      sacarComVersao(reqB, DOIS_SAQUES),
    ]);

    console.log('  requisicao A leu a versao', v1.versao, '-> affectedRows', v1.gravou);
    console.log('  requisicao B leu a versao', v2.versao, '-> affectedRows', v2.gravou);
    console.log('saldo final da conta:', await saldoAgora());
    console.log('As duas leram a MESMA versao. A primeira grava e avanca a versao;');
    console.log('a segunda leva `affectedRows` 0, porque o `WHERE` exigia a versao');
    console.log('velha e ela ja nao era mais a do registro. Conflito detectado, sem');
    console.log('travar linha e sem esperar ninguem — e o preco: quem perdeu tem de');
    console.log('tentar de novo, com a leitura refeita.');

    // ----------------------------------------------------- 4. o que fica
    console.log('\n=== 4. o que fica para a proxima aula ===');
    console.log('O `UPDATE` com condicao resolve o saldo sem lock nenhum, e e o que');
    console.log('a maioria dos sistemas usa. Ele NAO resolve tudo: nao segura um');
    console.log('grupo de operacoes e nao impede duas leituras de decidirem sobre os');
    console.log('mesmos dados ao mesmo tempo. Para isso existe travar a linha, e a');
    console.log('aula 2 mostra `SELECT ... FOR UPDATE` e o que ele custa.');

    // Nada e apagado no fim: a proxima rodada comeca pelo `ON DUPLICATE KEY
    // UPDATE` do comeco, que repoe o saldo inicial. Apagar aqui seria o mesmo
    // defeito do comeco, so que mais tarde e com outra execucao ja em curso.
  } finally {
    // Toda conexao aberta e fechada no `finally`: uma conexao que fica viva
    // segura o processo, e o portao espera ate o timeout.
    await Promise.all([
      observador.end(), reqA.end(), reqB.end(), reqC.end(),
    ]);
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

Banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1

=== 1. o defeito: ler, calcular fora, gravar ===
saldo da conta da ana: 100.00
duas requisicoes de 10.00 chegam ao mesmo tempo, cada uma com a
sua conexao. As duas leem antes de qualquer gravacao:
  requisicao A leu 100.00 -> gravou 90.00 (affectedRows 1)
  requisicao B leu 100.00 -> gravou 90.00 (affectedRows 1)
saldo final da conta: 90.00
o esperado era 80.00 — duas retiradas de 10.00
MEDIDO: sobrou 90.00 em vez de 80.00 — 10.00 ficaram na conta sem ninguem ter pedido.
As duas gravacoes venceram: a segunda escreveu por cima da
primeira, e o trabalho da primeira foi apagado.

=== 2. o conserto: um `UPDATE` so, com a condicao dentro ===
saldo da conta da ana voltou para 100.00
  requisicao A -> affectedRows 1
  requisicao B -> affectedRows 1
saldo final da conta: 80.00
as duas retiradas entraram, e nenhuma linha ficou travada.
A conta `saldo = saldo - 10` e o filtro `AND saldo >= 10` estao
no comando: quem decidiu foi o banco, no instante do `UPDATE`, e
nao o JavaScript com o valor que ele leu ha milissegundos atras.

mesma consulta, agora pedindo 500.00 de uma conta que tem 80.00:
  affectedRows: 0
  saldo: 80.00
`affectedRows` 0 e a resposta do banco para "nao deu": o filtro
`AND saldo >= ?` nao casou e a linha nao foi tocada.

=== 3. o lock otimista: a versao da linha no `WHERE` ===
saldo da conta da ana voltou para 100.00
  requisicao A leu a versao 2026-09-30 17:15:59.256706 -> affectedRows 0
  requisicao B leu a versao 2026-09-30 17:15:59.256706 -> affectedRows 1
saldo final da conta: 90.00
As duas leram a MESMA versao. A primeira grava e avanca a versao;
a segunda leva `affectedRows` 0, porque o `WHERE` exigia a versao
velha e ela ja nao era mais a do registro. Conflito detectado, sem
travar linha e sem esperar ninguem — e o preco: quem perdeu tem de
tentar de novo, com a leitura refeita.

=== 4. o que fica para a proxima aula ===
O `UPDATE` com condicao resolve o saldo sem lock nenhum, e e o que
a maioria dos sistemas usa. Ele NAO resolve tudo: nao segura um
grupo de operacoes e nao impede duas leituras de decidirem sobre os
mesmos dados ao mesmo tempo. Para isso existe travar a linha, e a
aula 2 mostra `SELECT ... FOR UPDATE` e o que ele custa.
Aula 2

Resolvendo com transação e bloqueio

SELECT ... FOR UPDATE: travar a linha até o fim

O UPDATE com condição resolve o saldo, mas não resolve "ler, decidir, gravar"

quando a decisão depende do que foi lido. Para isso existe o lock pessimista:

o SELECT devolve a linha e, no mesmo comando, pede a exclusão dela. É a

transação com lock: a transação existe para que a exclusão valha, e o bloqueio

da linha é o que serializa as duas requisições que apontam para a mesma conta.

Os dois caminhos têm nome. A aula 1 usa o UPDATE com condição, que resolve a

conta sem travar ninguém; esta aula usa a transação com lock, que resolve

esperando. A consulta que faz a segunda é SELECT ... FOR UPDATE — com o

FOR UPDATE no fim do WHERE, separado do ? do filtro. É a grafia que a

busca espera: o termo aparece em documentação como SELECT FOR UPDATE, sem os

pontos, e é o mesmo comando.

async function sacarComLock(conexao, id, valor) {
  await conexao.beginTransaction();
  try {
    const [linha] = await conexao.query(
      'SELECT saldo FROM tb_d08a2_conta WHERE id = ? FOR UPDATE', [id]);

    if (Number(linha[0].saldo) < valor) {
      await conexao.rollback();
      return { gravou: 0, motivo: 'saldo insuficiente' };
    }

    const [gravada] = await conexao.query(
      'UPDATE tb_d08a2_conta SET saldo = saldo - ? WHERE id = ?', [valor, id]);
    await conexao.commit();
    return { gravou: gravada.affectedRows };
  } catch (erro) {
    await conexao.rollback();
    throw erro;
  }
}

A tabela do exemplo é tb_d08a2_conta, e não tb_d08a1_conta, porque cada

aula do dia tem a sua. A tabela desta aula tem duas contas e duas linhas

são pedidas em ordem cruzada na seção do deadlock — dividi-la com a aula 1

misturaria as duas histórias.

Três regras que o exemplo mede:

  1. O FOR UPDATE vem antes do UPDATE. Depois, seria tarde: a outra

transação já teria escrito e o SELECT leria o saldo novo.

  1. Sem transação o lock não existe. FOR UPDATE fora de transação não trava

nada — o lock nasce do BEGIN e morre no COMMIT ou no ROLLBACK.

  1. Só trava a linha lida. WHERE id = 1 trava a conta 1 e nenhuma outra. Por

isso um índice em id importa: sem ele, o SELECT trava todas as linhas que

ele precisou varrer.

A segunda requisição não falha: ela espera na fila de espera até a primeira

fazer COMMIT ou ROLLBACK, e só então lê. O que muda é que as duas gravações

rodaram em série. O lock pessimista não impede a concorrência — ele a

serializa, e o preço é latência.

A fila de espera vira erro

A espera não é infinita, e é isso que salva a produção de travar inteiro. A

variável innodb_lock_wait_timeout diz quantos segundos a transação espera pelo

lock antes de desistir. O padrão é alto, e o exemplo pergunta ao servidor

qual é (SELECT @@innodb_lock_wait_timeout) em vez de afirmar o número.

try {
  await conexao.query('SELECT saldo FROM tb_d08a2_conta WHERE id = ? FOR UPDATE', [id]);
} catch (erro) {
  if (erro.code === 'ER_LOCK_WAIT_TIMEOUT') return tentarDeNovo();
}

O que decide a resposta é o erro.code, nunca o erro.message:

propriedadevalorserve para comparar?
erro.codeER_LOCK_WAIT_TIMEOUTsim — é o contrato do driver
erro.errno1205sim, numérico e estável
erro.sqlStateHY000genérico demais para distinguir
erro.message"Lock wait timeout exceeded…"não — muda de tradução em tradução

ER_LOCK_WAIT_TIMEOUT e ER_LOCK_DEADLOCK são aviso de disputa, não erro de

lógica. Repetir a operação é a resposta certa; repetir uma consulta que falhou com

ER_NO_SUCH_TABLE só gasta tempo.

O catch que re-tenta

const ERRO_NAO_ESPERADO = new Set(['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT']);

async function comRetry(funcao, tentativas = 5) {
  for (let n = 1; n <= tentativas; n++) {
    try {
      return await funcao(n);
    } catch (erro) {
      if (!ERRO_NAO_ESPERADO.has(erro.code) || n === tentativas) throw erro;
      await esperar(150);
    }
  }
}

A pausa entre as tentativas é o que faz o retry funcionar. Repetir colado

na outra não muda nada: a linha que causou a disputa ainda está presa, e a

tentativa seguinte morre exatamente igual. Medido no exemplo: com três tentativas

imediatas, uma segunda execução do mesmo exemplo morria de espera; com cinco

tentativas e 150 ms de pausa, entra.

comRetry não abre transação: quem abre e fecha é a função que ele recebe, e

cada tentativa abre a sua. O motivo é concreto — a transação que morreu com

ER_LOCK_DEADLOCK já foi desfeita pelo banco e não pode ser retomada; continuar

nela é continuar num estado que não existe mais.

O rollback no catch é o que volta os dados. O aluno vê o affectedRows de

uma consulta mudar do 1 para 0 e não entende sem saber que o rollback desfaz o

que a transação gravou — sem essa linha, o exemplo ensinaria o oposto do que

prega.

LOCK IN SHARE MODE: travar contra quem escreve

O FOR UPDATE é exclusivo. O LOCK IN SHARE MODE é o mesmo mecanismo com outra

condição: ele trava a linha contra quem escreve e deixa passar quem lê.

Os dois dividem o trabalho com a atualização atômica do UPDATE com condição,

que resolve a conta sem travar ninguém — a aula 1 mediu o ganho e o limite. A

divisão é o que decide qual dos dois a transação precisa: quando a conta muda

sozinha, o UPDATE bastava; quando a decisão depende do que foi lido, a

atualização atômica não alcança e o bloqueio é o caminho.

SELECT saldo FROM tb_d08a2_conta WHERE id = 1 LOCK IN SHARE MODE;
SELECT saldo FROM tb_d08a2_conta WHERE id = 1 FOR SHARE;   -- forma nova, equivalente

Duas transações com LOCK IN SHARE MODE na mesma linha leem as duas, sem esperar

uma da outra. Um UPDATE nessa linha, porém, espera — e estoura com o mesmo

ER_LOCK_WAIT_TIMEOUT quando a espera estoura.

FOR SHARE é a forma moderna do MySQL 8. Ela não existe no servidor desta

produção: a primeira versão do exemplo usou FOR SHARE e o banco devolveu

ER_PARSE_ERROR. LOCK IN SHARE MODE é a forma antiga, funciona nos dois, e é

a que está no exemplo.

Vale a regra do material inteiro: **a página não afirma o que o servidor

suporta, ela pergunta**. Uma aula que diz "isto funciona" está mentindo na

máquina de quem tem outra versão.

SELECT puro, sem nenhuma das duas, é o terceiro modo: **lock compartilhado de

transação** em REPEATABLE READ, sem travar ninguém. Por isso uma leitura

comum não bloqueia nem é bloqueada — o isolamento vem do nível, não da consulta.

Nível de isolamento: de onde vem a leitura "fantasma"

Duas transações que contam as mesmas linhas, com uma inserção no meio, discordam

sobre a segunda resposta. E a diferença não está no código: são as mesmas

consultas nos dois casos.

nívela segunda contagemo que garante
READ UNCOMMITTEDvê até o que não foi confirmadonada
READ COMMITTEDvê o que entrou depoisnada fica sujo
REPEATABLE READrepete a primeiraleitura estável
SERIALIZABLErepete a primeira e serializa escritaisolamento total

O padrão é REPEATABLE READ, e é ele que impede o fantasma: a transação conta

2 linhas, outra transação insere a terceira, e a primeira volta a contar 2 — o

mesmo snapshot do começo.

await conexao.query('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');

READ COMMITTED troca a estabilidade pela ausência do bloqueio: cada comando vê

o estado mais novo, e a linha que entrou no meio aparece na segunda contagem.

A leitura "fantasma" é justamente esse efeito — um resultado que muda no meio de

uma transação que deveria estar lendo um retrato fixo. Quem prefere o retrato

fixo fica no padrão; quem precisa de leitura sempre atualizada troca de nível.

Quem usa FOR UPDATE não sente muita diferença entre os dois, porque a linha já

está travada: o isolamento decide o que acontece com as linhas que não estão

travadas.

Para comparar os dois, o exemplo pergunta ao servidor qual é o padrão em uso

(SELECT @@tx_isolation) em vez de afirmar o número no texto: a máquina de quem

estuda pode ter outro.

SET TRANSACTION ISOLATION LEVEL não aceita ?. A frase só pode vir de uma

lista fechada do próprio código — concatenar valor de usuário aqui seria

injeção de SQL, e a consulta parametrizada do dia 8 do 2º trimestre não protege

um ponto onde não há parâmetro.

ER_LOCK_DEADLOCK: dois se segurando

O deadlock não é uma transação lenta esperando outra: é um ciclo. Cada

transação segura uma linha e pede a linha que a outra segura, e nenhuma pode

avançar.

transaçãosegurapedeestado
requisição 1conta 1conta 2esperando
requisição 2conta 2conta 1esperando

Cada uma espera uma linha que só sai quando a outra faz COMMIT — e nenhuma

das duas pode fazer COMMIT enquanto espera. O banco reconhece o ciclo, e a

espera vira erro.

O banco detecta o ciclo e escolhe uma das duas para morrer. Ela morre com

ER_LOCK_DEADLOCK (errno 1213), e a transação sobrevivente volta a funcionar. A

escolha é do InnoDB e muda de execução para execução — nenhum código deve

depender de qual delas morre.

O catch trata o deadlock igual ao timeout: os dois estão em

ERRO_NAO_ESPERADO. Mas há uma diferença honesta. O timeout se resolve com o

retry, porque a linha fica livre sozinha. O deadlock nasce da ordem em que

as linhas são pedidas: repetir o mesmo código na mesma ordem repete o mesmo

ciclo, e o retry não tem como vencer. A correção de verdade é uma só — **pegar

as linhas sempre na mesma ordem**, em todo o código que mexe nelas:

-- transação A: linhas 1, depois 3
SELECT * FROM tb_d08a2_conta WHERE id IN (1, 3) ORDER BY id FOR UPDATE;
-- transação B: linhas 2, depois 3
SELECT * FROM tb_d08a2_conta WHERE id IN (2, 3) ORDER BY id FOR UPDATE;

O ORDER BY id é o que impede o ciclo, e ele não é decoração: sem ele o

InnoDB pode pegar as linhas em qualquer ordem e o deadlock volta.

Fechar tudo, sempre

Um lock esquecido atravessa execuções. Toda transação precisa de commit ou

rollback, e toda conexão de end() — e o finally é o único lugar onde isso

é garantido quando o exemplo lança:

try {
  await conexao.beginTransaction();
  await conexao.query('UPDATE tb_d08a2_conta SET saldo = saldo - 10 WHERE id = 1');
  await conexao.commit();
} catch (erro) {
  await conexao.rollback();
  throw erro;
} finally {
  await conexao.end();
}

Um exemplo de material que abre transação e não fecha deixa lock preso, e o

problema aparece na execução seguinte — longe do código que causou.

E uma linha da saída muda entre a página e a sua máquina: **qual requisição

morreu** no deadlock. A escolha é do InnoDB e muda a cada execução, então o

exemplo imprime quem morreu, sem afirmar um nome.

SET SESSION innodb_lock_wait_timeout = 2 em cada conexão é o que permite

medir a espera de propósito sem passar do tempo. O padrão do servidor é bem

maior — a saída da página mostra os dois números, o que o servidor respondeu e

o que o exemplo pediu — e um exemplo que espera o padrão estoura qualquer

limite de 45 s antes de a aula conseguir mostrar o erro.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 8: resolver a concorrencia com transacao e bloqueio.
//
// A aula 1 mostrou o defeito sem solucao. Aqui cada solucao roda de verdade
// contra o mesmo banco:
//
//   1. `SELECT ... FOR UPDATE`      — lock pessimista: a linha fica travada
//   2. `ER_LOCK_WAIT_TIMEOUT` e `ER_LOCK_DEADLOCK` — e o `catch` que re-tenta
//   3. `LOCK IN SHARE MODE`        — lock de leitura: varias leituras passam
//   4. `REPEATABLE READ` x `READ COMMITTED` — onde nasce a leitura "fantasma"
//
// O detalhe que faz este exemplo nao travar o portao: `innodb_lock_wait_timeout`
// e abaixado em TODA conexao, logo depois de aberta. O padrao do servidor e
// 50 segundos, e um exemplo que espera o padrao estoura o limite de 45s do
// portao enquanto o aluno ainda espera a fila liberar.
//
// E o detalhe que impede transacao aberta no fim: todo `beginTransaction` tem
// `commit` ou `rollback`, e o `finally` ainda chama `end()` nas conexoes.
// Lock sem `finally` fica preso na proxima execucao.

const { createConnection } = require('mysql2/promise');

// Segundos que a transacao espera por um lock antes de desistir. Precisa ser
// pequeno: o exemplo provoca a espera de proposito, e o timeout do portao e de
// 45 segundos para o exemplo inteiro.
const ESPERA_LOCK_S = 2;

// A conexao que RE-TENTA usa uma espera menor que a da transacao lenta, e e
// por isso que a primeira tentativa falha: ela desiste antes de a linha
// aparecer. A segunda tentativa, feita logo depois, ja pega a linha.
const TIMEOUT_DO_RETRY_S = 1;

// Quanto tempo a transacao "lenta" segura a linha. Maior que
// `TIMEOUT_DO_RETRY_S` e menor que `ESPERA_LOCK_S + TIMEOUT_DO_RETRY_S`: e o
// intervalo em que a primeira tentativa morre e a segunda tem sucesso.
//
// A folga e de 700 ms, e nao de 50, por causa de uma falha medida. Com a folga
// curta, rodar a aula 2 junto com a aula 1 na mesma maquina deu falha em 1 de 12
// pares com `ER_LOCK_WAIT_TIMEOUT` na TERCEIRA tentativa: sob carga de CPU, o
// `setTimeout` atrasa, a transacao lenta ainda segura o lock quando a tentativa
// volta, e o exemplo sai com `rc=1` — reprovado sem ter defeito.
//
// O `validar.py` roda os exemplos um de cada vez, entao essa situacao nao
// aparece no portao. Ela aparece na maquina de quem estuda abrindo dois terminais
// e rodando o dia inteiro, que e a situacao real do material. A folga larga
// custa 700 ms de exemplo e compra a estabilidade.
const SEGURA_LOCK_MS = (ESPERA_LOCK_S - 1) * 1000 + 700;

// O atraso entre uma tentativa e a outra do `comRetry`. Precisa ser uma
// espera real, e nao um instante: tres tentativas coladas uma na outra
// encontram a linha livre exatamente no mesmo instante da transacao lenta e
// vao todas morrer de espera. Medido: este era o defeito que sobrava quando
// duas copias do exemplo rodavam juntas.
const PAUSA_ENTRE_TENTATIVAS_MS = 150;

// Espera entre as duas transacoes que precisam se cruzar. Nao e
// sincronismo, e so um atraso proposital que cria o conflito na ordem que o
// exemplo explica.
const JANELA_MS = 120;

const espera = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const texto = (n) => Number(n).toFixed(2);

const ID_ANA = 1;
const ID_BRUNO = 2;
const ID_EXTERNO = 99;
const SALDO_INICIAL = 100.00;

// Os dois casos em que o `catch` re-tenta. E o `code` que decide, nunca a
// mensagem: `erro.message` muda de traducao em traducao e nao serve para nada.
const ERRO_NAO_ESPERADO = new Set(['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT']);

// O que o servidor respondeu quando a conexao foi aberta, lido ANTES de mudar
// qualquer coisa. E assim que o exemplo sabe qual e o padrao da maquina que
// rodou, em vez de afirmar um numero no texto.
let esperaPadrao = null;
let nivelPadrao = null;

// ===========================================================================
// as consultas
// ===========================================================================

const SQL_SALDO =
  'SELECT saldo FROM tb_d08a2_conta WHERE id = ?';

// O `FOR UPDATE` e o que trava a linha. Nao e um SELECT diferente em nada:
// e o mesmo SELECT que, alem de devolver a linha, pede a exclusao dela para
// quem fez a consulta.
const SQL_SALDO_TRAVADO =
  'SELECT saldo FROM tb_d08a2_conta WHERE id = ? FOR UPDATE';

const SQL_SUBTRAI =
  'UPDATE tb_d08a2_conta SET saldo = saldo - ?, dt_atualizacao = NOW(6) WHERE id = ?';

const SQL_ZERA =
  'UPDATE tb_d08a2_conta SET saldo = ?, dt_atualizacao = NOW(6) WHERE id = ?';

// ===========================================================================
// as funcoes
// ===========================================================================

// Abre uma conexao pronta para a aula: ja com a espera de lock curta.
//
// O `SET SESSION` e o que impede o exemplo de estourar o portao, e o `SELECT`
// do padrao vem ANTES dele — depois do `SET` o valor lido seria o do exemplo,
// e a pagina affirmaria um padrao que nao e o do servidor.
function novaConexao(esperaSegundos = ESPERA_LOCK_S) {
  return createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
  }).then(async (conexao) => {
    if (esperaPadrao === null) {
      const [padrao] = await conexao.query(
        'SELECT @@innodb_lock_wait_timeout AS s, @@tx_isolation AS i');
      esperaPadrao = padrao[0].s;
      nivelPadrao = padrao[0].i;
    }
    await conexao.query('SET SESSION innodb_lock_wait_timeout = ?', [esperaSegundos]);
    return conexao;
  });
}

// Funcao que saca com lock pessimista: abre transacao, trava a linha com o
// `SELECT`, le, e so entao grava.
//
// A ordem importa: o `SELECT ... FOR UPDATE` vem ANTES do `UPDATE`. Estiver
// depois, a outra transacao ja teria escrito e o `SELECT` leria o saldo novo —
// que e o defeito da aula 1, so que agora com transacao em volta.
async function sacarComLock(conexao, id, valor, enquantoSegura) {
  await conexao.beginTransaction();
  try {
    const [linha] = await conexao.query(SQL_SALDO_TRAVADO, [id]);
    if (enquantoSegura) await enquantoSegura(linha[0].saldo);

    if (Number(linha[0].saldo) < valor) {
      await conexao.rollback();
      return { saldo: linha[0].saldo, gravou: 0, motivo: 'saldo insuficiente' };
    }

    const [gravada] = await conexao.query(SQL_SUBTRAI, [valor, id]);
    await conexao.commit();
    return { saldo: linha[0].saldo, gravou: gravada.affectedRows };
  } catch (erro) {
    // O `rollback` volta o que a transacao gravou. Sem esta linha o `UPDATE`
    // de antes ficaria no banco mesmo depois do erro, e o aluno veria o
    // `affectedRows` mudar sem entender que foi o `rollback` que desfez.
    await conexao.rollback();
    throw erro;
  }
}

// Funcao que re-tenta quando o banco responde que houve disputa.
//
// `comRetry` NAO abre transacao: quem abre e fecha transacao e a funcao que
// ele recebe. O motivo e concreto — cada tentativa precisa de uma transacao
// NOVA, porque a que morreu com `ER_LOCK_DEADLOCK` ja foi desfeita pelo banco
// e nao pode ser retomada.
async function comRetry(funcao, tentativas = 5) {
  for (let n = 1; n <= tentativas; n++) {
    try {
      return await funcao(n);
    } catch (erro) {
      const disputado = ERRO_NAO_ESPERADO.has(erro.code);

      // O par que o CONTRATO.md exige: o `console.error` alimenta o terminal e
      // o `console.log` vai para o stdout, que e o que o gerador embute na
      // pagina. Sem o `console.log` a decisao do `catch` fica invisivel.
      console.error('tentativa ' + n + '/' + tentativas + ' falhou: '
        + erro.code + ' - ' + erro.message);
      console.log('  tentativa ' + n + '/' + tentativas + ' falhou com',
        erro.code, '- disputa de lock, o banco mandou tentar de novo');

      // So re-tenta no que e disputa. Qualquer outro erro e erro de verdade e
      // sai daqui: repetir uma consulta com `ER_NO_SUCH_TABLE` so gasta tempo.
      if (!disputado || n === tentativas) throw erro;
      await espera(PAUSA_ENTRE_TENTATIVAS_MS);
    }
  }
}

// Funcao que saca com espera curta e re-tenta sozinha. Cada tentativa abre
// conexao e transacao proprias e fecha as DUAS no `finally`.
async function sacarComRetryDeLock(id, valor) {
  return comRetry(async (tentativa) => {
    const c = await novaConexao(TIMEOUT_DO_RETRY_S);
    try {
      await c.beginTransaction();
      const [linha] = await c.query(SQL_SALDO_TRAVADO, [id]);
      const [gravada] = await c.query(SQL_SUBTRAI, [valor, id]);
      await c.commit();
      return { tentativa, saldo: linha[0].saldo, gravou: gravada.affectedRows };
    } catch (erro) {
      await c.rollback();
      throw erro;
    } finally {
      await c.end();
    }
  });
}

// ===========================================================================
// o programa
// ===========================================================================

async function main() {
  const observador = await novaConexao();
  const req1 = await novaConexao();
  const req2 = await novaConexao();
  const req3 = await novaConexao();

  try {
    const [versao] = await observador.query('SELECT VERSION() AS versao');
    console.log('Banco em uso:', versao[0].versao);
    console.log('nivel de isolamento padrao do servidor:', nivelPadrao);
    console.log('espera de lock padrao do servidor:', esperaPadrao,
      's — este exemplo baixa para', ESPERA_LOCK_S, 's nas conexoes dele');
    console.log('os dois numeros sao lidos do servidor com SELECT @@variavel,');
    console.log('e nao escritos aqui: a maquina de quem estuda pode ter outro padrao');

    await observador.query(`
      CREATE TABLE IF NOT EXISTS tb_d08a2_conta (
        id             INT PRIMARY KEY,
        nm_titular     VARCHAR(40) NOT NULL,
        saldo          DECIMAL(10,2) NOT NULL DEFAULT 0,
        dt_atualizacao DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                       ON UPDATE CURRENT_TIMESTAMP(6)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // O estado inicial e posto AQUI, no comeco, e com `ON DUPLICATE KEY UPDATE`
    // em vez de `DELETE` + `INSERT`. O motivo e o mesmo da aula 1: o `DELETE`
    // apagava a linha que outra execucao do MESMO exemplo podia estar lendo,
    // o `SELECT` voltava zero linha e o `linha[0]` quebrava. `DELETE` so entra
    // para a linha auxiliar da secao 5, e sempre com o filtro proprio dela.
    await observador.query(`
      INSERT INTO tb_d08a2_conta (id, nm_titular, saldo)
      VALUES (?, ?, ?), (?, ?, ?)
      ON DUPLICATE KEY UPDATE nm_titular = VALUES(nm_titular), saldo = VALUES(saldo)
    `, [ID_ANA, 'ana', SALDO_INICIAL, ID_BRUNO, 'bruno', SALDO_INICIAL]);

    const saldoDe = async (id) => {
      const [linha] = await observador.query(SQL_SALDO, [id]);
      return linha[0].saldo;
    };

    const zera = async () => {
      await observador.query(SQL_ZERA, [SALDO_INICIAL, ID_ANA]);
      await observador.query(SQL_ZERA, [SALDO_INICIAL, ID_BRUNO]);
    };

    // ---------------------------------------- 1. o lock pessimista
    console.log('\n=== 1. `SELECT ... FOR UPDATE`: a linha travada ===');
    await zera();
    console.log('saldo da ana:', await saldoDe(ID_ANA));

    const [ana, bruno] = await Promise.all([
      sacarComLock(req1, ID_ANA, 10.00, async () => {
        // Janela proposital: enquanto a ana segura o lock, a bruno chega e
        // bate na fila de espera. E aqui que o exemplo mede a espera.
        console.log('  a requisicao da ana pegou o lock e esta lendo');
        await espera(JANELA_MS);
      }),
      (async () => {
        await espera(JANELA_MS / 2);
        return sacarComLock(req2, ID_ANA, 10.00);
      })(),
    ]);

    console.log('  requisicao da ana  -> affectedRows', ana.gravou, ana.motivo ?? '');
    console.log('  requisicao da bruno -> affectedRows', bruno.gravou, bruno.motivo ?? '');
    console.log('saldo da ana depois das duas:', await saldoDe(ID_ANA));
    console.log('As duas leituras rodaram em paralelo, mas as DUAS gravacoes');
    console.log('rodaram em serie: a segunda esperou o `commit` da primeira e so');
    console.log('entao leu o saldo novo. E a fila de espera — o lock pessimista');
    console.log('nao impede a concorrencia, ela a serializa e paga com latencia.');

    // A mesma transacao, sem saldo: o `rollback` e o que devolve.
    console.log('\nmesma transacao pedindo 500.00 de uma conta que nao tem:');
    const semSaldo = await sacarComLock(req3, ID_BRUNO, 500.00);
    console.log('  affectedRows:', semSaldo.gravou, '-', semSaldo.motivo);
    console.log('saldo do bruno:', await saldoDe(ID_BRUNO));
    console.log('O `rollback` e o que mantem o bruno em', texto(SALDO_INICIAL) + ':');
    console.log('o `UPDATE` nem chegou a rodar, e e o `rollback` que fecha o grupo');
    console.log('sem deixar nada gravado. Ver o saldo depois e o que prova isso.');

    // ------------------------------ 2. a fila de espera e o ER_LOCK_WAIT_TIMEOUT
    console.log('\n=== 2. `ER_LOCK_WAIT_TIMEOUT`: esperar e desistir ===');
    await zera();
    await req1.beginTransaction();
    await req1.query(SQL_SALDO_TRAVADO, [ID_BRUNO]);
    console.log('a requisicao 1 travou a linha do bruno com `FOR UPDATE`');
    console.log('a requisicao 2 vai pedir a MESMA linha e vai esperar', ESPERA_LOCK_S, 's');

    const t0 = Date.now();
    let houveTimeout = false;
    try {
      await req2.query(SQL_SALDO_TRAVADO, [ID_BRUNO]);
      console.log('  a requisicao 2 passou?! ninguem segurava o lock');
    } catch (erro) {
      houveTimeout = true;
      console.error(erro.code + ': ' + erro.message);
      console.log('  erro:', erro.code, '-', erro.message);
      console.log('  depois de', ((Date.now() - t0) / 1000).toFixed(1), 's de espera');
      console.log('  sqlState:', erro.sqlState, '| errno:', erro.errno);
    }
    console.log('o `catch` recebeu', houveTimeout ? 'ER_LOCK_WAIT_TIMEOUT' : 'nenhum erro',
      '- e esse `code` e o que se compara, nunca a `message`');

    // O `rollback` devolve a linha. Sem ele a requisicao 1 segura o lock ate o
    // fim do exemplo, e a proxima execucao trava esperando o mesmo lock.
    await req1.rollback();
    console.log('rollback da requisicao 1: a linha voltou a ficar livre');
    const [testeLivre] = await req2.query(SQL_SALDO_TRAVADO, [ID_BRUNO]);
    console.log('  a requisicao 2 passou: saldo', testeLivre[0].saldo);
    await req2.rollback();

    // ------------------------------- o catch que re-tenta, e ele funcionando
    console.log('\n=== 3. o `catch` que re-tenta, medido ===');
    await zera();
    console.log('a requisicao 1 segura a linha por', SEGURA_LOCK_MS, 'ms');
    console.log('a requisicao 2 desiste em', TIMEOUT_DO_RETRY_S, 's e tenta de novo');

    // A transacao lenta NAO e aguardada aqui: ela segura o lock em segundo
    // plano, e e por isso que a primeira tentativa da requisicao 2 morre.
    const segurando = (async () => {
      await req1.beginTransaction();
      await req1.query(SQL_SALDO_TRAVADO, [ID_ANA]);
      await espera(SEGURA_LOCK_MS);
      const [g] = await req1.query(SQL_SUBTRAI, [10.00, ID_ANA]);
      await req1.commit();
      return g.affectedRows;
    })();

    const res = await sacarComRetryDeLock(ID_ANA, 10.00);
    await segurando;
    console.log('  entrou na tentativa', res.tentativa, '- affectedRows', res.gravou);
    console.log('saldo da ana:', await saldoDe(ID_ANA));
    console.log('A primeira tentativa morre de espera; a seguinte, feita depois');
    console.log('da pausa, acha a linha livre e grava. Isso e o que o `catch` faz');
    console.log('com `ER_LOCK_WAIT_TIMEOUT` e com `ER_LOCK_DEADLOCK`: os dois');
    console.log('estao em `ERRO_NAO_ESPERADO`, e os dois sao "tenta de novo".');
    console.log('A pausa entre uma tentativa e a outra conta: repetir colado nao');
    console.log('muda nada, porque a linha ainda esta presa. E o que distingue um');
    console.log('`retry` que funciona de um `retry` que so gasta conexao.');

    // --------------------------------- 4. `LOCK IN SHARE MODE`: lock de leitura
    console.log('\n=== 4. `LOCK IN SHARE MODE`: varias leituras, uma escrita ===');
    await zera();

    await req1.beginTransaction();
    await req1.query('SELECT saldo FROM tb_d08a2_conta WHERE id = ? LOCK IN SHARE MODE', [ID_ANA]);
    await req2.beginTransaction();
    const [leitura2] = await req2.query(
      'SELECT saldo FROM tb_d08a2_conta WHERE id = ? LOCK IN SHARE MODE', [ID_ANA]);
    console.log('duas transacoes com `LOCK IN SHARE MODE` na MESMA linha:');
    console.log('  a requisicao 1 pegou o lock de leitura');
    console.log('  a requisicao 2 tambem leu, sem esperar:', leitura2[0].saldo);
    console.log('  o lock de leitura e compartilhado: ele trava contra quem');
    console.log('  ESCREVE, e nao contra quem le.');

    const t1 = Date.now();
    let esperouEscrita = false;
    try {
      await req3.query('UPDATE tb_d08a2_conta SET saldo = saldo - 1 WHERE id = ?', [ID_ANA]);
    } catch (erro) {
      esperouEscrita = true;
      console.error(erro.code + ': ' + erro.message);
      console.log('  a ESCRITA esperou e desistiu:', erro.code,
        '| depois de', ((Date.now() - t1) / 1000).toFixed(1), 's');
    }
    console.log('  a escrita esperou?', esperouEscrita ? 'sim' : 'nao');

    await req1.rollback();
    await req2.rollback();
    console.log('  as duas leituras fecharam com rollback; a linha esta livre');
    console.log('`FOR UPDATE` e `LOCK IN SHARE MODE` sao o mesmo mecanismo com');
    console.log('condicao diferente: exclusivo contra compartilhado.');

    // ------------------------------------------- 5. isolamento e fantasma
    console.log('\n=== 5. isolamento: `REPEATABLE READ` e `READ COMMITTED` ===');
    await zera();

    // A contagem e filtrada pelo `id` do INSERT de dentro da secao, e nao e
    // `COUNT(*)` da tabela inteira. A razao e medida: duas execucoes do mesmo
    // exemplo rodando ao mesmo tempo se atrapalham na `tb_d08a2_conta` compartilhada,
    // e o numero da contagem passa a depender da outra. Filtrando pelo proprio
    // `id`, o resultado depende so desta transacao.
    const contaComFantasma = async (nivel) => {
      await req1.query('SET SESSION TRANSACTION ISOLATION LEVEL ' + nivel);
      await req1.beginTransaction();
      const [antes] = await req1.query(
        'SELECT COUNT(*) AS n FROM tb_d08a2_conta WHERE id >= ?', [ID_EXTERNO]);
      // A outra transacao insere enquanto a primeira esta aberta.
      await req2.query(
        'INSERT INTO tb_d08a2_conta (id, nm_titular, saldo) VALUES (?, ?, ?)',
        [ID_EXTERNO, 'carla', 50.00]);
      const [depois] = await req1.query(
        'SELECT COUNT(*) AS n FROM tb_d08a2_conta WHERE id >= ?', [ID_EXTERNO]);
      await req1.rollback();
      await req2.query('DELETE FROM tb_d08a2_conta WHERE id = ?', [ID_EXTERNO]);
      await req1.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
      return { antes: antes[0].n, depois: depois[0].n };
    };

    const rr = await contaComFantasma('REPEATABLE READ');
    console.log('REPEATABLE READ (o padrao deste servidor):');
    console.log('  1a contagem', rr.antes, '| 2a contagem', rr.depois);
    console.log('  ', rr.depois === rr.antes
      ? 'a segunda repetiu a primeira: a linha da carla nao apareceu'
      : 'a segunda viu a linha nova');

    const rc = await contaComFantasma('READ COMMITTED');
    console.log('READ COMMITTED:');
    console.log('  1a contagem', rc.antes, '| 2a contagem', rc.depois);
    console.log('  ', rc.depois > rc.antes
      ? 'a segunda VIU a linha nova: e a leitura "fantasma"'
      : 'a segunda repetiu a primeira');

    console.log('A diferenca e o nivel de isolamento, e nao o codigo: as duas');
    console.log('transacoes fizeram exatamente as mesmas consultas. `READ');
    console.log('COMMITTED` entrega a leitura mais nova a cada comando;');
    console.log('`REPEATABLE READ` repete o que a transacao viu no primeiro.');

    // ------------------------------------- 7. `ER_LOCK_DEADLOCK`
    console.log('\n=== 7. `ER_LOCK_DEADLOCK`: dois se segurando ===');
    await zera();

    // Cada transacao segura uma linha e pede a OUTRA. O banco ve o ciclo e
    // escolhe uma das duas para morrer — e ela morre com `ER_LOCK_DEADLOCK`,
    // nao com erro de sintaxe nem com violacao de chave.
    await req1.beginTransaction();
    await req1.query('UPDATE tb_d08a2_conta SET saldo = saldo - 1 WHERE id = ?', [ID_ANA]);
    await req2.beginTransaction();
    await req2.query('UPDATE tb_d08a2_conta SET saldo = saldo - 1 WHERE id = ?', [ID_BRUNO]);
    console.log('  a requisicao 1 pegou a ana, a requisicao 2 pegou o bruno');
    console.log('  agora cada uma pede a linha que a outra segura');

    const [d1, d2] = await Promise.allSettled([
      (async () => { await espera(JANELA_MS); return req1.query(SQL_SUBTRAI, [1.00, ID_BRUNO]); })(),
      (async () => { await espera(JANELA_MS); return req2.query(SQL_SUBTRAI, [1.00, ID_ANA]); })(),
    ]);

    const mortos = [['requisicao 1', d1], ['requisicao 2', d2]];
    for (const [nome, r] of mortos) {
      if (r.status === 'rejected') {
        console.error(nome + ' morreu com ' + r.reason.code + ': ' + r.reason.message);
        console.log('  ', nome, 'morreu com', r.reason.code,
          '- o banco escolheu quem desfaz, e o motivo esta no `code`');
      } else {
        console.log('  ', nome, 'atualizou sem erro');
      }
    }

    // O `rollback` das DUAS e obrigatorio: a transacao que morreu ja foi
    // desfeita pelo banco, mas a que sobreviveu continua aberta com a linha
    // travada, e e ela que prende a proxima execucao.
    await req1.rollback().catch(() => {});
    await req2.rollback().catch(() => {});
    console.log('  as duas transacoes fecharam com rollback');

    console.log('`ER_LOCK_DEADLOCK` e `ER_LOCK_WAIT_TIMEOUT` vao para o mesmo');
    console.log('`catch` e para o mesmo `ERRO_NAO_ESPERADO`: os dois sao aviso de');
    console.log('disputa, nao erro de logica. Comparar `erro.message` nao funciona —');
    console.log('ela muda de traducao em traducao e nao diz nada.');
    console.log('A diferenca honesta entre os dois: o `ER_LOCK_WAIT_TIMEOUT` da secao');
    console.log('3 se resolve com o retry, porque a linha fica livre sozinha. O');
    console.log('deadlock nasce da ORDEM em que as linhas sao pedidas — repetir o');
    console.log('mesmo codigo na mesma ordem repete o mesmo ciclo, e o retry nao');
    console.log('tem como vencer. A correcao de verdade e uma so: pegar as linhas');
    console.log('sempre na MESMA ordem, em todo o codigo que mexe nelas.');

    // -------------------------------------------------- 8. o resumo
    console.log('\n=== 8. o que cada uma resolve ===');
    console.log('`UPDATE tb_d08a2_conta SET saldo = saldo - 10');
    console.log('   WHERE id = 1 AND saldo >= 10`');
    console.log('  sem lock, resolve o saldo, e e o mais barato. Nao segura um');
    console.log('  grupo de operacoes nem impede duas leituras de decidir juntas.');
    console.log('`SELECT ... FOR UPDATE` dentro de transacao');
    console.log('  segura a linha e serializa quem mexe nela. Paga em latencia e');
    console.log('  traz `ER_LOCK_WAIT_TIMEOUT` e `ER_LOCK_DEADLOCK` para tratar.');
    console.log('`WHERE id = 1 AND versao = ?` (lock otimista)');
    console.log('  nao trava nada e nao espera ninguem. Quem perdeu leva');
    console.log('  `affectedRows` 0 e precisa tentar de novo.');

    // Nada e apagado no fim: as proximas rodadas comecam pelo `ON DUPLICATE KEY
    // UPDATE` do comeco, que repoe os saldos. Um `DELETE` aqui seria o mesmo
    // defeito do comeco, so que mais tarde e com a outra execucao ja em curso.
  } finally {
    // Toda conexao fecha aqui, mesmo com lancamento: uma conexao viva segura
    // o processo, e o portao espera ate o timeout.
    await Promise.all([
      observador.end(), req1.end(), req2.end(), req3.end(),
    ]);
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

Banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1
nivel de isolamento padrao do servidor: REPEATABLE-READ
espera de lock padrao do servidor: 50 s — este exemplo baixa para 2 s nas conexoes dele
os dois numeros sao lidos do servidor com SELECT @@variavel,
e nao escritos aqui: a maquina de quem estuda pode ter outro padrao

=== 1. `SELECT ... FOR UPDATE`: a linha travada ===
saldo da ana: 100.00
  a requisicao da ana pegou o lock e esta lendo
  requisicao da ana  -> affectedRows 1 
  requisicao da bruno -> affectedRows 1 
saldo da ana depois das duas: 80.00
As duas leituras rodaram em paralelo, mas as DUAS gravacoes
rodaram em serie: a segunda esperou o `commit` da primeira e so
entao leu o saldo novo. E a fila de espera — o lock pessimista
nao impede a concorrencia, ela a serializa e paga com latencia.

mesma transacao pedindo 500.00 de uma conta que nao tem:
  affectedRows: 0 - saldo insuficiente
saldo do bruno: 100.00
O `rollback` e o que mantem o bruno em 100.00:
o `UPDATE` nem chegou a rodar, e e o `rollback` que fecha o grupo
sem deixar nada gravado. Ver o saldo depois e o que prova isso.

=== 2. `ER_LOCK_WAIT_TIMEOUT`: esperar e desistir ===
a requisicao 1 travou a linha do bruno com `FOR UPDATE`
a requisicao 2 vai pedir a MESMA linha e vai esperar 2 s
  erro: ER_LOCK_WAIT_TIMEOUT - Lock wait timeout exceeded; try restarting transaction
  depois de 2.0 s de espera
  sqlState: HY000 | errno: 1205
o `catch` recebeu ER_LOCK_WAIT_TIMEOUT - e esse `code` e o que se compara, nunca a `message`
rollback da requisicao 1: a linha voltou a ficar livre
  a requisicao 2 passou: saldo 100.00

=== 3. o `catch` que re-tenta, medido ===
a requisicao 1 segura a linha por 1700 ms
a requisicao 2 desiste em 1 s e tenta de novo
  tentativa 1/5 falhou com ER_LOCK_WAIT_TIMEOUT - disputa de lock, o banco mandou tentar de novo
  entrou na tentativa 2 - affectedRows 1
saldo da ana: 80.00
A primeira tentativa morre de espera; a seguinte, feita depois
da pausa, acha a linha livre e grava. Isso e o que o `catch` faz
com `ER_LOCK_WAIT_TIMEOUT` e com `ER_LOCK_DEADLOCK`: os dois
estao em `ERRO_NAO_ESPERADO`, e os dois sao "tenta de novo".
A pausa entre uma tentativa e a outra conta: repetir colado nao
muda nada, porque a linha ainda esta presa. E o que distingue um
`retry` que funciona de um `retry` que so gasta conexao.

=== 4. `LOCK IN SHARE MODE`: varias leituras, uma escrita ===
duas transacoes com `LOCK IN SHARE MODE` na MESMA linha:
  a requisicao 1 pegou o lock de leitura
  a requisicao 2 tambem leu, sem esperar: 100.00
  o lock de leitura e compartilhado: ele trava contra quem
  ESCREVE, e nao contra quem le.
  a ESCRITA esperou e desistiu: ER_LOCK_WAIT_TIMEOUT | depois de 2.0 s
  a escrita esperou? sim
  as duas leituras fecharam com rollback; a linha esta livre
`FOR UPDATE` e `LOCK IN SHARE MODE` sao o mesmo mecanismo com
condicao diferente: exclusivo contra compartilhado.

=== 5. isolamento: `REPEATABLE READ` e `READ COMMITTED` ===
REPEATABLE READ (o padrao deste servidor):
  1a contagem 0 | 2a contagem 0
   a segunda repetiu a primeira: a linha da carla nao apareceu
READ COMMITTED:
  1a contagem 0 | 2a contagem 1
   a segunda VIU a linha nova: e a leitura "fantasma"
A diferenca e o nivel de isolamento, e nao o codigo: as duas
transacoes fizeram exatamente as mesmas consultas. `READ
COMMITTED` entrega a leitura mais nova a cada comando;
`REPEATABLE READ` repete o que a transacao viu no primeiro.

=== 7. `ER_LOCK_DEADLOCK`: dois se segurando ===
  a requisicao 1 pegou a ana, a requisicao 2 pegou o bruno
  agora cada uma pede a linha que a outra segura
   requisicao 1 atualizou sem erro
   requisicao 2 morreu com ER_LOCK_DEADLOCK - o banco escolheu quem desfaz, e o motivo esta no `code`
  as duas transacoes fecharam com rollback
`ER_LOCK_DEADLOCK` e `ER_LOCK_WAIT_TIMEOUT` vao para o mesmo
`catch` e para o mesmo `ERRO_NAO_ESPERADO`: os dois sao aviso de
disputa, nao erro de logica. Comparar `erro.message` nao funciona —
ela muda de traducao em traducao e nao diz nada.
A diferenca honesta entre os dois: o `ER_LOCK_WAIT_TIMEOUT` da secao
3 se resolve com o retry, porque a linha fica livre sozinha. O
deadlock nasce da ORDEM em que as linhas sao pedidas — repetir o
mesmo codigo na mesma ordem repete o mesmo ciclo, e o retry nao
tem como vencer. A correcao de verdade e uma so: pegar as linhas
sempre na MESMA ordem, em todo o codigo que mexe nelas.

=== 8. o que cada uma resolve ===
`UPDATE tb_d08a2_conta SET saldo = saldo - 10
   WHERE id = 1 AND saldo >= 10`
  sem lock, resolve o saldo, e e o mais barato. Nao segura um
  grupo de operacoes nem impede duas leituras de decidir juntas.
`SELECT ... FOR UPDATE` dentro de transacao
  segura a linha e serializa quem mexe nela. Paga em latencia e
  traz `ER_LOCK_WAIT_TIMEOUT` e `ER_LOCK_DEADLOCK` para tratar.
`WHERE id = 1 AND versao = ?` (lock otimista)
  nao trava nada e nao espera ninguem. Quem perdeu leva
  `affectedRows` 0 e precisa tentar de novo.