Nhật ký kỹ thuật · Hà Nội · Backend & CloudVol. 2026
← Tất cả bài viết
Số 0818 phút đọc

Thiết kế hệ thống đặt lịch khám không bao giờ đặt trùng

Hai bệnh nhân bấm cùng một khung 9:30 trong cùng một mili giây. Chỉ một người được thắng, lần nào cũng vậy, kể cả lúc 7 giờ sáng khi cả thành phố cùng mở app. Yêu cầu, con số, schema, bốn cách giữ invariant, và thứ gì vỡ trước khi traffic dồn lên.

  • system-design
  • postgresql
  • concurrency
  • redis
Tranh khắc gỗ sảnh tiếp đón phòng khám thế kỷ 19: cuốn sổ trên quầy, bức tường ô số, nhiều bàn tay cùng với tới một ô.
Trong bài này
  1. Bài toán
  2. Yêu cầu
  3. Functional
  4. Non-functional
  5. Back-of-the-envelope
  6. Data model
  7. Giữ invariant: một slot, một người
  8. 1. Check rồi insert (sai)
  9. 2. Pessimistic lock: SELECT … FOR UPDATE
  10. 3. Conditional update (cách mình chọn)
  11. 4. Unique constraint làm safety net
  12. Sao không dùng Redis lock?
  13. Giữ chỗ rồi xác nhận
  14. Lazy expiry thay cho sweeper job
  15. Khoản thanh toán về muộn
  16. Idempotency: cú chạm hai lần
  17. Kiến trúc tổng thể
  18. Spike lúc 7 giờ sáng
  19. Waiting room, không phải bức tường
  20. Làm read path thật nhàm chán
  21. Transaction ngắn, ít connection
  22. Scale out
  23. Khi mọi thứ hỏng
  24. Các đánh đổi mình chọn
  25. 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ượngTrung 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ăm26 triệu (≈ 8 GB kể cả index)—
Giờ cao điểm: 20.000 bệnh nhân mở app trong hai phút đầu sau 7:00, mỗi người làm mới khoảng năm lần và thử đặt một lần, phần lớn dồn vào 50 bác sĩ đông khách nhất.

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ữ.

schema.sqlsql
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

Tranh in màu cuốn sổ cái có khóa và cuống vé đóng dấu sáp đỏ.
Bản I. Sổ cái và con dấu. Dù đặt thứ gì phía trước, dòng trong database vẫn là sổ cái: nơi duy nhất quyết định slot thuộc về ai.

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 đó.

DWG B-01SCALE · NTSTransaction Abệnh nhân Anslots · dòng 4209:30 · Dr. MinhTransaction Bbệnh nhân Bìnht0t1t2t3thời gian ↓SELECT status'open'SELECT status'open'INSERT appt (An)INSERT appt (Bình)khe hở2 lịch hẹn · 1 slot
Hình 1. Race condition “check rồi insert”. Cả hai transaction đều đọc thấy open trước khi bên nào kịp ghi; lần ghi thứ hai lặng lẽ đè lên lịch của bệnh nhân đầu.

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.

hold.sqlsql
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:00Dùng cho
Check rồi insertKhôngĐặt trùngKhông gì cả
SELECT … FOR UPDATECóLock queue giữ connectionSửa nhiều dòng (đổi lịch)
conditional UPDATECóBên thua fail ngay trong vài µsGiữ một slot cụ thể
Unique indexCóBáo lỗi khi vi phạmSafety net cho mọi lối vào
Redis SET NXKhông nếu dùng riêngPre-filter rất nhanhLoạ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.

DWG B-02SCALE · NTSOPENcòn trốngHELD5 phút, một ngườiBOOKEDđã xác nhậnholdstatus='open'confirmheld_by=mehủy · thanh toán lỗihủy lịch hẹn → slot mở lạiđặt thẳng (không trả trước)hết hạn → người kháclấy được (lazy expiry)
Hình 2. State machine của slot. Mỗi mũi tên là một câu conditional UPDATE; mũi tên nét đứt là lazy expiry, chỉ xảy ra khi có người khác muốn lấy slot.
Giữ chỗ là một lời hứa có gắn đồng hồ cát: năm phút, rồi slot quay lại kệ.

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.

confirm.service.tsts
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.

idempotency.sqlsql
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ưu

Kiến trúc tổng thể

DWG B-03SCALE · NTSCLIENTApp bệnh nhânWebQuầy lễ tânEdgeCloudFront + WAFlimit theo IPGatewayauth · rate limitwaiting room 6:55–7:10Availability APIđược phép staleBooking APIgiữ invariantRedisavail:{doc}:{day}Read replicaPostgresprimary · PgBouncerPayment gatewayOutbox relayQueueSQS / RabbitMQWorkersnotificationcache invalidationhoàn tiềnEmail · SMS · Pushfallback3 stmtrepl.outboxwebhookwrite path: bắt buộc đúngcòn lại: được phép stale hoặc chậm
Hình 3. Hệ thống đặt lịch. Màu terracotta là write path nắm giữ invariant; mọi thứ màu xám có thể cũ, chậm hoặc chết mà không phá vỡ nó.
  • 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.

DWG B-04SCALE · NTS0100200300400người/giây06:5807:0007:0207:0407:06mở lịchkhông có waiting roomdồn một lúc: lock queue, cạn poolcho vào: 150/giâycùng số người, đều được phục vụtrước khi đường này hạ xuống
Hình 4. Số bệnh nhân đổ vào mỗi giây quanh đợt mở lịch 7:00 (mô phỏng 20.000 người), có và không có waiting room. Lượng cho vào được giới hạn ở mức primary chịu thoải mái; phần còn lại xếp hàng ngoài cửa thay vì trong lock queue, và khoảng hai phút sau thì ai cũng đã vào.

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 raCách xử lý
Double tap / client retryCùng một request hai lầnIdempotency 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ácXác nhận theo held_by; mất thì hoàn tiền
Pod API chết giữa chừngTransaction rollbackKhông cần dọn; client retry cùng key
Redis chếtMất cache, mất pre-filterĐọc từ replica; write path không ảnh hưởng
Replica lagLịch trống hiển thị cũGhi thì trả 409 + lựa chọn khác
Queue / notifier chếtEmail bị trễOutbox giữ event; relay chạy tiếp
Primary failover~30 giây lỗi ghiClient 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 đó.

Hết bài

Bài viết của Vũ Duy Anh, kỹ sư backend ở Hà Nội. Câu hỏi hay góp ý sửa sai xin gửi qua email.