医療予約システムの設計:タイムスロットと重複防止

1. 概要

医療予約は簡単に見えます。患者が医者を選んで、時間を選択して「予約」をクリックするだけです。難しさは別のところにあります。診療所が予約を開始した朝7時に、数百人が同じスロットをクリックする可能性があります。システムは、同じ枠を2人が獲得しないようにする必要があります。

この記事では、設計全体を説明します。要件、データモデル、API、予約とキャンセルのフローです。その後、最も重要な部分である重複防止と突発的トラフィック対応に焦点を当てます。この記事を読み終わったら、Spring BootとPostgreSQLを使って、予約機能の中核部分を自分で構築できるようになります。この中核部分は、複数のリクエストが同時に到達しても、正しく動作します。

2. 要件

2.1. 機能要件

  • 医者(またはadmin)が勤務スケジュールを作成します。システムは固定されたスロット(例えば各スロットが15分)を自動生成します。
  • 各スロットにはcapacityがあります。診察は通常1人です。予防接種は1時間枠に10人かもしれません。
  • 患者は医者と日付ごとに空きスロットを確認して、予約またはキャンセルできます。
  • 1人の患者が同じスロットに複数の有効な予約を持つことはできません。
  • 予約成功後、システムはSMSまたはメールで確認を送信します。

2.2. 規模と制約

  • 医者は2,000人。各医者は毎日約40スロット。スケジュールは14日先まで公開されており、約110万個のスロットが稼働中です。
  • 読み取りが書き込みよりはるかに多く、比率は約50:1です。
  • 突発的トラフィック:有名な医者のスケジュールが朝7時に公開されます。最初の30秒間に、数十のスロットへ毎秒5,000リクエストが集中する可能性があります。
  • 強い一貫性が必要な予約操作。capacityを超えることはできません。空きスロットのリストは数秒遅れてもかまいません。
  • クライアントがネットワークエラーで再試行するかもしれません。したがって、予約操作はべき等性を持つ必要があります。

3. データモデル

重要な決定があります。スロットを事前に作成し、各行として保存する方法です。勤務スケジュールから動的に「空き時間」を計算する代わりにです。各スロットが1行の場合、capacityをチェックおよび更新するための具体的な場所があります。競合は単一の行の範囲内で解決されます。

CREATE TABLE time_slot (
    id            BIGSERIAL PRIMARY KEY,
    doctor_id     BIGINT      NOT NULL,
    start_at      TIMESTAMPTZ NOT NULL,
    end_at        TIMESTAMPTZ NOT NULL,
    capacity      INT         NOT NULL CHECK (capacity > 0),
    booked_count  INT         NOT NULL DEFAULT 0,
    CONSTRAINT chk_booked CHECK (booked_count BETWEEN 0 AND capacity),
    CONSTRAINT uq_doctor_start UNIQUE (doctor_id, start_at)
);

CREATE TABLE appointment (
    id               BIGSERIAL PRIMARY KEY,
    slot_id          BIGINT      NOT NULL REFERENCES time_slot(id),
    patient_id       BIGINT      NOT NULL,
    status           VARCHAR(20) NOT NULL,          -- BOOKED, CANCELLED
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX uq_active_patient_slot
    ON appointment (slot_id, patient_id)
    WHERE status = 'BOOKED';

スキーマの各制約は独自の役割を果たします。

  • chk_bookedは最後の防線です。コードにバグがあった場合、データベースは依然としてbooked_countがcapacityを超えることを拒否します。
  • uq_doctor_startは、スケジュール作成ジョブが再度実行されたときのスロット重複を防ぎます。
  • uq_active_patient_slotはPostgreSQLの部分一意インデックスです。患者は各スロットで最大1つのBOOKED予約を持つことができます。キャンセルされた予約はカウントされないため、患者は再度予約できます。
  • idempotency_key UNIQUEにより、クライアントの再試行が2番目の予約を生成しないようにします。

対応するJPA entityはtime_slot用(gettersは省略):

@Entity
@Table(name = "time_slot")
public class TimeSlot {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long doctorId;
    private Instant startAt;
    private Instant endAt;
    private int capacity;
    private int bookedCount;

    protected TimeSlot() {}

    public TimeSlot(Long doctorId, Instant startAt, int capacity) {
        this.doctorId = doctorId;
        this.startAt = startAt;
        this.endAt = startAt.plus(15, ChronoUnit.MINUTES);
        this.capacity = capacity;
    }
    // getters...
}

4. API設計

GET    /api/doctors/{doctorId}/slots?date=2026-10-12   -> 空きスロット一覧
POST   /api/appointments                               -> 予約を追加
       Header: Idempotency-Key: 6f1c...
       Body:   { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id}                          -> キャンセル

POST /api/appointmentsの応答コード:

201 Created   -> 予約成功
200 OK        -> 同じIdempotency-Keyで再試行。古い予約を返す
409 Conflict  -> スロット満席または患者がこのスロットに予約済み
429 Too Many Requests -> レート制限超過

クライアントはユーザーが「予約」ボタンをクリックするたびにIdempotency-Key(UUID)を生成します。再試行時、クライアントは同じキーを送信します。

5. 重複防止

5.1. なぜ「チェック後に書き込む」が間違っているのか

最初にたくさんの人が考えるアプローチ:

TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) {   // (1) 読み取り
    slot.setBookedCount(slot.getBookedCount() + 1); // (2) 書き込み
    appointmentRepository.save(appointment);
}

AとBという2つのthreadが同時にステップ(1)を実行し、booked_count = 0、capacity = 1を見ます。どちらも条件を満たし、どちらも書き込みます。結果として、同じスロットに2つの予約ができます。PostgreSQLのデフォルトの分離レベルREAD COMMITTEDでは、これがロスト・アップデートのバグです。

5.2. Conditional update:チェックと書き込みを1つのステートメントで

修正は、チェックと書き込みを1つのUPDATE文の条件に統合することです。データベースは更新中、行をロックします。2番目のthreadは待つ必要があり、その後、新しい値に対して再度WHERE条件を評価します。

public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {

    @Modifying
    @Query("""
        update TimeSlot s
           set s.bookedCount = s.bookedCount + 1
         where s.id = :id
           and s.bookedCount < s.capacity
           and s.startAt > :now
        """)
    int tryReserve(@Param("id") long id, @Param("now") Instant now);

    @Modifying
    @Query("update TimeSlot s set s.bookedCount = s.bookedCount - 1 where s.id = :id and s.bookedCount > 0")
    int release(@Param("id") long id);
}

tryReserveは影響を受けた行数を返します。結果が1の場合、その枠を確保しました。0の場合、スロットが満席、時間が過ぎた、または存在しません。SELECT ... FOR UPDATEも、optimistic lockingとversionカラムも必要ありません。ロックはわずかな時間だけ保持されます。

予約するサービス:

@Service
public class BookingService {
    private final TimeSlotRepository slotRepository;
    private final AppointmentRepository appointmentRepository;
    private final Clock clock;

    public BookingService(TimeSlotRepository slotRepository,
                          AppointmentRepository appointmentRepository,
                          Clock clock) {
        this.slotRepository = slotRepository;
        this.appointmentRepository = appointmentRepository;
        this.clock = clock;
    }

    @Transactional
    public Appointment book(long slotId, long patientId, String idempotencyKey) {
        var existing = appointmentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return existing.get();                       // 再試行:古い結果を返す
        }
        if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
            throw new SlotFullException(slotId);
        }
        // saveAndFlushでunique制約違反をこのtransactionで即座に発生させる
        return appointmentRepository.saveAndFlush(
                Appointment.booked(slotId, patientId, idempotencyKey));
    }
}

Clockはbeanとして宣言する必要があります。例えばClock.systemUTC()です。テストは固定時刻に置き換えることができます。コードは次のように動作します:

  • べき等性チェックは最初に実行されます。クライアントが最初のリクエストのcommit後に再試行した場合、クライアントは正確に古い予約を取得します。
  • tryReserveが成功したがsaveAndFlushがuq_active_patient_slotに違反する場合(患者がこのスロットに既に予約がある)、SpringはDataIntegrityViolationExceptionをスロー。transactionは全体でrollback。booked_countの増加も含めて。空きは自動的に返されます。
  • 2つの再試行リクエストがほぼ同時に到達し、どちらもキーを見ない場合があります。その場合、1つのリクエストがidempotency_key UNIQUEに違反します。controller層では、このエラーをキャッチし、キーで再度読み取り、200を返します。
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "このタイムスロットに既に予約があります"));
}

注: 上記の例は、コードを簡潔にするため、すべての制約違反に対して409を返しています。実際のシステムでは、制約名で2つのケースを区別すべきです。uq_active_patient_slotの違反なら409を返します。idempotency_keyのユニーク制約違反ならキーで読み直して200を返します。

5.3. キャンセルも条件付きにする必要がある

キャンセルもrace条件に直面します。患者が「キャンセル」を2回クリックした場合、booked_countが2回引かれる可能性があります。修正方法は同じです。ステータスの遷移が実際に発生する場合にのみ引きます。

@Transactional
public void cancel(long appointmentId) {
    Appointment appt = appointmentRepository.findById(appointmentId).orElseThrow();
    int changed = appointmentRepository.markCancelled(appointmentId); // where status = 'BOOKED'
    if (changed == 1) {
        slotRepository.release(appt.getSlotId());
    }
}

markCancelledは@Modifyingクエリです:update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'。2番目のキャンセルリクエストはchanged = 0を取得し、何もしません。

5.4. 複数スレッドの同時テスト

以下のテストでは、20人の患者が容量1のスロットを競います。Testcontainersを使ってPostgreSQLで実行すべきです。H2は部分インデックスをサポートしていないためです。

@SpringBootTest
class BookingServiceConcurrencyTest {

    @Autowired BookingService bookingService;
    @Autowired TimeSlotRepository slotRepository;

    @Test
    void onlyOnePatientGetsSingleCapacitySlot() throws Exception {
        TimeSlot slot = slotRepository.save(
                new TimeSlot(42L, Instant.now().plus(1, ChronoUnit.DAYS), 1));
        int patients = 20;
        var start = new CountDownLatch(1);
        var success = new AtomicInteger();

        try (ExecutorService pool = Executors.newFixedThreadPool(patients)) {
            for (int i = 0; i < patients; i++) {
                long patientId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        bookingService.book(slot.getId(), patientId, "key-" + patientId);
                        success.incrementAndGet();
                    } catch (SlotFullException ignored) {
                    }
                    return null;
                });
            }
            start.countDown();   // 20個のthreadを同時に起動
        }                        // close()ですべてのtaskが終了するまで待つ

        assertThat(success.get()).isEqualTo(1);
        assertThat(slotRepository.findById(slot.getId()).orElseThrow().getBookedCount())
                .isEqualTo(1);
    }
}

CountDownLatchにより、すべてのthreadが同時に起動され、race条件がより起こりやすくなります。期待される結果:

success      = 1
booked_count = 1
残りの19スレッドはSlotFullExceptionを受け取ります

セクション5.1の「読み取り後に書き込む」方法に変更した場合、このテストはしばしばsuccessが1より大きい状態で失敗します。

6. 予約開始時の突発的トラフィックへの対応

Conditional updateは正確さを保証しますが、トラフィック耐性はまだです。毎秒5,000のリクエストが数行に集中すると、データベース内のロック待機キューが形成されます。connection poolが枯渇し、他のAPI低下します。3つの層の処理があります。

6.1. 空きスロット一覧のキャッシング

トラフィックの大部分はGET slotsです。結果をdoctorId + dateごとにRedisにTTL短く(例えば3~5秒)キャッシュできます。予約またはキャンセルが成功するたびにキーを削除します。リストは古い可能性がありますが、問題ありません。最終的な決定は依然としてデータベース上のtryReserveによります。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

6.2. レート制限と満席スロットの早期ブロック

@Component
public class BookingGate {
    private final StringRedisTemplate redis;

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

    public boolean allowPatient(long patientId) {
        String key = "rl:booking:" + patientId;
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofSeconds(10));
        }
        return count != null && count <= 5;          // 10秒間に最大5回の予約
    }

    public boolean isKnownFull(long slotId) {
        return Boolean.TRUE.equals(redis.hasKey("slot:full:" + slotId));
    }

    public void markFull(long slotId) {
        redis.opsForValue().set("slot:full:" + slotId, "1", Duration.ofSeconds(30));
    }
}

Controllerは先にallowPatientを呼び出します。結果がfalseの場合、controllerは429を返します。tryReserveが0を返す場合、markFullを呼び出します。同じスロットへの後続リクエストはRedisで即座に拒否され、データベースに到達しません。30秒のTTLにより、誰かがキャンセルするとスロットが自動的に「再度開く」ことができます。

注: Redisを容量の情報源として使用しないでください。例えば、カウンターをDECRして予約済みと見なす場合。RedisとデータベースがズレるとRedis再起動やtransaction rollbackでも、キャパシティオーバーまたはスロット損失が発生します。Redisはデータベースの前に立つフィルターにすぎません。

6.3. Outboxパターンで非同期通知を送信

予約transactionの内部にSMSサービスを呼び出さないでください。代わりに、同じtransaction内でoutbox_eventテーブルに行を追加してください。Worker(またはDebezium)がAppointmentBookedイベントをKafkaにプッシュし、notificationサービスがそのイベントを消費します。予約transactionはそれゆえ短く、外部サービスに依存しません。rollbackされた予約のためにSMSを送信することもありません。

7. 設計を拡張する必要がある場合

  • 支払い前に枠を確保:HELDステータスとexpires_atカラムを追加します。tryReserveは依然としてbooked_countを増加させます。定期的なjobが期限切れHELD予約をEXPIREDに変更し、releaseを呼び出します。スロットが無期限に保持されてはいけません。
  • 患者が同じ時間に2人の医者の予約を取らないようにする:slot_idのunique indexだけでは不十分です。appointmentにtstzrange(start_at, end_at)を追加し、PostgreSQLのexclusion constraint(EXCLUDE USING gist、btree_gist拡張が必要)をpatient_idと時間範囲のペアに使用します。
  • 柔軟な期間のスロット(医者が15、30、または45分のシフトを受け入れる):固定スロットモデルはもう適切ではありません。capacityの数え方の代わりに、各医者の時間範囲に対するexclusion constraintが必要です。
  • システム全体でSERIALIZABLEを使用しないでください重複を防ぐだけならば。これは正しいのですが、高負荷で多くのserializationエラーが発生します。Conditional updateと制約は、より低コストでこの問題を解決します。
  • distributed lock(Redisson、Zookeeper)は1行だけをロックする必要がある場合は不要です。データベースは既にロックを提供しており、追加される各コンポーネントは1つの失敗ポイントです。

8. 結論

医療予約システムの設計は1つの原則の周りに回ります:重複防止を強い一貫性のある場所、つまりデータベースに置くことです。具体的には:

  • 各スロットをcapacityとbooked_countを持つ行としてモデル化します。
  • Conditional updateを使用して、チェックと書き込みを1つの原子的ステップで実行します。
  • CHECK制約と部分unique indexを最後の防線として使用します。
  • Idempotency-Keyを使用して安全に再試行します。

上記のレイヤー(Redisキャッシュとレート制限、満席スロットのマーク、KafkaとのOutbox)は、予約開始時の突発的トラフィックに対応するのに役立ちます。このレイヤーは予約の正確さには影響しません。最後に、セクション5.4のような複数スレッドテストが常に実データベースで実行されるようにしてください。これは、同じスロットに同時に2人を決して許さないシステムを証明する最も信頼できる方法です。

system-designspring-bootpostgresqlredisconcurrency