Dois usuários compraram o último ingresso ao mesmo tempo

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 LockOptimistic LockRedis + TTL
Consistência✅ Forte✅ Forte✅ Forte
Concorrência alta⚠️ Contenção✅ Bom✅ Bom
UX (tempo de reserva)❌ Sem reserva❌ Sem reserva✅ Reserva real
ComplexidadeBaixaMédiaMé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.