医疗预约系统设计:时间槽与并发防护
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 节所示在真实数据库上运行。这是最可靠的方式来证明系统永远不会让两个人进入同一个时间槽。