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 sangEXPIREDvà 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_overlapnghĩa là không được có hai dòng nào cùngcar_idmà cóperiodgiao nhau (&&). Điều kiệnWHEREloại các bookingCANCELLED,EXPIRED,COMPLETEDra khỏi ràng buộc, nên một booking đã huỷ không còn chiếm lịch.- Extension
btree_gistcần thiết để kết hợp phép=trênBIGINTvới phép&&trên range trong cùng một GiST index. provider_txn_id UNIQUElà nền tảng của idempotency khi xử lý webhook thanh toán.- Bảng
outboxphụ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:
- INSERT vào
paymentvớiON CONFLICT DO NOTHING. Nếuinserted == 0thì đây là webhook trùng, ta bỏ qua. - 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. - Nếu update không trúng dòng nào thì hold đã
EXPIREDvà 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àmcancelledAt, 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
TIMESTAMPTZvà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 theocar_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.