Trong bài này
- Bài toán
- Yêu cầu
- Functional
- Non-functional
- Back-of-the-envelope
- Data model
- Giữ invariant: một slot, một người
- 1. Check rồi insert (sai)
- 2. Pessimistic lock: SELECT … FOR UPDATE
- 3. Conditional update (cách mình chọn)
- 4. Unique constraint làm safety net
- Sao không dùng Redis lock?
- Giữ chỗ rồi xác nhận
- Lazy expiry thay cho sweeper job
- Khoản thanh toán về muộn
- Idempotency: cú chạm hai lần
- Kiến trúc tổng thể
- Spike lúc 7 giờ sáng
- Waiting room, không phải bức tường
- Làm read path thật nhàm chán
- Transaction ngắn, ít connection
- Scale out
- Khi mọi thứ hỏng
- Các đánh đổi mình chọn
- Kết
Bài toán
Khi làm booking flow cho Sepolia Health, bug đầu tiên QC báo về không phải là sập server. Đó là hai email xác nhận cho cùng một bác sĩ, cùng ngày, cùng khung 9:30. Hai bệnh nhân bấm Đặt hẹn cách nhau vài mili giây, cả hai request đều đọc thấy slot còn trống, và cả hai đều ghi một lịch hẹn. Không có dòng code nào sai cú pháp; chỉ là code được viết như thể request đến lần lượt từng cái một.
Bài này thiết kế lại hệ thống đó cho đúng, từ đầu, ở quy mô lớn hơn bản mình đã làm: một chuỗi phòng khám, nơi các bác sĩ đông bệnh nhân mở lịch tuần sau vào 7:00 mỗi sáng. Mục tiêu gói trong một câu phải đúng trong mọi tình huống: tại mọi thời điểm, một slot chỉ được giữ hoặc đặt bởi tối đa một bệnh nhân. Mọi thứ khác (cache, queue, sharding) đều có thể thương lượng. Câu đó thì không.
Yêu cầu
Functional
- Bệnh nhân xem lịch trống của bác sĩ theo ngày và chọn một khung giờ.
- Đặt lịch gồm hai bước khi cần trả trước: giữ chỗ, thanh toán, rồi xác nhận. Nếu không cần trả trước, giữ và xác nhận diễn ra trong một lần gọi.
- Bệnh nhân hủy hoặc đổi lịch; slot bị hủy phải mở lại cho người khác.
- Lễ tân đặt lịch cho khách đến trực tiếp tại quầy, qua cùng một API.
- Xác nhận và nhắc lịch được gửi qua push, email và SMS.
Non-functional
- Correctness: không có lịch trùng nào. Đây là invariant, không phải một con số phần trăm.
- Latency: đọc lịch trống p99 dưới 200 ms; giữ chỗ / xác nhận p99 dưới 500 ms.
- Availability: 99,95% cho booking flow. Read path được phép degrade (hơi stale) trước write path.
- Fairness: trong đợt mở lịch 7:00, người dùng điện thoại chậm không được thua một con bot bắn 50 request mỗi giây.
- Auditability: mọi thay đổi trạng thái của slot đều tra lại được (ai, lúc nào, từ kênh nào).
Back-of-the-envelope
Giả sử một chuỗi khu vực: 200 phòng khám, 3.000 bác sĩ, mỗi bác sĩ 24 slot mỗi ngày. Tức 72.000 slot mỗi ngày. Nếu lấp đầy 80%, khoảng 60.000 lượt đặt mỗi ngày.
| Đại lượng | Trung bình | Đỉnh 7:00 |
|---|---|---|
| Lượt ghi (giữ + xác nhận + hủy) | ~4 / giây trong 12 giờ ban ngày | ~170 / giây trong hai phút |
| Lượt đọc lịch trống (≈30 mỗi lượt đặt) | ~42 / giây | ~850 / giây |
| Số người tranh một hot slot | ~1 | ~15–20 |
| Số dòng slot mỗi năm | 26 triệu (≈ 8 GB kể cả index) | — |
Bảng trên cho ra hai kết luận. Một, khối lượng nhỏ: một PostgreSQL primary xử lý vài nghìn transaction ghi ngắn mỗi giây, và tám gigabyte mỗi năm vừa trên một chiếc laptop. Hai, đỉnh gấp khoảng 100 lần trung bình và dồn vào một nhóm dòng rất nhỏ. Đây là bài toán contention, không phải bài toán throughput. Phần lớn thiết kế xoay quanh chuyện gì xảy ra khi hai mươi transaction cùng muốn một dòng trong cùng một khoảnh khắc.
Data model
Slot được materialize sẵn thành row: một job chạy đêm trải mẫu lịch của từng bác sĩ ra thành các row cụ thể cho vài tuần tới. Tính lịch trống tại chỗ bằng “mẫu lịch trừ lịch hẹn” nghe gọn hơn, nhưng khi đó không có row nào để lock và không có chỗ nào giữ invariant. Mỗi slot một row cho database một thứ cụ thể để canh giữ.
CREATE TYPE slot_status AS ENUM ('open', 'held', 'booked', 'blocked');
CREATE TABLE slots (
id bigint PRIMARY KEY,
clinic_id int NOT NULL, -- sau này làm shard key
doctor_id int NOT NULL,
starts_at timestamptz NOT NULL,
status slot_status NOT NULL DEFAULT 'open',
held_by bigint, -- id bệnh nhân
hold_expires_at timestamptz,
version int NOT NULL DEFAULT 0,
UNIQUE (doctor_id, starts_at)
);
CREATE INDEX slots_by_day ON slots (doctor_id, starts_at) WHERE status = 'open';
CREATE TABLE appointments (
id bigint PRIMARY KEY,
slot_id bigint NOT NULL REFERENCES slots(id),
patient_id bigint NOT NULL,
status text NOT NULL, -- confirmed | cancelled | no_show
channel text NOT NULL, -- app | web | counter
created_at timestamptz NOT NULL DEFAULT now()
);
-- tuyến phòng thủ cuối: mỗi slot chỉ một lịch hẹn còn hiệu lực
CREATE UNIQUE INDEX one_live_appt_per_slot
ON appointments (slot_id) WHERE status = 'confirmed';Có hai chi tiết cố ý. Partial index slots_by_day chỉ chứa slot còn mở, nên query lịch trống vẫn nhanh dù bảng đã tích vài năm lịch sử. Còn unique index có điều kiện trên appointments nghĩa là kể cả khi mọi lớp phía trên đều có bug, chính PostgreSQL sẽ từ chối lịch hẹn thứ hai cho cùng một slot.
Giữ invariant: một slot, một người

1. Check rồi insert (sai)
Phiên bản sinh ra hai email kia đọc slot, thấy open, rồi chèn lịch hẹn. Giữa lần đọc và lần ghi có một khe hở, và khi tải cao, hai request lọt vừa vào khe đó.
Bọc nó trong transaction cũng không cứu được ở isolation level mặc định READ COMMITTED của PostgreSQL: cả hai transaction vẫn thấy trạng thái đã commit trước khi bên nào ghi. SERIALIZABLE sẽ bắt được, đổi lại phải viết retry loop cho mọi serialization failure. Có những công cụ rẻ hơn.
2. Pessimistic lock: SELECT … FOR UPDATE
Lock row trước, rồi mới quyết định. Transaction thứ hai chờ lock cho tới khi transaction đầu commit, sau đó đọc trạng thái mới và rút lui. Đúng và dễ suy luận. Cái giá lộ ra lúc 7:00: hai mươi transaction xếp hàng chờ một row lock, mỗi cái giữ một connection trong pool trong lúc chờ. Với pool 50 connection, chỉ ba slot nóng là đủ làm mọi request khác của service bị starve.
3. Conditional update (cách mình chọn)
Gộp bước kiểm tra vào chính lệnh ghi. Một câu UPDATE … WHERE status = 'open' vừa kiểm tra vừa chiếm slot; số dòng bị ảnh hưởng cho biết bạn thắng hay thua.
UPDATE slots
SET status = 'held',
held_by = $2,
hold_expires_at = now() + interval '5 minutes',
version = version + 1
WHERE id = $1
AND (status = 'open'
OR (status = 'held' AND hold_expires_at < now())) -- lazy expiry
RETURNING version;
-- 1 dòng -> bạn đang giữ slot
-- 0 dòng -> người khác đã giữ; trả 409 kèm vài slot trống gần đóVì sao cách này an toàn ở READ COMMITTED: khi hai lệnh UPDATE cùng chạm một dòng, lệnh thứ hai chờ lệnh đầu commit, rồi đánh giá lại điều kiện WHERE trên phiên bản mới của dòng. Lúc này nó thấy held, không khớp, và trả về 0 dòng. Lock chỉ bị giữ trong vài micro giây của chính câu update, không kéo dài qua một vòng gọi từ ứng dụng, nên lock queue lúc 7:00 tan nhanh và người thua biết mình thua ngay thay vì xếp hàng chờ để thua.
Một biến thể hữu ích: khi bệnh nhân chọn “khung nào buổi sáng cũng được” thay vì một khung cụ thể, dùng SELECT … FOR UPDATE SKIP LOCKED LIMIT 1. Mỗi người tranh sẽ bỏ qua những dòng người khác đang chiếm và lấy dòng trống kế tiếp, nên hai mươi request rải ra hai mươi slot thay vì cùng lao vào slot đầu tiên.
4. Unique constraint làm safety net
Unique index có điều kiện trong schema không phải cơ chế chính, nó là tấm chắn cuối. Một công cụ lễ tân do đội khác viết, một đợt migrate dữ liệu, một message bị gửi lại: bất kỳ thứ nào cũng có thể đi vòng qua logic giữ chỗ. Index biến những bug đó thành lỗi 23505 unique_violation ồn ào thay vì một lịch trùng lặng lẽ.
Sao không dùng Redis lock?
SET slot:123 patient NX PX 5000 nhanh và phổ biến. Dùng làm pre-filter thì ổn: nó loại được những người chắc chắn thua trước khi họ chạm tới database. Dùng làm source of truth thì không, vì lock và dữ liệu nằm ở hai hệ thống khác nhau. Một process có thể bị dừng (GC, mạng chậm) quá TTL của lock, process khác lấy được lock, và cả hai cùng ghi. Muốn vá phải dùng fencing token do chính database kiểm tra, mà tới lúc đó thì database mới là thứ làm việc thật. Mình giữ một source of truth duy nhất, và để nó là dòng dữ liệu.
| Cách | Đúng? | Lúc 7:00 | Dùng cho |
|---|---|---|---|
| Check rồi insert | Không | Đặt trùng | Không gì cả |
| SELECT … FOR UPDATE | Có | Lock queue giữ connection | Sửa nhiều dòng (đổi lịch) |
| conditional UPDATE | Có | Bên thua fail ngay trong vài µs | Giữ một slot cụ thể |
| Unique index | Có | Báo lỗi khi vi phạm | Safety net cho mọi lối vào |
| Redis SET NX | Không nếu dùng riêng | Pre-filter rất nhanh | Loại sớm người chắc thua |
Giữ chỗ rồi xác nhận
Thanh toán tốn thời gian: app ngân hàng mở ra, OTP về, bệnh nhân lưỡng lự. Ta không thể giữ một transaction mở suốt quãng đó. Thay vào đó slot đi qua các trạng thái rõ ràng, và mỗi bước chuyển là một câu conditional update thật ngắn.

Lazy expiry thay cho sweeper job
Cách dễ nghĩ ra nhất để nhả các slot bị bỏ dở là một cron job reset row hết hạn mỗi phút. Chạy được, nhưng thêm một bộ phận chuyển động và tới một phút “chết” trên những slot nóng nhất. Để ý rằng câu giữ chỗ phía trên đã coi held AND hold_expires_at < now() là trống. Hết hạn được tính đúng lúc có người muốn slot, nên một lượt giữ bị bỏ dở có thể lấy lại ngay khi vừa hết hạn. Query lịch trống dùng cùng predicate đó. Vẫn có thể chạy một job dọn mỗi giờ cho gọn dữ liệu báo cáo, nhưng tính đúng không còn phụ thuộc vào nó.
Luôn so với now() của database, đừng bao giờ dùng clock của app server. Mười pod API lệch nhau vài trăm mili giây sẽ cãi nhau xem một lượt giữ đã hết hạn hay chưa.
Khoản thanh toán về muộn
Cuộc đua khó chịu nhất là giữa payment gateway và đồng hồ: bệnh nhân trả tiền ở phút 4:58, webhook về lúc 5:03. Lazy expiry cho ta một grace period miễn phí. Xác nhận bằng WHERE id = $1 AND status = 'held' AND held_by = $2, không kiểm tra thời hạn. Nếu chưa ai lấy slot, dòng vẫn đang được giữ bởi bệnh nhân này và lệnh xác nhận thành công. Nếu đã có người lấy, held_by không còn khớp, update trả về 0 dòng, và ta tự động hoàn tiền rồi báo cho bệnh nhân. Cả hai kết cục đều rõ ràng; không cái nào là đặt trùng.
async confirm(slotId: bigint, patientId: bigint, paymentId: string) {
return this.db.tx(async (tx) => {
const claimed = await tx.query(
`UPDATE slots SET status = 'booked', version = version + 1
WHERE id = $1 AND status = 'held' AND held_by = $2`,
[slotId, patientId],
);
if (claimed.rowCount === 0) {
// mất slot sau khi đã trả tiền: hoàn tiền, tuyệt đối không đặt trùng
await tx.outbox.push("payment.refund_requested", { paymentId, slotId });
return { ok: false, reason: "SLOT_TAKEN" } as const;
}
await tx.appointments.insert({ slotId, patientId, status: "confirmed" });
await tx.outbox.push("appointment.confirmed", { slotId, patientId });
return { ok: true } as const;
});
}Đổi lịch chạm vào hai slot: chiếm slot mới, nhả slot cũ, trong cùng một transaction. Luôn lock hai row theo thứ tự id tăng dần; nếu không, hai bệnh nhân đổi chéo slot cho nhau có thể gây deadlock.
Idempotency: cú chạm hai lần
Mạng di động tự gửi lại. Ngón tay sốt ruột bấm hai lần. Payment gateway gửi webhook “ít nhất một lần”. Mỗi chuyện đó biến một ý định thành nhiều request, và nếu không cẩn thận, request thứ hai hoặc báo lỗi khó hiểu (“slot đã có người đặt”, mà người đó là chính bạn), hoặc với bước xác nhận thì gửi thêm một email.
Client sinh một Idempotency-Key (UUID) cho mỗi lần thử đặt lịch và gửi kèm ở mọi lần retry. Server lưu key cùng hash của request và response đã trả, trong cùng transaction với việc đặt lịch. Lần lặp lại cùng key, cùng nội dung nhận lại đúng response cũ; cùng key mà khác nội dung là lỗi phía client, trả 422. Webhook dùng event id của payment gateway làm key.
CREATE TABLE idempotency_keys (
key uuid PRIMARY KEY,
patient_id bigint NOT NULL,
request_hash text NOT NULL,
response jsonb, -- null khi lần thử đầu còn đang chạy
created_at timestamptz NOT NULL DEFAULT now()
);
-- câu đầu tiên trong transaction đặt lịch:
INSERT INTO idempotency_keys (key, patient_id, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO NOTHING; -- 0 dòng -> lặp lại: trả response đã lưuKiến trúc tổng thể
- Booking API (NestJS, stateless, scale ngang). Phụ trách giữ chỗ, xác nhận, hủy. Chỉ nói chuyện với primary.
- Availability API. Đọc từ Redis, fallback về read replica khi cần. Được phép cũ vài giây, vì write path kiểm tra lại mọi thứ.
- PostgreSQL primary đứng sau PgBouncer ở chế độ transaction, để hàng trăm pod API dùng chung vài chục connection thật.
- Transactional outbox. Event được ghi trong cùng transaction với thay đổi trạng thái; một relay publish chúng lên queue. Notification worker, cache invalidation và flow hoàn tiền đều consume từ đó.
- Waiting room ở gateway, chỉ bật quanh giờ mở lịch (phần sau).
Outbox quan trọng hơn vẻ ngoài của nó. Bản ngây thơ commit lịch hẹn xong mới gọi email service. Nếu process chết giữa hai bước, bệnh nhân có lịch nhưng không bao giờ được báo. Nếu gọi email trước mà commit thất bại, bệnh nhân được báo về một lịch không tồn tại. Ghi event vào cùng transaction biến “đã đặt” và “sẽ được báo” thành một sự thật atomic.
Spike lúc 7 giờ sáng
Correctness đã xong; thứ vỡ trước trong đợt spike mở lịch là mọi thứ xung quanh nó. Trong các lần test tải của mình, thứ tự luôn giống nhau: connection pool, rồi endpoint lịch trống, rồi CPU database. Mỗi thứ có cách chữa riêng.
Waiting room, không phải bức tường
Từ 6:55, gateway phát cho mỗi client vừa tới một signed ticket kèm vị trí ngẫu nhiên (ngẫu nhiên chứ không phải ai đến trước được trước, để bấm F5 lúc 6:59:59,9 không được thưởng). Ticket được cho vào với tốc độ cố định, ví dụ 150 mỗi giây, chỉnh theo kết quả load test. Client được vào nhận một token ngắn hạn mà Booking API sẽ kiểm tra. Trang hiển thị đúng vị trí trong hàng. Cách này biến vách đá thành con dốc, và cùng với rate limit theo bệnh nhân (mỗi người một lượt giữ chỗ cho mỗi bác sĩ mỗi ngày) lấy đi gần hết lợi thế của bot.
Làm read path thật nhàm chán
Lịch trống của một bác sĩ trong một ngày là một object rất nhỏ: 24 id slot và trạng thái. Cache nó trong Redis với key avail:{doctor}:{date}, warm sẵn cache của các bác sĩ đông khách lúc 6:59, và invalidate cache từ event stream của outbox mỗi khi slot đổi trạng thái. Giữa hai lần invalidate, cache có thể hiện một slot vừa bị lấy một giây trước. Điều đó chấp nhận được chính vì write path kiểm tra lại: bệnh nhân nhận 409 kèm ba lựa chọn gần nhất, hiển thị tức thì từ cùng cache đó. Đừng bao giờ để stale read len vào quyết định ghi.
Transaction ngắn, ít connection
Một transaction đặt lịch chỉ nên có ba câu lệnh và không có lời gọi mạng nào: insert idempotency key, conditional update, insert outbox. Không gọi API thanh toán, không gửi email, không gọi logging service bên trong. Với transaction ngắn như vậy, PgBouncer ở chế độ transaction cho 40 connection thật phục vụ hàng nghìn request đồng thời, và statement timeout một giây đảm bảo không query chậm nào bắt cả pool làm con tin.
Scale out
Một primary gánh được tải này trong nhiều năm, nên các bước scale đầu tiên là scale dọc và scale phía đọc: máy lớn hơn, thêm replica, thêm cache. Khi chuỗi lan ra nhiều region, shard key tự nhiên là clinic_id. Một lượt đặt lịch không bao giờ trải qua nhiều phòng khám, nên mọi thao tác giữ, xác nhận, hủy vẫn là transaction trên một shard, và invariant không cần distributed coordination. Mỗi phòng khám có một home region nắm quyền ghi; read path phục vụ ở đâu cũng được.
Query duy nhất chạy qua nhiều shard là màn hình “lịch hẹn của tôi”. Thay vì hỏi tất cả các shard, một consumer của event stream outbox duy trì một read model theo patient_id. Nó trễ một hai giây, hoàn toàn ổn cho một danh sách, và không bao giờ gây đặt trùng vì không có gì ghi qua nó.
Khi mọi thứ hỏng
| Sự cố | Điều gì xảy ra | Cách xử lý |
|---|---|---|
| Double tap / client retry | Cùng một request hai lần | Idempotency key trả lại response đầu tiên |
| Thanh toán sau khi hết giữ chỗ | Slot có thể đã thuộc người khác | Xác nhận theo held_by; mất thì hoàn tiền |
| Pod API chết giữa chừng | Transaction rollback | Không cần dọn; client retry cùng key |
| Redis chết | Mất cache, mất pre-filter | Đọc từ replica; write path không ảnh hưởng |
| Replica lag | Lịch trống hiển thị cũ | Ghi thì trả 409 + lựa chọn khác |
| Queue / notifier chết | Email bị trễ | Outbox giữ event; relay chạy tiếp |
| Primary failover | ~30 giây lỗi ghi | Client retry cùng key; không có booking dở dang |
Các đánh đổi mình chọn
- Materialize slot thay vì tính lịch trống on the fly. Nhiều dòng hơn và thêm job chạy đêm, đổi lại có một dòng cụ thể để canh giữ và query lịch trống chỉ cần đọc index.
- Conditional update thay vì lock hay SERIALIZABLE. Bên thua fail fast, không cần retry loop. Cái giá là mọi bước chuyển trạng thái phải viết được thành một câu
UPDATE … WHERE, điều mà state machine giúp dễ dàng. - Lazy expiry thay vì sweeper job. Không có dead time trên hot slot và có sẵn grace period cho thanh toán muộn; đổi lại các reporting query phải dùng cùng predicate.
- Stale read, strict write. Read path rẻ và thỉnh thoảng sai; write path chặt và luôn đúng. Giờ cao điểm người dùng gặp vài lần 409 lịch sự mỗi ngày.
- Waiting room thay vì mua thêm capacity. Bệnh nhân đợi vài giây trong một queue minh bạch, thay vì ta trả tiền hạ tầng cho một đỉnh hai phút mỗi ngày một lần.
Kết
Đặt invariant ở đúng một chỗ, để chỗ đó là database, và cho phép mọi thứ khác được quyền sai.
Nhìn lại Sepolia Health, cách sửa hai email kia chỉ là bốn dòng SQL. Phần còn lại của thiết kế này là để bốn dòng đó vẫn đúng khi traffic lớn gấp trăm lần và ập đến cùng một lúc. Nếu chỉ được giữ ba thứ: conditional update trên slot materialize sẵn, idempotency key cho mọi lệnh ghi, và outbox cho mọi việc xảy ra sau đó.
