医疗预约系统设计:时间槽与并发防护

1. 概述

医疗预约听起来很简单:患者选择医生,选择时间,然后点击“预约”。难点在别的地方。数百人可能在诊所上午7点开放预约时同时点击某个时间槽。系统必须确保不会有两个人占据同一个位置。

本文介绍完整的设计过程:需求、数据模型、API、预约和取消流程。之后重点讲述最关键的部分——防止重复预约和处理突发流量。读完后,你可以用 Spring Boot 和 PostgreSQL 自己构建预约系统的核心部分。这个核心在处理大量并发请求时也能保证正确性。

2. 需求分析

2.1. 功能需求

  • 医生(或管理员)创建工作日程。系统生成固定的时间槽,例如每个槽位 15 分钟。
  • 每个时间槽有容量。专科门诊通常是 1。疫苗接种可能是 10 人每个时间段。
  • 患者按医生和日期查看空闲时间槽,然后预约或取消。
  • 一个患者在同一个时间槽内不能有两个有效预约。
  • 预约成功后,系统发送 SMS 或邮件确认。

2.2. 规模与约束

  • 有 2000 名医生。每名医生每天约 40 个时间槽。预约提前 14 天开放,即约 110 万个活跃时间槽。
  • 读远大于写,比例约 50:1。
  • 突发流量:名医的预约在 7:00 开放。头 30 秒可能有 5000 请求/秒涌向几十个时间槽。
  • 强一致性。永远不能超过容量。空闲时间槽列表可以延迟几秒钟。
  • 客户端可能会重试。因此预约操作必须幂等。

3. 数据模型

有一个重要决定:预先创建每一行为一个时间槽,而不是动态计算“空闲时间”。当每个时间槽是一行时,你有一个具体的点来加锁和检查容量。竞争在单行范围内解决。

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 是最后的防线。即使代码有 bug,数据库也会拒绝 booked_count 超过 capacity。
  • uq_doctor_start 防止创建日程的任务重跑时生成重复的时间槽。
  • uq_active_patient_slot 是 PostgreSQL 的部分唯一索引。一个患者在每个时间槽中最多有一个 BOOKED 预约。已取消的预约不计入,所以患者仍可重新预约。
  • idempotency_key UNIQUE 防止客户端重试时创建第二个预约。

对应的 JPA Entity(简化部分 getter):

@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 同时在步骤 (1) 读取,都看到 booked_count = 0、capacity = 1。两者都通过了条件,两者都进行写入。结果是两个预约占同一个位置。在 PostgreSQL 的默认隔离级别 READ COMMITTED 下,这就是丢失更新错误。

5.2. 条件更新:在单个 SQL 语句中检查和写入

修复方法是将检查和写入合并成一个带条件的 UPDATE 语句。数据库在更新期间锁定该行。第二个线程必须等待,然后在新值上重新计算 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 和版本列。锁仅在极短的时间内持有。

预约服务代码:

@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 约束违反立即在这个事务中抛出
        return appointmentRepository.saveAndFlush(
                Appointment.booked(slotId, patientId, idempotencyKey));
    }
}

Clock 需要声明为 bean,例如 Clock.systemUTC(),这样测试可以替换为固定时间。代码工作流程如下:

  • idempotency 检查首先执行。如果客户端在第一个请求已提交后重试,客户端会得到相同的旧预约。
  • 如果 tryReserve 成功但 saveAndFlush 违反 uq_active_patient_slot(患者在此槽位已有预约),Spring 抛出 DataIntegrityViolationException。整个事务回滚,包括 booked_count 的增加。位置自动返还。
  • 两个重试请求可能几乎同时到达,两者都看不到该键。此时其中一个请求会违反 idempotency_key UNIQUE。在控制器层,捕获此错误,按键重新读取,然后返回 200。
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "您已在此时间段有预约"));
}

注意: 上面的示例对所有约束违反都返回 409 以保持代码简洁。在真实系统中,应该按约束名称区分。违反 uq_active_patient_slot 则返回 409。违反 idempotency_key 的唯一性则按键重新读取预约并返回 200。

5.3. 取消预约也必须有条件

取消预约也会遇到竞态。如果患者点击“取消”两次,booked_count 可能被减两次。修复方式仍是相同的:只有当状态转换真正发生时才减少。

@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'。第二个取消请求得到 changed = 0 且不做任何额外操作。

5.4. 多线程并发测试

下面的测试让 20 个患者争夺一个容量为 1 的时间槽。你应该使用真实的 PostgreSQL 通过 Testcontainers 运行测试,因为 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 个线程
        }                        // close() 等待所有任务完成

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

CountDownLatch 使所有线程同时启动,这样竞态更容易发生。预期结果:

success      = 1
booked_count = 1
其余 19 个线程接收 SlotFullException

如果将 tryReserve 替换为第 5.1 节中的“读后写”方式,此测试通常会失败,其中 success 大于 1。

6. 处理开放预约时的突发流量

条件更新保证正确,但还不能保证承载能力。5000 请求/秒同时涌向几行会在数据库中创建锁队列。连接池逐渐耗尽,其他 API 随之变慢。有三层处理方法。

6.1. 缓存空闲时间槽列表

大部分流量是 GET slots。可以在 Redis 中按 doctorId + date 缓存结果,使用短 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;          // 最多 5 次预约 / 10 秒
    }

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

控制器先调用 allowPatient。如果结果为 false,控制器返回 429。当 tryReserve 返回 0 时,调用 markFull。后续到达同一时间槽的请求将在 Redis 处被拒绝,不会到达数据库。30 秒 TTL 帮助时间槽在有人取消预约时“自动重新打开”。

注意: 不要将 Redis 用作容量的真实数据源,例如 DECR 一个计数器然后视为预约完成。当 Redis 和数据库不同步时(Redis 重启、事务回滚),你会超售或丢失位置。Redis 应该只是过滤层,站在数据库前面。

6.3. 通过 Outbox 模式发送异步通知

不要在预约事务内调用 SMS 服务。改为在同一事务中添加一行到 outbox_event 表。worker(或 Debezium)将推送 AppointmentBooked 事件到 Kafka,通知服务消费此事件。预约事务因此缩短且不依赖外部服务。你也永远不会为已被回滚的预约发送 SMS。

7. 何时需要扩展设计

  • 当需要预先支付时保留位置:添加 HELD 状态和 expires_at 列。tryReserve 仍增加 booked_count。定期作业将已过期的 HELD 预约转换为 EXPIRED 并调用 release。不要让位置被无限期保留。
  • 阻止患者在同一时间预约两个医生:按 slot_id 的唯一索引还不够。改为添加 tstzrange(start_at, end_at) 到 appointment,然后使用 PostgreSQL 的排斥约束(EXCLUDE USING gist,需要 btree_gist 扩展)在 patient_id 和时间范围对上。
  • 灵活持续时间的时间槽(医生接受 15、30 或 45 分钟的班次):固定时间槽模型不再适用。你需要按时间范围的排斥约束对于每个医生,而不是计算容量。
  • 不应该对整个系统使用 SERIALIZABLE 来防止重复。这种方式是正确的,但会导致许多序列化错误在高负载下。条件更新和约束以更低的成本解决问题。
  • 不应该在只需要锁定一行时使用分布式锁(Redisson、Zookeeper)。数据库已经为你锁定。添加的每个组件都是另一个可能出故障的点。

8. 结论

医疗预约系统设计围绕一个原则:将防止重复的工作下推到具有强一致性的地方,即数据库。具体来说:

  • 将每个时间槽建模为具有 capacity 和 booked_count 的一行。
  • 使用条件更新在单个原子步骤中检查和写入。
  • 使用 CHECK 约束和部分唯一索引作为最后的防线。
  • 使用 Idempotency-Key 来安全地重试。

上层(Redis 缓存、速率限制、标记已满时间槽、带 Kafka 的 Outbox)帮助系统承受开放预约时的突发流量。这一层不影响预约的正确性。最后,应该始终有一个多线程测试如第 5.4 节所示在真实数据库上运行。这是最可靠的方式来证明系统永远不会让两个人进入同一个时间槽。

system-designspring-bootpostgresqlredisconcurrency