自驾租车系统设计:车辆日程、押金和取消订单
1. 概述
自驾租车听起来很简单:客户选择车辆、选择日期、支付押金然后取车。最难的是确保一辆车不会被两个人在同一时间段预订。系统还必须正确处理支付网关多次调用 webhook 的情况,并且要能承受春节或 4 月 30 日假期前交通量的激增。
这篇文章涉及完整的设计:需求、数据模型、API、保留流程、押金和取消订单。每个难点都附带 Java 21、Spring Boot 3.x、PostgreSQL、Redis 和 Kafka 的示例代码。读完后,你就掌握了一个足以用于任何时间段预订系统的设计框架,无论对象是车辆、房间、运动场还是会议室。
2. 需求
2.1. 功能需求
- 客户按城市和时间段搜索车辆,例如
2026-01-25 08:00→2026-01-28 20:00。 - 客户保留(hold) 一辆车 15 分钟以支付押金。
- 成功支付押金后,预订转为
CONFIRMED。15 分钟后还未支付,预订变为EXPIRED,车辆被释放。 - 客户取消订单可获得退款,政策如下:提前 72 小时退款 100%,提前 24 小时退款 50%,时间更短则不退。
- 车主取消订单,客户获得全额退款。
- 预订状态更改时发送通知(短信、推送)。
2.2. 规模和约束
| 指标 | 正常 | 高峰(春节) |
|---|---|---|
| 车辆数 | 20,000 | 20,000 |
| 搜索请求 | ~200 req/s | ~3,000 req/s |
| 保留请求 | ~10 req/s | ~300 req/s |
| 读写比例 | ~20:1 | ~10:1,集中在数百辆“热门”车 |
最关键的约束是永远不允许重复预订。系统可以返回“车辆刚被预订”的错误,但不能让两个订单在同一时间段指向同一辆车。因此预订数据必须是强一致的。搜索页面可以接受几十秒旧的数据。
3. 数据模型
PostgreSQL 是合适的选择,因为它有 tstzrange 类型和排他约束。有了这两个工具,数据库本身就能阻止同一辆车的两个重叠时间段。
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
);
需要注意以下几点:
no_overlap意味着不能有两行具有相同car_id的记录,其period相交(&&)。WHERE条件将CANCELLED、EXPIRED、COMPLETED的预订排除在约束之外,所以已取消的预订不再占用日程。- Extension
btree_gist是必需的,以便在同一个 GiST 索引中组合BIGINT上的=操作和 range 上的&&操作。 provider_txn_id UNIQUE是处理支付 webhook 幂等性的基础。outbox表用于将事件发送到 Kafka 而不丢失消息(见第 8 节)。
**注意:**车辆在两次租赁之间需要清洁和检查时间。你应该保存已添加缓冲区的
period,例如在还车时间后增加 2 小时。不要在应用代码中添加缓冲区,因为迟早会有地方忘记添加。
4. API 设计
| 方法 | 端点 | 说明 |
|---|---|---|
GET |
/cars?city=HN&from=…&to=… |
查找可用车辆 |
POST |
/bookings |
保留车辆,返回 bookingId 和支付链接 |
POST |
/payments/webhook |
支付网关回调结果 |
POST |
/bookings/{id}/cancel |
取消订单 |
POST /bookings 接收 Idempotency-Key 头。当移动应用因网络故障重试时,重复的请求不会创建新的预订。当车辆已被预订时,服务器返回 409 Conflict,成功保留时返回 201 Created,响应体如下:
{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
"paymentUrl": "https://pay.example/checkout/98123" }
5. 保留流程
5.1. 通过约束防止重复预订
常见但错误的做法是“先 SELECT 检查是否可用,然后 INSERT”。两个同时到达的请求都会看到车辆可用,两者都会成功 INSERT。相反,你应该直接 INSERT,让排他约束决定哪个请求成功。
例如使用 JdbcClient 需要以下依赖项:
<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");
}
}
范围 '[)' 包含起点但不包含终点。这样,在 20:00 结束的租赁和在 20:00 开始的租赁不会被视为重叠。当违反约束时,PostgreSQL 返回 SQLState 23P01,Spring 将其转换为 DataIntegrityViolationException。我们将其转换为 CarUnavailableException,以便控制器映射到 409。
当两个客户同时保留车辆 42 的同一时间段时,日志看起来像这样:
customer=1001 -> 201 Created bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Car 42 is already booked in this period
5.2. 保留过期
一个定期任务将超期的 HOLD 预订转为 EXPIRED。预订 EXPIRED 不在约束的 WHERE 条件范围内,所以车辆立即被释放。
@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);
}
}
}
记得在配置类上启用 @EnableScheduling。这个任务在多个实例上并行运行仍然是安全的,因为 UPDATE ... WHERE status = 'HOLD' 本身就是幂等的。
6. 押金:幂等 webhook 和与过期时间的竞速
支付网关通常调用 webhook 至少一次,意味着可能调用两次、三次。此外,webhook 可能在 hold 过期之后到达,例如客户在第 14:59 秒支付但银行处理缓慢。两种情况都必须处理。
@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 重复,已处理过
}
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));
}
}
}
处理流程如下:
- 使用
ON CONFLICT DO NOTHING插入到payment。如果inserted == 0,则这是重复的 webhook,我们跳过。 - 使用条件更新(
WHERE status = 'HOLD')转换状态。这个语句充当 compare-and-set,所以不需要显式锁。 - 如果更新没有匹配任何行,则 hold 已
EXPIRED,车辆可能已属于其他人。此时我们自动发出退款命令,不试图“复活”预订。
所有操作都在一个事务中。要么支付、预订状态和 outbox 事件同时被写入,要么什么都不写。
7. 取消订单和退款
退款政策是纯逻辑,所以应该分离出来成为单独的类,便于测试:
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;
}
}
使用 JUnit 5 和 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);
}
}
在服务层,取消操作也使用条件更新和 RETURNING 在同一条语句中获取计算退款的数据:
UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;
如果语句没有返回任何行,预订已被取消或客户已取车(ON_TRIP)。API 此时返回 409。如果有行返回,我们计算 refundAmount,然后在同一事务中将 refund.requested 事件写入 outbox。
注意:始终使用服务器时间(注入的
Clock)作为cancelledAt,不要使用客户端时间。在 24 小时标记附近相差几分钟就会相差数百万元。
8. 突发流量和可靠事件发送
8.1. 分离读写
在高峰期,大部分流量来自搜索操作。搜索结果可以使用密钥 avail:{city}:{date} 在 Redis 中缓存,TTL 为 30-60 秒。缓存数据可能是过时的,这是可以接受的,因为 PostgreSQL 中的约束才是最终真实来源。最坏的情况是,客户看到车辆“可用”,然后在保留时收到 409。
8.2. 防止车辆被锁定
春节前,一些客户(或机器人)会保留大量车辆但不支付。你可以使用 Redis 限制每个客户每 15 分钟最多保留 3 次:
<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 是原子操作,所以多个实例同时调用仍然会正确计数。15 分钟窗口内的第 4 次尝试将收到 false,API 返回 429 Too Many Requests。
8.3. Outbox 到 Kafka
在事务中直接发送 Kafka 风险很大。如果 DB 提交成功但 Kafka 出错,事件就会丢失。如果反过来做,我们会发送一个“幽灵”事件给从未被保存的更改。事务型 outbox 模式解决了这个问题:在同一事务中将事件写入 outbox 表,然后 relay 读取该表并推送到 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 允许多个 relay 实例并行运行而不会获取重复的行。Relay 仍然可能重复发送事件,例如在 send 之后但 UPDATE 之前崩溃。因此消费者(通知服务、退款服务)必须根据 bookingId 是幂等的。
9. 常见错误和何时不应该应用
- 通过 SELECT 检查然后 INSERT。 这种方式总是有竞速条件。让约束完成它的工作。
- 用 Redis 锁代替约束。 锁可能在中途过期,例如 GC 暂停时。Redis 应该只是辅助保护层,不应该是真实来源。
- 保存没有时区的时间。 使用
TIMESTAMPTZ和OffsetDateTime,特别是在以后扩展到多个国家时。 - 在取消请求中同步退款。 缓慢的支付网关会导致取消 API 也很慢。通过 outbox 推送退款。
- 当日程根据
car_id以外的标准分片到多个数据库时,不应该使用排他约束。 约束仅在一个数据库中有效。如果需要分片,请按car_id分片,以便一辆车的所有预订都在同一个地方。
10. 总结
自驾租车问题围绕三个主要点。首先,让数据库使用 tstzrange 和排他约束阻止重复预订。其次,使用条件更新结合唯一键转换状态,以处理重复的 webhook 和与 hold 过期的竞速。第三,通过事务型 outbox 和 Kafka 将通知和退款等辅助操作从主流程中分离出来。Redis 处理负载:缓存搜索和限制保留。真实来源仍然在 PostgreSQL。
这个设计框架适用于大多数时间段预订系统。你只需要将 car 替换为酒店房间、运动场或诊疗日程。