Designing a Voucher System: Usage Limits Under High Load

1. Overview

A voucher like “discount 100k, only 1,000 uses” sounds simple. When a flash sale hits, tens of thousands of requests arrive simultaneously. That’s when the system might issue 1,050 uses instead of 1,000, and the extra amount is real money lost. This article walks through a complete case study: requirements, data model, API, then addresses bottlenecks one by one—contention, consistency, and traffic spikes. After reading, you’ll know when a single atomic UPDATE suffices, and when you need to bring Redis and Kafka into play.

2. Requirements

2.1. Functionality

  • Create a voucher with code, discount amount, minimum order value, and validity period.
  • Enforce a total usage limit and per-customer usage limit.
  • During checkout, a voucher is reserved. After successful payment, it’s confirmed. If an order is canceled or the reservation expires, it’s released.

2.2. Scale and Constraints

  • About 10 million customers. A flash sale voucher with 1,000 uses may receive 50,000 requests/sec over a few seconds.
  • p99 latency of the reserve API under 100 ms.
  • Never oversell. Temporary undersell is acceptable—for example, one use held and returned after 15 minutes.
  • A retry request must not consume an extra use. In other words, the API must be idempotent by orderId.

3. Data Model

The database (PostgreSQL) is the single source of truth:

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)
);

A few points to note:

  • CHECK (used_count <= total_limit) is the final safety net. Even if the code has bugs, the database prevents oversell.
  • order_id UNIQUE ensures idempotency: each order uses a voucher at most once.
  • UNIQUE (voucher_code, customer_id) is correct only when per_user_limit = 1, which is the most common case. If allowing multiple uses per customer, drop this constraint and count in a separate table.

4. API

POST   /vouchers/{code}/reservations        body: {customerId, orderId, orderAmount}
       201 {orderId, discountAmount}
       409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
       422 {error: "NOT_ELIGIBLE"}           (expired, order value too low)
POST   /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}

orderId is generated by the client (order service). This way, on retry, the client sends the same old orderId and the server recognizes it as a duplicate.

5. Naive Approach and Why It Fails

@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;
}

This is the classic “check-then-act” bug. Two threads both read usedCount = 999, both see there’s room, both write 1000. Result: two customers get the discount, but the counter only increments by one. At the default READ COMMITTED isolation level, transaction does not save you here.

6. Solution 1: Conditional UPDATE in the Database

The simplest fix is to merge the check and write into a single atomic statement:

@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; // out of uses or expired
        }
        jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'CONFIRMED')
                """, code, customerId, orderId);
        return true;
    }
}

Here’s how it works:

  • The UPDATE statement locks the voucher row. The database re-evaluates used_count < total_limit after acquiring the lock, so no two requests can both pass at the final use.
  • updated == 0 means the condition failed: out of uses or voucher expired.
  • If the customer already used the voucher, or the orderId is a retry, the INSERT throws DuplicateKeyException. The transaction rolls back and used_count reverts. In the controller, map this exception to 409 ALREADY_USED.

6.1. Test Behavior Under Concurrent Load

@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) disables the default transaction in @JdbcTest. Otherwise, data created in setUp isn’t committed and other threads won’t see it. With 200 virtual threads competing for 10 uses, the result is always exactly 10:

success = 10, used_count = 10

6.2. Limitations of Solution 1

This approach is correct, and for most normal vouchers (a few hundred requests/sec) it’s sufficient. The problem only appears under hot row contention—50,000 requests/sec all queuing for a lock on the single FLASH100 row. Each transaction holds the lock for a few milliseconds, so actual throughput caps around a few thousand per second. The rest timeout, the connection pool exhausts, and even the database slows down, including unrelated tables.

7. Solution 2: Redis as Atomic Gate with Lua Script

The idea: keep the remaining-use counter in Redis. Redis runs single-threaded; each Lua script executes atomically in microseconds. Only requests that grab a slot proceed to the database. For a 1,000-use voucher, the database sees at most 1,000 writes instead of 50,000.

Dependency:

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

Reserve script, saved at resources/scripts/reserve.lua:

-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users      (hash customerId -> usage count)
-- 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, already reserved
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end            -- voucher not loaded
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end                      -- customer limit reached
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end      -- out of stock
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1

Keys use hash tag {code}. On a Redis Cluster, all three keys land in the same slot, allowing the script to run.

Java side:

@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 is treated as success: on retry, the client gets the same response. NOT_LOADED means the voucher hasn’t been “warmed up” yet. You can load lazily from the DB using SET ... NX, but better to pre-load before the sale opens:

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

One more optimization: once remaining hits 0, an API gateway or local cache (with a few-second TTL) can return OUT_OF_STOCK immediately without calling Redis. Tens of thousands of late-arriving requests stop at the outermost layer.

8. Consistency Between Redis and Database

Redis is now the decider, but the database is the ground truth about money. There are two sync strategies.

Strategy A: write database immediately after Redis. You still run the UPDATE from section 6. If the DB fails, call a release script to return the use to Redis. This is simpler and more tightly consistent, suitable for small limits.

Strategy B: push events through Kafka to fully decouple the database from the hot path:

<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());
        }
    }
}

On the service side, after Lua returns RESERVED, call kafkaTemplate.send("voucher-reserved", code, event) with key code. The consumer has these properties:

  • Kafka guarantees at-least-once, so the same event may arrive twice. ON CONFLICT (order_id) DO NOTHING makes the second time a no-op, and used_count only increments when a row is actually inserted.
  • The CHECK (used_count <= total_limit) constraint remains. If Redis is loaded wrong, the database rejects it and the event goes to a dead-letter topic for manual review.

Note: If Redis reserves successfully but Kafka send fails, that use gets “stuck” in Redis. That’s undersell—safe money-wise, but needs cleanup. This is why you need a reconciliation job (section 8.1).

8.1. Reservation Expiry and Reconciliation

If a customer reserves a voucher and abandons the cart, that use must be returned. A periodic job handles this:

@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")));
}

The UPDATE ... RETURNING statement (PostgreSQL) changes status and returns the affected rows in one go. So even if two instances run the job at once, a row already marked RELEASED won’t match the condition again. You also need to decrement used_count in the DB accordingly. The release function calls a Lua script in reverse: only when HDEL orders orderId returns 1 do we INCR remaining and HINCRBY users customerId -1. This way, calling release twice is safe.

Add one more job after each sale to reconcile: compare total_limit - used_count in the DB with remaining in Redis and alert if they diverge.

9. Common Mistakes and When Not to Use This

  • Call GET then DECR as two separate commands from Java. That’s check-then-act again, just in Redis. The check and decrement must live in one Lua script.
  • Only use DECR then check if negative and INCR back. It works, but the value goes negative momentarily and another request might read wrong. It also makes releasing more complex.
  • Optimistic locking (@Version) for hot vouchers. When 50,000 requests fight over one row, nearly all fail and retry. This is worse than pessimistic locking.
  • Redis without persistence/replication. If Redis restarts and loses data, and warm-up loads from total_limit, you’ll oversell. Always load from total_limit - used_count in the DB, minus outstanding RESERVED uses.
  • Apply Redis + Kafka to every voucher. Normal vouchers with low traffic only need solution 6. Adding two more systems means two more places to diverge. Only enable “flash-sale mode” for marked vouchers.

Note: Everything above is optimization. The constraints in the database (CHECK, UNIQUE) are the final guarantee that no use is ever over-issued.

10. Conclusion

The problem of enforcing a usage limit on vouchers is fundamentally about atomic counting under contention. With moderate load, a single UPDATE ... WHERE used_count < total_limit plus unique constraints is both correct and fast enough. When a flash sale creates a hot row, Redis Lua scripts serve as an atomic gate. Kafka moves database writes off the hot path, while reservation expiry and reconciliation jobs handle drift. The throughline is: tolerate undersell temporarily, never oversell, and let the database be your final line of defense.

system-designspring-bootrediskafkaconcurrency