6 pontos por GN⁺ 5 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • O sistema de reserva de estoque é uma infraestrutura essencial para evitar oversell, em que o mesmo produto é vendido duas vezes durante o processamento do pagamento, e a Shopify o operou por anos com base em Redis
  • Aproveitando o recurso SKIP LOCKED do MySQL 8, o sistema foi redesenhado de uma coluna de quantidade por item para uma estrutura de 1 linha por unidade vendável, alcançando alto desempenho sem Redis
  • Combinando técnicas de otimização no MySQL como chave primária composta, nível de isolamento READ COMMITTED, ordem consistente de locks e processamento em lote com UNION ALL, a empresa eliminou contenção de locks e deadlocks
  • O gargalo real não estava na query de reserva, mas na ocupação de conexões; ao instrumentar todo o fluxo de checkout, a Shopify reduziu em 50% as leituras no banco e em 33% as transações
  • Tomando como referência o pico da Black Friday de 2025, a empresa processou US$ 5,1 milhões por minuto em vendas mantendo CPU de writer abaixo de 50% e CPU de reader abaixo de 16%, superando a capacidade-alvo

Contexto: requisitos de um sistema de prevenção de oversell

  • É necessário um sistema de prevenção de oversell (Oversell Protection) que garanta que ainda exista estoque de fato no momento da conclusão do checkout
    • Reserve: no início do pagamento, bloqueia temporariamente o item por alguns minutos
    • Claim: ao concluir o pagamento, reduz permanentemente a quantidade no ledger de estoque
  • Não há margem para erro em nenhuma das duas direções
    • Se algo der errado, duas pessoas podem comprar o mesmo produto ou um item pode ser marcado como esgotado mesmo havendo estoque, causando perda de receita
  • Requisito de escala: a Shopify responde por mais de 14% do e-commerce dos EUA e, na Black Friday de 2025, registrou US$ 5,1 milhões por minuto em vendas, 11% acima do ano anterior
  • Estoque multi-localização (Multi-location inventory), garantias ACID, alto throughput e precisão em primeiro lugar são requisitos centrais

Limites do modelo anterior com Redis

  • No Redis, cada item tem uma chave de quantidade, e a reserva é tratada com DECR, enquanto a liberação usa INCR
  • Problema central: os dados de reserva (Redis) e o ledger de estoque (MySQL) existiam em sistemas diferentes
    • Na etapa de claim, não era possível agrupar a atualização no MySQL e a limpeza no Redis em uma única transação atômica
    • Dependendo da ordem de execução, podia ocorrer oversell (produto vendido, mas ledger não reduzido) ou undersell (ledger reduzido, mas ainda marcado como reservado)
  • Também não havia suporte para reconhecimento de estoque multi-localização, além do custo operacional de manter um cluster Redis separado

Solução principal: redesenho em MySQL com base em SKIP LOCKED

Estrutura básica: uma linha por unidade (One Row Per Unit)

  • Em vez de uma coluna de quantidade por item, foi adotada uma estrutura de 1 linha por unidade vendável
    • Ex.: um item com estoque 10 → 10 linhas; ao reservar 3 unidades, 3 linhas são selecionadas e movidas dentro de uma única transação
  • Ao colocar a reserva e o ledger de estoque no mesmo banco MySQL, reserve e claim passaram a ser tratados como transações ACID, eliminando a classe de bugs que existia com Redis
  • SKIP LOCKED: linhas bloqueadas por outras transações são ignoradas, e linhas disponíveis são retornadas imediatamente → menos contenção sem esperar pela mesma linha

Limite do tamanho do pool: no máximo 1.000 linhas por localização

  • As linhas disponíveis por combinação item/localização foram limitadas a 1.000 para manter o tamanho da tabela e o desempenho de varredura sob controle
    • Ex.: evita um cenário em que 50.000 unidades de estoque × 10 localizações se tornariam 500.000 linhas
  • Quando o pool se esgota, um reabastecimento inline (replenishment) é acionado; um lock garante que apenas uma transação faça o reabastecimento, evitando um thundering herd com várias transações inserindo linhas ao mesmo tempo
  • Se o pool esvaziar completamente, apenas aquela reserva sofre atraso; compradores com estoque real disponível não recebem status de esgotado por engano

Quatro decisões técnicas centrais

1. Menos locks com chave primária composta

  • No protótipo inicial, ao usar um ID autoincremental como chave primária, o InnoDB bloqueava tanto o índice secundário quanto o índice clustered, gerando 2 locks de linha por reserva
  • Foi aplicada uma chave primária composta formada por shop_id, inventory_item_id, inventory_group_id, id → como as colunas de filtro passaram a fazer parte da chave primária, o número de locks caiu para 1
  • Em um ambiente com milhares de reservas por segundo, o desenho de índices e chave primária afeta diretamente a quantidade de locks e o throughput

2. Eliminação de gap locks com READ COMMITTED

  • Ao executar SELECT ... FOR UPDATE SKIP LOCKED em uma tabela vazia, surgiam gap locks (incluindo supremum), bloqueando o INSERT da transação de reabastecimento e causando deadlocks
  • O nível de isolamento foi alterado do padrão do MySQL, REPEATABLE READ, para READ COMMITTED → isso mudou o comportamento dos gap locks e permitiu que a transação de reabastecimento prosseguisse normalmente
  • Foi a primeira vez que essa base de código usou um nível de isolamento diferente do padrão, exigindo um pequeno suporte no framework para configurar o isolamento por transação

3. Prevenção de deadlock com ordem consistente de locks

  • Reserve e claim acessavam duas tabelas em ordens diferentes, o que causava deadlocks
    • reserve: reserved_quantities INSERTreservation_units DELETE
    • claim: reserved_quantities DELETE
  • Solução: padronizar a ordem para que reserve sempre faça primeiro o DELETE na tabela de units e depois o INSERT em reserved_quantities → eliminando a espera circular (circular wait)

4. Menos round trips com batch via UNION ALL

  • Quando o carrinho tinha vários line items, a query de reserva era processada em lote em um único round trip com UNION ALL
  • A redução do total de round trips melhorou a latência sob carga

O gargalo real: não era a query, e sim a ocupação de conexões

Como o problema foi descoberto

  • Em produção, o sistema batia no teto antes da capacidade-alvo; a latência P90 estava boa, a CPU abaixo do máximo e as queries já estavam otimizadas
  • Sintomas observados nos testes de carga:
    • enfileiramento de threads dentro do MySQL
    • picos de CPU quando os trabalhos acumulados na fila eram executados
    • esgotamento das conexões de backend MySQL na camada ProxySQL

Obtendo visibilidade sobre conexões

  • Na camada de aplicação: foram adicionados comentários de identificação do processo de negócio em todas as instruções SQL, no formato /* conn_tag:checkout_completion */
  • Na camada ProxySQL: foi adicionada análise dessas tags e agregação do tempo de ocupação de conexão por chamador
  • Resultado: ficou possível identificar imediatamente qual processo mantinha conexões ocupadas e por quanto tempo

O que foi encontrado e como foi resolvido

  • Havia outros trechos de código no fluxo de checkout, além da reserva, mantendo conexões ocupadas por mais tempo do que o necessário
    • Esses trechos não tinham sido incluídos nas otimizações porque não atingiam o limite antes
  • Após a limpeza do fluxo de checkout: 50% menos leituras no banco primário e 33% menos transações
  • Um ajuste na configuração de concorrência de threads do InnoDB, definida de forma conservadora anos antes e nunca revisada, removeu mais um gargalo
  • Depois das melhorias, mesmo em flash sales de alto volume, a CPU do writer ficou abaixo de 50% e a do reader abaixo de 16%

Como foi feita a migração: Shadow Mode

  • Em vez de trocar Redis por MySQL de uma vez, a Shopify operou os dois sistemas em paralelo em Shadow Mode
    • Todas as reservas eram gravadas simultaneamente em Redis e MySQL, enquanto o Redis seguia como source of truth
    • A precisão e o desempenho do MySQL foram validados em paralelo com tráfego real de produção
  • Isso permitiu a troca sem migrar reservas em voo (in-flight), já que os dois sistemas estavam ativos ao mesmo tempo
  • Mesmo após o MySQL se tornar a source of truth, um kill switch foi mantido, e o caminho de escrita dupla garantiu que o Redis continuasse sempre atualizado
  • O rollout foi gradual, por pod, começando pelos pods de menor tráfego até chegar aos merchants de maior volume

Lições

1. Reavaliar decisões antigas

  • O que não era possível com MySQL 5 anos atrás passou a ser viável hoje com recursos novos como SKIP LOCKED
  • Configurações “de regra prática”, como limites de threads, precisam ser revisitadas quando a carga de trabalho e o hardware mudam
  • Se a CPU está baixa, mas há fila, é essencial investigar a causa

2. Começar pequeno e observar

  • Sem usar todo o framework Rails, foi montado um protótipo mínimo com pequenos scripts Ruby e MySQL
  • Observar diretamente o comportamento dos locks em um segundo terminal ensinou mais do que a teoria
  • O padrão de instrumentação de ocupação de conexões (tag na camada de app + agregação no proxy) é simples de implementar e pode ser aplicado imediatamente

1 comentários

 
hso2341 31 분 전

Fazia tempo que não aparecia um texto com cara de desenvolvimento de verdade.