M1 · Chống bán trùng ghế
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
- Race condition — check-then-act, vì sao test tuần tự không bắt được.
- Compound action — atomic cho từng bước chưa đủ.
- synchronized — monitor thuộc về một JVM.
- DB locks — row lock,
FOR UPDATE,SKIP LOCKED, deadlock. - ACID & isolation — READ COMMITTED có ngăn lost update không.
- JPA locking —
@Version,@Lock(PESSIMISTIC_WRITE),OptimisticLockException. - @Transactional pitfalls — lock trước hay sau khi transaction mở.
- Redis distributed lock —
SET NX PX, token, xóa an toàn bằng Lua. - Test concurrency —
CountDownLatch, executor, không dùng@Transactionaltrên test.
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
| Method | Path | Body | Response |
|---|---|---|---|
| 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
- 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ế đó.
- Không có response 500 nào (lock conflict phải map về 409).
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.- Đổi strategy chỉ bằng biến env, không sửa code.
- Có bảng benchmark 5 strategy × 2 kịch bản (1 ghế nóng, 1000 ghế khác nhau).
- Redis strategy: lock luôn được nhả bằng token (không xóa lock của người khác).
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| M1-T1 | Giữ ghế trống | 1 request | 201, seat HELD, held_by = user |
| M1-T2 | Ghế đã giữ | request thứ 2 tuần tự | 409 SEAT_TAKEN |
| M1-T3 | 1000 đồng thời, 1 ghế | parameterized theo 4 strategy phân tán | 1 thành công, 999 conflict, 1 booking |
| M1-T4 | 1000 đồng thời, 1000 ghế | mỗi request ghế khác | 1000 thành công, không deadlock |
| M1-T5 | synchronized, 2 instance | 2 app context/2 container cùng DB, k6 bắn qua gateway | Chứng minh > 1 booking |
| M1-T6 | Ghế không tồn tại | seatCode=Z99 | 404 |
| M1-T7 | Redis lock hết hạn trước khi ghi DB | TTL 50ms + sleep giả lập GC pause | Hà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ạo | Hành vi mong đợi |
|---|---|---|
| Chạy 2 instance với synchronized | docker compose up --scale booking-service=2, k6 qua gateway | Oversell xảy ra — đây là kết quả mong muốn để chứng minh |
DB chậm khi giữ FOR UPDATE | Toxiproxy latency 500ms trên booking → postgres | Pool cạn? Ghi lại p99 và số connection chờ; có lock timeout, không treo vô hạn |
| Redis chết | docker compose stop redis khi strategy=redis | Trả 503 rõ ràng, không fallback âm thầm sang không lock |
| App chết khi đang giữ Redis lock | docker kill giữa chừng | Lock 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 / p99 | Hot seat: throughput | 1000 ghế: throughput | Ghi 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
- ☐ Acceptance criteria có test tự động và xanh
- ☐ Failure scenario đã chạy thật, có ghi kết quả
- ☐
docs/benchmarks/m1-seat-lock.mdđủ 5 dòng - ☐ ADR: chọn strategy mặc định cho production và lý do
- ☐ Không hardcode secret
- ☐ Bằng chứng synchronized hỏng ở 2 instance (log, số booking)
- ☐ Lỗi lock map về 409, không 500
- ☐ Đã qua review + phỏng vấn của AI
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:
- Ngoài JUnit: build image,
--scale booking-service=2, gateway load balance, k6 bắn. Đếm booking bằng SQL. Đơn giản và giống thật. - Trong JUnit: tạo 2 instance của strategy, mỗi cái giữ một monitor riêng (giống hai JVM), chia request cho hai bên. Nghĩ xem lock object của bạn là
this,statichay map theo seat — cách nào mô phỏng đúng 2 JVM?
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ĩ:
- Retry để làm gì? Lần sau đọc lại, ghế đã HELD, kết quả vẫn là 409. Với hot seat, retry chỉ thêm tải.
- Retry có ích khi xung đột là ở field khác, hoặc nhiều ghế trong một entity. Bài của bạn có trường hợp đó không?
- Bắt exception ở đâu? Nó ném lúc flush/commit, tức là ngoài method của bạn nếu
@Transactionalở service. Xem lại JPA locking.
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
@Transactionalcó bao đúng phạm vi lock không? Lock synchronized có nằm ngoài transaction (nếu không, commit xảy ra sau khi nhả lock)?FOR UPDATEcólock_timeout/query timeout không?- Redis:
SET NX PXmột lệnh, token ngẫu nhiên, xóa bằng Lua so token. - Kiểm tra số dòng UPDATE trả về (
rows == 1) chứ không bỏ qua. - Test concurrency có thật sự song song (latch) và không gắn
@Transactional? - Exception mapping: conflict → 409, không lộ stack trace.
- Benchmark có ghi môi trường đo, số lần chạy?
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
- Check-then-act là gốc của oversell.
- Lock trong JVM không qua được ranh giới instance.
- Một câu UPDATE có điều kiện + đếm số dòng = đơn giản nhất.
- Redis lock là tối ưu, DB là sự thật.
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.