自驾租车系统设计:车辆日程、押金和取消订单

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));
        }
    }
}

处理流程如下:

  1. 使用 ON CONFLICT DO NOTHING 插入到 payment。如果 inserted == 0,则这是重复的 webhook,我们跳过。
  2. 使用条件更新(WHERE status = 'HOLD')转换状态。这个语句充当 compare-and-set,所以不需要显式锁。
  3. 如果更新没有匹配任何行,则 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 替换为酒店房间、运动场或诊疗日程。

system-designspring-bootpostgresqlkafkaredis