Design de sistema de voucher: limitar uso sob alta carga

1. Visão geral

Um voucher “desconto de 100k, apenas 1.000 usos” parece simples. No horário do flash sale, dezenas de milhares de requisições chegam simultaneamente. Nesse momento, o sistema pode emitir 1.050 usos em vez de 1.000, e o excedente representa dinheiro real perdido. Este artigo passa por um estudo de caso completo: requisitos, modelo de dados, API, e então resolve sequencialmente os pontos difíceis: concorrência, consistência e picos de carga. Após ler, você saberá quando precisa apenas de uma sentença UPDATE atômica e quando precisa trazer Redis e Kafka para a conversa.

2. Requisitos

2.1. Funcionalidades

  • Criar voucher com código, valor de desconto, valor mínimo do pedido, período de validade.
  • Limitar uso total e uso por cliente.
  • No checkout, o voucher é reservado. Após pagamento bem-sucedido, é confirmado. Se o pedido for cancelado ou expirar, é liberado.

2.2. Escala e restrições

  • Aproximadamente 10 milhões de clientes. Voucher de flash sale com 1.000 usos, mas pode receber 50.000 requisições/segundo nos primeiros segundos.
  • p99 da API de reserva abaixo de 100 ms.
  • Nunca vender acima do limite (oversell). Vender abaixo do limite temporariamente é aceitável, por exemplo, um uso sendo reservado e liberado 15 minutos depois.
  • Uma requisição retentada (retry) não deve consumir outro uso. Em outras palavras, a API deve ser idempotente por orderId.

3. Modelo de dados

Banco de dados (PostgreSQL) é a fonte única da verdade:

CREATE TABLE voucher (
    code             VARCHAR(32) PRIMARY KEY,
    discount_amount  BIGINT      NOT NULL,
    min_order_amount BIGINT      NOT NULL DEFAULT 0,
    total_limit      INT         NOT NULL,
    used_count       INT         NOT NULL DEFAULT 0,
    per_user_limit   INT         NOT NULL DEFAULT 1,
    starts_at        TIMESTAMP   NOT NULL,
    ends_at          TIMESTAMP   NOT NULL,
    CONSTRAINT ck_used CHECK (used_count <= total_limit)
);

CREATE TABLE voucher_redemption (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
    customer_id  BIGINT      NOT NULL,
    order_id     VARCHAR(64) NOT NULL UNIQUE,
    status       VARCHAR(16) NOT NULL, -- RESERVED, CONFIRMED, RELEASED
    created_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);

Alguns pontos a observar:

  • CHECK (used_count <= total_limit) é a última rede de segurança. Mesmo se o código tiver um bug, o banco de dados não permitirá oversell.
  • order_id UNIQUE garante idempotência: cada pedido pode usar o voucher apenas uma vez.
  • UNIQUE (voucher_code, customer_id) só é válido quando per_user_limit = 1, que é o caso mais comum. Se permitir múltiplos usos por cliente, remova essa constraint e conte em uma tabela separada.

4. API

POST   /vouchers/{code}/reservations        body: {customerId, orderId, orderAmount}
       201 {orderId, discountAmount}
       409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
       422 {error: "NOT_ELIGIBLE"}           (expirado, pedido abaixo do valor mínimo)
POST   /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}

orderId é gerado pelo cliente (serviço de pedidos). Dessa forma, em um retry, o cliente reenvia o mesmo orderId e o servidor reconhece que é uma requisição duplicada.

5. A abordagem ingênua e por que está errada

@Transactional
public boolean redeemNaive(String code) {
    Voucher v = voucherRepository.findById(code).orElseThrow();
    if (v.getUsedCount() >= v.getTotalLimit()) {
        return false;
    }
    v.setUsedCount(v.getUsedCount() + 1);
    return true;
}

Este é o clássico antipadrão “check-then-act”. Duas threads leem usedCount = 999, ambas veem que há espaço, ambas escrevem 1000. O resultado é que dois clientes recebem desconto, mas o contador só aumenta uma vez. No nível de isolamento READ COMMITTED padrão, a transação não o salva neste caso.

6. Solução 1: UPDATE condicional no banco de dados

A forma mais simples de corrigir é mesclar a verificação e a escrita em uma única sentença atômica:

@Service
public class VoucherService {

    private final JdbcTemplate jdbc;

    public VoucherService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public boolean redeem(String code, long customerId, String orderId) {
        int updated = jdbc.update("""
                UPDATE voucher SET used_count = used_count + 1
                WHERE code = ?
                  AND used_count < total_limit
                  AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
                """, code);
        if (updated == 0) {
            return false; // uso esgotado ou expirado
        }
        jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'CONFIRMED')
                """, code, customerId, orderId);
        return true;
    }
}

O código acima funciona da seguinte forma:

  • A sentença UPDATE bloqueia a linha do voucher. O banco de dados reavalia used_count < total_limit após adquirir o lock, então duas requisições não podem passar na última rodada.
  • updated == 0 significa que a condição é falsa: uso esgotado ou voucher expirado.
  • Se o cliente já usou o voucher ou orderId foi reenviado, a sentença INSERT lança DuplicateKeyException. A transação é revertida, used_count volta ao valor anterior. Na camada de controle, você mapeia essa exceção para 409 ALREADY_USED.

6.1. Testando o comportamento sob carga paralela

@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired VoucherService voucherService;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM voucher_redemption");
        jdbc.update("DELETE FROM voucher");
        jdbc.update("""
                INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
                VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
                        DATEADD('DAY', 1, CURRENT_TIMESTAMP))
                """);
    }

    @Test
    void shouldNeverExceedTotalLimit() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 200; i++) {
                long customerId = i;
                executor.submit(() -> {
                    if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
                        success.incrementAndGet();
                    }
                });
            }
        }

        Integer used = jdbc.queryForObject(
                "SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
        assertThat(success.get()).isEqualTo(10);
        assertThat(used).isEqualTo(10);
    }
}

@Transactional(propagation = NOT_SUPPORTED) desativa a transação padrão do @JdbcTest. Se não desativar, os dados criados no setUp não são commitados e as outras threads não os veem. Há 200 threads virtuais competindo por 10 usos, e o resultado é sempre 10:

success = 10, used_count = 10

6.2. Limitações da Solução 1

Essa abordagem é correta e, para a maioria dos vouchers normais (algumas centenas de requisições/segundo), é suficiente. O problema só aparece quando a carga se concentra em uma única linha (hot row). 50.000 requisições/segundo enfileiram esperando pelo lock na linha FLASH100. Cada transação segura o lock por alguns milissegundos, então a taxa de transferência real é limitada a alguns milhares por segundo. As requisições restantes expiram, o pool de conexões seca e todo o banco de dados fica lento, incluindo tabelas não relacionadas aos vouchers.

7. Solução 2: Redis como “portão” com Lua script

A ideia: colocar o contador de usos restantes no Redis. O Redis é single-threaded, cada Lua script executa atomicamente e leva apenas alguns microssegundos. Apenas as requisições que conseguem reserva prosseguem para o banco de dados. Com um voucher de 1.000 usos, o banco de dados recebe no máximo 1.000 escritas em vez de 50.000.

Dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Script de reserva, salvo em resources/scripts/reserve.lua:

-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users      (hash customerId -> número de usos)
-- KEYS[3] = voucher:{code}:orders     (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end   -- retry, já reservado
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end            -- voucher não carregado
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end                      -- cliente já usou
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end      -- sem usos disponíveis
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1

As chaves usam hash tag {code}. Isso garante que, em um Redis Cluster, as três chaves ficam no mesmo slot e o script é executado.

No lado Java:

@Component
public class VoucherGate {

    public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }

    private static final RedisScript<Long> RESERVE =
            RedisScript.of(new ClassPathResource("scripts/reserve.lua"), Long.class);

    private final StringRedisTemplate redis;

    public VoucherGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
        String p = "voucher:{" + code + "}:";
        Long r = redis.execute(RESERVE,
                List.of(p + "remaining", p + "users", p + "orders"),
                String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
        return switch (r.intValue()) {
            case 1 -> Result.RESERVED;
            case 0 -> Result.DUPLICATE;
            case -1 -> Result.OUT_OF_STOCK;
            case -2 -> Result.ALREADY_USED;
            default -> Result.NOT_LOADED;
        };
    }
}

DUPLICATE é considerado sucesso: quando o cliente retenta, recebe o mesmo resultado anterior. NOT_LOADED significa que o voucher ainda não foi “aquecido” (carregado). Você pode carregar preguiçosamente do DB com SET ... NX, ou melhor ainda, carregar antecipadamente antes da abertura da venda com um job:

redis-cli SET "voucher:{FLASH100}:remaining" 1000

Uma otimização adicional: quando remaining chega a 0, um API gateway ou flag de cache local (local cache, TTL de alguns segundos) pode retornar OUT_OF_STOCK imediatamente sem chamar Redis. Dezenas de milhares de requisições “atrasadas” serão bloqueadas na camada mais externa.

8. Consistência entre Redis e banco de dados

Redis agora é o árbitro, mas o banco de dados é a verdade sobre o dinheiro. Existem duas formas de sincronizar.

Abordagem A: escrever no DB sincronamente logo após Redis. Você ainda executa a sentença UPDATE da seção 6. Se o DB falhar, chame o script release para devolver o uso ao Redis. Essa abordagem é simples e tem consistência mais forte, adequada quando o número de usos é pequeno.

Abordagem B: empurrar eventos através do Kafka quando deseja desacoplar completamente o DB do caminho quente:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}

@Component
public class RedemptionConsumer {

    private final JdbcTemplate jdbc;

    public RedemptionConsumer(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
    @Transactional
    public void on(VoucherReserved e) {
        int inserted = jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'RESERVED')
                ON CONFLICT (order_id) DO NOTHING
                """, e.code(), e.customerId(), e.orderId());
        if (inserted == 1) {
            jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
        }
    }
}

No lado do serviço, após Lua retornar RESERVED, você chama kafkaTemplate.send("voucher-reserved", code, event) com chave code. O consumer tem as seguintes características:

  • Kafka garante at-least-once, então o mesmo evento pode chegar duas vezes. ON CONFLICT (order_id) DO NOTHING torna a segunda vez uma no-op, e used_count só aumenta quando uma linha realmente é inserida.
  • A constraint CHECK (used_count <= total_limit) ainda está lá. Se o Redis for carregado com um número errado, o DB o rejeita e o evento cai no dead-letter topic para o operador manipular.

Nota: Se Redis reservar com sucesso, mas falhar em enviar para Kafka, esse uso fica “pendurado” no Redis. É um undersell, seguro em termos monetários mas precisa de limpeza. Por isso você precisa de um job de reconciliação (seção 8.1).

8.1. Expiração de reserva e reconciliação

Se um cliente reservar um voucher mas abandonar o carrinho, esse uso deve ser devolvido. Um job periódico manipula isso:

@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
    List<Map<String, Object>> expired = jdbc.queryForList("""
            UPDATE voucher_redemption SET status = 'RELEASED'
            WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
            RETURNING voucher_code, customer_id, order_id
            """);
    expired.forEach(row -> voucherGate.release(
            (String) row.get("voucher_code"),
            ((Number) row.get("customer_id")).longValue(),
            (String) row.get("order_id")));
}

A sentença UPDATE ... RETURNING (PostgreSQL) muda de status e retorna a lista de linhas alteradas. Portanto, duas instâncias executando o job ao mesmo tempo não liberam duplicatas: qualquer linha já marcada como RELEASED não corresponde à condição. Você também precisa diminuir used_count no DB correspondentemente. A função release chama um Lua script ao contrário: apenas quando HDEL orders orderId retorna 1 é que INCR remaining e HINCRBY users customerId -1. Assim, chamar release duas vezes ainda é seguro.

Adicione um job de reconciliação que é executado após cada rodada de venda: compare total_limit - used_count no DB com remaining no Redis e alerte se divergirem.

9. Erros comuns e quando não usar

  • Usar GET e depois DECR como dois comandos separados do Java. Isso novamente é check-then-act, apenas transferido para Redis. A verificação e a subtração devem estar no mesmo Lua script.
  • Usar apenas DECR e depois verificar se é negativo para INCR novamente. Isso funciona, mas há uma janela onde o valor é negativo e outra requisição pode ler errado. Também torna o trabalho de liberação mais complexo.
  • Optimistic locking (@Version) para vouchers quentes. Com 50.000 requisições competindo por uma linha, quase todas falham e precisam fazer retry. A carga é pior do que com pessimistic lock.
  • Redis sem persistência/replicação ativada. Se o Redis reiniciar e perder dados, e o job de aquecimento carregar de total_limit, você terá oversell. Sempre carregue de total_limit - used_count no DB, menos os usos que estão em RESERVED.
  • Aplicar Redis + Kafka para cada voucher. Vouchers normais com carga baixa precisam apenas da solução 6. Adicionar dois sistemas é adicionar dois lugares onde os dados podem divergir. Apenas ative o “modo flash sale” para vouchers marcados.

Nota: Cada camada acima é apenas uma otimização. As constraints no banco de dados (CHECK, UNIQUE) são a garantia final de que nenhum uso é emitido além do limite.

10. Conclusão

O problema de limitar o uso de vouchers é essencialmente um problema de contagem atômica sob contenção. Com carga moderada, uma sentença UPDATE ... WHERE used_count < total_limit junto com unique constraints é correto e rápido o suficiente. Quando há flash sale e hot row, você usa Redis Lua script como um portão atômico. Kafka ajuda a tirar a escrita do DB do caminho quente, enquanto o mecanismo de reserva com expiração e jobs de reconciliação lidam com divergências. O princípio consistente é: aceitar undersell temporário, nunca oversell, e deixar o banco de dados como a camada de proteção final.

system-designspring-bootrediskafkaconcurrency