Shopify substitui sistema de reserva de estoque de Redis por MySQL
(shopify.engineering)- 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 LOCKEDdo 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 comUNION 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 usaINCR - 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 LOCKEDem uma tabela vazia, surgiam gap locks (incluindo supremum), bloqueando oINSERTda transação de reabastecimento e causando deadlocks - O nível de isolamento foi alterado do padrão do MySQL,
REPEATABLE READ, paraREAD 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_quantitiesINSERT→reservation_unitsDELETE - claim:
reserved_quantitiesDELETE
- reserve:
- Solução: padronizar a ordem para que reserve sempre faça primeiro o
DELETEna tabela de units e depois oINSERTemreserved_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
Fazia tempo que não aparecia um texto com cara de desenvolvimento de verdade.