M2 · Hold + expiry

5 câu hỏi~2–3 ngày codeTừ khóa: state machine, TTL, hold expiry, @Scheduled, ShedLock, SKIP LOCKED, clock, idempotent transition

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 đề:

             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

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

MethodPathBodyResponse
POST/api/bookings/holdsnhư M1201 + "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

  1. Ghế HELD quá TTL được trả về AVAILABLE trong ≤ TTL + 2 × chu kỳ quét.
  2. 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).
  3. Mọi chuyển trạng thái ngoài bảng cho phép trả 409 INVALID_TRANSITION, DB không đổi.
  4. Confirm sau hạn trả 410 dù job chưa kịp chạy (kiểm tra hold_expires_at lúc confirm).
  5. 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.
  6. TTL và chu kỳ quét chỉnh bằng config; test dùng Clock inject được, không Thread.sleep(10 phút).
  7. Job xử lý 10k booking hết hạn mà không giữ một transaction dài (theo batch).

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
M2-T1Hold → confirm trong hạnclock +5 phútCONFIRMED, seat SOLD
M2-T2Confirm sau hạn, job chưa chạyclock +11 phút410, booking EXPIRED hoặc vẫn HELD chờ job (ghi rõ)
M2-T3Job expire100 booking hết hạn100 EXPIRED, 100 ghế AVAILABLE
M2-T4Chuyển saiconfirm booking CANCELLED; cancel booking CONFIRMED409, không đổi DB
M2-T5Race confirm vs expire2 thread, latch, 200 lần lặpKhông có booking CONFIRMED mà seat AVAILABLE
M2-T62 instance job2 container, 1000 booking hết hạnbooking_expired_total tổng = 1000
M2-T7Giữ lại sau khi expireexpire rồi user khác hold201, 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ạoHành vi mong đợi
Instance giữ ShedLock bị killdocker kill instance đang chạy jobSau lockAtMostFor, instance kia tiếp quản; không mất booking
Job chạy lâu hơn lockAtMostForBatch lớn + Toxiproxy latency 2s trên DBCó 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ệchInject clock lệch +5 phút ở 1 instanceNế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 batchdocker compose stop postgres khi job chạyBatch 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:

8. Checklist hoàn thành

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?

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:

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

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


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.