Thiết kế hệ thống đặt vé máy bay: giữ chỗ, thanh toán, chống overbooking

1. Tổng quan

Bán vé máy bay nghe đơn giản: khách chọn ghế, trả tiền, nhận vé. Cái khó là hàng nghìn người cùng tranh một số ghế giới hạn. Thanh toán lại đi qua bên thứ ba, có thể chậm hoặc trả kết quả nhiều lần.

Trong bài này, bạn sẽ thiết kế một hệ thống đặt vé từ yêu cầu đến code. Hệ thống gồm giữ chỗ có thời hạn, thanh toán idempotent, chống bán trùng ghế và chịu tải đột biến. Đọc xong, bạn có một khung thiết kế để áp dụng cho các bài toán tương tự như bán vé concert, đặt phòng khách sạn hay flash sale.

2. Yêu cầu bài toán

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

  • Tìm chuyến bay và xem sơ đồ ghế còn trống.
  • Giữ chỗ (hold) một hoặc nhiều ghế trong 10 phút.
  • Thanh toán. Thành công thì xuất vé, thất bại hoặc hết hạn thì nhả ghế.
  • Huỷ vé và hoàn tiền theo chính sách.

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

  • Khoảng 5.000 chuyến/ngày, mỗi chuyến 180 ghế.
  • Lượng đọc (tìm kiếm, xem ghế) gấp khoảng 100 lần lượng ghi.
  • Lúc mở bán vé 0 đồng, có thể có 50.000 request/giây đổ vào vài chục chuyến.
  • Ràng buộc cứng: một ghế không bao giờ được bán cho hai người. Số vé bán theo hạng không được vượt quá giới hạn mà hãng cho phép.

Lưu ý: Hãng bay cố ý overbooking theo tỷ lệ, ví dụ bán 183 vé cho 180 ghế hạng phổ thông. Lý do là luôn có khách bỏ chuyến. Điều chúng ta chống là overbooking ngoài ý muốn do race condition. Mức overbooking có chủ đích phải là một tham số được kiểm soát.

Hai ràng buộc trên quyết định lựa chọn kiến trúc. Dữ liệu tồn kho ghế cần nhất quán mạnh. Phần tìm kiếm thì chấp nhận nhất quán cuối cùng (eventual consistency) và được cache mạnh tay.

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

Ta dùng PostgreSQL làm nguồn sự thật (source of truth) cho tồn kho:

CREATE TABLE flight (
    id              BIGINT PRIMARY KEY,
    code            VARCHAR(10) NOT NULL,
    departure_at    TIMESTAMPTZ NOT NULL
);

CREATE TABLE fare_inventory (
    flight_id       BIGINT NOT NULL REFERENCES flight(id),
    fare_class      VARCHAR(10) NOT NULL,        -- ECONOMY, BUSINESS
    capacity        INT NOT NULL,
    overbook_limit  INT NOT NULL DEFAULT 0,
    sold            INT NOT NULL DEFAULT 0,
    held            INT NOT NULL DEFAULT 0,
    PRIMARY KEY (flight_id, fare_class),
    CHECK (sold + held <= capacity + overbook_limit)
);

CREATE TABLE seat (
    flight_id   BIGINT NOT NULL REFERENCES flight(id),
    seat_no     VARCHAR(4) NOT NULL,
    status      VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
    booking_id  BIGINT,
    held_until  TIMESTAMPTZ,
    PRIMARY KEY (flight_id, seat_no)
);

CREATE TABLE booking (
    id           BIGINT PRIMARY KEY,
    flight_id    BIGINT NOT NULL,
    customer_id  BIGINT NOT NULL,
    status       VARCHAR(12) NOT NULL,   -- HELD, CONFIRMED, EXPIRED, CANCELLED
    amount       NUMERIC(12,2) NOT NULL,
    expires_at   TIMESTAMPTZ NOT NULL
);

CREATE TABLE payment (
    id               BIGINT PRIMARY KEY,
    booking_id       BIGINT NOT NULL,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    status           VARCHAR(10) NOT NULL
);

Thiết kế có hai tầng tồn kho:

  • fare_inventory đếm theo hạng vé. Ràng buộc CHECK là lưới an toàn cuối cùng: kể cả khi code có bug, database cũng từ chối overbooking vượt mức.
  • seat quản lý từng ghế cụ thể. Khoá chính (flight_id, seat_no) đảm bảo mỗi ghế chỉ có một dòng trạng thái.

4. API

GET  /flights?from=SGN&to=HAN&date=2026-12-20     -> danh sách chuyến (cache)
GET  /flights/{id}/seats                           -> sơ đồ ghế (cache 2-5 giây)
POST /bookings                                     -> giữ chỗ, trả bookingId + expiresAt
POST /bookings/{id}/payments                       -> header Idempotency-Key bắt buộc
POST /payments/webhook                             -> cổng thanh toán gọi về
DELETE /bookings/{id}                              -> huỷ

Request giữ chỗ:

{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }

Sơ đồ ghế được phép cũ vài giây. Người dùng có thể thấy ghế “trống” rồi bị từ chối khi giữ chỗ. Đó là đánh đổi chấp nhận được, miễn bước giữ chỗ luôn kiểm tra lại trên database.

5. Giữ chỗ: chống bán trùng ghế

Cách sai phổ biến nhất là “đọc rồi ghi”: SELECT status, thấy AVAILABLE thì UPDATE. Hai transaction có thể cùng đọc thấy trống rồi cùng ghi. Kết quả là một ghế bị bán cho hai người.

Giải pháp là conditional update: gộp điều kiện kiểm tra vào câu UPDATE và đếm số dòng bị ảnh hưởng.

public interface SeatRepository extends JpaRepository<Seat, SeatId> {

    @Modifying
    @Query(value = """
        UPDATE seat
           SET status = 'HELD', booking_id = :bookingId, held_until = :until
         WHERE flight_id = :flightId
           AND seat_no IN (:seatNos)
           AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
        """, nativeQuery = true)
    int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);

    @Modifying
    @Query(value = """
        UPDATE seat SET status = 'SOLD', held_until = NULL
         WHERE booking_id = :bookingId AND status = 'HELD'
        """, nativeQuery = true)
    int markSold(Long bookingId);
}

public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {

    @Modifying
    @Query(value = """
        UPDATE fare_inventory SET held = held + :n
         WHERE flight_id = :flightId AND fare_class = :fareClass
           AND sold + held + :n <= capacity + overbook_limit
        """, nativeQuery = true)
    int reserve(Long flightId, String fareClass, int n);
}

PostgreSQL khoá dòng khi thực thi UPDATE. Transaction thứ hai phải chờ, rồi đánh giá lại điều kiện WHERE trên dữ liệu mới. Khi đó điều kiện sai, nên câu lệnh trả về 0 dòng. Điều kiện held_until < now() cho phép “cướp” lại ghế đã hết hạn giữ mà không cần chờ job dọn dẹp.

Service ghép hai bước trong một transaction:

@Service
@RequiredArgsConstructor
public class BookingService {

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

    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final BookingRepository bookingRepository;
    private final IdGenerator idGenerator;

    @Transactional
    public Booking hold(HoldRequest req) {
        int n = req.seats().size();
        if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
            throw new SoldOutException(req.flightId(), req.fareClass());
        }

        Instant until = Instant.now().plus(HOLD_TTL);
        long bookingId = idGenerator.next();
        int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
        if (held != n) {
            // Rollback cả reserve lẫn những ghế đã giữ được
            throw new SeatUnavailableException(req.seats());
        }

        return bookingRepository.save(new Booking(bookingId, req.flightId(),
                req.customerId(), BookingStatus.HELD, req.amount(), until));
    }
}

SeatUnavailableException là RuntimeException, nên Spring rollback toàn bộ transaction. Khách hoặc giữ được tất cả ghế đã chọn, hoặc không giữ ghế nào. Không có chuyện giữ được 12A mà mất 12B.

Lưu ý: Để tránh deadlock khi hai khách cùng chọn {12A, 12B} và {12B, 12A}, hãy sắp xếp seatNos trước khi gửi xuống. Postgres không đảm bảo thứ tự khoá theo danh sách IN. Với nhóm ghế lớn, bạn nên dùng SELECT … ORDER BY seat_no FOR UPDATE trước khi update.

Nếu ghế vừa hết hạn bị người khác cướp, bộ đếm held của booking cũ vẫn còn. Job dọn dẹp ở mục 7 sẽ trừ lại.

6. Thanh toán idempotent

Cổng thanh toán có thể gọi webhook nhiều lần. Người dùng có thể bấm “Thanh toán” hai lần. Mọi thao tác tiền bạc phải idempotent.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final BookingRepository bookingRepository;
    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final OutboxRepository outboxRepository;

    @Transactional
    public PaymentResult confirm(long bookingId, String idempotencyKey) {
        Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return PaymentResult.of(existing.get());   // lần gọi lặp: trả kết quả cũ
        }

        // HELD -> CONFIRMED, chỉ thắng nếu booking chưa bị expire
        int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
        if (updated == 0) {
            paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
            return PaymentResult.REFUND;
        }

        Booking booking = bookingRepository.findById(bookingId).orElseThrow();
        int seats = seatRepository.markSold(bookingId);
        inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);

        paymentRepository.save(Payment.success(bookingId, idempotencyKey));
        outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
        return PaymentResult.SUCCESS;
    }
}

Câu transition chỉ là một conditional update khác:

UPDATE booking SET status = :to WHERE id = :id AND status = :from

Điểm mấu chốt nằm ở race giữa “tiền về” và “hết giờ giữ chỗ”. Cả luồng thanh toán lẫn job expire đều chuyển trạng thái từ HELD. Chỉ một bên thắng. Nếu tiền về muộn, khách được hoàn tiền tự động thay vì nhận một ghế đã bán cho người khác.

Cột idempotency_key UNIQUE chặn trường hợp hai request trùng key chạy song song. Cả hai đều không thấy bản ghi cũ, nhưng request thứ hai sẽ lỗi DataIntegrityViolationException khi insert, và transaction của nó rollback.

6.1. Outbox và Kafka

Xuất vé, gửi email và đồng bộ sang hệ thống check-in đều chạy bất đồng bộ. Đừng gửi Kafka trực tiếp trong transaction. Nếu DB commit nhưng gửi lỗi (hoặc ngược lại), dữ liệu sẽ lệch. Hãy ghi vào bảng outbox cùng transaction, rồi một relay đẩy sang Kafka:

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafka;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() {
        for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
            kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
            e.markPublished();
        }
    }
}

Relay đảm bảo at-least-once, nên consumer phía xuất vé cũng phải idempotent, ví dụ bằng cách lưu bookingId đã xử lý. Dùng bookingId làm key giúp các sự kiện của cùng một booking vào cùng partition và giữ đúng thứ tự.

7. Dọn dẹp giữ chỗ hết hạn

@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
    for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
            BookingStatus.HELD, Instant.now())) {
        if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
            int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
            inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
        }
    }
}

Job trừ held theo seatCount của booking, không theo số ghế vừa nhả. Lý do là có những ghế đã bị người khác cướp từ trước, nhưng bộ đếm thì vẫn tính cho booking cũ. Khi chạy nhiều instance, hãy dùng SELECT … FOR UPDATE SKIP LOCKED hoặc ShedLock để các node không xử lý trùng.

8. Chịu tải đột biến

Khi mở bán vé rẻ, 50.000 request/giây cùng đập vào vài dòng fare_inventory. Đó là hot row. Mọi transaction xếp hàng chờ cùng một row lock. Database không chết vì CPU, mà chết vì connection pool cạn.

Chiến lược là lọc trước khi tới DB:

  1. Phòng chờ ảo (waiting room): chỉ cho N người/giây vào trang đặt chỗ, số còn lại nhận token xếp hàng.
  2. Bộ lọc nhanh trên Redis: khoá ghế bằng SET NX để từ chối sớm những request chắc chắn thua.
  3. DB vẫn là trọng tài cuối cùng.
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {

    private final StringRedisTemplate redis;

    public boolean tryEnter(long flightId, String seatNo, long customerId) {
        String key = "seat:%d:%s".formatted(flightId, seatNo);
        Boolean ok = redis.opsForValue()
                .setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
        return Boolean.TRUE.equals(ok);
    }
}

Nếu tryEnter trả false, API trả ngay 409 Conflict mà không mở transaction nào. TTL 15 giây đủ cho bước giữ chỗ trên DB, và key tự biến mất nếu instance crash.

Lưu ý: Đừng biến Redis thành nguồn sự thật cho tồn kho. Khi Redis failover, bạn có thể mất key và cho hai người cùng qua cổng. Như vậy cũng không sao, vì conditional update trên DB vẫn chặn được. Ngược lại, nếu chỉ tin Redis, bạn sẽ bán trùng ghế.

Với hot row đếm theo hạng vé, bạn có thể chia fare_inventory thành nhiều “bucket” (ví dụ 10 dòng, mỗi dòng 18 ghế) và chọn ngẫu nhiên một bucket để giảm tranh chấp lock.

9. Kiểm thử chống bán trùng

Test quan trọng nhất: 50 thread cùng giữ một ghế, chỉ đúng một thread thắng. Hãy chạy với PostgreSQL thật qua Testcontainers, vì hành vi khoá của H2 khác Postgres.

@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BookingService bookingService;

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int threads = 50;
        AtomicInteger success = new AtomicInteger();
        CountDownLatch start = new CountDownLatch(1);

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < threads; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
                                List.of("12A"), new BigDecimal("1500000")));
                        success.incrementAndGet();
                    } catch (SeatUnavailableException | SoldOutException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(success.get()).isEqualTo(1);
    }
}

Test giả định dữ liệu mẫu (chuyến 1001, ghế 12A trống) được nạp qua Flyway hoặc data.sql. CountDownLatch thả tất cả thread cùng lúc để tối đa hoá tranh chấp. Khối try-with-resources của ExecutorService (Java 21) chờ mọi task xong rồi mới đóng. Kết quả mong đợi: đúng một lần thành công, 49 lần nhận SeatUnavailableException.

10. Lỗi hay gặp

  • Dùng synchronized hoặc lock trong JVM: chỉ đúng khi chạy một instance. Scale lên hai pod là hỏng.
  • Optimistic locking với @Version cho hot row: đúng về logic, nhưng khi tải đột biến, tỷ lệ retry rất cao. Conditional update hiệu quả hơn vì không cần đọc trước.
  • Giữ transaction mở trong lúc gọi cổng thanh toán: row lock bị giữ hàng giây. Hãy giữ chỗ, commit, gọi thanh toán, rồi xác nhận trong một transaction mới.
  • Quên xử lý tiền về sau khi hết hạn giữ chỗ: đây là nguồn khiếu nại lớn nhất. Luôn có nhánh hoàn tiền tự động.
  • Cache sơ đồ ghế quá lâu: người dùng liên tục chọn ghế đã mất. 2–5 giây là đủ.

11. Kết luận

Hệ thống đặt vé máy bay xoay quanh một nguyên tắc: tách phần cần nhất quán mạnh (tồn kho ghế, trạng thái booking) khỏi phần chấp nhận trễ (tìm kiếm, thông báo, xuất vé). Conditional update trên database giải quyết tranh chấp mà không cần lock phân tán. Máy trạng thái HELD → CONFIRMED/EXPIRED với chuyển đổi có điều kiện xử lý race giữa thanh toán và hết hạn. Idempotency key cùng outbox giữ cho tiền và sự kiện không bị nhân đôi. Redis và phòng chờ ảo bảo vệ database khi tải tăng đột biến.

Bạn có thể áp dụng nguyên khung này cho bất kỳ bài toán “tài nguyên khan hiếm, nhiều người tranh” nào: vé concert, phòng khách sạn, slot khám bệnh hay flash sale.

system-designspring-bootconcurrencyrediskafka