Thiết kế hệ thống đặt phòng khách sạn: tồn kho theo ngày và đặt trùng

1. Tổng quan

Bài toán đặt phòng khách sạn nghe đơn giản. Kiểm tra còn phòng thì cho đặt. Nhưng khi hai khách cùng bấm “Đặt” cho căn phòng cuối cùng của đêm 31/12, hệ thống rất dễ bán một phòng cho hai người.

Bài này đi qua một thiết kế thực tế: yêu cầu, mô hình tồn kho theo từng đêm, API, luồng giữ phòng (hold) rồi xác nhận, cách chống đặt trùng bằng PostgreSQL, và cách chịu tải đột biến. Đọc xong, bạn có thể tự dựng phần lõi đặt phòng bằng Spring Boot 3.x mà không bị overbooking ngoài ý muốn.

2. Yêu cầu

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

  • Tìm phòng trống theo khách sạn, loại phòng, ngày nhận (check-in) và ngày trả (check-out).
  • Giữ phòng trong 10 phút để khách thanh toán.
  • Xác nhận đặt phòng sau khi thanh toán thành công.
  • Huỷ đặt phòng và trả lại tồn kho.

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

  • Khoảng 50.000 khách sạn. Mỗi khách sạn có trung bình 5 loại phòng. Hệ thống mở bán trước 365 ngày.
  • Lượng đọc lớn hơn lượng ghi rất nhiều. Tỉ lệ tìm kiếm so với đặt phòng khoảng 100:1.
  • Khoảng 50 lượt đặt mỗi giây vào giờ cao điểm. Khi có flash sale, con số này có thể tăng gấp 20 lần.
  • Ràng buộc cứng: không bán quá số phòng đã cấu hình. Kết quả tìm kiếm được phép lệch vài giây.

Số dòng tồn kho vào khoảng 50.000 × 5 × 365 ≈ 91 triệu dòng. Một bảng PostgreSQL có index tốt vẫn xử lý được con số này.

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

Quyết định quan trọng nhất của bài này: khách không đặt một phòng cụ thể (số 302), mà đặt một loại phòng trong một khoảng đêm. Hệ thống chỉ gán số phòng lúc check-in. Vì vậy tồn kho được lưu theo bộ khoá (khách sạn, loại phòng, đêm).

CREATE TABLE room_inventory (
    hotel_id      BIGINT  NOT NULL,
    room_type_id  BIGINT  NOT NULL,
    stay_date     DATE    NOT NULL,
    total_rooms   INT     NOT NULL,
    reserved      INT     NOT NULL DEFAULT 0,
    PRIMARY KEY (hotel_id, room_type_id, stay_date),
    CONSTRAINT chk_not_oversold CHECK (reserved >= 0 AND reserved <= total_rooms)
);

CREATE TABLE booking (
    id               BIGSERIAL PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    customer_id      BIGINT      NOT NULL,
    hotel_id         BIGINT      NOT NULL,
    room_type_id     BIGINT      NOT NULL,
    check_in         DATE        NOT NULL,
    check_out        DATE        NOT NULL,
    quantity         INT         NOT NULL,
    status           VARCHAR(16) NOT NULL, -- HOLD, CONFIRMED, CANCELLED, EXPIRED
    expires_at       TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_booking_expiry ON booking (expires_at) WHERE status = 'HOLD';

Giải thích các điểm chính:

  • Một kỳ nghỉ từ 10/12 đến 13/12 chiếm 3 đêm: 10, 11 và 12. Ngày check-out không tính. Mọi truy vấn đều dùng khoảng nửa mở [check_in, check_out).
  • CHECK (reserved <= total_rooms) là hàng rào cuối cùng. Nếu code có bug, database sẽ từ chối thay vì âm thầm bán quá.
  • Partial index idx_booking_expiry chỉ chứa các booking đang HOLD. Job dọn dẹp quét nhanh mà index không phình to theo lịch sử.

Lưu ý: Nhiều khách sạn cố ý overbooking khoảng 3–5% vì biết sẽ có khách huỷ. Bạn hãy biểu diễn chính sách này bằng cách đặt total_rooms lớn hơn số phòng vật lý. Đừng nới lỏng logic chống đặt trùng.

4. API

GET  /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings                      (Header: Idempotency-Key)  -> 201, status=HOLD
POST /bookings/{id}/confirm         (gọi sau khi thanh toán thành công)
POST /bookings/{id}/cancel

Request tạo booking:

public record HoldRequest(long customerId, long hotelId, long roomTypeId,
                          LocalDate checkIn, LocalDate checkOut, int quantity) {
    public HoldRequest {
        if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("checkOut phải sau checkIn");
        if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
    }

    public int nights() {
        return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
    }
}

Header Idempotency-Key do client sinh ra (UUID). Khi mạng chập chờn, client có thể retry thoải mái mà không tạo hai booking.

5. Luồng xử lý

Khách ──► POST /bookings ──► trừ tồn kho mọi đêm (1 transaction) ──► booking HOLD, hết hạn sau 10'
     ──► thanh toán ──► POST /confirm ──► HOLD → CONFIRMED
                     └► quá 10' không trả tiền ──► job dọn dẹp: HOLD → EXPIRED, cộng lại tồn kho

Tồn kho bị trừ ngay khi tạo hold, chứ không đợi đến lúc thanh toán. Cách này đảm bảo khách đã vào trang thanh toán thì chắc chắn còn phòng. Đổi lại, hệ thống phải có cơ chế trả phòng khi hold hết hạn.

6. Điểm khó 1: chống đặt trùng

6.1. Vì sao “đọc rồi ghi” không an toàn

Cách làm ngây thơ là SELECT lấy số phòng còn lại, kiểm tra trong Java, rồi UPDATE. Hai transaction có thể cùng đọc được reserved = 9, total = 10 và cùng ghi reserved = 10. Kết quả là hai khách cùng giữ một phòng. Đây là lỗi lost update kinh điển.

6.2. Khoá theo thứ tự rồi cập nhật có điều kiện

Thêm dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Repository dùng native query:

public interface InventoryRepository extends Repository<RoomInventory, InventoryId> {

    @Query(value = """
        SELECT stay_date FROM room_inventory
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        ORDER BY stay_date
        FOR UPDATE
        """, nativeQuery = true)
    List<LocalDate> lockNights(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved + :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
          AND total_rooms - reserved >= :qty
        """, nativeQuery = true)
    int reserve(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved - :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        """, nativeQuery = true)
    int release(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
}

Service tạo hold:

@Service
public class BookingService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);
    private final InventoryRepository inventory;
    private final BookingRepository bookings;
    private final Clock clock;

    public BookingService(InventoryRepository inventory, BookingRepository bookings, Clock clock) {
        this.inventory = inventory;
        this.bookings = bookings;
        this.clock = clock;
    }

    @Transactional
    public Booking hold(HoldRequest req, String idempotencyKey) {
        Optional<Booking> existing = bookings.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) return existing.get();

        inventory.lockNights(req.hotelId(), req.roomTypeId(), req.checkIn(), req.checkOut());
        int updated = inventory.reserve(req.hotelId(), req.roomTypeId(),
                req.checkIn(), req.checkOut(), req.quantity());

        if (updated != req.nights()) {
            throw new RoomUnavailableException(req.hotelId(), req.checkIn(), req.checkOut());
        }
        return bookings.save(Booking.hold(req, idempotencyKey, Instant.now(clock).plus(HOLD_TTL)));
    }
}

Từng bước hoạt động như sau:

  1. lockNights khoá các dòng tồn kho theo thứ tự stay_date. Giả sử khách A đặt 10–13 và khách B đặt 11–14. Nếu khoá không theo thứ tự cố định, A giữ đêm 10 chờ đêm 11, B giữ đêm 12 chờ đêm 11, và hai bên có thể deadlock. Khi mọi transaction khoá cùng một thứ tự, tình huống này không xảy ra.
  2. reserve chỉ cộng reserved ở những đêm còn đủ chỗ. Nó trả về số dòng đã cập nhật.
  3. Nếu số dòng nhỏ hơn số đêm, có thể vì một đêm đã hết phòng hoặc chưa mở bán (không có dòng). Service ném RoomUnavailableException. Đây là RuntimeException, nên @Transactional rollback toàn bộ, kể cả những đêm đã cộng.

Kết quả với phòng cuối cùng khi hai request đến cùng lúc:

T1: lockNights (giữ khoá) → reserve = 3 dòng → INSERT booking → COMMIT
T2: lockNights (chờ T1)  → reserve = 0 dòng → RoomUnavailableException → ROLLBACK → HTTP 409

Ở đây bạn không cần nâng isolation lên SERIALIZABLE. READ COMMITTED cùng với khoá dòng là đủ.

6.3. Idempotency khi retry đồng thời

Hai request mang cùng Idempotency-Key có thể cùng lọt qua bước findByIdempotencyKey. Ràng buộc UNIQUE sẽ chặn request thứ hai bằng DataIntegrityViolationException. Bạn bắt exception này ở tầng controller, đọc lại booking theo key, rồi trả về kết quả cũ. Transaction thua đã rollback, nên phần tồn kho nó vừa trừ cũng được hoàn lại.

7. Điểm khó 2: hold hết hạn và xác nhận tranh chấp

Job dọn dẹp chạy mỗi 30 giây:

@Component
public class HoldExpiryJob {

    private final BookingRepository bookings;
    private final InventoryRepository inventory;

    public HoldExpiryJob(BookingRepository bookings, InventoryRepository inventory) {
        this.bookings = bookings;
        this.inventory = inventory;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expireHolds() {
        // SELECT ... WHERE status='HOLD' AND expires_at < now() LIMIT 500 FOR UPDATE SKIP LOCKED
        for (Booking b : bookings.lockExpiredHolds(500)) {
            inventory.release(b.getHotelId(), b.getRoomTypeId(), b.getCheckIn(), b.getCheckOut(), b.getQuantity());
            b.markExpired();
        }
    }
}

FOR UPDATE SKIP LOCKED cho phép bạn chạy nhiều instance mà không xử lý trùng một booking. Mỗi lô chỉ lấy 500 dòng để transaction ngắn.

Nhớ bật @EnableScheduling trên một lớp @Configuration. Khi cần nhả tồn kho, bạn cũng gọi release theo cùng thứ tự đêm như lúc khoá để tránh deadlock với luồng hold.

Tranh chấp nguy hiểm nhất là: khách thanh toán xong đúng lúc job đang làm hết hạn hold. Bạn giải quyết bằng một câu UPDATE có điều kiện trạng thái:

UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();

Nếu câu lệnh trả về 0 dòng, hold đã hết hạn và phòng có thể đã thuộc về người khác. Lúc này service phải hoàn tiền, hoặc thử hold lại rồi mới xác nhận. Đừng bao giờ chuyển EXPIRED sang CONFIRMED mà không trừ lại tồn kho.

8. Điểm khó 3: tải đột biến

Lượng đọc gấp 100 lần lượng ghi, nên tìm kiếm không được chạm vào các dòng đang bị khoá. Bạn có thể cache tình trạng phòng trên Redis với TTL ngắn:

@Service
public class AvailabilityCache {

    private final StringRedisTemplate redis;

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

    public Optional<Integer> get(long hotelId, long roomTypeId, LocalDate night) {
        String v = redis.opsForValue().get(key(hotelId, roomTypeId, night));
        return Optional.ofNullable(v).map(Integer::valueOf);
    }

    public void put(long hotelId, long roomTypeId, LocalDate night, int available) {
        redis.opsForValue().set(key(hotelId, roomTypeId, night), String.valueOf(available), Duration.ofSeconds(30));
    }

    private String key(long h, long rt, LocalDate d) {
        return "avail:%d:%d:%s".formatted(h, rt, d);
    }
}

Nguyên tắc cần giữ:

  • Redis chỉ phục vụ đọc. Nguồn sự thật là PostgreSQL. Nếu kết quả tìm kiếm hiện “còn 1 phòng” nhưng bước hold thất bại, khách chỉ nhận HTTP 409 kèm thông báo “vừa hết phòng”. Không có booking sai nào được tạo ra.
  • Sau mỗi lần hold, release hoặc cancel, bạn ghi một event vào bảng outbox trong cùng transaction. Một relay đẩy event này lên Kafka topic inventory-changed. Consumer xoá key Redis hoặc cập nhật search index. Outbox đảm bảo không mất event khi app sập ngay sau commit.
  • Khi có flash sale, khoá dòng tập trung vào vài khách sạn “nóng”. Bạn nên giới hạn tốc độ theo hotelId ở API gateway và đặt lock_timeout khoảng 2 giây cho transaction đặt phòng. Như vậy request thất bại nhanh thay vì chiếm hết connection pool.

9. Kiểm thử đặt trùng

Test quan trọng nhất là cho nhiều thread cùng tranh phòng cuối cùng. Bạn cần PostgreSQL thật, vì H2 không mô phỏng đúng hành vi khoá. Thêm dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

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

    @Autowired BookingService bookingService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void onlyOneGuestGetsTheLastRoom() throws Exception {
        for (int d = 10; d <= 12; d++) {
            jdbc.update("INSERT INTO room_inventory VALUES (1, 1, ?, 1, 0)", LocalDate.of(2026, 12, d));
        }
        var req = new HoldRequest(42L, 1L, 1L, LocalDate.of(2026, 12, 10), LocalDate.of(2026, 12, 13), 1);

        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = IntStream.range(0, 20)
                .mapToObj(i -> pool.submit(() -> {
                    try {
                        bookingService.hold(req, "key-" + i);
                        success.incrementAndGet();
                    } catch (RoomUnavailableException ignored) { }
                }))
                .toList();
            for (Future<?> f : futures) f.get();
        }

        assertThat(success.get()).isEqualTo(1);
        assertThat(jdbc.queryForList("SELECT reserved FROM room_inventory", Integer.class))
            .containsOnly(1);
    }
}

Hai mươi virtual thread cùng gọi hold với key khác nhau. Test khẳng định đúng một thread thành công, và cả ba đêm đều có reserved = 1. Không đêm nào bị cộng dư do rollback sót. Bảng được tạo bằng script schema.sql ở mục 3, hoặc bằng migration Flyway.

10. Lỗi hay gặp

  • Tính cả ngày check-out là một đêm. Bug này khiến khách bị trừ dư một đêm tồn kho. Hãy dùng khoảng [check_in, check_out) ở mọi nơi.
  • Kiểm tra tồn kho trong Java sau khi SELECT không khoá. Cách này gây lost update. Điều kiện phải nằm trong câu UPDATE hoặc đi sau FOR UPDATE.
  • Không so sánh số dòng cập nhật với số đêm. Nếu một đêm chưa có dòng tồn kho, UPDATE vẫn “thành công” cho các đêm còn lại.
  • Gọi API thanh toán bên trong transaction giữ khoá. Khoá bị giữ hàng giây, và cả khách sạn bị nghẽn. Bạn nên tách bước: hold (commit), thanh toán, rồi confirm.
  • Dùng Redis DECR làm nguồn sự thật. Khi Redis failover và mất dữ liệu, bạn mất luôn tồn kho. Cách này chỉ đáng cân nhắc khi tải vượt xa khả năng của database, và khi đó bạn vẫn cần đối soát với DB.

Lưu ý: Mô hình đếm theo loại phòng không phù hợp khi khách được chọn đúng phòng số 302, ví dụ homestay hoặc biệt thự riêng. Khi đó bạn cần bảng room_night(room_id, stay_date) với ràng buộc UNIQUE, hoặc exclusion constraint trên daterange của PostgreSQL.

11. Kết luận

Lõi của hệ thống đặt phòng là một bảng tồn kho theo từng đêm. Phía trên nó là một transaction ngắn: khoá các đêm theo thứ tự, cập nhật có điều kiện, và kiểm tra số dòng bị ảnh hưởng. Ràng buộc CHECK làm lưới an toàn cuối cùng. Hold có hạn, kèm job dọn dẹp dùng SKIP LOCKED, giải quyết bài toán khách bỏ dở thanh toán. UPDATE có điều kiện trạng thái giải quyết tranh chấp giữa confirm và expire.

Phần đọc được tách ra Redis và đồng bộ qua outbox với Kafka, nên tìm kiếm chịu được tải lớn mà không làm yếu đi tính nhất quán ở bước ghi. Bạn có thể bắt đầu từ thiết kế này, rồi chỉ phân mảnh theo hotel_id khi một node PostgreSQL thật sự không còn đủ sức.

system-designspring-bootpostgresqlconcurrencyredis