M1 · Chống bán trùng ghế

5 câu hỏi~4–5 ngày codeTừ khóa: race condition, synchronized, atomic UPDATE, SELECT FOR UPDATE, @Version, optimistic lock, Redis SET NX, strategy pattern, benchmark

Hạ tầng xanh rồi. Giờ làm M1: chống bán trùng ghế. Mình muốn thử đủ 5 cách lock và so sánh bằng số liệu.

Đây là milestone quan trọng nhất của cả project. Bán trùng một ghế là lỗi khách hàng nhìn thấy ngay. Giao bài nhé.

1. Bối cảnh & bài toán

Ghế A12 còn trống. Hai người bấm "Giữ ghế" cùng lúc:

T1: SELECT status FROM seats WHERE id=12   → AVAILABLE
T2: SELECT status FROM seats WHERE id=12   → AVAILABLE
T1: UPDATE seats SET status='HELD', held_by='u1'
T2: UPDATE seats SET status='HELD', held_by='u2'   ← ghi đè
Kết quả: cả u1 và u2 nhận 200 OK. Một người đến nơi không có ghế.

Đây là check-then-act. Nếu bỏ qua, flash sale (M7) sẽ oversell hàng trăm ghế. Bạn sẽ cài 5 chiến lược sau cùng một interface, bật/tắt bằng config, rồi đo.

2. Ôn lại trước khi code

3. Đề bài

3.1. Schema (booking DB, Flyway V1__seats.sql)

CREATE TABLE seats (
  id              BIGSERIAL PRIMARY KEY,
  event_id        BIGINT      NOT NULL,
  code            VARCHAR(10) NOT NULL,
  status          VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE',
  version         BIGINT      NOT NULL DEFAULT 0,
  held_by         VARCHAR(64),
  hold_expires_at TIMESTAMPTZ,
  UNIQUE (event_id, code)
);
CREATE TABLE bookings (
  id         BIGSERIAL PRIMARY KEY,
  seat_id    BIGINT      NOT NULL REFERENCES seats(id),
  user_id    VARCHAR(64) NOT NULL,
  status     VARCHAR(16) NOT NULL,   -- HELD (M1), M2 mở rộng
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

3.2. API

MethodPathBodyResponse
POST/api/bookings/holds{"eventId":1,"seatCode":"A12"} (user lấy từ header X-User-Id tạm thời, M10 thay bằng JWT)201 {"bookingId":..,"seatCode":"A12","status":"HELD"}
409 {"error":"SEAT_TAKEN"}
GET/api/bookings/seats?eventId=1—200 danh sách ghế + status
POST/internal/test/reset?eventId=1—204, chỉ bật ở profile test/bench

3.3. Interface + strategy switch (chỉ chữ ký, bạn tự cài):

public interface SeatHoldStrategy {
  /** @return bookingId nếu giữ được; ném SeatTakenException nếu ghế đã bị giữ */
  long hold(long eventId, String seatCode, String userId);
  String name();
}

// 5 bean:
class SynchronizedStrategy      implements SeatHoldStrategy { }  // synchronized + read-check-write
class AtomicUpdateStrategy      implements SeatHoldStrategy { }  // UPDATE ... WHERE status='AVAILABLE'
class PessimisticLockStrategy   implements SeatHoldStrategy { }  // SELECT ... FOR UPDATE
class OptimisticVersionStrategy implements SeatHoldStrategy { }  // @Version + retry?
class RedisSetNxStrategy        implements SeatHoldStrategy { }  // SET lock:seat:{id} token NX PX

@Component
class SeatHoldStrategyResolver {
  // chọn theo booking.hold.strategy=synchronized|atomic|for-update|version|redis
  SeatHoldStrategy current() { /* TODO */ }
}
# application.yml
booking:
  hold:
    strategy: ${HOLD_STRATEGY:atomic}
    redis-lock-ttl: 5s
    optimistic-max-retries: 0   # tự quyết định, ghi ADR

Câu hỏi thiết kế bạn phải tự trả lời trong ADR: với Redis SET NX, sau khi có lock bạn vẫn ghi DB thế nào? Lock Redis hết hạn giữa chừng thì sao? Có cần thêm điều kiện ở DB làm hàng rào cuối không?

4. Acceptance criteria

  1. Với mỗi strategy ≠ synchronized: 1000 request đồng thời vào 1 ghế → đúng 1 response 201, 999 response 409, DB có đúng 1 booking cho ghế đó.
  2. Không có response 500 nào (lock conflict phải map về 409).
  3. synchronized đúng khi chạy 1 instance, nhưng sai (≥ 2 booking cho 1 ghế) khi chạy 2 instance sau load balancer — có log/ảnh chụp chứng minh.
  4. Đổi strategy chỉ bằng biến env, không sửa code.
  5. Có bảng benchmark 5 strategy × 2 kịch bản (1 ghế nóng, 1000 ghế khác nhau).
  6. Redis strategy: lock luôn được nhả bằng token (không xóa lock của người khác).

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
M1-T1Giữ ghế trống1 request201, seat HELD, held_by = user
M1-T2Ghế đã giữrequest thứ 2 tuần tự409 SEAT_TAKEN
M1-T31000 đồng thời, 1 ghếparameterized theo 4 strategy phân tán1 thành công, 999 conflict, 1 booking
M1-T41000 đồng thời, 1000 ghếmỗi request ghế khác1000 thành công, không deadlock
M1-T5synchronized, 2 instance2 app context/2 container cùng DB, k6 bắn qua gatewayChứng minh > 1 booking
M1-T6Ghế không tồn tạiseatCode=Z99404
M1-T7Redis lock hết hạn trước khi ghi DBTTL 50ms + sleep giả lập GC pauseHàng rào DB vẫn chặn được trùng
@SpringBootTest
@Testcontainers
class SeatHoldConcurrencyIT {
  @Container @ServiceConnection static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
  @Container @ServiceConnection static GenericContainer<?> redis =
      new GenericContainer<>("redis:7").withExposedPorts(6379);

  @Autowired Map<String, SeatHoldStrategy> strategies;

  @ParameterizedTest
  @ValueSource(strings = {"atomic", "for-update", "version", "redis"})
  void onlyOneWinsOnHotSeat(String name) throws Exception {
    int n = 1000;
    var start = new CountDownLatch(1);
    var ok = new AtomicInteger(); var conflict = new AtomicInteger();
    try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
      // TODO: submit n task, mỗi task await start rồi gọi hold(...)
      // TODO: đếm ok / conflict, mọi exception khác làm test fail
      start.countDown();
    }
    // TODO: assert ok == 1, conflict == n-1, đếm bookings trong DB == 1
  }
}
// tests/k6/m1-hot-seat.js
import http from 'k6/http';
export const options = { scenarios: { burst: {
  executor: 'shared-iterations', vus: 200, iterations: 1000 } } };
export default function () {
  // TODO: POST /api/bookings/holds, X-User-Id = `u${__VU}-${__ITER}`
  // TODO: check status 201 hoặc 409; tag theo status để đếm
}

6. Failure scenarios

Sự cốCách tạoHành vi mong đợi
Chạy 2 instance với synchronizeddocker compose up --scale booking-service=2, k6 qua gatewayOversell xảy ra — đây là kết quả mong muốn để chứng minh
DB chậm khi giữ FOR UPDATEToxiproxy latency 500ms trên booking → postgresPool cạn? Ghi lại p99 và số connection chờ; có lock timeout, không treo vô hạn
Redis chếtdocker compose stop redis khi strategy=redisTrả 503 rõ ràng, không fallback âm thầm sang không lock
App chết khi đang giữ Redis lockdocker kill giữa chừngLock tự hết hạn theo TTL, ghế không kẹt vĩnh viễn

7. Benchmark cần thu

Lưu ở docs/benchmarks/m1-seat-lock.md. Ghi rõ cấu hình máy, pool size, số VU.

StrategyĐúng? (1 inst / 2 inst)Hot seat: p50 / p99Hot seat: throughput1000 ghế: throughputGhi chú (retry, lỗi, CPU DB)
synchronized? / ?
atomic UPDATE
FOR UPDATE
@Version
Redis SET NX

Không đoán trước. Đo xong mới viết kết luận, và giải thích được vì sao mỗi số như vậy.

8. Checklist hoàn thành

Test synchronized với 2 instance thế nào? Integration test chỉ có một Spring context mà.

Đúng, một context là một JVM nên synchronized sẽ "đúng" trong test. Gợi ý hai hướng, chọn một:

Nếu 1000 request mà vẫn ra 1 booking, đừng vội kết luận. Kiểm tra cửa sổ giữa read và write có đủ rộng không, request có thực sự song song không.

Với @Version, 999 request bị OptimisticLockException. Có nên retry không?

Câu hỏi hay. Gợi ý cách suy nghĩ:

Quyết định nào cũng được, miễn ghi ADR và có số liệu.

Atomic UPDATE chạy nhanh nhất mà đơn giản nhất. Vậy cần FOR UPDATE làm gì?

Bạn hiểu gần đúng. Atomic UPDATE tốt khi điều kiện nằm gọn trong một câu SQL trên một dòng. Gợi ý tìm trường hợp nó không đủ: giữ 4 ghế liền nhau trong một lần, hoặc phải đọc giới hạn "mỗi user tối đa 6 ghế" ở bảng khác rồi mới quyết định. Khi đó bạn cần khóa nhiều dòng theo thứ tự cố định — và nghĩ xem không theo thứ tự thì chuyện gì xảy ra.

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. Làm sao chống bán trùng ghế?

🇻🇳 Tiếng Việt:

Em gộp check và update thành một câu: UPDATE seats SET status='HELD' WHERE id=? AND status='AVAILABLE', kiểm tra số dòng bị ảnh hưởng. DB đảm bảo chỉ một transaction thắng. Em đã đo 5 cách, cách này đơn giản và nhanh nhất cho một ghế.

🇬🇧 English:

I merge the check and the write into one conditional UPDATE and check the affected row count. The database guarantees a single winner. I benchmarked five approaches and this was the simplest and fastest for single-seat holds.

2. Vì sao synchronized không dùng được?

🇻🇳 Tiếng Việt:

Monitor chỉ tồn tại trong một JVM. Scale ra 2 instance là có 2 lock riêng, em đã chứng minh có booking trùng. Lock phải nằm ở nơi chung: DB hoặc Redis.

🇬🇧 English:

A monitor lives inside one JVM. With two instances there are two independent locks, and I reproduced double bookings. The lock must live in shared state: the database or Redis.

3. Optimistic hay pessimistic?

🇻🇳 Tiếng Việt:

Xung đột ít thì optimistic, không giữ lock lâu. Xung đột nhiều như hot seat thì optimistic sinh nhiều exception và retry vô ích; pessimistic hoặc atomic update hợp hơn.

🇬🇧 English:

Optimistic fits low contention since nothing is locked. Under high contention like a hot seat it produces many failures and useless retries, so a pessimistic lock or atomic update is better.

4. Redis lock có an toàn tuyệt đối không?

🇻🇳 Tiếng Việt:

Không. GC pause dài hơn TTL thì hai client cùng nghĩ mình giữ lock. Vì vậy em vẫn giữ điều kiện status ở DB làm hàng rào cuối.

🇬🇧 English:

No. A pause longer than the TTL lets two clients believe they hold the lock. So I keep the status condition in the database as the final fence.

Cách nhớ ngắn gọn


Tự kiểm tra

Atomic UPDATE chống trùng nhờ đâu?

Điều kiện status='AVAILABLE' được kiểm tra khi giữ row lock; transaction thứ hai đợi rồi thấy điều kiện sai, cập nhật 0 dòng.

Vì sao test concurrency không nên đánh @Transactional?

Mọi thao tác chạy trong một transaction của thread test, không phản ánh commit thật và có thể che race condition.

Nhả Redis lock sai cách gây gì?

Xóa nhầm lock của client khác (khi lock của mình đã hết hạn). Phải so token rồi xóa trong một script Lua.

Giữ nhiều ghế bằng FOR UPDATE cần chú ý gì?

Khóa theo thứ tự cố định (ví dụ theo id) để tránh deadlock, và đặt lock timeout.