
Shopify troca Redis por MySQL em reservas de estoque durante Black Friday
Contexto e motivação
Durante o checkout na Shopify, quando o comprador clica em "Completar compra", o sistema precisa garantir que os itens ainda estão disponíveis. Um erro pode causar overselling (dois compradores levam o mesmo último item) ou underselling (o sistema diz que esgotou quando ainda há estoque). Na Black Friday de 2025, os lojistas da plataforma atingiram um recorde de US$ 5,1 milhões em vendas por minuto no pico.
Por anos, a proteção contra overselling rodou em Redis. O modelo funcionava para concorrência, mas reservas e o ledger de inventário ficavam em sistemas separados, impossibilitando atomicidade entre reservar e efetivar a dedução. Isso abria espaço para inconsistências (pagamento aprovado sem dedução, ou dedução sem liberação da reserva). Além disso, Redis não tinha suporte a inventário multi-localização e adicionava custo operacional de um cluster separado.
A solução: SKIP LOCKED com uma linha por unidade
A equipe migrou para MySQL usando o recurso SKIP LOCKED (MySQL 8), com um design de uma linha por unidade vendável em vez de uma linha por item com coluna de quantidade. Reservar três unidades significa selecionar e mover três linhas em uma única transação ACID.
Para evitar que itens com dezenas de milhares de unidades gerassem tabelas enormes, o pool de linhas disponíveis é limitado a 1.000 por combinação item/localização. Um processo de reposição reabastece o pool a partir do ledger. Se o pool esgota durante uma flash sale, a reposição é disparada inline com lock para evitar thundering herd.
Decisões técnicas-chave
- Chave primária composta (
shop_id, inventory_item_id, inventory_group_id, id): reduziu de dois locks por linha para um, eliminando o lock extra no índice secundário que o auto-increment causava. - READ COMMITTED: evitou gap locks (incluindo no pseudo-record supremum) que bloqueavam a reposição e causavam deadlocks.
- Ordem consistente de locks: padronizou a sequência de operações entre reserve e claim para eliminar esperas circulares.
- Batching com UNION ALL: múltiplos itens do carrinho são reservados em uma única ida ao banco.
O gargalo real: conexões, não CPU
O limite de throughput não vinha das queries nem da CPU. Threads ficavam em fila, CPU subia quando o trabalho acumulado executava, e conexões ao MySQL via ProxySQL se esgotavam. A equipe adicionou atribuição por chamador: cada SQL recebe um comentário como /* conn_tag:checkout_completion */, e o ProxySQL agrega tempo de conexão por processo de negócio.
Isso revelou que outras partes do checkout seguravam conexões mais tempo que o necessário. A limpeza removeu 50% das reads e 33% das transações no banco primário. Ajustes na concorrência de threads do InnoDB (configurada conservadoramente anos antes) eliminaram o teto restante. Após as mudanças, CPU de escrita ficou abaixo de 50% e de leitura abaixo de 16%.
Cutover e aprendizados
A migração foi feita em modo sombra: ambos os sistemas rodaram em paralelo, com Redis como fonte de verdade, até validação de correção e performance. O rollout foi gradual, pod por pod.
Lições principais: decisões técnicas antigas merecem reavaliação (SKIP LOCKED não existia antes); o gargalo real estava em código que ninguém estava olhando; e o objetivo não era só tornar reservas rápidas, mas garantir que fossem "vizinhas seguras" sem degradar o banco para carrinhos, pagamentos e criação de pedidos.