Thiết kế hệ thống voucher: giới hạn lượt dùng khi tải cao

1. Tổng quan

Một voucher “giảm 100k, chỉ 1.000 lượt” nghe đơn giản. Đến giờ flash sale, hàng chục nghìn request đến cùng lúc. Lúc đó hệ thống dễ phát ra 1.050 lượt thay vì 1.000, và phần dôi ra là tiền thật bị mất. Bài này đi qua một case study đầy đủ: yêu cầu, mô hình dữ liệu, API, rồi giải lần lượt các điểm khó là tranh chấp, nhất quán và tải đột biến. Đọc xong, bạn sẽ biết khi nào chỉ cần một câu UPDATE nguyên tử, khi nào phải đưa Redis và Kafka vào.

2. Yêu cầu

2.1. Chức năng

  • Tạo voucher có mã, mức giảm, giá trị đơn tối thiểu, thời gian hiệu lực.
  • Giới hạn tổng lượt dùng và số lượt mỗi khách.
  • Khi checkout, voucher được giữ chỗ (reserve). Thanh toán thành công thì xác nhận (confirm). Huỷ đơn hoặc quá hạn thì trả lại (release).

2.2. Quy mô và ràng buộc

  • Khoảng 10 triệu khách hàng. Voucher flash sale 1.000 lượt nhưng có thể nhận 50.000 request/giây trong vài giây đầu.
  • p99 của API reserve dưới 100 ms.
  • Tuyệt đối không oversell. Undersell tạm thời thì chấp nhận được, ví dụ một lượt bị giữ rồi 15 phút sau mới trả lại.
  • Một request gửi lại (retry) không được tiêu thêm lượt. Nói cách khác, API phải idempotent theo orderId.

3. Mô hình dữ liệu

Database (PostgreSQL) là nguồn sự thật cuối cùng:

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

Vài điểm đáng chú ý:

  • CHECK (used_count <= total_limit) là lưới an toàn cuối cùng. Kể cả khi code có bug, database cũng không cho oversell.
  • order_id UNIQUE đảm bảo idempotency: một đơn chỉ dùng được một lượt.
  • UNIQUE (voucher_code, customer_id) chỉ đúng khi per_user_limit = 1, vốn là trường hợp phổ biến nhất. Nếu cho phép nhiều lượt mỗi khách, bạn bỏ constraint này và đếm trong bảng riêng.

4. API

POST   /vouchers/{code}/reservations        body: {customerId, orderId, orderAmount}
       201 {orderId, discountAmount}
       409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
       422 {error: "NOT_ELIGIBLE"}           (hết hạn, đơn chưa đủ giá trị)
POST   /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}

orderId do client (order service) sinh ra. Nhờ vậy, khi retry, client gửi lại đúng orderId cũ và server nhận ra đây là request trùng.

5. Cách làm ngây thơ và vì sao nó sai

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

Đây là lỗi kinh điển “check-then-act”. Hai thread cùng đọc usedCount = 999, cả hai thấy còn chỗ, cả hai ghi 1000. Kết quả là hai khách được giảm giá nhưng bộ đếm chỉ tăng một. Ở mức isolation READ COMMITTED mặc định, transaction không cứu được bạn trong trường hợp này.

6. Giải pháp 1: UPDATE có điều kiện trong database

Cách sửa đơn giản nhất là gộp phần kiểm tra và phần ghi vào cùng một câu lệnh nguyên tử:

@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; // hết lượt hoặc hết hạn
        }
        jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'CONFIRMED')
                """, code, customerId, orderId);
        return true;
    }
}

Code trên hoạt động như sau:

  • Câu UPDATE khoá dòng voucher. Database đánh giá lại used_count < total_limit sau khi lấy được lock, nên không thể có hai request cùng vượt qua ở lượt cuối.
  • updated == 0 nghĩa là điều kiện sai: hết lượt hoặc voucher đã hết hạn.
  • Nếu khách đã dùng voucher, hoặc orderId bị gửi lại, câu INSERT ném DuplicateKeyException. Transaction rollback, used_count trở về giá trị cũ. Ở tầng controller, bạn map exception này sang 409 ALREADY_USED.

6.1. Test hành vi dưới tải song song

@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) tắt transaction mặc định của @JdbcTest. Nếu không tắt, dữ liệu tạo trong setUp chưa commit và các thread khác không nhìn thấy. Có 200 virtual thread tranh nhau 10 lượt, và kết quả luôn là đúng 10:

success = 10, used_count = 10

6.2. Giới hạn của giải pháp 1

Cách này đúng, và với phần lớn voucher thông thường (vài trăm request/giây) thì như vậy là đủ. Vấn đề chỉ xuất hiện khi tải tập trung vào một dòng duy nhất (hot row). 50.000 request/giây cùng xếp hàng chờ lock trên dòng FLASH100. Mỗi transaction giữ lock vài mili giây, nên throughput thực tế bị chặn ở mức vài nghìn mỗi giây. Phần còn lại timeout, connection pool cạn, và cả database chậm theo, kể cả những bảng không liên quan đến voucher.

7. Giải pháp 2: Redis làm “cổng chặn” bằng Lua script

Ý tưởng: đặt bộ đếm lượt còn lại trong Redis. Redis chạy single-thread, mỗi Lua script thực thi nguyên tử và mất khoảng vài micro giây. Chỉ những request đã giữ được chỗ mới đi tiếp xuống database. Với voucher 1.000 lượt, database chỉ còn nhận tối đa 1.000 lần ghi thay vì 50.000.

Dependency:

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

Script reserve, lưu tại resources/scripts/reserve.lua:

-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users      (hash customerId -> số lượt)
-- 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, đã giữ rồi
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end            -- chưa nạp voucher
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end                      -- khách đã dùng
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end      -- hết lượt
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1

Các key dùng hash tag {code}. Nhờ đó, trên Redis Cluster, cả ba key nằm cùng một slot và script chạy được.

Phía 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 được coi là thành công: client retry thì nhận lại đúng kết quả cũ. NOT_LOADED nghĩa là voucher chưa được “warm up”. Bạn có thể nạp lười từ DB bằng SET ... NX, hoặc tốt hơn là nạp sẵn trước giờ mở bán bằng một job:

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

Thêm một tối ưu nữa: khi remaining đã về 0, API gateway hoặc một cờ cache cục bộ (local cache, TTL vài giây) có thể trả OUT_OF_STOCK ngay mà không gọi Redis. Hàng chục nghìn request “đến muộn” sẽ dừng ở tầng ngoài cùng.

8. Nhất quán giữa Redis và database

Redis giờ là nơi quyết định, nhưng database mới là sự thật về tiền. Có hai cách đồng bộ.

Cách A: ghi DB đồng bộ ngay sau Redis. Bạn vẫn chạy câu UPDATE ở mục 6. Nếu DB lỗi, gọi script release để trả lượt cho Redis. Cách này đơn giản và nhất quán chặt hơn, phù hợp khi số lượt nhỏ.

Cách B: đẩy sự kiện qua Kafka khi muốn tách hẳn DB khỏi đường nóng:

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

Ở phía service, sau khi Lua trả RESERVED, bạn gọi kafkaTemplate.send("voucher-reserved", code, event) với key là code. Consumer có các đặc điểm sau:

  • Kafka giao at-least-once, nên cùng một sự kiện có thể đến hai lần. ON CONFLICT (order_id) DO NOTHING biến lần thứ hai thành no-op, và used_count chỉ tăng khi thật sự chèn được dòng mới.
  • Constraint CHECK (used_count <= total_limit) vẫn còn đó. Nếu Redis bị nạp sai số, DB từ chối và sự kiện rơi vào dead-letter topic để người vận hành xử lý.

Lưu ý: Nếu Redis giữ chỗ thành công nhưng gửi Kafka thất bại, lượt đó bị “treo” trong Redis. Đó là undersell, an toàn về tiền nhưng cần dọn. Vì vậy bạn cần job đối soát (mục 8.1).

8.1. Hết hạn giữ chỗ và đối soát

Khách giữ voucher rồi bỏ giỏ hàng thì lượt đó phải được trả lại. Một job định kỳ xử lý việc này:

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

Câu UPDATE ... RETURNING (PostgreSQL) vừa đổi trạng thái vừa trả về danh sách dòng bị đổi. Vì vậy hai instance chạy job cùng lúc cũng không release trùng: dòng nào đã chuyển RELEASED thì không khớp điều kiện nữa. Bạn cũng cần giảm used_count trong DB tương ứng. Hàm release gọi một Lua script ngược lại: chỉ khi HDEL orders orderId trả 1 thì mới INCR remaining và HINCRBY users customerId -1. Nhờ vậy, gọi release hai lần vẫn an toàn.

Thêm một job đối soát chạy sau mỗi đợt sale: so total_limit - used_count trong DB với remaining trong Redis, rồi cảnh báo nếu lệch.

9. Lỗi hay gặp và khi nào không nên dùng

  • Dùng GET rồi DECR thành hai lệnh riêng từ Java. Đây lại là check-then-act, chỉ là chuyển sang Redis. Phép kiểm tra và phép trừ phải nằm trong cùng một Lua script.
  • Chỉ dùng DECR rồi kiểm tra âm thì INCR lại. Cách này chạy được, nhưng trong khoảnh khắc giá trị âm, một request khác có thể đọc sai. Nó cũng làm việc release phức tạp hơn.
  • Optimistic locking (@Version) cho voucher hot. Khi 50.000 request tranh một dòng, gần như tất cả đều thất bại và retry. Tải còn tệ hơn pessimistic lock.
  • Redis không bật persistence/replication. Nếu Redis restart mất dữ liệu và job warm-up nạp lại từ total_limit, bạn sẽ oversell. Luôn nạp lại từ total_limit - used_count trong DB, trừ thêm các lượt đang RESERVED.
  • Áp dụng Redis + Kafka cho mọi voucher. Voucher thường, tải thấp chỉ cần giải pháp 6. Thêm hai hệ thống là thêm hai nơi có thể lệch dữ liệu. Chỉ bật “chế độ flash sale” cho những voucher được đánh dấu.

Lưu ý: Mọi tầng phía trên chỉ là tối ưu. Constraint trong database (CHECK, UNIQUE) mới là lời đảm bảo cuối cùng rằng không có lượt nào bị phát vượt.

10. Kết luận

Bài toán giới hạn lượt dùng voucher thực chất là bài toán đếm nguyên tử dưới tranh chấp. Với tải vừa phải, một câu UPDATE ... WHERE used_count < total_limit cùng các unique constraint là đủ đúng và đủ nhanh. Khi có flash sale và hot row, bạn dùng Redis Lua script làm cổng chặn nguyên tử. Kafka giúp đưa phần ghi DB ra khỏi đường nóng, còn cơ chế giữ chỗ có hết hạn và job đối soát xử lý các trường hợp lệch. Nguyên tắc xuyên suốt là: chấp nhận undersell tạm thời, không bao giờ oversell, và để database giữ lớp bảo vệ cuối cùng.

system-designspring-bootrediskafkaconcurrency