Dia 8 — Transações e concorrência
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:
affectedRowsde 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
affectedRows | Significado | O que a rota responde |
|---|---|---|
| 1 | a linha casou e foi gravada | 200, com o saldo novo |
| 0 | o filtro não casou, nada foi tocado | 409 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ção | versão no WHERE | |
|---|---|---|
| o que impede | saque acima do saldo | qualquer escrita concorrente |
| o que não impede | duas leituras decidindo juntas | nada — detecta e devolve |
| espera | nenhuma | nenhuma |
o que o affectedRows 0 diz | "não deu" | "mudou, refaça" |
| custo | uma coluna a menos | dt_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.
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:
- O
FOR UPDATEvem antes doUPDATE. Depois, seria tarde: a outra
transação já teria escrito e o SELECT leria o saldo novo.
- Sem transação o lock não existe.
FOR UPDATEfora de transação não trava
nada — o lock nasce do BEGIN e morre no COMMIT ou no ROLLBACK.
- Só trava a linha lida.
WHERE id = 1trava 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:
| propriedade | valor | serve para comparar? |
|---|---|---|
erro.code | ER_LOCK_WAIT_TIMEOUT | sim — é o contrato do driver |
erro.errno | 1205 | sim, numérico e estável |
erro.sqlState | HY000 | gené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 SHAREe 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ível | a segunda contagem | o que garante |
|---|---|---|
READ UNCOMMITTED | vê até o que não foi confirmado | nada |
READ COMMITTED | vê o que entrou depois | nada fica sujo |
REPEATABLE READ | repete a primeira | leitura estável |
SERIALIZABLE | repete a primeira e serializa escrita | isolamento 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 LEVELnã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ção | segura | pede | estado |
|---|---|---|---|
| requisição 1 | conta 1 | conta 2 | esperando |
| requisição 2 | conta 2 | conta 1 | esperando |
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 = 2em 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.