Dois usuários compraram o último ingresso ao mesmo tempo
Dois usuários compraram o último ingresso ao mesmo tempo. Os dois receberam confirmação. O sistema vendeu o que não tinha.
Isso não é bug de lógica. É race condition. E acontece em produção todo dia.
O problema clássico
def comprar_ingresso(ingresso_id, usuario_id):
ingresso = db.query("SELECT * FROM ingressos WHERE id = ?", ingresso_id)
if ingresso.quantidade > 0:
db.execute("UPDATE ingressos SET quantidade = quantidade - 1 WHERE id = ?", ingresso_id)
db.execute("INSERT INTO pedidos ...")
return "Compra confirmada"
return "Esgotado"
Parece certo. Não está.
Se dois requests chegam ao mesmo tempo, os dois leem quantidade = 1, os dois passam no if, os dois decrementam. O estoque vai pra -1 e dois clientes recebem confirmação.
O SELECT e o UPDATE não são atômicos. Entre um e outro, outro processo pode entrar.
Solução 1 — Pessimistic lock
def comprar_ingresso(ingresso_id, usuario_id):
with db.transaction():
ingresso = db.query(
"SELECT * FROM ingressos WHERE id = ? FOR UPDATE",
ingresso_id
)
if ingresso.quantidade > 0:
db.execute("UPDATE ingressos SET quantidade = quantidade - 1 WHERE id = ?", ingresso_id)
db.execute("INSERT INTO pedidos ...")
return "Compra confirmada"
return "Esgotado"
O FOR UPDATE avisa o banco: “estou lendo isso pra modificar, ninguém toca até eu terminar.”
Resolve consistência. Mas em alta concorrência — Black Friday, show que acabou de abrir vendas — você cria fila no banco. Centenas de requests esperando o lock liberar.
Solução 2 — Optimistic lock
UPDATE ingressos
SET quantidade = quantidade - 1, version = version + 1
WHERE id = ? AND version = ? AND quantidade > 0
Sem lock. Sem espera. Se outro processo modificou antes, o WHERE version = ? não bate, nenhuma linha é afetada — você detecta o conflito e faz retry.
Melhor pra concorrência alta onde conflito é raro.
Solução 3 — Reserva com Redis + TTL
As duas anteriores resolvem consistência. Mas nenhuma resolve experiência do usuário.
O que acontece quando o ingresso é o último e o usuário está preenchendo o formulário de pagamento? Você trava o estoque enquanto ele digita o CPF?
A resposta do mercado é reserva temporária:
def reservar_ingresso(ingresso_id, usuario_id):
chave = f"reserva:{ingresso_id}"
# SET NX = só seta se a chave não existir (atômico)
reservado = redis.set(chave, usuario_id, nx=True, ex=600)
if not reservado:
return "Ingresso reservado por outro usuário"
return "Reservado. Você tem 10 minutos pra concluir."
def confirmar_compra(ingresso_id, usuario_id):
chave = f"reserva:{ingresso_id}"
dono = redis.get(chave)
if dono != usuario_id:
return "Reserva expirada ou inválida"
with db.transaction():
db.execute("UPDATE ingressos SET quantidade = quantidade - 1 WHERE id = ?", ingresso_id)
db.execute("INSERT INTO pedidos ...")
redis.delete(chave)
return "Compra confirmada"
O SET NX é atômico por natureza — Redis é single-threaded. Só um processo consegue setar a chave. O segundo já encontra ocupado e retorna na hora, sem tocar no banco.
Se o usuário abandonar o carrinho, o TTL de 10 minutos expira e o ingresso volta automaticamente. Sem job de limpeza, sem cron, sem coluna expirado_em na tabela.
Três abordagens, três trade-offs
| Pessimistic Lock | Optimistic Lock | Redis + TTL | |
|---|---|---|---|
| Consistência | ✅ Forte | ✅ Forte | ✅ Forte |
| Concorrência alta | ⚠️ Contenção | ✅ Bom | ✅ Bom |
| UX (tempo de reserva) | ❌ Sem reserva | ❌ Sem reserva | ✅ Reserva real |
| Complexidade | Baixa | Média | Média |
Na prática: sistemas de ingresso, e-commerce com estoque limitado e reserva de horário usam Redis. Sistemas financeiros com consistência crítica usam lock no banco. Os dois juntos não é exagero — é o padrão.
Race condition não é bug de iniciante. É um problema que só aparece quando o sistema escala e começa a receber carga real.
O código funciona perfeitamente sozinho. O problema é quando ele roda em paralelo com ele mesmo.