Thiết kế hệ thống bán vé xem phim: khoá ghế tạm thời và hết hạn giữ chỗ

1. Tổng quan

Khi mở bán vé suất chiếu đầu tiên của một bom tấn, hàng nghìn người cùng bấm vào một sơ đồ ghế chỉ có vài trăm chỗ. Hệ thống phải đảm bảo mỗi ghế chỉ thuộc về một người. Ghế được giữ tạm trong lúc khách thanh toán và phải tự nhả ra nếu khách bỏ đi.

Bài này đi qua toàn bộ thiết kế: yêu cầu, mô hình dữ liệu, API, luồng xử lý và các điểm khó. Code minh hoạ dùng Java 21, Spring Boot 3.x, SQL và Redis. Đọc xong, bạn có thể tự cài đặt cơ chế giữ ghế an toàn khi có nhiều request đồng thời. Bạn cũng sẽ biết cách xử lý ghế hết hạn mà không phụ thuộc vào một job dọn dẹp.

2. Yêu cầu

2.1. Yêu cầu chức năng

  • Khách xem sơ đồ ghế của một suất chiếu, với trạng thái trống, đang giữ hoặc đã bán.
  • Khách chọn tối đa 8 ghế và giữ chúng trong 10 phút.
  • Việc giữ ghế là all-or-nothing: nếu có một ghế không giữ được thì không giữ ghế nào.
  • Khách thanh toán trong thời gian giữ thì ghế chuyển sang đã bán.
  • Hết 10 phút mà chưa thanh toán thì ghế tự động trống trở lại.

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

  • Khoảng 500 rạp, mỗi rạp 10 phòng, mỗi phòng 100–300 ghế.
  • Lúc bình thường có vài trăm request/giây. Lúc mở bán phim hot có thể vọt lên 50.000 request/giây, dồn vào một vài suất chiếu.
  • Không được bán trùng ghế. Đây là ràng buộc cứng.
  • Tỉ lệ đọc so với ghi rất lệch: một lần giữ ghế đi kèm hàng chục lần xem sơ đồ.
  • Thanh toán đi qua cổng bên ngoài. Callback có thể đến trễ, đến hai lần hoặc không bao giờ đến.

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

Bảng trung tâm là show_seat. Mỗi dòng ứng với một ghế trong một suất chiếu cụ thể. Ta không tách bảng “giữ chỗ” riêng, vì trạng thái ghế và thông tin giữ ghế cần được cập nhật trong cùng một câu lệnh nguyên tử.

CREATE TABLE show_seat (
    showtime_id     BIGINT       NOT NULL,
    seat_code       VARCHAR(8)   NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
    hold_id         UUID,
    customer_id     BIGINT,
    hold_expires_at TIMESTAMP,
    booking_id      BIGINT,
    PRIMARY KEY (showtime_id, seat_code)
);

CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);

Một số điểm cần lưu ý:

  • Khoá chính (showtime_id, seat_code) cho phép cập nhật một nhóm ghế của cùng suất chiếu rất nhanh.
  • hold_id gom các ghế cùng một lần giữ. Bước thanh toán sẽ xác nhận cả nhóm theo hold_id này.
  • Index (status, hold_expires_at) phục vụ job dọn ghế hết hạn ở mục 7.

Các bảng showtime, booking và payment là bảng CRUD thông thường nên bài không trình bày chi tiết.

4. API

GET    /showtimes/{showtimeId}/seats          -> sơ đồ ghế + trạng thái
POST   /showtimes/{showtimeId}/holds          -> giữ ghế, body: {"seats":["F5","F6"]}
                                                 201 {"holdId":"...","expiresAt":"..."} | 409 nếu ghế bận
POST   /holds/{holdId}/checkout               -> tạo phiên thanh toán (header Idempotency-Key)
DELETE /holds/{holdId}                        -> khách huỷ, nhả ghế ngay
POST   /payments/callback                     -> cổng thanh toán gọi về

Trả 409 Conflict khi ghế đã bị người khác giữ. Client dựa vào mã này để làm mới sơ đồ và mời khách chọn ghế khác.

5. Luồng xử lý

Khách          API                 DB / Redis              Cổng thanh toán
  | chọn ghế    |                       |                          |
  |------------>| UPDATE có điều kiện   |                          |
  |             |---------------------->| HELD, hết hạn sau 10'    |
  |<------------| 201 holdId            |                          |
  | checkout    |                       |                          |
  |------------>| tạo payment (8')      |------------------------->|
  |             |                       |        callback SUCCESS  |
  |             |<-------------------------------------------------|
  |             | HELD -> BOOKED nếu    |                          |
  |             | chưa hết hạn          |                          |

Phiên thanh toán được cấu hình hết hạn sau 8 phút, ngắn hơn thời gian giữ ghế. Khoảng đệm 2 phút giúp phần lớn callback về kịp trước khi ghế bị nhả.

6. Khoá ghế tạm thời

Dependency cần dùng:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

6.1. Câu UPDATE có điều kiện là nguồn sự thật

Cách sai phổ biến là đọc trước rồi ghi sau: SELECT kiểm tra ghế trống, rồi UPDATE. Giữa hai câu lệnh, một request khác có thể chen vào giữ mất ghế. Kết quả là hai người cùng nghĩ mình giữ được ghế.

Cách đúng là gộp phần kiểm tra vào điều kiện WHERE, rồi đếm số dòng bị ảnh hưởng:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final NamedParameterJdbcTemplate jdbc;
    private final Clock clock;

    public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Transactional
    public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
        UUID holdId = UUID.randomUUID();
        Instant now = clock.instant();
        Instant expiresAt = now.plus(HOLD_TTL);

        var params = new MapSqlParameterSource()
                .addValue("holdId", holdId)
                .addValue("customerId", customerId)
                .addValue("showtimeId", showtimeId)
                .addValue("seatCodes", seatCodes.stream().sorted().toList())
                .addValue("now", Timestamp.from(now))
                .addValue("expiresAt", Timestamp.from(expiresAt));

        int updated = jdbc.update("""
                UPDATE show_seat
                   SET status = 'HELD', hold_id = :holdId,
                       customer_id = :customerId, hold_expires_at = :expiresAt
                 WHERE showtime_id = :showtimeId
                   AND seat_code IN (:seatCodes)
                   AND (status = 'AVAILABLE'
                        OR (status = 'HELD' AND hold_expires_at < :now))
                """, params);

        if (updated != seatCodes.size()) {
            throw new SeatUnavailableException(showtimeId, seatCodes);
        }
        return new SeatHold(holdId, expiresAt);
    }
}

public record SeatHold(UUID holdId, Instant expiresAt) {}

public class SeatUnavailableException extends RuntimeException {
    public SeatUnavailableException(long showtimeId, List<String> seats) {
        super("Seats " + seats + " of showtime " + showtimeId + " are not available");
    }
}

Đoạn code hoạt động như sau:

  • Câu UPDATE lấy row lock trên từng dòng ghế. Hai transaction cùng nhắm vào ghế F5 sẽ phải xếp hàng. Transaction đến sau đánh giá lại điều kiện WHERE và thấy status = 'HELD', nên không cập nhật được dòng đó.
  • Điều kiện status = 'HELD' AND hold_expires_at < :now là lazy expiration. Ghế đã hết hạn giữ được coi như trống, kể cả khi chưa có job nào dọn nó.
  • Nếu số dòng cập nhật ít hơn số ghế yêu cầu, ta ném exception. @Transactional rollback toàn bộ, nên ta có được tính all-or-nothing.
  • Clock được inject vào service để test có thể điều khiển thời gian.

Giả sử khách A giữ F5, F6, sau đó khách B giữ F6, F7. Khi đó B nhận được:

HTTP 409 Conflict
{"error":"Seats [F6, F7] of showtime 42 are not available"}

Ghế F7 của B vẫn trống, vì transaction của B đã bị rollback.

Lưu ý: Hãy sắp xếp seatCodes trước khi update. Nếu A khoá theo thứ tự F5 → F6 còn B khoá theo thứ tự F6 → F5, hai transaction có thể deadlock. Với PostgreSQL, thứ tự khoá phụ thuộc thứ tự quét dòng. Muốn chắc chắn, bạn có thể chạy SELECT ... ORDER BY seat_code FOR UPDATE trước khi update.

6.2. Redis làm lớp chặn nhanh

Khi tải đột biến, 50.000 request giữ ghế cùng đập vào vài trăm dòng của một suất chiếu. Database sẽ phải xếp hàng lock rất dài. Ta đặt Redis phía trước để loại sớm các request chắc chắn thua. Script Lua dưới đây giúp thao tác nhiều key trở thành nguyên tử:

@Component
public class RedisSeatGate {

    private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
            for _, key in ipairs(KEYS) do
              if redis.call('EXISTS', key) == 1 then return 0 end
            end
            for _, key in ipairs(KEYS) do
              redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
            end
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
        List<String> keys = seats.stream()
                .map(s -> "seat:{" + showtimeId + "}:" + s)
                .toList();
        Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
                String.valueOf(ttl.toMillis()));
        return ok != null && ok == 1L;
    }
}

Cách dùng như sau:

  • Redis chạy script Lua trên một thread duy nhất. Không request nào có thể chen vào giữa lúc kiểm tra EXISTS và lúc SET.
  • PX gắn TTL cho key, nên Redis tự xoá key khi hết hạn. Đây là cơ chế hết hạn tự nhiên của lớp cache.
  • Phần {showtimeId} là hash tag. Trên Redis Cluster, mọi key của một suất chiếu nằm cùng một slot. Nếu thiếu hash tag, script đa key sẽ báo lỗi CROSSSLOT.

Luồng đầy đủ là: gọi tryHold trên Redis trước. Nếu thất bại thì trả 409 ngay. Nếu thành công thì chạy câu UPDATE ở mục 6.1. Nếu UPDATE thất bại thì xoá key Redis vừa đặt.

Lưu ý: Redis không phải nguồn sự thật. Redis có thể mất dữ liệu khi failover, và key có thể hết hạn lệch vài mili giây so với DB. Quyết định cuối cùng luôn là câu UPDATE có điều kiện. Nếu hệ thống của bạn không có tải đột biến, hãy bỏ hẳn lớp Redis để bớt một thành phần phải vận hành.

7. Hết hạn giữ chỗ

Tính đúng đắn của việc hết hạn đã được đảm bảo bởi điều kiện hold_expires_at < :now ở mục 6.1. Tuy vậy, sơ đồ ghế vẫn hiển thị HELD cho đến khi có ai đó ghi đè. Vì thế ta cần thêm một job dọn dẹp để giao diện hiển thị đúng:

@Component
public class ExpiredHoldSweeper {

    private final JdbcTemplate jdbc;
    private final Clock clock;

    public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Scheduled(fixedDelay = 15_000)
    public void releaseExpired() {
        int released = jdbc.update("""
                UPDATE show_seat
                   SET status = 'AVAILABLE', hold_id = NULL,
                       customer_id = NULL, hold_expires_at = NULL
                 WHERE status = 'HELD' AND hold_expires_at < ?
                """, Timestamp.from(clock.instant()));
        if (released > 0) {
            // publish SeatsReleased để cập nhật cache sơ đồ ghế
        }
    }
}

Job này chạy 15 giây một lần. Nó có chạy trễ hay chạy trên nhiều instance cùng lúc cũng không sao, vì câu lệnh là idempotent. Nhờ lazy expiration, job chết vài phút cũng không gây bán trùng ghế. Hậu quả duy nhất là giao diện hiển thị lỗi thời trong khoảng thời gian đó. Nhớ bật @EnableScheduling trong cấu hình.

Phía xác nhận thanh toán phải kiểm tra thời hạn trong cùng câu lệnh:

@Transactional
public boolean confirm(UUID holdId, long bookingId) {
    int updated = jdbc.update("""
            UPDATE show_seat
               SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
             WHERE hold_id = :holdId AND status = 'HELD'
               AND hold_expires_at >= :now
            """, new MapSqlParameterSource()
                    .addValue("holdId", holdId)
                    .addValue("bookingId", bookingId)
                    .addValue("now", Timestamp.from(clock.instant())));
    return updated > 0;
}

Hàm này trả về false trong hai trường hợp: hold đã hết hạn, hoặc ghế đã bị người khác giữ lại. Cả hai trường hợp đều cần xử lý ở bước thanh toán.

8. Nhất quán với thanh toán

Callback từ cổng thanh toán là nơi dễ sai nhất. Có ba tình huống bạn cần xử lý:

  1. Callback đến hai lần. Lưu payment_id với ràng buộc UNIQUE. Lần thứ hai thấy giao dịch đã xử lý thì trả 200 và không làm gì thêm.
  2. Callback đến sau khi ghế hết hạn. confirm trả về false. Lúc này hệ thống đã thu tiền nhưng không giao được ghế. Ta đánh dấu booking FAILED và phát lệnh hoàn tiền tự động. Đừng cố “cướp lại” ghế, vì có thể người khác đã mua nó.
  3. Callback không bao giờ đến. Một job đối soát định kỳ hỏi trạng thái các payment PENDING quá 15 phút từ cổng thanh toán, rồi áp dụng logic như hai trường hợp trên.

Sau khi confirm thành công, các bước như gửi email, xuất vé QR hay cộng điểm thành viên không nên chạy trong cùng transaction. Hãy ghi một dòng BookingConfirmed vào bảng outbox trong transaction đó. Một tiến trình riêng sẽ đẩy sự kiện lên Kafka, với topic booking-events và key là bookingId. Cách này đảm bảo sự kiện chỉ phát ra khi DB đã commit, và commit thì sự kiện chắc chắn được phát.

9. Chống tải đột biến

Lớp Redis ở mục 6.2 mới giải quyết được phần ghi. Khi mở bán phim hot, bạn cần thêm các biện pháp sau:

  • Phòng chờ ảo (virtual waiting room). Mỗi suất chiếu hot chỉ cho N người vào trang chọn ghế cùng lúc. Người còn lại nhận token xếp hàng, được lưu trong Redis sorted set với score là thời điểm đến. Vài trăm ghế không cần 50.000 người cùng chọn.
  • Cache sơ đồ ghế. Sơ đồ được đọc từ Redis hoặc CDN và làm mới qua sự kiện SeatsHeld/SeatsReleased. Sơ đồ chậm 1–2 giây vẫn chấp nhận được, vì bước giữ ghế sẽ kiểm tra lại chính xác.
  • Rate limit theo khách hàng. Ví dụ tối đa 5 lần giữ ghế mỗi phút, để chặn bot quét ghế.
  • Phân vùng theo showtime_id. Mọi tranh chấp đều nằm trong một suất chiếu. Bạn có thể shard DB theo showtime_id mà không cần transaction phân tán.

10. Kiểm thử tranh chấp

Hành vi quan trọng nhất cần kiểm chứng là: nhiều khách cùng giữ một ghế thì chỉ một người thắng.

@SpringBootTest
class SeatHoldServiceTest {

    @Autowired SeatHoldService service;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM show_seat");
        jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int customers = 20;
        var start = new CountDownLatch(1);
        var successes = new AtomicInteger();

        try (var pool = Executors.newFixedThreadPool(customers)) {
            for (int i = 0; i < customers; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        service.hold(42, List.of("F5", "F6"), customerId);
                        successes.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(successes.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
                Integer.class)).isEqualTo(1);
    }
}

Giải thích bài test:

  • CountDownLatch giữ 20 thread ở vạch xuất phát rồi thả cùng lúc, để tối đa hoá khả năng tranh chấp.
  • try-with-resources trên ExecutorService (Java 21) chờ mọi task hoàn thành trước khi chạy phần assert.
  • Assertion thứ hai kiểm tra cả hai ghế thuộc cùng một hold_id, tức là không có chuyện “mỗi người một ghế”.

Test chạy trên H2 với file schema.sql lấy từ mục 3 (đổi kiểu UUID thành UUID của H2, vốn đã được hỗ trợ). Bạn cần khai báo một bean Clock.systemUTC(). Để test hết hạn, hãy thay bean đó bằng một Clock giả có thể tua thời gian.

Lưu ý: H2 không mô phỏng chính xác hành vi khoá của PostgreSQL hay MySQL. Với code liên quan đến concurrency, hãy chạy thêm bộ test trên đúng database production, ví dụ qua Testcontainers.

11. Lỗi hay gặp

  • Kiểm tra rồi mới ghi, tức SELECT xong mới UPDATE, mà không có lock. Đây là nguyên nhân số một gây bán trùng ghế.
  • Phụ thuộc hoàn toàn vào job dọn dẹp để nhả ghế. Job chết thì ghế bị kẹt mãi. Lazy expiration trong WHERE loại bỏ rủi ro này.
  • Dùng Redis lock làm nguồn sự thật. Lock có thể mất khi failover, dẫn đến hai người cùng giữ một ghế.
  • Dùng SELECT ... FOR UPDATE giữ lock suốt 10 phút chờ khách thanh toán. Connection pool sẽ cạn rất nhanh. Lock DB chỉ nên tồn tại trong vài mili giây. Thời gian giữ ghế dài phải được biểu diễn bằng dữ liệu, cụ thể là cột hold_expires_at.
  • Lấy thời gian từ đồng hồ của nhiều máy khác nhau. Hãy thống nhất một nguồn thời gian: hoặc now() của DB, hoặc Clock của app với NTP được đồng bộ chặt chẽ.

12. Kết luận

Trái tim của hệ thống bán vé là một câu UPDATE có điều kiện. Câu lệnh này vừa khoá ghế nguyên tử, vừa coi hold hết hạn là ghế trống, nên không bao giờ bán trùng và không cần job dọn dẹp mới đúng. Các thành phần còn lại xây quanh lõi đó:

  • Redis Lua script chặn tải sớm.
  • Job sweeper giúp giao diện hiển thị đúng trạng thái.
  • Idempotency và đối soát xử lý các callback thanh toán thất thường.
  • Outbox kết hợp Kafka phát sự kiện tin cậy.
  • Phòng chờ ảo hấp thụ tải đột biến.

Khi áp dụng, hãy bắt đầu với phần lõi SQL cùng bài test tranh chấp. Chỉ thêm Redis và phòng chờ ảo khi số liệu tải thực tế cho thấy bạn cần chúng.

system-designspring-bootredisconcurrencycase-study