Blog

Linha quente que serializa a aplicação: contador e saldo disputados por todas as transações

Às 21h da abertura de uma campanha de Black Friday, o checkout de um marketplace com 3.100 lojistas parou de crescer em 380 pedidos por segundo. O p99 do fechamento de pedido saiu de 220 ms para 11 segundos, o pool de conexões esgotou e a fila de requisições cresceu, mas a CPU do banco estava em 34% e as instâncias da aplicação tinham folga. Nenhuma consulta lenta nova, nenhum deploy. Duas linhas explicavam tudo: a conta da plataforma, que recebia a comissão de cada venda, e o contador de pedidos do dia que alimentava o painel comercial. Todas as transações de checkout atualizavam essas mesmas duas linhas, e o banco, corretamente, deixava uma de cada vez. Este artigo mostra por que uma linha quente limita a vazão do sistema inteiro ao inverso do tempo em que o lock fica preso, como provar a disputa com dados do próprio Postgres e as três mudanças que liberaram o checkout: encurtar a posse do lock, dividir contadores em fatias e trocar o saldo disputado por um livro-razão com consolidação.

2026-11-06 / Arquitetura / 15 min

01

A fila que não aparece na CPU

Um UPDATE em uma linha do PostgreSQL trava aquela linha até o fim da transação, não até o fim do comando. Quem chega depois para atualizar a mesma linha espera o COMMIT ou o ROLLBACK de quem chegou antes. Isso não é defeito: é o que impede dois créditos simultâneos de lerem o mesmo saldo e um deles sumir. A consequência é aritmética. Se cada transação segura o lock da linha por T milissegundos, aquela linha aceita no máximo 1.000 / T atualizações por segundo, não importa quantos núcleos o banco tenha nem quantas instâncias a aplicação rode.

No checkout do marketplace, o crédito na conta da plataforma era o segundo comando de uma transação com sete. Depois dele vinham a gravação dos itens, do pagamento, da reserva de estoque, do contador do painel, uma consulta de frete já calculado e o COMMIT, que espera o WAL ser gravado em disco. Somando as idas e voltas pela rede e o fsync do commit, o lock da linha ficava preso por cerca de 2,6 ms. 1.000 / 2,6 dá 385 transações por segundo, exatamente o teto observado.

  • A CPU do banco fica baixa porque sessões esperando lock não consomem CPU; elas só ocupam conexões.
  • O pool esgota porque cada conexão fica presa na fila da linha, e as requisições novas passam a esperar conexão antes de esperar o lock.
  • Aumentar instâncias da aplicação piora: mais transações concorrentes para a mesma linha, fila maior, mesma vazão.
  • Aumentar a máquina do banco não muda nada: o limite é o tempo de posse do lock, não o hardware.
  • Cada UPDATE cria uma nova versão da linha. Mil atualizações por segundo na mesma linha enchem a página de versões mortas, e as leituras dessa linha percorrem a cadeia até achar a versão visível.

O padrão se repete em qualquer sistema que concentra escrita em um agregado único: saldo de uma conta da plataforma, estoque de um produto em promoção, contador de ingressos vendidos de um show, sequência de numeração de notas fiscais mantida em tabela, total de mensagens enviadas no dia por cliente. Em volume baixo ninguém percebe. No pico, a linha vira um semáforo global da aplicação.

02

Como provar que o gargalo é uma linha, não o banco

Antes de mudar modelo de dados, é preciso evidência de que o tempo está indo para espera de lock em linhas específicas. Quatro leituras resolvem: quem espera por quem neste instante, quais UPDATEs têm tempo mínimo baixo e média alta, quantas atualizações cada linha viva recebeu e o que o próprio Postgres registra no log quando uma espera passa de um segundo.

-- 1. Quem espera agora e por quem. Dezenas de sessoes presas atras do mesmo pid,
--    todas executando o mesmo UPDATE, sao o retrato de uma linha quente.
SELECT pg_blocking_pids(a.pid) AS bloqueado_por,
       a.wait_event_type,
       a.wait_event,
       count(*) AS sessoes,
       left(a.query, 70) AS consulta
  FROM pg_stat_activity a
 WHERE a.wait_event_type = 'Lock'
 GROUP BY 1, 2, 3, 5
 ORDER BY sessoes DESC;

-- 2. UPDATE com minimo baixo e media alta: o tempo e espera, nao trabalho.
SELECT left(query, 70) AS consulta,
       calls,
       round(min_exec_time::numeric, 2)  AS min_ms,
       round(mean_exec_time::numeric, 2) AS media_ms,
       round(max_exec_time::numeric, 0)  AS max_ms
  FROM pg_stat_statements
 WHERE query ILIKE 'update%'
 ORDER BY total_exec_time DESC
 LIMIT 5;

-- 3. Atualizacoes por linha viva: milhoes de versoes para meia duzia de linhas.
SELECT relname,
       n_live_tup,
       n_tup_upd,
       n_tup_hot_upd,
       round(n_tup_upd::numeric / nullif(n_live_tup, 0), 0) AS updates_por_linha
  FROM pg_stat_user_tables
 WHERE relname IN ('contas', 'metricas_diarias');

-- 4. O proprio Postgres aponta a tupla disputada no log
--    (esperas acima de deadlock_timeout, 1 s por padrao).
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
-- LOG:      process 48211 still waiting for ShareLock on transaction 9182231 after 1000.112 ms
-- CONTEXT:  while updating tuple (0,7) in relation "contas"

Na noite do incidente, a primeira consulta mostrou 212 sessões com wait_event_type igual a Lock, quase todas presas atrás do mesmo pid e executando o UPDATE da conta da plataforma. A segunda mostrou esse UPDATE com mínimo de 0,04 ms e média de 1.840 ms: o trabalho levava microssegundos, o resto era fila. Na terceira, a tabela contas tinha 41 mil linhas vivas e 96 milhões de atualizações acumuladas, quase todas na mesma linha. E o log, com log_lock_waits ligado, apontou a tupla exata: a mesma posição na mesma relação, milhares de vezes por minuto.

Um detalhe ajuda a ler a fila. A primeira sessão que espera fica com o lock da tupla e aguarda o fim da transação de quem está escrevendo (wait_event transactionid); as demais esperam pela tupla (wait_event tuple). Uma pilha de sessões em tuple, todas com a mesma consulta, é a assinatura de linha quente. Espera espalhada por transactionid em consultas diferentes costuma ser outro problema, como transações longas ou ordem de lock inconsistente.

03

Primeiro passo: encurtar o tempo em que o lock fica preso

Como o teto é 1.000 / T, a mudança mais barata é reduzir T sem mexer no modelo. O lock nasce no UPDATE e morre no COMMIT, então tudo o que acontece entre os dois entra na conta. Mover as atualizações das linhas quentes para o fim da transação, imediatamente antes do COMMIT, tira do intervalo todos os comandos que não disputam nada.

// Antes: o credito da comissao era o segundo comando da transacao, e o lock da linha
// da conta da plataforma ficava preso durante todos os comandos seguintes e o COMMIT.
// Depois: tudo o que nao disputa linha vem primeiro; as linhas quentes sao tocadas
// imediatamente antes do COMMIT, no menor intervalo possivel.
const CONTA_PLATAFORMA = 1;

export async function fecharPedido(pool, pedido) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    await client.query(
      'INSERT INTO pedidos (id, loja_id, valor_centavos) VALUES ($1, $2, $3)',
      [pedido.id, pedido.lojaId, pedido.valorCentavos],
    );
    await client.query(
      'INSERT INTO itens_pedido (pedido_id, sku, quantidade) ' +
        'SELECT $1, i.sku, i.quantidade ' +
        'FROM jsonb_to_recordset($2::jsonb) AS i(sku text, quantidade int)',
      [pedido.id, JSON.stringify(pedido.itens)],
    );
    await client.query(
      'INSERT INTO pagamentos (pedido_id, gateway_id, valor_centavos) VALUES ($1, $2, $3)',
      [pedido.id, pedido.gatewayId, pedido.valorCentavos],
    );

    // Linhas quentes por ultimo: o lock vive so ate o COMMIT logo abaixo.
    await client.query(
      'UPDATE contas SET saldo_centavos = saldo_centavos + $2 WHERE id = $1',
      [CONTA_PLATAFORMA, pedido.comissaoCentavos],
    );
    await client.query(
      "UPDATE metricas_diarias SET valor = valor + 1 WHERE metrica = 'pedidos' AND dia = current_date",
    );

    await client.query('COMMIT');
  } catch (erro) {
    await client.query('ROLLBACK').catch(() => {});
    throw erro;
  } finally {
    client.release();
  }
}

Com essa reordenação, o lock passou a durar uma ida e volta do UPDATE mais o commit, cerca de 1,4 ms, e o teto subiu para pouco mais de 700 pedidos por segundo. Dobrou a vazão com uma mudança de ordem, mas ainda ficou abaixo dos 1.600 por segundo previstos para o pico. A tabela abaixo resume o que encurta o lock e o que parece encurtar, mas cobra caro.

AçãoEfeito no tempo de lockCusto ou risco
Atualizar linhas quentes por últimoRemove do intervalo todos os comandos anterioresNenhum; exige revisar a ordem em todos os fluxos que tocam a linha
Tirar chamadas de rede externas da transaçãoRemove dezenas ou centenas de msExige outbox ou etapa posterior para efeitos externos
Juntar comandos em um só (CTE ou função)Reduz idas e voltas pela redeLógica mais difícil de ler e testar
Aplicação e banco na mesma zonaReduz a latência de cada ida e voltaDepende da infraestrutura
synchronous_commit = off na transaçãoRemove a espera do fsync do commitPode perder as últimas transações confirmadas se o servidor cair; inaceitável para dinheiro

Atenção à ordem quando duas linhas quentes são atualizadas na mesma transação: todos os fluxos precisam tocá-las na mesma sequência, ou a reordenação troca fila por deadlock. Aqui, conta da plataforma antes do contador em todos os caminhos de código.

04

Contadores: dividir a linha em fatias

O contador de pedidos do dia não precisa de exatidão no instante da escrita, só na leitura, e soma é comutativa. Isso permite trocar uma linha por N linhas: cada escrita sorteia uma fatia e incrementa só ela, e a leitura soma todas. Com 16 fatias, 16 transações podem incrementar o contador ao mesmo tempo, e o teto efetivo multiplica por um fator próximo de 16.

-- Um contador vira N linhas; cada escrita toca so uma delas.
CREATE TABLE metrica_fatia (
  metrica text     NOT NULL,
  dia     date     NOT NULL,
  fatia   smallint NOT NULL,
  valor   bigint   NOT NULL DEFAULT 0,
  PRIMARY KEY (metrica, dia, fatia)
);

-- Incremento: a fatia ($3, entre 0 e 15) e sorteada pela aplicacao.
-- O upsert cria a fatia do dia na primeira escrita, sem job de virada.
INSERT INTO metrica_fatia (metrica, dia, fatia, valor)
VALUES ($1, current_date, $3, $2)
ON CONFLICT (metrica, dia, fatia)
DO UPDATE SET valor = metrica_fatia.valor + EXCLUDED.valor;

-- Leitura: a soma das fatias, no maximo 16 linhas pela chave primaria.
SELECT coalesce(sum(valor), 0) AS total
  FROM metrica_fatia
 WHERE metrica = 'pedidos'
   AND dia = current_date;

-- Armadilha: random() no WHERE e avaliado uma vez por linha, nao uma vez por comando.
-- Este UPDATE pode incrementar zero, uma ou varias fatias.
UPDATE metrica_fatia
   SET valor = valor + 1
 WHERE metrica = 'pedidos'
   AND dia = current_date
   AND fatia = floor(random() * 16)::int;

O último comando do bloco é a armadilha que mais aparece nessa técnica. Como random() é uma função volátil, o Postgres a avalia de novo para cada linha que examina, e não uma vez por comando. O UPDATE compara cada fatia com um sorteio diferente e pode atualizar nenhuma, uma ou várias, e o contador passa a errar de forma silenciosa. A fatia precisa vir pronta da aplicação, como parâmetro.

  • Quantidade de fatias: vazão de pico vezes o tempo de lock vezes uma folga de 2 a 4. Com 1.600 por segundo e 1,4 ms, dá cerca de 4,5 escritas simultâneas; 16 fatias deixam folga para crescer.
  • Mais fatias não é de graça: a leitura soma mais linhas e as colisões caem cada vez menos a partir de certo ponto.
  • Sorteio aleatório distribui bem com muitos clientes; usar o pid da conexão concentra escrita quando o pool tem poucas conexões ativas.
  • Fatias funcionam para somas e contagens. Não funcionam para valores que precisam de verificação no momento da escrita, como saldo que não pode ficar negativo.

05

Saldos: livro-razão com consolidação em vez de UPDATE disputado

A conta da plataforma tem uma exigência que o contador não tem: débitos, como repasses e estornos, só podem acontecer se houver saldo. Fatiar o saldo obrigaria cada débito a travar todas as fatias. A alternativa que resolve a escrita e ainda entrega histórico para auditoria é parar de atualizar o saldo a cada venda. Cada crédito vira um lançamento inserido, que nunca disputa linha com outro, e um processo consolidador aplica os lançamentos pendentes no saldo em lotes.

-- Saldo consolidado: uma linha por conta, tocada so pelo consolidador e por debitos.
CREATE TABLE conta_saldo (
  conta_id       bigint PRIMARY KEY,
  saldo_centavos bigint NOT NULL DEFAULT 0,
  consolidado_em timestamptz NOT NULL DEFAULT now()
);

-- Historico permanente e append-only: auditoria e conciliacao leem daqui.
CREATE TABLE lancamento (
  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  conta_id       bigint NOT NULL,
  valor_centavos bigint NOT NULL,
  referencia     text   NOT NULL,
  criado_em      timestamptz NOT NULL DEFAULT now(),
  UNIQUE (conta_id, referencia)
);

-- O que ainda nao entrou no saldo consolidado. Cada linha vive poucos segundos.
CREATE TABLE lancamento_pendente (
  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  lancamento_id  bigint NOT NULL,
  conta_id       bigint NOT NULL,
  valor_centavos bigint NOT NULL
);
CREATE INDEX lancamento_pendente_conta ON lancamento_pendente (conta_id, id);

-- Tabela pequena com muita insercao e exclusao: vacuum por volume absoluto.
ALTER TABLE lancamento_pendente SET (
  autovacuum_vacuum_scale_factor = 0,
  autovacuum_vacuum_threshold = 5000
);

-- Saldo atual = consolidado + pendentes, lidos no mesmo snapshot.
SELECT s.saldo_centavos
       + coalesce((SELECT sum(p.valor_centavos)
                     FROM lancamento_pendente p
                    WHERE p.conta_id = s.conta_id), 0) AS saldo_centavos
  FROM conta_saldo s
 WHERE s.conta_id = $1;

-- Consolidacao em lotes: apaga pendentes e soma no saldo na mesma transacao.
WITH lote AS (
  DELETE FROM lancamento_pendente
   WHERE id IN (
     SELECT id
       FROM lancamento_pendente
      WHERE conta_id = $1
      ORDER BY id
      LIMIT 5000
      FOR UPDATE SKIP LOCKED
   )
  RETURNING valor_centavos
), soma AS (
  SELECT count(*) AS linhas, coalesce(sum(valor_centavos), 0) AS total
    FROM lote
)
UPDATE conta_saldo s
   SET saldo_centavos = s.saldo_centavos + soma.total,
       consolidado_em = now()
  FROM soma
 WHERE s.conta_id = $1
   AND soma.linhas > 0
RETURNING soma.linhas;

Dois detalhes sustentam a correção. Primeiro, o saldo atual é o consolidado mais a soma dos pendentes, lidos em um único comando: os dois valores vêm do mesmo snapshot, e como o consolidador apaga pendentes e soma no saldo na mesma transação, a leitura nunca conta um lançamento duas vezes nem deixa de contá-lo. Segundo, os pendentes ficam em uma tabela separada e são apagados ao consolidar. A versão que guarda só o último id consolidado parece mais simples e perde dinheiro: ids de identidade são distribuídos na inserção, não no commit, então um lançamento com id menor pode ficar visível depois que o consolidador já passou por ele.

const LOTE = 5000;

const LANCAR_SQL = [
  'WITH novo AS (',
  '  INSERT INTO lancamento (conta_id, valor_centavos, referencia)',
  '  VALUES ($1, $2, $3)',
  '  ON CONFLICT (conta_id, referencia) DO NOTHING',
  '  RETURNING id, conta_id, valor_centavos',
  ')',
  'INSERT INTO lancamento_pendente (lancamento_id, conta_id, valor_centavos)',
  'SELECT id, conta_id, valor_centavos FROM novo',
].join('\n');

const SALDO_SQL = [
  'SELECT s.saldo_centavos + coalesce((',
  '  SELECT sum(p.valor_centavos) FROM lancamento_pendente p',
  '   WHERE p.conta_id = s.conta_id), 0) AS saldo_centavos',
  '  FROM conta_saldo s WHERE s.conta_id = $1',
].join('\n');

const CONSOLIDAR_SQL = [
  'WITH lote AS (',
  '  DELETE FROM lancamento_pendente WHERE id IN (',
  '    SELECT id FROM lancamento_pendente WHERE conta_id = $1',
  '     ORDER BY id LIMIT ' + LOTE + ' FOR UPDATE SKIP LOCKED)',
  '  RETURNING valor_centavos',
  '), soma AS (',
  '  SELECT count(*) AS linhas, coalesce(sum(valor_centavos), 0) AS total FROM lote',
  ')',
  'UPDATE conta_saldo s SET saldo_centavos = s.saldo_centavos + soma.total,',
  '       consolidado_em = now()',
  '  FROM soma WHERE s.conta_id = $1 AND soma.linhas > 0',
  'RETURNING soma.linhas',
].join('\n');

export class SaldoInsuficiente extends Error {}

// Credito: so insere. Roda dentro da transacao do pedido e nao disputa nenhuma linha.
// A referencia (ex.: "pedido:8812:comissao") torna a repeticao inofensiva.
export async function creditar(client, contaId, valorCentavos, referencia) {
  await client.query(LANCAR_SQL, [contaId, valorCentavos, referencia]);
}

// Debito: raro (repasse, estorno, saque). Trava so a linha consolidada da conta,
// e o saldo e lido em um comando seguinte, com snapshot novo, depois do lock.
export async function debitar(pool, contaId, valorCentavos, referencia) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('SELECT 1 FROM conta_saldo WHERE conta_id = $1 FOR UPDATE', [contaId]);
    const { rows } = await client.query(SALDO_SQL, [contaId]);
    if (Number(rows[0].saldo_centavos) < valorCentavos) {
      throw new SaldoInsuficiente('saldo insuficiente na conta ' + contaId);
    }
    await client.query(LANCAR_SQL, [contaId, -valorCentavos, referencia]);
    await client.query('COMMIT');
  } catch (erro) {
    await client.query('ROLLBACK').catch(() => {});
    throw erro;
  } finally {
    client.release();
  }
}

// Aplica os pendentes em lotes; cada lote e uma transacao curta e independente.
export async function consolidar(pool, contaId) {
  let aplicadas = 0;
  for (;;) {
    const { rows } = await pool.query(CONSOLIDAR_SQL, [contaId]);
    const linhas = rows.length ? Number(rows[0].linhas) : 0;
    aplicadas += linhas;
    if (linhas < LOTE) return aplicadas;
  }
}

export function iniciarConsolidador(pool, contaId, intervaloMs = 500) {
  let parado = false;
  const ciclo = async () => {
    if (parado) return;
    try {
      await consolidar(pool, contaId);
    } catch (erro) {
      console.error('consolidacao da conta ' + contaId + ' falhou', erro);
    }
    setTimeout(ciclo, intervaloMs);
  };
  ciclo();
  return () => {
    parado = true;
  };
}

O débito é o único caminho que ainda trava uma linha, e é raro: algumas dezenas por hora contra 1.600 créditos por segundo. Ele trava a linha consolidada, lê o saldo em um comando seguinte, que no READ COMMITTED enxerga tudo o que foi confirmado até ali, e só então insere o lançamento negativo. Dois débitos simultâneos ficam em fila entre si, que é o comportamento desejado. Créditos continuam entrando sem esperar, e como só aumentam o saldo, não invalidam a verificação de quem está debitando.

Checkout (1.600 por segundo)                 Repasse / estorno (poucos por hora)
        |                                                  |
        v                                                  v
INSERT pedidos, itens, pagamentos              SELECT ... FROM conta_saldo FOR UPDATE
        |                                                  |
        v                                                  v
INSERT lancamento + lancamento_pendente        saldo = consolidado + soma dos pendentes
  (sem disputa: cada pedido cria a sua linha)              |
        |                                       suficiente? -- não --> recusa
        v                                                  | sim
upsert em metrica_fatia (1 de 16 fatias)                   v
        |                                       INSERT lançamento negativo
        v                                                  |
      COMMIT                                             COMMIT

Consolidador, a cada 500 ms:
  DELETE até 5.000 pendentes + UPDATE conta_saldo, na mesma transação

06

Como escolher a técnica e o que mudou no checkout

Não existe uma solução única para linha quente; existe a combinação certa para o tipo de valor. A pergunta que decide é: o valor precisa ser verificado no momento da escrita ou só precisa estar certo quando alguém lê?

TécnicaDisputa na escritaCusto da leituraRecusa débito sem saldo?Quando usar
Encurtar o lockReduz, não elimina1 linhaSimSempre, como primeiro passo
FatiasDividida por NSoma de N linhasSó travando todas as fatiasContadores e somas sem verificação
Livro-razão com consolidaçãoNenhuma nos créditosConsolidado mais pendentesSim, travando a conta só no débitoSaldos com histórico e débitos raros
Contador em memória ou Redis com descarga periódicaNenhuma no bancoAtrasada e sem garantiaNãoMétricas que toleram perda
Fila com um único escritorNenhuma no banco, serial na filaAtrasadaSim, no consumidorQuando o resultado pode ser assíncrono

No marketplace, o contador do painel foi para 16 fatias, a comissão da plataforma passou a ser lançamento com consolidação a cada 500 ms e as contas dos lojistas, que recebem poucas escritas por segundo cada uma, continuaram como estavam. Mexer só no que a medição apontou como quente manteve a mudança pequena e fácil de revisar. O ensaio de carga com o volume previsto para o pico e a primeira noite de campanha seguinte confirmaram os números abaixo.

MétricaAntesDepois
Vazão máxima do checkout380 pedidos/s1.600 pedidos/s sustentados no ensaio
p99 do fechamento de pedido no pico11 s240 ms
Sessões esperando lock no pico2123
Tempo médio do UPDATE da conta da plataforma1.840 msSubstituído por INSERT de 0,3 ms
Atraso máximo do saldo consolidado01,2 s (o saldo lido inclui os pendentes)
Diferença na conciliação mensal-Zero centavos

FAQ

Perguntas frequentes

Usar isolamento SERIALIZABLE ou SELECT FOR UPDATE resolve a disputa?

Não. Os dois servem para manter a correção, não para aumentar a vazão. SELECT FOR UPDATE antes do UPDATE só antecipa o lock e aumenta o tempo em que ele fica preso. SERIALIZABLE ainda serializa as escritas na mesma linha e acrescenta falhas de serialização que obrigam a aplicação a repetir a transação inteira, o que no pico gera mais carga justamente onde já falta capacidade. A saída é reduzir o tempo de lock ou deixar de escrever na mesma linha.

Por que não manter o saldo no Redis com INCRBY e gravar no banco depois?

Porque o saldo passa a ter duas fontes da verdade e nenhuma garantia de que concordam. Se o processo que descarrega no banco cair entre o INCRBY e a gravação, ou se o Redis perder dados em uma falha, o saldo diverge do histórico e a diferença só aparece na conciliação. Para métricas de painel, essa troca costuma valer a pena. Para dinheiro, o livro-razão dentro do mesmo banco e da mesma transação do pedido mantém a atomicidade sem a disputa.

O consolidador não vira ele mesmo uma linha quente?

Não, porque ele atualiza a linha do saldo uma vez por lote, e não uma vez por venda. Com lotes de até 5.000 lançamentos a cada 500 ms, são poucas atualizações por segundo na linha consolidada, contra 1.600 antes. Se houver mais de um consolidador por segurança, o SKIP LOCKED faz cada um pegar pendentes diferentes, e o UPDATE do saldo fica em fila só entre eles, por poucos milissegundos. O que precisa de atenção é a tabela de pendentes, com muita inserção e exclusão, que exige autovacuum configurado por volume absoluto.

Vazão limitada por uma linha não se resolve com hardware

O checkout não estava lento porque o banco era pequeno. Estava em fila porque todas as vendas precisavam passar, uma de cada vez, pelas mesmas duas linhas, e o tempo de posse do lock definia o teto do sistema inteiro. Medir a espera, encurtar a transação, fatiar o que é só soma e transformar o saldo disputado em lançamentos consolidados tirou a fila sem abrir mão de nenhuma garantia sobre dinheiro. A pergunta que vale levar para outros sistemas é simples: qual linha toda transação importante atualiza?