Shopping cart

Subtotal $0.00

View cartCheckout

Building better devs

TnewsTnews
Banco de Dados

Shopify Trocou o Redis por MySQL (e Escalou na Black Friday)

Email : 8

A Shopify jogou fora o Redis. E o MySQL aguentou US$ 5,1 milhões por minuto.

Se alguém te falasse em 2024 que uma das maiores plataformas de e-commerce do planeta ia trocar Redis por MySQL no sistema de reservas de inventário, você provavelmente riria. Eu riria. Redis é rápido, é elegante, é o queridinho de todo mundo pra qualquer coisa que envolva concorrência e velocidade. MySQL? Aquele banco relacional que seu professor de faculdade usava nos slides de 2009?

Pois é. A Shopify fez exatamente isso. E não só funcionou: escalou pra US$ 5,1 milhões em vendas por minuto na Black Friday de 2025. Sem Redis. Sem cache distribuído. Só MySQL 8, uma feature chamada SKIP LOCKED e bastante engenharia bem pensada.

Vou te contar como, por que, e o que isso muda na forma como a gente pensa sobre escolhas de banco de dados.

O problema real que o Redis criava

Antes de achar que a Shopify ficou maluca, precisa entender o cenário. O sistema de reservas de inventário faz o seguinte: quando você clica em “pagar” numa loja Shopify, o sistema precisa reservar aquele item pra você. Tipo segurar a última camiseta no carrinho enquanto você digita os dados do cartão. Se duas pessoas tentam comprar o último item ao mesmo tempo, só uma pode ganhar.

O Redis resolvia isso com DECR e INCR. Simples, atômico, rápido. Decrementava o contador quando alguém iniciava o pagamento, incrementava se o pagamento falhava. Bonito no papel.

O problema? As reservas viviam no Redis e o ledger de inventário (o registro real do estoque) vivia no MySQL. Dois sistemas diferentes. E aí mora o perigo.

Quando o pagamento era confirmado, o sistema precisava fazer duas coisas: atualizar o MySQL (descontar o item de verdade) e limpar o Redis (remover a reserva temporária). Essas duas operações não podiam ser atômicas. Não dá pra colocar Redis e MySQL na mesma transação.

O que acontecia na prática:

  • Overselling: o pagamento passava no MySQL, mas a limpeza do Redis falhava. O contador no Redis ficava errado, e o sistema vendia itens que não existiam mais.
  • Underselling: o Redis decrementava, o pagamento falhava, mas o incremento de volta não acontecia. Itens fantasmas ficavam “reservados” eternamente.
  • Race conditions silenciosas: em flash sales com milhares de requests simultâneos, esses descompassos se acumulavam. E não eram fáceis de debugar.

Além disso, manter um cluster Redis separado só pra reservas adicionava complexidade operacional. Mais uma peça pra monitorar, mais um ponto de falha, mais uma coisa pra dar errado às 3 da manhã da Black Friday.

A ideia maluca: uma linha por unidade

A equipe da Shopify se inspirou numa abordagem que a 37signals (turma do Rails/Basecamp) já tinha usado pra filas de jobs no banco de dados. A sacada é simples, mas contraintuitiva.

Em vez de ter uma linha dizendo “este item tem 500 unidades em estoque”, eles criaram uma linha por unidade vendável. Um item com 500 unidades gera 500 linhas na tabela. Reservar 3 unidades significa selecionar e travar 3 linhas específicas dentro de uma transação.

Parece desperdício? Parece. Mas quando você junta isso com SKIP LOCKED, vira mágica.

SKIP LOCKED: a feature do MySQL 8 que ninguém usa

SELECT ... FOR UPDATE SKIP LOCKED é uma das features mais subestimadas do MySQL 8.0. O que ela faz é quase óbvio demais: quando uma query tenta travar linhas que já estão travadas por outra transação, em vez de ficar esperando (comportamento padrão), ela simplesmente pula essas linhas e pega as próximas disponíveis.

Isso muda tudo em cenários de alta concorrência.

Imagina 200 compradores tentando reservar o mesmo produto ao mesmo tempo. Sem SKIP LOCKED, a primeira transação trava as linhas, e as outras 199 ficam na fila esperando. Com SKIP LOCKED, cada transação pega linhas diferentes instantaneamente. Não tem fila. Não tem espera. Cada comprador agarra uma unidade diferente do pool e segue em frente.


-- Reservar 3 unidades de um item
BEGIN;

SELECT id FROM inventory_units
WHERE inventory_item_id = 42
  AND status = 'available'
LIMIT 3
FOR UPDATE SKIP LOCKED;

-- Marca as unidades como reservadas
DELETE FROM inventory_units WHERE id IN (selected_ids);
INSERT INTO reserved_quantities (unit_id, checkout_id, reserved_at)
VALUES ...;

COMMIT;

O ponto crucial: como tudo acontece no mesmo MySQL que guarda o ledger de inventário, a transação é ACID. Ou tudo funciona, ou nada funciona. Acabou o problema de consistência entre dois sistemas diferentes.

O pool limitado: 1.000 linhas, não 500.000

Aqui a engenharia ficou esperta. Se um item tem 500.000 unidades em estoque, criar 500.000 linhas seria insano. A tabela ficaria gigantesca, os índices pesados, e o MySQL sofreria.

A solução? Um pool com cap de 1.000 linhas por combinação item/localização. Funciona assim:

Conceito Como funciona
———- ————–
Pool inicial Cria até 1.000 linhas representando unidades disponíveis
Reserva Remove linhas do pool (DELETE) e insere na tabela de reservas
Replenishment Um processo de background reabastece o pool a partir do ledger real
Inline replenishment Em demanda extrema, o reabastecimento acontece dentro da própria transação
Serialization lock Quando o pool esgota, um lock de serialização evita thundering herd

Na prática, 1.000 linhas é mais do que suficiente pra absorver picos de concorrência. Mesmo em flash sales agressivas, o pool não esgota porque o replenishment é contínuo. E se esgota, o mecanismo de fallback entra em ação sem derrubar o sistema.

As otimizações que fizeram funcionar de verdade

A ideia central é elegante, mas sem refinamento ia explodir em produção. A equipe da Shopify encontrou quatro gargalos e resolveu cada um.

Chave primária composta em vez de auto-increment

O protótipo inicial usava id auto-increment como chave primária. Parece inofensivo, mas no InnoDB isso gera dois locks por reserva: um no índice secundário e outro no índice clusterizado (a chave primária). Dobrando a contenção sem necessidade.

A solução foi trocar pra uma chave primária composta: (shop_id, inventory_item_id, inventory_group_id, id). Com isso, o índice secundário sumiu e cada reserva passou a gerar um lock só. O throughput dobrou com essa mudança.


CREATE TABLE inventory_units (
  shop_id BIGINT NOT NULL,
  inventory_item_id BIGINT NOT NULL,
  inventory_group_id BIGINT NOT NULL,
  id BIGINT NOT NULL,
  PRIMARY KEY (shop_id, inventory_item_id, inventory_group_id, id)
) ENGINE=InnoDB;

READ COMMITTED em vez de REPEATABLE READ

O MySQL usa REPEATABLE READ como nível de isolamento padrão. Pra maioria dos casos, faz sentido. Mas pra esse workload específico, o REPEATABLE READ criava gap locks, aqueles locks invisíveis em ranges de índice que o InnoDB mantém pra prevenir phantom reads.

Esses gap locks causavam dois problemas: bloqueavam o processo de replenishment (que precisa inserir novas linhas no mesmo range) e geravam deadlocks circulares entre reservas e reabastecimento.

Trocar pra READ COMMITTED eliminou os gap locks. O trade-off? Phantom reads se tornam possíveis. Mas nesse caso específico, phantom reads não causam dano porque o SKIP LOCKED já garante que cada transação trabalha com linhas únicas.

Ordem consistente de locks

Deadlocks acontecem quando transação A trava recurso 1 e espera recurso 2, enquanto transação B trava recurso 2 e espera recurso 1. Clássico.

A solução também é clássica: definir uma ordem fixa pra todas as operações:

  • Reservar: DELETE na tabela de unidades primeiro, depois INSERT na tabela de reservas
  • Claim (confirmar compra): DELETE na tabela de reservas apenas

Com essa ordenação, as dependências circulares desaparecem.

Batching com UNION ALL

Num checkout com 5 itens diferentes no carrinho, a abordagem ingênua faria 5 queries separadas. Cinco round-trips ao banco. Cinco chances de contenção.

A otimização: usar UNION ALL pra buscar todas as unidades necessárias num único round-trip.


(SELECT id FROM inventory_units WHERE inventory_item_id = 42 LIMIT 2 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id FROM inventory_units WHERE inventory_item_id = 87 LIMIT 1 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id FROM inventory_units WHERE inventory_item_id = 153 LIMIT 3 FOR UPDATE SKIP LOCKED);

Uma query, todas as reservas, uma transação.

O gargalo que ninguém esperava

Aqui a história fica interessante de verdade. Depois de todas essas otimizações, o sistema foi pra produção gradualmente (shadow mode: escrevendo em Redis e MySQL ao mesmo tempo, validando resultados). Os números estavam bons. Até que começaram os flash sales pesados.

O gargalo não era o MySQL. Não era lock contention. Não era I/O de disco. Era o pool de conexões.

Outras operações no caminho do checkout (validação de endereço, cálculo de frete, verificação de fraude) seguravam conexões do banco por tempo demais. Quando o pico de reservas chegava, não tinha conexão disponível pra processar as queries. O sistema inteiro engasgava, e a culpa não era da feature nova.

Como descobriram

A equipe implementou uma técnica simples e genial: SQL comment tagging. Cada operação do checkout adicionava um comentário na query indicando de onde vinha.


/* conn_tag:checkout_completion */ SELECT * FROM orders WHERE ...
/* conn_tag:inventory_reservation */ SELECT id FROM inventory_units WHERE ...

Com o ProxySQL agregando esses comentários, conseguiram ver exatamente quem estava segurando conexões por mais tempo. O resultado foi revelador:

  • 50% de redução em leituras no banco primário após a limpeza
  • 33% de redução em transações no primário
  • O innodb_thread_concurrency estava configurado de forma conservadora desde anos atrás e nunca tinha sido recalibrado

Esse é o tipo de descoberta que não aparece em nenhum tutorial de MySQL. O sistema mais otimizado do mundo não adianta nada se o pool de conexões está saturado por queries legadas que ninguém olha faz dois anos.

A migração: shadow mode e rollout gradual

A Shopify não fez um “big bang” de migração. O processo foi cirúrgico:

  1. Dual-write: cada reserva era escrita no Redis E no MySQL simultaneamente
  2. Redis como source of truth: durante a validação, o Redis continuava sendo a autoridade
  3. Comparação contínua: um processo comparava os resultados dos dois sistemas em tempo real
  4. Rollout pod a pod: começando por merchants de baixo tráfego e escalando gradualmente
  5. Rollback instantâneo: se qualquer anomalia aparecesse, era só voltar pro Redis com um flag

Esse padrão de migração, aliás, é algo que vale roubar pra qualquer refatoração de sistema crítico. Nada de “deploy na sexta e reza”. Validação incremental, com capacidade de reverter em segundos.

Os números da Black Friday 2025

Com o MySQL rodando solo (Redis já aposentado), a Black Friday de 2025 bateu recordes:

Métrica Resultado
——— ———–
Vendas no pico US$ 5,1 milhões por minuto
Crescimento YoY +11% vs Black Friday 2024
CPU do writer MySQL Abaixo de 50%
CPU do reader MySQL Abaixo de 16%
Overselling incidents Zero reportado

Abaixo de 50% de CPU no writer durante o maior pico de e-commerce do ano. Isso significa que o sistema tinha headroom de sobra pra crescer mais. O MySQL não estava nem suando.

O que isso significa pra você

Eu sei o que você está pensando: “legal, mas eu não sou a Shopify”. Justo. Mas tem lições aqui que se aplicam a qualquer sistema.

Primeiro: Redis não é sempre a resposta. A gente automatizou tanto a decisão de usar Redis pra qualquer coisa que envolve velocidade que esquecemos de perguntar se o banco relacional que já temos não resolve. O MySQL 8 com SKIP LOCKED lida com workloads que antes pareciam impossíveis sem cache distribuído.

Segundo: consistência ganha de velocidade quando dinheiro está envolvido. O Redis é microsegundos mais rápido? É. Mas se você precisa de duas operações atômicas em sistemas diferentes, essa velocidade vira um passivo. Uma transação ACID no MySQL é mais lenta em números absolutos, mas elimina uma categoria inteira de bugs que custam caro.

Terceiro: o gargalo nunca está onde você acha. A Shopify otimizou queries, locks, chaves primárias, nível de isolamento. E o problema real era o pool de conexões sendo drenado por operações que ninguém tinha olhado. Instrumentação é mais importante que otimização prematura.

Quarto: migrações de sistemas críticos não precisam ser traumáticas. Shadow mode, dual-write, rollout gradual, rollback instantâneo. Dá mais trabalho montar essa infraestrutura? Dá. Mas é a diferença entre dormir tranquilo na Black Friday e ficar acordado com o PagerDuty no ouvido.

Quando NÃO fazer o que a Shopify fez

Pra ser justo, essa abordagem não é bala de prata. Existem cenários onde Redis ainda é a escolha certa:

  • Rate limiting: se você precisa contar requests por segundo, Redis com INCR e TTL é imbatível
  • Cache de sessão: dados efêmeros que podem ser perdidos sem consequência
  • Pub/Sub em tempo real: Redis Streams e Pub/Sub são ferramentas que MySQL simplesmente não tem
  • Leaderboards e rankings: sorted sets do Redis fazem em uma operação o que levaria queries complexas no MySQL

O ponto da Shopify não é “Redis é ruim”. É “Redis era a ferramenta errada pra esse problema específico”. E ter coragem de questionar uma decisão arquitetural consolidada é o que separa engenharia boa de cargo cult.

SKIP LOCKED na prática: 3 padrões que você pode usar hoje

Se você quer experimentar SKIP LOCKED sem reescrever seu sistema inteiro, aqui vão três padrões prontos.

1. Fila de jobs no banco


-- Worker pega o próximo job disponível
BEGIN;

SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

UPDATE jobs SET status = 'processing', worker_id = 'worker-42'
WHERE id = @job_id;

COMMIT;

Cada worker pega um job diferente. Sem contenção. Sem Sidekiq. Sem Redis.

2. Distribuição de tarefas com prioridade


BEGIN;

SELECT id, task_data FROM tasks
WHERE status = 'queued' AND priority <= 3
ORDER BY priority ASC, created_at ASC
LIMIT 5
FOR UPDATE SKIP LOCKED;

-- Processa as tarefas...

UPDATE tasks SET status = 'done' WHERE id IN (...);

COMMIT;

3. Reserva de recursos (vagas, ingressos, slots)


-- Reservar 2 vagas num evento
BEGIN;

SELECT id FROM event_slots
WHERE event_id = 99 AND status = 'available'
LIMIT 2
FOR UPDATE SKIP LOCKED;

UPDATE event_slots SET status = 'reserved', user_id = 123
WHERE id IN (...);

COMMIT;

Se você tem MySQL 8.0+, essas queries funcionam hoje. Sem instalar nada. Sem configurar nada. Só SQL.

Vale a pena repensar suas escolhas?

A próxima vez que você estiver desenhando um sistema e o instinto disser “bota um Redis aí”, para por 5 minutos. Pergunta: os dados precisam de consistência forte? As operações precisam ser atômicas com outros dados que já estão no banco relacional? O volume real (não o imaginado) justifica a complexidade de mais um sistema?

Se a Shopify conseguiu processar US$ 5,1 milhões por minuto com MySQL puro, talvez, só talvez, o seu sistema de reservas de 200 requests por segundo não precise de um cluster Redis de 3 nós.

O código fonte do artigo original da Shopify Engineering está disponível aqui, e vale cada minuto de leitura se você trabalha com sistemas de alta concorrência.

Leia também


Fonte de inspiração: We replaced Redis with MySQL for inventory reservations—and it scaled (Shopify Engineering)

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts