Nội dung bài viết
Video học lập trình mỗi ngày
👉👉 Source: Tại đây
👉👉 Guide step by step: Tại đây
xxxx.com — Flash Sale Ticket System
Project thực hành xây dựng hệ thống đặt vé flash sale chịu tải cao theo DDD. Bài toán chính là bán vé sự kiện — stock giới hạn, nhiều người đặt cùng lúc, không được oversell, server không được sập.

Vì sao dự án này tồn tại?
Vì sao dự án này tồn tại?
Anh em thử nhớ lại lần gần nhất canh giờ vàng Shopee xem, kiểu 9.9, 10.10, 11.11, 12.12 hay mấy phiên live săn deal 1k đó. Đúng giờ, bấm nút, xong hết hàng. Chưa đầy vài giây. Quen không?
Đằng sau cái cảm giác "chớp mắt là hết hàng" đó là một bài toán kỹ thuật không nhẹ nhàng chút nào: hàng nghìn, hàng chục nghìn request đổ vào cùng một giây, tranh nhau một mớ tồn kho ít ỏi, mà hệ thống thì không được sập, cũng không được bán vượt kho (oversell). Nói cho gọn thì seckill hay flash sale gì đó, thực ra chỉ là bài toán xử lý đồng thời (high concurrency) được đóng gói lại cho dễ hình dung thôi.
Cái hay là dự án của anh em có làm seckill hay không thì năng lực xử lý đồng thời vẫn phải có. Vì sớm muộn gì cũng đụng thôi, traffic tăng đột biến, chạy campaign marketing, hoặc tự dưng một hôm app lên trend. Học sớm được ngày nào hay ngày đó, đừng để tới lúc production sập mới ngồi học.
Tài liệu về seckill trên mạng thì không thiếu, nhưng đọc xong vẫn chưa biết bắt tay code kiểu gì. Đa phần chỉ dừng ở lý thuyết, thiếu chỗ để "sờ" vào. Nên dự án này làm theo hướng ngược lại, code thẳng một hệ thống đặt vé flash sale từ số 0, đầy đủ DDD, Redis, Kafka, Outbox, SAGA, Idempotency, rate limit, circuit breaker, có cả benchmark ra số liệu hẳn hoi. Vừa đọc code vừa vỡ ra kiểu "à, thì ra pattern này sinh ra để giải quyết đúng cái này".
Đọc xong anh em có được gì?
- Tư duy thiết kế kiến trúc seckill/flash sale từ đầu tới cuối, không phải nghe cho vui
- Cache 2 tầng: local (Guava Cache) kết hợp phân tán (Redis), dùng cái nào lúc nào và vì sao phải kết hợp cả hai
- Đặt hàng kiểu đồng bộ (CAS trên DB) khi tranh chấp cao, biết luôn giới hạn của cách này
- Đặt hàng bất đồng bộ qua Kafka, xử lý theo event, không bắt người dùng ngồi chờ DB
- Chống bán vượt kho bằng distributed lock (Redisson), cộng ý tưởng chia nhỏ kho theo bucket để giảm tranh chấp
- Rate limiting, circuit breaker (Resilience4j) để chặn từ xa trước khi cả hệ thống sập theo dây chuyền
- Benchmark/load test ra số thật (k6, JMeter) chứ không phải kiểu nói suông "hệ thống chịu tải cao"
- Mấy pattern mà thiếu là dính bug ngay trong hệ phân tán: Outbox, Idempotency Key, SAGA compensating transaction, Order Queue polling
- DDD áp dụng thực chiến, domain sạch, không phụ thuộc ngược vào infrastructure
- Quan sát hệ thống bằng Prometheus, Grafana, ELK, Actuator, code xong phải biết soi nó chạy ra sao
Vậy ai nên đọc?
- Sinh viên/fresher đã có tí nền Java, Spring, giờ muốn hiểu kiến trúc seckill cho nhanh, khỏi mò mẫm
- Backend dev muốn lên tay về thiết kế hệ thống high concurrency
- Anh em đang ôn phỏng vấn, vì system design về flash sale/seckill là câu hỏi kinh điển, hỏi hoài không chán
- Ai tò mò Shopee, Lazada, Tiki xử lý kiểu gì mà hàng chục nghìn request/giây vẫn không sập, không bán lụt kho Architecture

2. DDD Module Layers

xxxx-domainkhông phụ thuộc layer nào khác — infrastructure implement interface của domain, không phải ngược lại.
3. Order Flow (MQ Path)

Module structure
xxxx.com
├── xxxx-start # Spring Boot entry point, application.yml
├── xxxx-controller # REST controllers, DTOs, response wrapper
├── xxxx-application # Use cases, cron jobs, app services
├── xxxx-domain # Entities, domain services, repository interfaces
├── xxxx-infrastructure # JPA, Redis, Kafka, Redisson implementations
└── xxxx.fe.com # Frontend — React + Vite
Tech Stack
| Java 21 | Virtual Threads bật sẵn |
| Spring Boot 3.3.5 | |
| MySQL 8 | HikariCP pool size 100 |
| Redis | Lettuce, dùng cho stock cache + distributed lock (Redisson) |
| Kafka 3.7 | KRaft mode — không cần Zookeeper |
| Guava Cache | Local cache L1 |
| Resilience4j | Circuit Breaker + Rate Limiter |
| Prometheus + Grafana | Metrics |
| ELK | Log aggregation |
| k6 + JMeter | Load testing |
How to run?
Yêu cầu: Java 21, Maven 3.8+, Docker Desktop, Node 18+
1. Khởi động infrastructure
cd environment
# MySQL, Redis, Prometheus, Grafana, ELK
docker compose -f docker-compose-dev.yml up -d
# Kafka + Kafka UI
docker compose -f docker-compose-kafka.yml up -d
| Service | URL / Port |
|---|---|
| MySQL | localhost:3316 — db vetautet, pass root1234 |
| Redis | localhost:6319 |
| Kafka | localhost:9094 |
| Kafka UI | http://localhost:8989 |
| Prometheus | http://localhost:9090 |
| Grafana | http://localhost:3000 — admin/admin |
| Kibana | http://localhost:5601 |
2. Build & chạy backend
mvn clean package -DskipTests
java -jar xxxx-start/target/xxxx-start-1.0-SNAPSHOT.jar
App chạy tại http://localhost:1122. Check health: curl localhost:1122/actuator/health
3. Frontend
cd xxxx.fe.com && npm install && npm run dev
# → http://localhost:5173
Section SAGA | Outbox | Order Queue | Idempotency
4 pattern giải quyết các failure case của luồng đặt vé async.
Luồng tổng quan
POST /order/mq
│ generate token = "TOKEN_TICKET_USER_{userId}_{ticketId}"
│ pre-check stock Redis (DECRBY)
│
▼
[DB Transaction]
INSERT order_queue (token, status=PENDING)
INSERT outbox_event (token, payload, status=PENDING)
COMMIT
│
│ → trả token về cho client ngay, không chờ Kafka
│
▼
[OutboxPublisherJob — cron 1s]
SELECT outbox_event WHERE status=PENDING LIMIT 500
→ send Kafka → await broker ACK
→ UPDATE status=PUBLISHED
│
▼
[KafkaOrderConsumer — concurrency=10]
INSERT IGNORE idempotency_key(token) ← nếu affected=0 thì skip
UPDATE ticket_detail SET stock=stock-qty WHERE stock>=qty
INSERT ticker_order
UPDATE order_queue SET status=1 (hoặc 2 nếu hết vé)
COMMIT
│
▼
GET /order/queue/{token} → client polling để lấy kết quả
Outbox Pattern
Vấn đề cần giải quyết: ghi DB xong, app crash trước khi gửi Kafka → message mất, đơn không được xử lý.
Cách fix: thay vì gửi Kafka trực tiếp trong request, ghi message vào bảng outbox_event trong cùng transaction với business data. Một cron job chạy riêng sẽ đọc và gửi Kafka sau.
OutboxPublisherJob (fixedDelay=1s)
├── publishRowByRow() ← đang dùng
│ gửi từng row, nhận ACK, update PUBLISHED ngay
│ failure window nhỏ — nếu crash chỉ 1 row bị retry
│
└── publishBatch() ← option thay thế nếu cần throughput cao
gửi async hàng loạt, bulk UPDATE một lần
cần consumer idempotent chặt hơn vì failure window lớn hơn
File: xxxx-application/.../cronjob/OutboxPublisherJob.java
outbox_event (id, aggregate_id, payload, status, created_at, published_at)
-- PENDING → PUBLISHED
Idempotency Key
Kafka có thể retry hoặc duplicate message — consumer phải xử lý được trường hợp cùng 1 message đến 2 lần mà không tạo ra 2 đơn hàng.
Cách xử lý: mỗi message có token duy nhất, consumer INSERT IGNORE token đó vào idempotency_key trong cùng transaction với toàn bộ business logic:
boolean isNew = idempotencyKeyRepository.tryInsert(token, expiredAt);
if (!isNew) {
log.info("[IDEMPOTENCY] Duplicate skip token={}", token);
return;
}
// xử lý đơn bình thường...
Tại sao để trong cùng transaction: nếu xử lý fail và rollback, row idempotency cũng rollback → Kafka retry sẽ insert được lại → xử lý lại đúng. Nếu không có cùng transaction thì trường hợp này sẽ bị bỏ qua nhầm.
idempotency_key (token, expired_at)
-- UNIQUE KEY (token), INSERT IGNORE để atomic check+insert
Order Queue
Vì đặt vé async, client cần biết kết quả mà không cần hold connection.
order_queue lưu trạng thái theo token, client polling sau khi nhận token:
status 0 = đang xử lý
status 1 = thành công (kèm order_number)
status 2 = thất bại (kèm message lý do)
GET /order/queue/{token}
→ { status: 1, orderNumber: "MQ-123-1718000000000" }
SAGA — compensating transaction
Luồng MQ pre-deduct stock Redis trước khi gửi Kafka (để reject sớm khi hết vé). Nhưng nếu consumer xử lý xong thì DB không đủ stock (race condition giữa nhiều consumer), cần hoàn lại Redis:
boolean stockDecreased = tickerOrderDomainService.decreaseStockLevel1(ticketId, quantity);
if (!stockDecreased) {
stockOrderCacheService.increaseStockCache(ticketId, quantity); // hoàn Redis
orderQueueRepository.updateStatus(token, 2, null, "Hết vé");
return;
}
API
# Ticket
GET /ticket/{id}
POST /ticket
PUT /ticket/{id}
POST /ticket/detail
# Order
POST /order/cas sync, dùng CAS trên DB
POST /order/mq async qua Kafka, dùng cho flash sale thật
GET /order/queue/{token}
GET /order/list
# Payment
POST /payment/vnpay
GET /payment/vnpay/callback
# Booking
POST /booking
GET /booking/{id}
Benchmark
k6
# chạy mặc định — CAS, 2000 requests, stock 1000
k6 run benchmark/k6/flash-sale.js
# so sánh với MQ
k6 run benchmark/k6/flash-sale.js -e ENDPOINT=/order/mq
# tuỳ chỉnh
k6 run benchmark/k6/flash-sale.js \
-e TICKET_ID=23 \
-e STOCK=1000 \
-e TOTAL_USERS=2000 \
-e VUS=500
Output:
╔══════════════════════════════════════════════════════════╗
║ FLASH SALE BENCHMARK — RESULT ║
╠══════════════════════════════════════════════════════════╣
║ ✅ Đặt thành công : 1000 ║
║ 🚫 Hết vé : 1000 ║
║ Throughput (RPS) : 1234.5 ║
║ Latency p95 (ms) : 420 ║
║ Oversell Check : ✅ OK (1000 ≤ 1000) ║
╚══════════════════════════════════════════════════════════╝
JMeter
benchmark/jmeter/POST Order CAS.jmx
benchmark/jmeter/HTTP Request level 0.jmx
Monitoring
| Grafana | http://localhost:3000 |
| Prometheus | http://localhost:9090 |
| Kibana | http://localhost:5601 |
| Kafka UI | http://localhost:8989 |
| Actuator | http://localhost:1122/actuator |
http.server.requests — track p50/p95/p99, SLO buckets 100ms/500ms/1s/2s/5s hikaricp.connections.acquire — DB pool pressure Circuit Breaker state — /actuator/health
Config nhanh (application.yml)
server:
port: 1122
tomcat:
accept-count: 2000
max-connections: 10000
spring:
threads:
virtual:
enabled: true # Java 21
datasource:
hikari:
maximum-pool-size: 100
kafka:
bootstrap-servers: localhost:9094
consumer:
group-id: order-consumer-group
resilience4j:
circuitbreaker:
instances:
checkRandom:
slidingWindowSize: 10
failureRateThreshold: 50
waitDurationInOpenState: 5s
ratelimiter:
instances:
backendA:
limitForPeriod: 2
limitRefreshPeriod: 10s
👉👉 Source: Tại đây
👉👉 Guide step by step: Tại đây

