Thiết kế hệ thống cho thuê xe tự lái: lịch xe, đặt cọc và huỷ chuyến

1. Tổng quan

Thuê xe tự lái nghe đơn giản: khách chọn xe, chọn ngày, trả cọc rồi nhận xe. Khó nhất là bảo đảm một chiếc xe không bị hai người đặt trùng giờ. Hệ thống còn phải xử lý đúng khi cổng thanh toán gọi webhook nhiều lần, và phải chịu được lượng truy cập tăng vọt trước kỳ nghỉ Tết hay 30/4.

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 giữ chỗ, đặt cọc và huỷ chuyến. Mỗi điểm khó đi kèm code minh hoạ bằng Java 21, Spring Boot 3.x, PostgreSQL, Redis và Kafka. Đọc xong, bạn có một khung thiết kế đủ dùng cho một hệ thống đặt chỗ theo khoảng thời gian, kể cả khi đối tượng không phải là xe mà là phòng, sân bóng hay phòng họp.

2. Yêu cầu

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

  • Khách tìm xe theo thành phố và khoảng thời gian, ví dụ 2026-01-25 08:00 → 2026-01-28 20:00.
  • Khách giữ chỗ (hold) một xe trong 15 phút để thanh toán cọc.
  • Thanh toán cọc thành công thì booking chuyển sang CONFIRMED. Hết 15 phút mà chưa trả thì booking chuyển sang EXPIRED và xe được nhả ra.
  • Khách huỷ chuyến được hoàn cọc theo chính sách: trước 72 giờ hoàn 100%, trước 24 giờ hoàn 50%, sát giờ hơn thì không hoàn.
  • Chủ xe huỷ chuyến thì khách được hoàn toàn bộ cọc.
  • Gửi thông báo (SMS, push) khi trạng thái booking thay đổi.

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

Chỉ số Bình thường Cao điểm (Tết)
Số xe 20.000 20.000
Lượt tìm kiếm ~200 req/s ~3.000 req/s
Lượt giữ chỗ ~10 req/s ~300 req/s
Tỷ lệ đọc/ghi ~20:1 ~10:1, dồn vào vài trăm xe “hot”

Ràng buộc quan trọng nhất là không bao giờ double-booking. Hệ thống được phép trả lỗi “xe vừa có người đặt”, nhưng không được để hai hợp đồng cùng trỏ vào một xe trong cùng một khoảng giờ. Vì vậy dữ liệu đặt chỗ phải nhất quán mạnh. Trang tìm kiếm thì chấp nhận được dữ liệu cũ vài chục giây.

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

PostgreSQL là lựa chọn phù hợp vì nó có kiểu tstzrange và exclusion constraint. Với hai công cụ này, chính database sẽ chặn hai khoảng thời gian chồng lấn trên cùng một xe.

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE car (
    id          BIGSERIAL PRIMARY KEY,
    plate       VARCHAR(20)  NOT NULL UNIQUE,
    model       VARCHAR(100) NOT NULL,
    city        VARCHAR(50)  NOT NULL,
    daily_price BIGINT       NOT NULL
);

CREATE TABLE booking (
    id              BIGSERIAL PRIMARY KEY,
    car_id          BIGINT      NOT NULL REFERENCES car(id),
    customer_id     BIGINT      NOT NULL,
    period          TSTZRANGE   NOT NULL,
    status          VARCHAR(20) NOT NULL,
    deposit_amount  BIGINT      NOT NULL,
    hold_expires_at TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT no_overlap EXCLUDE USING gist (
        car_id WITH =,
        period WITH &&
    ) WHERE (status IN ('HOLD', 'CONFIRMED', 'ON_TRIP'))
);

CREATE TABLE payment (
    id              BIGSERIAL PRIMARY KEY,
    booking_id      BIGINT      NOT NULL REFERENCES booking(id),
    provider_txn_id VARCHAR(64) NOT NULL UNIQUE,
    type            VARCHAR(10) NOT NULL,   -- DEPOSIT | REFUND
    amount          BIGINT      NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    topic        VARCHAR(100) NOT NULL,
    payload      JSONB        NOT NULL,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

Một vài điểm cần chú ý:

  • no_overlap nghĩa là không được có hai dòng nào cùng car_id mà có period giao nhau (&&). Điều kiện WHERE loại các booking CANCELLED, EXPIRED, COMPLETED ra khỏi ràng buộc, nên một booking đã huỷ không còn chiếm lịch.
  • Extension btree_gist cần thiết để kết hợp phép = trên BIGINT với phép && trên range trong cùng một GiST index.
  • provider_txn_id UNIQUE là nền tảng của idempotency khi xử lý webhook thanh toán.
  • Bảng outbox phục vụ việc gửi sự kiện sang Kafka mà không mất tin (xem mục 7).

Lưu ý: Xe cần thời gian vệ sinh và kiểm tra giữa hai chuyến. Bạn nên lưu period đã cộng thêm buffer, ví dụ 2 giờ sau giờ trả xe. Đừng cộng buffer trong code ứng dụng, vì sớm muộn sẽ có một chỗ quên cộng.

4. Thiết kế API

Method Endpoint Mô tả
GET /cars?city=HN&from=…&to=… Tìm xe trống
POST /bookings Giữ chỗ, trả về bookingId và link thanh toán
POST /payments/webhook Cổng thanh toán báo kết quả
POST /bookings/{id}/cancel Huỷ chuyến

POST /bookings nhận header Idempotency-Key. Khi app mobile retry vì mạng chập chờn, request lặp lại sẽ không tạo thêm booking mới. Server trả về 409 Conflict khi xe đã bị đặt, và 201 Created kèm body như sau khi giữ chỗ thành công:

{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
  "paymentUrl": "https://pay.example/checkout/98123" }

5. Luồng giữ chỗ

5.1. Chống đặt trùng bằng constraint

Cách làm quen thuộc nhưng sai là “SELECT kiểm tra trống rồi mới INSERT”. Hai request đến cùng lúc đều thấy xe trống, và cả hai cùng INSERT thành công. Thay vào đó, bạn cứ INSERT và để exclusion constraint quyết định request nào thắng.

Ví dụ dùng JdbcClient nên cần dependency sau:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
@Service
public class BookingService {

    private final JdbcClient jdbc;

    public BookingService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public long hold(long carId, long customerId,
                     OffsetDateTime from, OffsetDateTime to, long deposit) {
        try {
            return jdbc.sql("""
                    INSERT INTO booking(car_id, customer_id, period, status,
                                        deposit_amount, hold_expires_at)
                    VALUES (:carId, :customerId,
                            tstzrange(CAST(:from AS timestamptz), CAST(:to AS timestamptz), '[)'),
                            'HOLD', :deposit, now() + interval '15 minutes')
                    RETURNING id
                    """)
                .param("carId", carId)
                .param("customerId", customerId)
                .param("from", from)
                .param("to", to)
                .param("deposit", deposit)
                .query(Long.class)
                .single();
        } catch (DataIntegrityViolationException e) {
            throw new CarUnavailableException(carId);
        }
    }
}

public class CarUnavailableException extends RuntimeException {
    public CarUnavailableException(long carId) {
        super("Car " + carId + " is already booked in this period");
    }
}

Khoảng '[)' gồm điểm đầu nhưng không gồm điểm cuối. Nhờ vậy, chuyến kết thúc lúc 20:00 và chuyến bắt đầu lúc 20:00 không bị tính là chồng lấn. Khi vi phạm constraint, PostgreSQL trả SQLState 23P01, và Spring dịch lỗi này thành DataIntegrityViolationException. Ta đổi nó thành CarUnavailableException để controller map sang 409.

Khi hai khách cùng giữ xe 42 cho cùng khoảng giờ, log sẽ trông như sau:

customer=1001 -> 201 Created  bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Car 42 is already booked in this period

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

Một job định kỳ chuyển các booking HOLD quá hạn sang EXPIRED. Booking EXPIRED nằm ngoài điều kiện WHERE của constraint, nên xe được giải phóng ngay.

@Component
public class HoldExpiryJob {

    private final JdbcClient jdbc;

    public HoldExpiryJob(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Scheduled(fixedDelay = 30_000)
    public void expireHolds() {
        int expired = jdbc.sql("""
                UPDATE booking SET status = 'EXPIRED'
                WHERE status = 'HOLD' AND hold_expires_at < now()
                """).update();
        if (expired > 0) {
            System.out.println("Expired holds: " + expired);
        }
    }
}

Nhớ bật @EnableScheduling trên một lớp cấu hình. Job này chạy song song trên nhiều instance vẫn an toàn, vì câu UPDATE ... WHERE status = 'HOLD' vốn đã idempotent.

6. Đặt cọc: webhook idempotent và race với hết hạn

Cổng thanh toán thường gọi webhook ít nhất một lần, nghĩa là có thể gọi hai, ba lần. Ngoài ra, webhook có thể đến sau khi hold đã hết hạn, chẳng hạn khi khách trả tiền ở giây 14:59 còn ngân hàng xử lý chậm. Cả hai tình huống đều phải được xử lý.

@Service
public class PaymentService {

    private final JdbcClient jdbc;
    private final OutboxWriter outbox;

    public PaymentService(JdbcClient jdbc, OutboxWriter outbox) {
        this.jdbc = jdbc;
        this.outbox = outbox;
    }

    @Transactional
    public void onDepositPaid(long bookingId, String providerTxnId, long amount) {
        int inserted = jdbc.sql("""
                INSERT INTO payment(booking_id, provider_txn_id, type, amount)
                VALUES (:bookingId, :txn, 'DEPOSIT', :amount)
                ON CONFLICT (provider_txn_id) DO NOTHING
                """)
            .param("bookingId", bookingId).param("txn", providerTxnId).param("amount", amount)
            .update();
        if (inserted == 0) {
            return; // webhook lặp lại, đã xử lý trước đó
        }

        int confirmed = jdbc.sql("""
                UPDATE booking SET status = 'CONFIRMED', hold_expires_at = NULL
                WHERE id = :id AND status = 'HOLD'
                """).param("id", bookingId).update();

        if (confirmed == 1) {
            outbox.write("booking.confirmed", Map.of("bookingId", bookingId));
        } else {
            outbox.write("refund.requested", Map.of("bookingId", bookingId, "amount", amount));
        }
    }
}

Luồng xử lý diễn ra như sau:

  1. INSERT vào payment với ON CONFLICT DO NOTHING. Nếu inserted == 0 thì đây là webhook trùng, ta bỏ qua.
  2. Chuyển trạng thái bằng conditional update (WHERE status = 'HOLD'). Câu lệnh này đóng vai trò compare-and-set, nên không cần lock tường minh.
  3. Nếu update không trúng dòng nào thì hold đã EXPIRED và có thể xe đã thuộc về người khác. Lúc này ta tự động phát lệnh hoàn tiền, không cố “hồi sinh” booking.

Mọi thao tác nằm trong một transaction. Hoặc payment, trạng thái booking và sự kiện outbox cùng được ghi, hoặc không có gì được ghi.

7. Huỷ chuyến và hoàn cọc

Chính sách hoàn cọc là logic thuần, nên tách thành một lớp riêng để dễ test:

public final class CancellationPolicy {

    private CancellationPolicy() {}

    public static long refundAmount(long deposit, OffsetDateTime pickupAt,
                                    OffsetDateTime cancelledAt, boolean cancelledByHost) {
        if (cancelledByHost) {
            return deposit;
        }
        long hoursBefore = Duration.between(cancelledAt, pickupAt).toHours();
        if (hoursBefore >= 72) return deposit;
        if (hoursBefore >= 24) return deposit / 2;
        return 0;
    }
}

Test với JUnit 5 và AssertJ:

class CancellationPolicyTest {

    private final OffsetDateTime pickup = OffsetDateTime.parse("2026-01-25T08:00:00+07:00");

    @Test
    void refundsFullWhenCancelledThreeDaysBefore() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(72), false))
            .isEqualTo(2_000_000);
    }

    @Test
    void refundsHalfWhenCancelledOneDayBefore() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(30), false))
            .isEqualTo(1_000_000);
    }

    @Test
    void refundsNothingWhenCancelledLastMinute() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(2), false))
            .isZero();
    }

    @Test
    void refundsFullWhenHostCancels() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(1), true))
            .isEqualTo(2_000_000);
    }
}

Ở tầng service, phép huỷ cũng dùng conditional update và RETURNING để lấy dữ liệu tính tiền trong cùng một câu lệnh:

UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;

Nếu câu lệnh không trả về dòng nào, booking đã bị huỷ trước đó hoặc khách đã nhận xe (ON_TRIP). API khi đó trả 409. Nếu có dòng trả về, ta tính refundAmount rồi ghi sự kiện refund.requested vào outbox trong cùng transaction.

Lưu ý: Luôn dùng thời điểm server (Clock được inject) làm cancelledAt, không lấy thời gian từ client. Chênh vài phút quanh mốc 24 giờ là khác nhau cả triệu đồng.

8. Tải đột biến và gửi sự kiện tin cậy

8.1. Tách đọc khỏi ghi

Trong mùa cao điểm, phần lớn tải đến từ thao tác tìm kiếm. Kết quả tìm kiếm có thể cache trong Redis theo khoá avail:{city}:{date} với TTL 30–60 giây. Dữ liệu cache có thể cũ, và điều đó chấp nhận được, vì constraint trong PostgreSQL mới là nguồn sự thật cuối cùng. Trường hợp xấu nhất, khách thấy xe “trống” rồi nhận 409 khi giữ chỗ.

8.2. Chống găm xe

Trước Tết, một số khách (hoặc bot) giữ chỗ hàng loạt xe rồi không trả tiền. Bạn có thể giới hạn mỗi khách tối đa 3 lượt hold mỗi 15 phút bằng Redis:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class HoldRateLimiter {

    private final StringRedisTemplate redis;

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

    public boolean tryAcquire(long customerId) {
        String key = "hold-limit:" + customerId;
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofMinutes(15));
        }
        return count != null && count <= 3;
    }
}

increment là thao tác nguyên tử, nên nhiều instance gọi cùng lúc vẫn đếm đúng. Lượt thứ 4 trong cửa sổ 15 phút sẽ nhận false, và API trả 429 Too Many Requests.

8.3. Outbox sang Kafka

Gửi Kafka ngay trong transaction rất rủi ro. Nếu DB commit thành công còn Kafka lỗi, sự kiện bị mất. Nếu làm ngược thứ tự, ta gửi đi một sự kiện “ma” cho thay đổi chưa từng được lưu. Pattern transactional outbox giải quyết vấn đề này: ghi sự kiện vào bảng outbox trong cùng transaction, rồi một relay đọc bảng đó và đẩy sang Kafka.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 1_000)
    @Transactional
    public void publish() {
        var rows = jdbc.sql("""
                SELECT id, topic, payload::text AS payload FROM outbox
                WHERE published_at IS NULL ORDER BY id LIMIT 100
                FOR UPDATE SKIP LOCKED
                """).query().listOfRows();

        for (var row : rows) {
            kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
            jdbc.sql("UPDATE outbox SET published_at = now() WHERE id = :id")
                .param("id", row.get("id")).update();
        }
    }
}

FOR UPDATE SKIP LOCKED cho phép nhiều instance relay chạy song song mà không lấy trùng dòng. Relay vẫn có thể gửi lặp một sự kiện, ví dụ khi crash sau send nhưng trước UPDATE. Vì vậy consumer (dịch vụ thông báo, dịch vụ hoàn tiền) phải idempotent theo bookingId.

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

  • Kiểm tra trống bằng SELECT rồi INSERT. Cách này luôn có race condition. Hãy để constraint làm việc của nó.
  • Lock bằng Redis thay cho constraint. Lock có thể hết hạn giữa chừng, ví dụ khi GC pause. Redis chỉ nên là lớp bảo vệ phụ, không nên là nguồn sự thật.
  • Lưu thời gian không kèm múi giờ. Hãy dùng TIMESTAMPTZ và OffsetDateTime, nhất là khi sau này mở rộng sang nhiều nước.
  • Hoàn tiền đồng bộ trong request huỷ. Cổng thanh toán chậm sẽ kéo theo API huỷ chậm. Hãy đẩy việc hoàn tiền qua outbox.
  • Không nên dùng exclusion constraint khi lịch được shard sang nhiều database theo tiêu chí khác car_id. Constraint chỉ có hiệu lực trong một database. Nếu cần shard, hãy shard theo car_id để mọi booking của một xe nằm chung một chỗ.

10. Kết luận

Bài toán cho thuê xe tự lái xoay quanh ba điểm chính. Thứ nhất, để database chặn double-booking bằng tstzrange và exclusion constraint. Thứ hai, chuyển trạng thái bằng conditional update kết hợp unique key để xử lý webhook lặp và race với hold hết hạn. Thứ ba, tách việc phụ như thông báo và hoàn tiền ra khỏi luồng chính bằng transactional outbox và Kafka. Redis đảm nhận phần chịu tải: cache tìm kiếm và giới hạn lượt giữ chỗ. Nguồn sự thật vẫn nằm ở PostgreSQL.

Khung thiết kế này áp dụng được cho hầu hết hệ thống đặt chỗ theo khoảng thời gian. Bạn chỉ cần thay car bằng phòng khách sạn, sân bóng hay lịch khám bệnh.

system-designspring-bootpostgresqlkafkaredis