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 UNIQUEensures idempotency: each order uses a voucher at most once.UNIQUE (voucher_code, customer_id)is correct only whenper_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
UPDATEstatement locks the voucher row. The database re-evaluatesused_count < total_limitafter acquiring the lock, so no two requests can both pass at the final use. updated == 0means the condition failed: out of uses or voucher expired.- If the customer already used the voucher, or the
orderIdis a retry, theINSERTthrowsDuplicateKeyException. The transaction rolls back andused_countreverts. In the controller, map this exception to409 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 NOTHINGmakes the second time a no-op, andused_countonly 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
GETthenDECRas 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
DECRthen check if negative andINCRback. 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 fromtotal_limit - used_countin the DB, minus outstandingRESERVEDuses. - 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.