レンタカーシステムの設計:車両スケジュール、デポジット、キャンセル

1. 概要

レンタカーシステムは一見シンプルです。顧客が車を選び、日付を選び、デポジットを支払って車を受け取ります。最大の課題は、1台の車が2人以上から同時に予約されないようにすることです。システムはまた、決済ゲートウェイが複数回webhookを呼び出す場合を正しく処理する必要があり、旧正月やベトナム独立戦争記念日前後のアクセス急増に耐える必要があります。

この記事では、要件、データモデル、API、予約保留フロー、デポジット処理、キャンセル処理を含む完全な設計を説明します。各難関にはJava 21、Spring Boot 3.x、PostgreSQL、Redis、Kafkaを使用したコード例が付いています。最後に、時間帯に基づく予約システムに十分な設計フレームワークを得られます。対象が車ではなく、ホテルの部屋、テニスコート、会議室の場合にも適用できます。

2. 要件

2.1. 機能要件

  • 顧客は都市と時間範囲で車を検索します。例:2026-01-25 08:00 → 2026-01-28 20:00。
  • 顧客は15分間、車を保留(hold)してデポジットを支払う時間を確保します。
  • デポジット支払いが成功すると、予約はCONFIRMEDに変更されます。15分以内に支払いがない場合、予約はEXPIREDに変更され、車は利用可能になります。
  • 顧客がキャンセルする場合、払い戻しポリシーに従います:72時間前で100%、24時間前で50%、それ以降は0%。
  • オーナーがキャンセルする場合、顧客は全額払い戻しを受け取ります。
  • 予約ステータスが変更されたときに通知(SMS、プッシュ)を送信します。

2.2. スケール と制約

メトリック 通常 ピーク(旧正月)
車の総数 20.000 20.000
検索リクエスト ~200 req/s ~3.000 req/s
保留リクエスト ~10 req/s ~300 req/s
読み書き比 ~20:1 ~10:1、数百台の「ホット」車に集中

最重要な制約は、オーバーブッキングをしないことです。システムは「車は予約されました」というエラーを返すことができますが、2つの契約が同じ時間範囲で同じ車を指すようにしてはいけません。したがって、予約データは強い一貫性を持つ必要があります。検索ページは数十秒古いデータを受け入れてもいいです。

3. データモデル

PostgreSQLはtstzrange型と除外制約を備えているため、適切な選択肢です。これら2つのツールにより、データベース自体が同じ車での時間帯の重複を防ぎます。

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を持つ2つの行がperiodと交差することはできないことを意味します(&&)。WHERE条件はCANCELLED、EXPIRED、COMPLETEDの予約を制約から除外するため、キャンセルされた予約はスケジュールをブロックしません。
  • btree_gist拡張は、1つのGiSTインデックス内でBIGINTの=演算と範囲の&&演算を組み合わせるために必要です。
  • provider_txn_id UNIQUEは、決済webhookを処理する際のべき等性の基礎です。
  • outboxテーブルは、イベントをKafkaに確実に送信するために使用されます(セクション7を参照)。

注意: 車両は2つのトリップ間でクリーニングと検査の時間が必要です。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」です。2つのリクエストが同時に来た場合、両方とも車が空いていることを確認し、両方とも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にマップするようにします。

2人の顧客が同じ時間帯で車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を少なくとも1回呼び出します。つまり、2回、3回呼び出されることもあります。さらに、webhookは保留の有効期限が切れた後に到着することもあります。例えば、顧客が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. paymentにINSERTします。ON CONFLICT DO NOTHINGを使用します。inserted == 0ならこれは重複したwebhookなので、スキップします。
  2. 条件付きUPDATE(WHERE status = 'HOLD')でステータスを変更します。このステートメントはcompare-and-setとして機能するため、明示的なロックは不要です。
  3. UPDATEが行に一致しない場合、保留はEXPIREDであり、車が他人に属している可能性があります。この場合、自動的に払い戻しコマンドを実行し、予約を「復活」させようとしません。

すべての操作は1つのトランザクション内にあります。支払い、予約ステータス、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);
    }
}

サービス層では、キャンセルも条件付きUPDATEとRETURNINGを使用して、1つのステートメントで払い戻し計算用のデータを取得します:

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

ステートメントが行を返さない場合、予約は既にキャンセルされているか、顧客は既に車を受け取っています(ON_TRIP)。APIはこのとき409を返します。行が返される場合、refundAmountを計算して、同じトランザクション内でoutboxにrefund.requestedイベントを書き込みます。

注意: cancelledAtには常にサーバーの時刻(Clockをインジェクト)を使用し、クライアントの時刻を使用しないでください。24時間前後で数分の差は、払い戻し額に大きな違いを生じさせます。

8. ピーク時の負荷とイベント配信の信頼性

8.1. 読み取りと書き込みの分離

ピーク期間の大部分のロードは検索操作から来ます。検索結果はRedisでキーをavail:{city}:{date}でキャッシュでき、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. Kafkaへの外接箱

トランザクション内でKafkaに直接送信するのは非常に危険です。DBのコミットが成功したがKafkaが失敗した場合、イベントは失われます。逆の順序で実行すると、保存されたことのない変更の「ゴースト」イベントが送信されます。トランザクション外接箱パターンはこの問題を解決します:同じトランザクション内でoutboxテーブルにイベントを書き込み、その後リレーがテーブルを読み取って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により、複数のリレーインスタンスが並行実行して同じ行を取得することなく実行できます。リレーはsendの後だがUPDATEの前にクラッシュした場合、イベントを重複送信することはできます。したがって、コンシューマー(通知サービス、払い戻しサービス)はbookingIdに対してべき等である必要があります。

9. よくあるエラーと適用してはいけない場合

  • SELECTで確認してからINSERT。 この方法は常に競合状態の危険があります。制約にその仕事をさせてください。
  • 制約の代わりにRedisでロック。 ロックはGC一時停止などの時にタイムアウトする可能性があります。Redisは補助的な保護層であるべきで、真実のソースであるべきではありません。
  • タイムゾーンなしでタイムスタンプを保存。 TIMESTAMPTZとOffsetDateTimeを使用してください。特に将来複数の国に拡張する場合は重要です。
  • キャンセルリクエスト内で同期的に払い戻しを実行。 決済ゲートウェイが遅いとキャンセルAPIが遅くなります。払い戻しをoutboxでプッシュしてください。
  • car_id以外の基準でスケジュールを複数のデータベースにシャーディングする場合、除外制約を使用しないでください。 制約は1つのデータベース内でのみ有効です。シャーディングが必要な場合は、car_idでシャーディングして、1台の車の予約がすべて同じ場所にあるようにしてください。

10. まとめ

レンタカーシステムの問題は3つの主要なポイントを中心に回転しています。第1に、tstzrangeと除外制約でデータベースにダブルブッキングを防止させます。第2に、条件付きUPDATEとユニークキーを組み合わせてステータスを変更し、webhookの重複と保留の有効期限切れの競合状態を処理します。第3に、トランザクションアウトボックスとKafkaを使用して、通知と払い戻しなどの補助的なタスクをメインフローから分離します。Redisは負荷処理を担当します:検索キャッシングと保留リクエストのレート制限。PostgreSQLが唯一の真実のソースです。

この設計フレームワークは、ほとんどの時間帯ベースの予約システムに適用できます。carをホテルルーム、テニスコート、医療予約カレンダーに置き換えるだけです。

system-designspring-bootpostgresqlkafkaredis