M2 · Hold + expiry
Giữ ghế chạy đúng rồi. Nhưng người ta giữ xong bỏ đi thì ghế kẹt mãi. Làm M2 hold + expiry nhé.
Đúng vấn đề. Ở M1 ghế chỉ đi một chiều. M2 cho ghế một vòng đời có luật.
1. Bối cảnh & bài toán
Flash sale 20k ghế. Bot giữ 5k ghế rồi không thanh toán. Không có expiry thì 5k ghế đó "đã bán" trên sơ đồ nhưng không ai trả tiền. Thêm vấn đề:
- Job quét hết hạn chạy trên 2 instance → cùng nhả một ghế hai lần, có thể nhả luôn ghế vừa được người khác giữ lại.
- Không có state machine → confirm một ghế đã EXPIRED, hoặc expire một ghế đã CONFIRMED (khách trả tiền rồi mất ghế).
hold() confirm() (trước hạn)
AVAILABLE ───────────▶ HELD ─────────────────────▶ CONFIRMED
▲ │
│ release() │ expire() (job, now > hold_expires_at)
└───────────────────┤
▼
EXPIRED (booking) ─▶ seat về AVAILABLE
Lưu ý: ghế và booking có trạng thái riêng. Ghế: AVAILABLE / HELD / SOLD. Booking: HELD / CONFIRMED / EXPIRED / CANCELLED. Bạn tự quyết mapping, ghi vào ADR.
2. Ôn lại trước khi code
- Ticket booking — vì sao cần hold có TTL, các cách expire.
- DB locks —
FOR UPDATE SKIP LOCKEDcho job xử lý theo batch. - Events & async —
@Scheduled, thread pool của scheduler. - Partial failure & clock — đồng hồ các máy lệch nhau, nên lấy
now()ở đâu. - Design patterns — State pattern hay enum với bảng chuyển trạng thái.
- Redis data types — so sánh với cách dùng key TTL/keyspace notification.
3. Đề bài
3.1. Migration V2__hold_expiry.sql
ALTER TABLE bookings ADD COLUMN hold_expires_at TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
ALTER TABLE bookings ADD CONSTRAINT bookings_status_chk
CHECK (status IN ('HELD','CONFIRMED','EXPIRED','CANCELLED'));
CREATE INDEX idx_seats_hold_exp ON seats (hold_expires_at) WHERE status = 'HELD';
-- ShedLock
CREATE TABLE shedlock (
name VARCHAR(64) PRIMARY KEY,
lock_until TIMESTAMPTZ NOT NULL,
locked_at TIMESTAMPTZ NOT NULL,
locked_by VARCHAR(255) NOT NULL
);
3.2. API
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/bookings/holds | như M1 | 201 + "holdExpiresAt" |
| POST | /api/bookings/{id}/confirm | — (M3 gắn thanh toán) | 200 CONFIRMED; 409 INVALID_TRANSITION nếu không ở HELD; 410 HOLD_EXPIRED |
| DELETE | /api/bookings/{id} | — | 200 CANCELLED, ghế về AVAILABLE |
| GET | /api/bookings/{id} | — | 200 booking + status |
3.3. Skeleton
public enum BookingStatus {
HELD, CONFIRMED, EXPIRED, CANCELLED;
// TODO: Set<BookingStatus> allowedNext()
// TODO: void assertCanMoveTo(BookingStatus next) → ném InvalidTransitionException
}
public interface BookingLifecycle {
Booking confirm(long bookingId, String userId);
Booking cancel(long bookingId, String userId);
/** @return số booking đã expire trong batch này */
int expireBatch(int limit);
}
@Component
class HoldExpiryJob {
@Scheduled(fixedDelayString = "${booking.hold.expiry-scan:PT5S}")
@SchedulerLock(name = "hold-expiry", lockAtMostFor = "PT30S", lockAtLeastFor = "PT2S")
void run() { /* TODO: gọi expireBatch đến khi hết hoặc hết thời gian */ }
}
booking:
hold:
ttl: ${HOLD_TTL:PT10M}
expiry-scan: PT5S
expiry-batch-size: 500
Ràng buộc quan trọng: confirm và expire cùng chạm một booking ở cùng thời điểm thì chỉ một bên thắng. Tự chọn cách bảo đảm (gợi ý: điều kiện trạng thái trong UPDATE, như M1).
4. Acceptance criteria
- Ghế HELD quá TTL được trả về AVAILABLE trong ≤ TTL + 2 × chu kỳ quét.
- Chạy 2 instance: mỗi booking hết hạn chỉ bị expire một lần (đếm log/metric
booking_expired_total= số booking hết hạn). - Mọi chuyển trạng thái ngoài bảng cho phép trả 409
INVALID_TRANSITION, DB không đổi. - Confirm sau hạn trả 410 dù job chưa kịp chạy (kiểm tra
hold_expires_atlúc confirm). - Confirm và expire đồng thời trên 1 booking: kết quả cuối là đúng một trong hai, ghế nhất quán với booking.
- TTL và chu kỳ quét chỉnh bằng config; test dùng
Clockinject được, khôngThread.sleep(10 phút). - Job xử lý 10k booking hết hạn mà không giữ một transaction dài (theo batch).
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| M2-T1 | Hold → confirm trong hạn | clock +5 phút | CONFIRMED, seat SOLD |
| M2-T2 | Confirm sau hạn, job chưa chạy | clock +11 phút | 410, booking EXPIRED hoặc vẫn HELD chờ job (ghi rõ) |
| M2-T3 | Job expire | 100 booking hết hạn | 100 EXPIRED, 100 ghế AVAILABLE |
| M2-T4 | Chuyển sai | confirm booking CANCELLED; cancel booking CONFIRMED | 409, không đổi DB |
| M2-T5 | Race confirm vs expire | 2 thread, latch, 200 lần lặp | Không có booking CONFIRMED mà seat AVAILABLE |
| M2-T6 | 2 instance job | 2 container, 1000 booking hết hạn | booking_expired_total tổng = 1000 |
| M2-T7 | Giữ lại sau khi expire | expire rồi user khác hold | 201, job không nhả nhầm hold mới |
class BookingStatusTest {
@ParameterizedTest
@CsvSource({
"HELD,CONFIRMED,true", "HELD,EXPIRED,true", "HELD,CANCELLED,true",
"CONFIRMED,EXPIRED,false", "EXPIRED,CONFIRMED,false", "CANCELLED,HELD,false"
// TODO: đủ 16 cặp
})
void transitions(BookingStatus from, BookingStatus to, boolean allowed) {
// TODO
}
}
@SpringBootTest @Testcontainers
class HoldExpiryIT {
@MockitoBean Clock clock; // hoặc MutableClock tự viết
@Test void expiredHoldReleasesSeat() {
// TODO: hold tại t0, chỉnh clock t0+11m, gọi job.run(), assert seat AVAILABLE
}
}
6. Failure scenarios
| Sự cố | Cách tạo | Hành vi mong đợi |
|---|---|---|
| Instance giữ ShedLock bị kill | docker kill instance đang chạy job | Sau lockAtMostFor, instance kia tiếp quản; không mất booking |
Job chạy lâu hơn lockAtMostFor | Batch lớn + Toxiproxy latency 2s trên DB | Có thể 2 instance cùng chạy — nhưng nhờ UPDATE có điều kiện, không expire trùng. Ghi lại. |
| Đồng hồ app lệch | Inject clock lệch +5 phút ở 1 instance | Nếu so sánh bằng now() của DB thì không ảnh hưởng — chứng minh lựa chọn của bạn |
| DB chết giữa batch | docker compose stop postgres khi job chạy | Batch rollback, lần sau quét lại, không trạng thái nửa vời |
7. Số liệu cần thu
Lưu ở docs/benchmarks/m2-expiry.md:
- Độ trễ nhả ghế: thời điểm hết hạn → thời điểm AVAILABLE (p50/p99) với chu kỳ quét 5s và 30s.
- Thời gian expire 10k booking theo batch size 100 / 500 / 2000.
- Có/không có partial index
idx_seats_hold_exp:EXPLAIN ANALYZEcâu quét.
8. Checklist hoàn thành
- ☐ Acceptance criteria có test tự động và xanh
- ☐ Failure scenario đã chạy thật, có ghi kết quả
- ☐
docs/benchmarks/m2-expiry.md - ☐ ADR: quét DB + ShedLock hay Redis TTL/delay queue; lấy thời gian ở app hay DB
- ☐ Không hardcode secret
- ☐ Bảng chuyển trạng thái có unit test đủ cặp
- ☐ Metric
booking_expired_total,booking_hold_active - ☐ Đã qua review + phỏng vấn của AI
ShedLock đã chặn 2 instance chạy cùng lúc rồi, sao còn cần điều kiện trạng thái trong UPDATE?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: ShedLock chặn job với job, không chặn job với request confirm. Và ShedLock có lockAtMostFor — hết thời gian là lock tự nhả dù job chưa xong. Thử tự trả lời: trong 3 trường hợp sau, cái gì bảo vệ dữ liệu?
- Job đang expire booking 42, user bấm confirm booking 42.
- Job instance A chạy quá 30s, instance B lấy lock và chạy.
- Bạn chuyển sang
FOR UPDATE SKIP LOCKEDvà bỏ ShedLock luôn — được không?
Dùng Redis key TTL rồi nghe keyspace notification cho nhẹ, khỏi quét DB, được không?
Ý tưởng hợp lý, nhiều người nghĩ tới. Trước khi chọn, kiểm tra mấy điểm:
- Keyspace notification là pub/sub: không có subscriber lúc đó thì sự kiện mất. App restart đúng lúc thì sao?
- Redis expire key một cách lười + quét mẫu, thời điểm phát event không chính xác.
- Nguồn sự thật là DB. Nếu Redis báo hết hạn mà DB update fail, ai sửa lại?
Nếu vẫn dùng, cần một job quét DB làm lưới an toàn. Lúc đó có còn nhẹ hơn không? Ghi so sánh vào ADR.
Test hết hạn mà phải chờ 10 phút thì lâu quá.
Đúng, đừng chờ. Gợi ý: inject java.time.Clock thành bean, mọi chỗ tính hạn dùng Instant.now(clock). Test thay bằng clock điều khiển được. Nhưng cẩn thận: nếu câu SQL quét dùng now() của Postgres thì clock giả trong Java không tác động. Bạn phải chọn: truyền thời điểm từ app vào query, hay giữ now() và dùng TTL ngắn trong test. Mỗi cách có giá của nó.
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review
- Mọi UPDATE trạng thái có
WHERE status = :expectedvà kiểm tra số dòng? - Expire cập nhật cả booking lẫn seat trong cùng transaction?
- Seat chỉ được nhả nếu
held_byvẫn là booking đó (tránh nhả hold mới — M2-T7)? - Job xử lý theo batch, transaction ngắn, có giới hạn thời gian mỗi lần chạy?
lockAtMostForcó lớn hơn thời gian chạy thật đã đo?- Thời gian lấy từ một nguồn nhất quán (Clock bean hoặc DB)?
- Bảng chuyển trạng thái nằm một chỗ, không rải
ifkhắp service?
Câu hỏi phỏng vấn
1. Ghế giữ mà không thanh toán thì xử lý sao?
🇻🇳 Tiếng Việt:
Mỗi hold có hold_expires_at. Job chạy mỗi vài giây quét các hold hết hạn theo batch, chuyển booking sang EXPIRED và trả ghế về AVAILABLE. Confirm cũng tự kiểm tra hạn nên không phụ thuộc job chạy kịp.
🇬🇧 English:
Each hold has an expiry timestamp. A job scans expired holds in batches every few seconds, marks bookings EXPIRED and frees the seats. Confirm also checks the expiry itself, so correctness does not depend on job timing.
2. Chạy nhiều instance thì job có chạy trùng không?
🇻🇳 Tiếng Việt:
Em dùng ShedLock để chỉ một instance chạy. Nhưng ShedLock có thể hết hạn, nên em vẫn làm mỗi chuyển trạng thái idempotent bằng UPDATE có điều kiện. Chạy trùng cũng không sai dữ liệu.
🇬🇧 English:
ShedLock ensures one instance runs the job. Since that lock can expire, every transition is also an idempotent conditional UPDATE, so a duplicate run cannot corrupt data.
3. Confirm và expire cùng lúc thì sao?
🇻🇳 Tiếng Việt:
Cả hai đều UPDATE ... WHERE status='HELD'. Row lock làm một bên chờ, bên sau thấy status đã đổi và cập nhật 0 dòng. Confirm nhận 0 dòng thì trả 410 hoặc 409.
🇬🇧 English:
Both run UPDATE ... WHERE status='HELD'. The row lock serializes them; the second sees the new status and updates zero rows. If confirm loses, it returns 410 or 409.
Cách nhớ ngắn gọn
- Trạng thái có bảng luật, không có lối tắt.
- ShedLock giảm trùng, UPDATE có điều kiện mới đảm bảo đúng.
- Confirm tự kiểm hạn, không tin job.
- Thời gian từ một nguồn, test bằng Clock.
Tự kiểm tra
lockAtMostFor dùng để làm gì?
Giới hạn thời gian giữ lock nếu instance chết, để instance khác tiếp quản. Nếu job chạy lâu hơn giá trị này, có thể 2 instance cùng chạy.
Vì sao FOR UPDATE SKIP LOCKED hợp với job quét?
Nhiều worker lấy các dòng khác nhau mà không chờ nhau, không xử lý trùng một dòng đang bị khóa.
Keyspace notification của Redis có rủi ro gì khi dùng làm expiry?
Pub/sub không bền: mất event khi không có subscriber; thời điểm phát không chính xác. Cần job DB dự phòng.
Nhả ghế khi expire cần điều kiện gì thêm?
Chỉ nhả khi ghế vẫn đang được giữ bởi chính booking đó, tránh nhả nhầm hold mới của người khác.