Thiết kế hệ thống đặt vé concert: hàng đợi ảo cho hàng triệu người
1. Tổng quan
Một concert lớn mở bán lúc 10:00. Chỉ vài giây sau đó, hàng triệu người cùng bấm “Mua vé” để tranh nhau khoảng 50.000 ghế. Nếu thả toàn bộ lượng request này vào database, hệ thống sẽ sập. Nếu chặn sai cách, hai người có thể mua trùng một ghế.
Bài này đi qua từng bước thiết kế: yêu cầu, mô hình dữ liệu, API và luồng xử lý. Trọng tâm là ba điểm khó: tải đột biến, tranh chấp ghế và tính nhất quán khi thanh toán. Đọc xong, bạn sẽ dựng được một hàng đợi ảo bằng Redis, một cơ chế giữ ghế không bị bán trùng và một luồng xác nhận đơn an toàn khi bị gọi lặp.
2. Yêu cầu bài toán
2.1. Yêu cầu chức năng
- Người dùng vào trang sự kiện và được xếp vào hàng đợi ảo trước giờ mở bán.
- Hệ thống cho người dùng vào khu chọn ghế theo từng đợt, theo thứ tự đến trước.
- Người dùng chọn tối đa 4 ghế. Ghế được giữ (hold) trong 5 phút để thanh toán.
- Thanh toán thành công thì ghế chuyển sang trạng thái đã bán và vé được phát hành.
- Quá hạn mà chưa thanh toán thì ghế được trả về kho.
2.2. Quy mô và ràng buộc
| Chỉ số | Giá trị ước lượng |
|---|---|
| Người dùng đồng thời lúc mở bán | 2.000.000 |
| Số ghế | 50.000 |
| Request đỉnh vào trang sự kiện | ~500.000 RPS |
| Số người được ở khu chọn ghế cùng lúc | 5.000 |
Ràng buộc quan trọng nhất là không bao giờ bán trùng ghế. Tính nhất quán ở đây phải là strong consistency. Ngược lại, vị trí trong hàng đợi chỉ cần gần đúng: hiển thị “còn khoảng 12.000 người phía trước” là đủ.
Từ hai con số trên có thể rút ra nhận xét cốt lõi: chỉ 2,5% người dùng có vé. Vì vậy hệ thống phải từ chối hoặc trì hoãn phần lớn request ở tầng rẻ nhất có thể, trước khi chúng chạm tới database.
3. Kiến trúc và luồng xử lý
Client ──> CDN (trang tĩnh, trang chờ)
│
├─> Queue Service ──> Redis (ZSET hàng đợi, access token)
│ ▲
│ └── Admitter (scheduled, thả N người/giây)
│
└─> Booking Service (yêu cầu access token)
├─> PostgreSQL (seat, order, outbox)
└─> Kafka (ticket-issued) ──> Ticket/Email Service
Luồng chính diễn ra như sau:
- Client gọi
POST /events/{id}/queuevà nhận vềqueueId. - Client poll
GET /events/{id}/queue/{queueId}vài giây một lần để xem vị trí. Khi tới lượt, client nhận đượcaccessToken. - Client mang token gọi
POST /events/{id}/holdsvới danh sách ghế. - Client thanh toán. Payment provider gọi webhook về, hệ thống gọi
POST /orders/{id}/confirmkèm idempotency key. - Booking Service ghi đơn và sự kiện outbox trong cùng một transaction. Sau đó sự kiện được đẩy sang Kafka để phát hành vé.
Trang chờ được phục vụ tĩnh qua CDN. Chỉ có API join và poll đi vào backend, và cả hai chỉ chạm tới Redis.
4. Mô hình dữ liệu
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
Trạng thái ghế nằm ngay trên bảng seat. Nhờ vậy, việc giữ ghế chỉ cần một câu UPDATE có điều kiện. Ràng buộc UNIQUE trên idempotency_key là chốt chặn cuối cùng chống ghi đơn trùng.
5. Hàng đợi ảo với Redis
Thêm dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Vào hàng đợi
Hàng đợi là một Redis Sorted Set. Score là số thứ tự tăng dần, lấy từ INCR:
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
Giải thích:
incrementtrả về số thứ tự toàn cục. Bạn không dùng timestamp làm score vì nhiều request có thể trùng cùng một mili-giây.addIfAbsenttương ứng vớiZADD NX. Người dùng bấm F5 liên tục cũng không bị đẩy xuống cuối hàng.rank(ZRANK) có độ phức tạp O(log N). Với 2 triệu phần tử, lệnh này vẫn chỉ tốn vài micro-giây.
Ba người lần lượt vào hàng sẽ nhận kết quả như sau:
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 (vào lại, giữ nguyên vị trí)
Lưu ý: Nếu cần chặn bot, hãy xác thực người dùng (đăng nhập, captcha) trước khi cho join. Khi đã vào ZSET, bot cũng giữ một chỗ trong hàng y như người thật.
5.2. Admitter: thả người theo đợt
Một job chạy mỗi giây. Job này kiểm tra khu chọn ghế còn bao nhiêu chỗ, rồi lấy đúng chừng đó người từ đầu hàng:
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
Giải thích:
active:{eventId}là một ZSET có score bằng thời điểm hết hạn.removeRangeByScoredọn những người đã hết phiên, sau đózCardcho biết số người đang ở trong khu chọn ghế.popMin(ZPOPMIN) lấy và xóa nguyên tử N người đứng đầu hàng.- Khi poll, client đọc
token-of:.... Nếu key tồn tại nghĩa là đã tới lượt, và client nhận token.
Bạn cần bật @EnableScheduling và chạy job này trên đúng một instance, ví dụ dùng ShedLock hoặc leader election. Nếu hai instance cùng chạy, khu chọn ghế có thể vượt MAX_ACTIVE.
Bạn cũng không cần chạy nhiều Admitter để xử lý 2 triệu người. Tốc độ thả người được giới hạn bởi sức chứa của Booking Service, không phải bởi Redis.
5.3. Chặn request không có token
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Not admitted");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
Filter này dùng token làm khóa tra cứu, không tin customerId do client gửi lên. Người dùng không thể chen hàng bằng cách tự gọi thẳng API /holds.
6. Giữ ghế: xử lý tranh chấp
Nhiều người có thể cùng chọn ghế A-12. Cách đơn giản và đúng nhất là để database tự quyết định bằng một câu UPDATE có điều kiện:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Max 4 seats");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Seat taken: " + seatId); }
}
Giải thích:
- Mệnh đề
WHEREvừa kiểm tra vừa cập nhật trong một lệnh nguyên tử. Database lock dòng đó. Request đến sau sẽ chờ, sau đó đánh giá lại điều kiện và thấystatus = 'HELD', nên nhậnupdated = 0. - Điều kiện
hold_expires_at < ?cho phép lấy lại ghế đã quá hạn giữ mà không cần đợi job dọn dẹp. - Nếu một ghế trong nhóm thất bại, exception làm rollback toàn bộ transaction. Người dùng không bao giờ bị giữ “nửa nhóm” ghế.
- Việc sắp xếp
seatIdstrước khi update giúp mọi transaction lock theo cùng một thứ tự, tránh deadlock khi hai người chọn chéo nhau, chẳng hạn {A-1, A-2} và {A-2, A-1}.
Lưu ý: Đừng làm theo kiểu “SELECT kiểm tra trạng thái rồi mới UPDATE” mà không lock. Đó chính là race condition kinh điển dẫn đến bán trùng ghế.
6.1. Test hành vi chống bán trùng
Ta cho 50 thread cùng giành một ghế. Kết quả mong đợi là chỉ đúng một thread thành công.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
Bảng seat được tạo từ src/test/resources/schema.sql. Bạn dùng đúng câu CREATE TABLE seat ở mục 4, đổi BIGSERIAL thành BIGINT AUTO_INCREMENT nếu tạo thêm các bảng còn lại. @Transactional(NOT_SUPPORTED) tắt transaction bao quanh test, để dữ liệu được commit và các thread khác nhìn thấy.
7. Thanh toán và tính nhất quán
Webhook của payment provider có thể bị gọi nhiều lần. Vì thế bước xác nhận đơn phải idempotent:
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // đã xử lý trước đó
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("Hold expired, refund required");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
Đoạn code này xử lý ba việc:
ON CONFLICT DO NOTHING(cú pháp PostgreSQL) bảo đảm webhook gọi lặp không tạo hai đơn.- Ghế chỉ chuyển sang
SOLDkhi vẫn đang được chính khách hàng này giữ. Nếu hold đã hết hạn và ghế đã thuộc về người khác, transaction rollback. Khi đó đơn cần được đưa sang luồng hoàn tiền. - Transactional outbox: sự kiện được ghi cùng transaction với đơn hàng. Một tiến trình riêng (hoặc Debezium) đọc bảng
outboxrồi đẩy sang Kafka. Nhờ vậy không có trường hợp đơn đã lưu mà vé không được phát hành, hoặc ngược lại.
Lưu ý: Đặt TTL của hold dài hơn timeout của trang thanh toán một chút, ví dụ hold 5 phút và trang thanh toán 4 phút. Khoảng đệm này giảm số ca khách đã trả tiền nhưng hold vừa hết hạn.
8. Các điểm khó và lỗi hay gặp
- Tải đột biến ở tầng đầu: trang chờ phải là trang tĩnh trên CDN. API poll nên trả kèm header gợi ý chu kỳ poll, ví dụ 5–15 giây tùy vị trí. Nếu client poll mỗi giây, 2 triệu người sẽ tạo ra 2 triệu RPS vào Redis.
- Redis là điểm lỗi đơn: dùng Redis Cluster hoặc Sentinel. Nếu chỉ có một sự kiện lớn, toàn bộ ZSET nằm trên một key, nên sẽ nằm trên một shard. Một node Redis vẫn chịu được hàng trăm nghìn ops/s, nhưng bạn cần đo trước.
- Hiển thị “còn ghế” không chính xác: số ghế trống trên giao diện có thể đọc từ cache và chấp nhận lệch vài giây. Chỉ câu UPDATE ở mục 6 mới là nguồn sự thật.
- Hold rác: người dùng giữ ghế rồi đóng tab. Điều kiện hết hạn trong UPDATE đã xử lý việc lấy lại ghế. Bạn chỉ cần thêm một job định kỳ trả các ghế quá hạn về
AVAILABLEđể giao diện hiển thị đúng. - Khi KHÔNG cần hàng đợi ảo: với sự kiện vài trăm ghế và lượng truy cập vừa phải, rate limiting cộng với UPDATE có điều kiện là đủ. Hàng đợi ảo thêm độ phức tạp vận hành và làm trải nghiệm chậm hơn. Chỉ nên dùng khi lượng người muốn mua vượt xa sức chứa của tầng booking.
9. Kết luận
Hệ thống bán vé chịu tải tốt nhờ tách hai mối quan tâm. Hàng đợi ảo trên Redis hấp thụ tải đột biến và giữ công bằng. Database chỉ xử lý số ít người đã được thả vào và bảo đảm không bán trùng bằng UPDATE có điều kiện.
Bạn đã thấy cách dùng ZADD NX và ZPOPMIN để xếp hàng và thả người theo đợt, cách giữ ghế nguyên tử và tránh deadlock, cùng cách xác nhận đơn idempotent kết hợp transactional outbox để đẩy sự kiện sang Kafka. Từ khung này, bạn có thể mở rộng thêm chống bot, hàng đợi ưu tiên cho hội viên hoặc nhiều sự kiện chạy song song.