Thiết kế hệ thống đặt lịch khám bệnh: slot thời gian và chống đặt trùng

1. Tổng quan

Đặt lịch khám bệnh nghe đơn giản: bệnh nhân chọn bác sĩ, chọn giờ rồi bấm “Đặt”. Khó khăn nằm ở chỗ khác. Hàng trăm người có thể cùng bấm vào một slot lúc 7 giờ sáng, khi phòng khám mở lịch. Hệ thống phải đảm bảo không có hai người nhận cùng một chỗ.

Bài này đi qua toàn bộ thiết kế: yêu cầu, mô hình dữ liệu, API, luồng đặt và hủy lịch. Sau đó bài tập trung vào phần quan trọng nhất là chống đặt trùng và chịu tải đột biến. Đọc xong, bạn có thể tự dựng phần lõi đặt lịch bằng Spring Boot và PostgreSQL. Phần lõi này đúng ngay cả khi có nhiều request đồng thời.

2. Yêu cầu bài toán

2.1. Yêu cầu chức năng

  • Bác sĩ (hoặc admin) tạo lịch làm việc. Hệ thống sinh ra các slot cố định, ví dụ mỗi slot 15 phút.
  • Mỗi slot có capacity. Khám chuyên khoa thường là 1. Tiêm chủng có thể là 10 người cho mỗi khung giờ.
  • Bệnh nhân xem các slot còn trống theo bác sĩ và ngày, rồi đặt lịch hoặc hủy lịch.
  • Một bệnh nhân không được giữ hai lịch hợp lệ trong cùng một slot.
  • Sau khi đặt thành công, hệ thống gửi SMS hoặc email xác nhận.

2.2. Quy mô và ràng buộc

  • Có 2.000 bác sĩ. Mỗi bác sĩ có khoảng 40 slot mỗi ngày. Lịch mở trước 14 ngày, tức khoảng 1,1 triệu slot đang hoạt động.
  • Lượt đọc lớn hơn lượt ghi rất nhiều, tỉ lệ khoảng 50:1.
  • Tải đột biến: lịch của bác sĩ nổi tiếng mở lúc 7:00. Trong 30 giây đầu có thể có 5.000 request/giây vào vài chục slot.
  • Nhất quán mạnh cho thao tác đặt lịch. Không bao giờ được vượt capacity. Danh sách slot trống thì có thể trễ vài giây.
  • Client có thể retry khi mạng chập chờn. Vì vậy thao tác đặt lịch phải idempotent.

3. Mô hình dữ liệu

Có một quyết định quan trọng: tạo slot sẵn thành từng dòng thay vì tính “giờ trống” động từ lịch làm việc. Khi mỗi slot là một dòng, ta có một điểm cụ thể để khóa và kiểm tra capacity. Tranh chấp được giải quyết trong phạm vi một row.

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';

Mỗi ràng buộc trong schema giữ một vai trò riêng:

  • chk_booked là hàng rào cuối cùng. Nếu code có bug, database vẫn từ chối việc booked_count vượt capacity.
  • uq_doctor_start ngăn việc sinh trùng slot khi job tạo lịch chạy lại.
  • uq_active_patient_slot là partial unique index của PostgreSQL. Một bệnh nhân chỉ có tối đa một lịch BOOKED trong mỗi slot. Lịch đã hủy không bị tính, nên bệnh nhân vẫn đặt lại được.
  • idempotency_key UNIQUE giúp retry của client không tạo ra lịch thứ hai.

Entity JPA tương ứng cho time_slot (rút gọn 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. Thiết kế API

GET    /api/doctors/{doctorId}/slots?date=2026-10-12   -> danh sách slot còn trống
POST   /api/appointments                               -> đặt lịch
       Header: Idempotency-Key: 6f1c...
       Body:   { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id}                          -> hủy lịch

Mã phản hồi cho POST /api/appointments:

201 Created   -> đặt thành công
200 OK        -> retry với cùng Idempotency-Key, trả lại lịch cũ
409 Conflict  -> slot đã đầy hoặc bệnh nhân đã đặt slot này
429 Too Many Requests -> vượt giới hạn tần suất

Client tự sinh Idempotency-Key (UUID) cho mỗi lần bấm nút. Khi retry, client gửi lại đúng key đó.

5. Chống đặt trùng

5.1. Vì sao “kiểm tra rồi ghi” là sai

Cách viết đầu tiên mà nhiều người nghĩ tới:

TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) {   // (1) đọc
    slot.setBookedCount(slot.getBookedCount() + 1); // (2) ghi
    appointmentRepository.save(appointment);
}

Giả sử hai thread A và B cùng đọc ở bước (1) và thấy booked_count = 0, capacity = 1. Cả hai đều qua điều kiện và cả hai đều ghi. Kết quả là hai lịch cho cùng một chỗ. Với mức isolation mặc định READ COMMITTED của PostgreSQL, đây chính là lỗi lost update.

5.2. Conditional update: kiểm tra và ghi trong một câu lệnh

Cách sửa là gộp kiểm tra và ghi vào một câu UPDATE có điều kiện. Database khóa row trong lúc cập nhật. Thread thứ hai phải chờ, sau đó đánh giá lại điều kiện WHERE trên giá trị mới.

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 trả về số dòng bị ảnh hưởng. Nếu kết quả là 1, bạn đã giành được chỗ. Nếu là 0, slot đã đầy, đã qua giờ, hoặc không tồn tại. Không cần SELECT ... FOR UPDATE, cũng không cần optimistic locking với cột version. Lock chỉ được giữ trong thời gian rất ngắn.

Service đặt lịch:

@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();                       // retry: trả lại kết quả cũ
        }
        if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
            throw new SlotFullException(slotId);
        }
        // saveAndFlush để lỗi unique constraint nổ ngay trong transaction này
        return appointmentRepository.saveAndFlush(
                Appointment.booked(slotId, patientId, idempotencyKey));
    }
}

Clock cần được khai báo thành bean, ví dụ Clock.systemUTC(), để test có thể thay bằng giờ cố định. Đoạn code hoạt động như sau:

  • Bước kiểm tra idempotency chạy trước. Nếu client retry sau khi request đầu đã commit, client nhận lại đúng lịch cũ.
  • Nếu tryReserve thành công nhưng saveAndFlush vi phạm uq_active_patient_slot (bệnh nhân đã có lịch), Spring ném DataIntegrityViolationException. Transaction rollback toàn bộ, kể cả lệnh tăng booked_count. Chỗ trống được trả lại tự động.
  • Hai request retry có thể đến gần như cùng lúc và cả hai đều không thấy key. Khi đó một request sẽ vi phạm idempotency_key UNIQUE. Ở tầng controller, bạn bắt lỗi này, đọc lại theo key, rồi trả về 200.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "Bạn đã có lịch ở khung giờ này"));
}

Lưu ý: Ví dụ trên trả 409 cho mọi vi phạm ràng buộc để giữ code ngắn. Trong hệ thống thật, bạn nên phân biệt hai trường hợp theo tên constraint. Vi phạm uq_active_patient_slot thì trả 409. Vi phạm unique của idempotency_key thì đọc lại lịch theo key và trả 200.

5.3. Hủy lịch cũng phải có điều kiện

Hủy lịch cũng gặp race. Nếu bệnh nhân bấm “Hủy” hai lần, booked_count có thể bị trừ hai lần. Cách sửa vẫn như trên: chỉ trừ khi chuyển trạng thái thực sự xảy ra.

@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 là một @Modifying query: update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. Request hủy thứ hai nhận changed = 0 và không làm gì thêm.

5.4. Kiểm thử với nhiều thread đồng thời

Test dưới đây cho 20 bệnh nhân tranh một slot có capacity 1. Bạn nên chạy test với PostgreSQL thật qua Testcontainers, vì H2 không hỗ trợ partial index.

@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();   // thả 20 thread cùng lúc
        }                        // close() chờ mọi task kết thúc

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

CountDownLatch giữ cho tất cả thread xuất phát cùng một lúc, nhờ đó race dễ xảy ra hơn. Kết quả mong đợi:

success      = 1
booked_count = 1
19 thread còn lại nhận SlotFullException

Nếu bạn thay tryReserve bằng cách “đọc rồi ghi” ở mục 5.1, test này thường fail với success lớn hơn 1.

6. Chịu tải đột biến lúc mở lịch

Conditional update đảm bảo đúng, nhưng chưa đảm bảo chịu tải. 5.000 request/giây cùng dồn vào vài row sẽ tạo hàng đợi lock trong database. Connection pool cạn dần và các API khác bị chậm theo. Có ba lớp xử lý.

6.1. Cache danh sách slot trống

Phần lớn traffic là GET slots. Bạn có thể cache kết quả theo doctorId + date trong Redis với TTL ngắn, ví dụ 3–5 giây. Khi đặt hoặc hủy thành công thì xóa key tương ứng. Danh sách có thể hơi cũ, nhưng không sao: quyết định cuối cùng vẫn do tryReserve đưa ra trên database.

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

6.2. Rate limit và chặn sớm slot đã đầy

@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;          // tối đa 5 lần đặt / 10 giây
    }

    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 gọi allowPatient trước. Nếu kết quả là false, controller trả 429. Khi tryReserve trả về 0, gọi markFull. Các request đến sau vào cùng slot sẽ bị từ chối ngay tại Redis và không chạm tới database. TTL 30 giây giúp slot tự “mở lại” khi có người hủy lịch.

Lưu ý: Không dùng Redis làm nguồn sự thật cho capacity, ví dụ DECR một bộ đếm rồi coi như đặt xong. Khi Redis và database lệch nhau (Redis restart, transaction rollback), bạn sẽ bán vượt hoặc mất chỗ. Redis chỉ nên là bộ lọc đứng trước database.

6.3. Gửi thông báo bất đồng bộ qua outbox

Không gọi dịch vụ SMS bên trong transaction đặt lịch. Hãy ghi thêm một dòng vào bảng outbox_event trong cùng transaction. Một worker (hoặc Debezium) sẽ đẩy event AppointmentBooked lên Kafka, và notification service tiêu thụ event đó. Transaction đặt lịch nhờ vậy ngắn và không phụ thuộc vào dịch vụ bên ngoài. Bạn cũng không bao giờ gửi SMS cho một lịch đã bị rollback.

7. Khi nào cần mở rộng thiết kế

  • Giữ chỗ khi phải thanh toán trước: thêm trạng thái HELD cùng cột expires_at. tryReserve vẫn tăng booked_count. Một job định kỳ chuyển các lịch HELD đã quá hạn sang EXPIRED và gọi release. Đừng để chỗ bị giữ vô thời hạn.
  • Chặn bệnh nhân đặt hai bác sĩ trùng giờ: unique index theo slot_id không đủ cho trường hợp này. Hãy lưu thêm tstzrange(start_at, end_at) vào appointment, rồi dùng exclusion constraint của PostgreSQL (EXCLUDE USING gist, cần extension btree_gist) trên cặp patient_id, khoảng thời gian.
  • Slot thời lượng linh hoạt (bác sĩ nhận ca 15, 30 hoặc 45 phút): mô hình slot cố định không còn phù hợp. Bạn cần exclusion constraint theo khoảng thời gian cho từng bác sĩ thay vì đếm capacity.
  • Không nên dùng SERIALIZABLE cho toàn hệ thống chỉ để chống trùng. Cách này đúng, nhưng gây nhiều lỗi serialization dưới tải cao. Conditional update cùng các ràng buộc giải quyết được bài toán với chi phí thấp hơn nhiều.
  • Không nên dùng distributed lock (Redisson, Zookeeper) khi chỉ cần khóa một row. Database đã khóa sẵn cho bạn, và mỗi thành phần thêm vào là thêm một điểm có thể hỏng.

8. Kết luận

Thiết kế hệ thống đặt lịch xoay quanh một nguyên tắc: đưa việc chống trùng xuống nơi có nhất quán mạnh, tức là database. Cụ thể:

  • Mô hình hóa mỗi slot thành một row có capacity và booked_count.
  • Dùng conditional update để kiểm tra và ghi trong một bước nguyên tử.
  • Dùng CHECK constraint và partial unique index làm hàng rào cuối cùng.
  • Dùng Idempotency-Key để retry an toàn.

Tầng phía trên (cache Redis, rate limit, đánh dấu slot đầy, outbox với Kafka) giúp hệ thống chịu được đợt tải đột biến lúc mở lịch. Tầng này không ảnh hưởng tới tính đúng đắn của việc đặt chỗ. Cuối cùng, hãy luôn có một test nhiều thread như ở mục 5.4 chạy trên database thật. Đó là cách đáng tin cậy nhất để chứng minh hệ thống không bao giờ cho hai người cùng vào một slot.

system-designspring-bootpostgresqlredisconcurrency