Thiết kế flash sale: bán 10.000 sản phẩm trong 1 giây không bán âm kho
1. Tổng quan
Flash sale là bài toán thiết kế hệ thống kinh điển. Hàng trăm nghìn người cùng bấm “Mua” vào đúng một thời điểm. Kho chỉ có vài nghìn sản phẩm. Hệ thống phải chịu được tải đột biến và tuyệt đối không bán quá số lượng.
Bài này đi qua một case study cụ thể: bán 10.000 sản phẩm trong khoảng 1 giây. Đọc xong, bạn sẽ biết cách nêu yêu cầu, thiết kế mô hình dữ liệu và API, giữ chỗ nguyên tử bằng Redis Lua, ghi đơn bất đồng bộ qua Kafka và hoàn kho khi khách không thanh toán.
2. Yêu cầu
2.1. Yêu cầu chức năng
- Mỗi đợt sale có một hoặc nhiều sản phẩm, mỗi sản phẩm có số lượng cố định và giá sale.
- Người dùng đã đăng nhập bấm “Mua” và nhận kết quả: giữ chỗ thành công, hết hàng hoặc đã mua rồi.
- Mỗi người dùng chỉ được mua 1 sản phẩm mỗi đợt.
- Đơn giữ chỗ phải thanh toán trong 10 phút. Quá hạn thì trả hàng về kho để người khác mua.
2.2. Quy mô
- Kho: 10.000 sản phẩm.
- Người tham gia: khoảng 500.000 người, phần lớn bấm trong 1–2 giây đầu.
- Đỉnh tải ước tính: 200.000–500.000 request/giây vào endpoint mua.
Chỉ 10.000 trong số đó thành công. Hơn 98% request sẽ là “hết hàng”. Đây là chi tiết then chốt: hệ thống phải từ chối rẻ và nhanh.
2.3. Ràng buộc
- Không bán âm kho: số đơn hợp lệ không bao giờ vượt quá 10.000.
- Không bán thiếu quá nhiều: hàng giữ chỗ nhưng không thanh toán phải được hoàn lại.
- Hệ thống bán hàng thường ngày không bị ảnh hưởng khi flash sale diễn ra.
3. Kiến trúc tổng thể
Một database quan hệ đơn lẻ chịu được vài nghìn UPDATE mỗi giây trên cùng một dòng. Con số đó còn kém xa vài trăm nghìn. Vì vậy ta tách luồng thành nhiều tầng, mỗi tầng lọc bớt tải cho tầng sau:
Client ──► CDN (trang sale tĩnh, đếm ngược)
│
▼
API Gateway (rate limit theo user/IP, chặn bot)
│
▼
Flash Sale Service ──► Redis (Lua: kiểm tra + trừ kho nguyên tử)
│ │ thành công (~10.000 lần)
│ ▼
│ Kafka topic "flash-sale-orders"
│ │
▼ ▼
202 Accepted Order Consumer ──► PostgreSQL (nguồn sự thật cuối cùng)
Ý tưởng chính:
- Redis là cửa chặn. Thao tác trừ kho chạy trong bộ nhớ, đơn luồng, nên nguyên tử và nhanh.
- Kafka là bộ đệm. Chỉ khoảng 10.000 message thành công đi vào queue. Consumer ghi DB theo tốc độ của DB.
- PostgreSQL là lưới an toàn. Ràng buộc trong DB bảo đảm không bán âm kho, kể cả khi Redis có sự cố.
4. Mô hình dữ liệu
Trong PostgreSQL, ta cần hai bảng:
CREATE TABLE flash_sale_item (
id BIGINT PRIMARY KEY,
sale_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
sold INT NOT NULL DEFAULT 0,
CONSTRAINT chk_not_oversold CHECK (sold <= quantity)
);
CREATE TABLE flash_sale_order (
id UUID PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES flash_sale_item(id),
user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING, PAID, EXPIRED
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_item_user UNIQUE (item_id, user_id)
);
Hai ràng buộc quan trọng:
chk_not_oversoldchặnsoldvượtquantityở tầng DB.uq_item_uservừa áp quy tắc “mỗi người 1 sản phẩm”, vừa giúp consumer idempotent khi Kafka giao lại message.
Trong Redis, mỗi sản phẩm sale dùng hai key:
{item:1001}:stock -> "10000" (String, số lượng còn lại)
{item:1001}:buyers -> {userId, ...} (Set, những người đã giữ chỗ)
Phần {item:1001} là hash tag. Nhờ nó, hai key luôn nằm trên cùng một slot trong Redis Cluster. Lua script chỉ chạy được khi mọi key nằm cùng slot.
Trước giờ mở bán, một job nạp sẵn tồn kho vào Redis:
redis-cli SET "{item:1001}:stock" 10000
5. API
POST /api/flash-sales/items/{itemId}/purchase
Authorization: Bearer <token>
202 Accepted {"orderId": "c0a8...", "status": "PENDING"}
409 Conflict {"error": "ALREADY_PURCHASED"}
410 Gone {"error": "SOLD_OUT"}
GET /api/flash-sales/orders/{orderId}
200 OK {"orderId": "c0a8...", "status": "PENDING"}
Endpoint mua trả về 202 chứ không phải 201, vì đơn chưa được ghi vào DB ngay. Client giữ orderId và poll endpoint GET cho tới khi đơn sẵn sàng thanh toán. userId lấy từ token, không lấy từ body, để người dùng không thể mua hộ người khác.
6. Giữ chỗ nguyên tử bằng Redis Lua
Thêm dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Sai lầm phổ biến nhất là viết GET rồi DECR thành hai lệnh riêng từ Java. Giữa hai lệnh, hàng trăm thread khác đã kịp đọc cùng giá trị 1 và cùng trừ. Kết quả là kho âm. Lua script giải quyết việc này vì Redis chạy cả script như một khối, không xen lệnh khác vào giữa.
File src/main/resources/lua/reserve.lua:
-- KEYS[1] = stock, KEYS[2] = buyers, ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1
Script trả về -2 nếu người dùng đã mua, -1 nếu hết hàng, còn lại là số lượng còn lại sau khi trừ. Kiểm tra và trừ kho xảy ra trong cùng một bước, nên không có race condition.
Script hoàn kho src/main/resources/lua/release.lua:
-- Chỉ cộng lại kho nếu user thực sự đang giữ chỗ
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
return redis.call('INCR', KEYS[1])
end
return -1
Điều kiện SREM == 1 ngăn việc hoàn kho hai lần cho cùng một người. Đây là lỗi hay gặp khi job hết hạn và luồng huỷ đơn chạy song song.
Service gọi script:
public enum ReserveResult { SUCCESS, SOLD_OUT, ALREADY_PURCHASED }
@Service
public class StockReservationService {
private static final RedisScript<Long> RESERVE =
RedisScript.of(new ClassPathResource("lua/reserve.lua"), Long.class);
private static final RedisScript<Long> RELEASE =
RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);
private final StringRedisTemplate redis;
public StockReservationService(StringRedisTemplate redis) {
this.redis = redis;
}
public ReserveResult reserve(long itemId, long userId) {
Long result = redis.execute(RESERVE, keys(itemId), String.valueOf(userId));
if (result == null) throw new IllegalStateException("Redis returned null");
if (result == -2) return ReserveResult.ALREADY_PURCHASED;
if (result == -1) return ReserveResult.SOLD_OUT;
return ReserveResult.SUCCESS;
}
public void release(long itemId, long userId) {
redis.execute(RELEASE, keys(itemId), String.valueOf(userId));
}
private static List<String> keys(long itemId) {
String tag = "{item:" + itemId + "}";
return List.of(tag + ":stock", tag + ":buyers");
}
}
Spring Data Redis gửi script bằng EVALSHA và tự fallback sang EVAL nếu Redis chưa cache script. Bạn không cần tự quản lý SHA.
7. Ghi đơn bất đồng bộ qua Kafka
Thêm dependency:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
7.1. Controller: giữ chỗ rồi publish event
public record OrderPlaced(UUID orderId, long itemId, long userId) {}
@RestController
@RequestMapping("/api/flash-sales")
public class PurchaseController {
private final StockReservationService reservation;
private final KafkaTemplate<String, OrderPlaced> kafka;
public PurchaseController(StockReservationService reservation,
KafkaTemplate<String, OrderPlaced> kafka) {
this.reservation = reservation;
this.kafka = kafka;
}
@PostMapping("/items/{itemId}/purchase")
public ResponseEntity<Map<String, Object>> purchase(@PathVariable long itemId,
@AuthenticationPrincipal Jwt jwt) {
long userId = Long.parseLong(jwt.getSubject());
return switch (reservation.reserve(itemId, userId)) {
case SOLD_OUT -> ResponseEntity.status(410).body(Map.of("error", "SOLD_OUT"));
case ALREADY_PURCHASED -> ResponseEntity.status(409).body(Map.of("error", "ALREADY_PURCHASED"));
case SUCCESS -> {
var event = new OrderPlaced(UUID.randomUUID(), itemId, userId);
kafka.send("flash-sale-orders", String.valueOf(itemId), event)
.whenComplete((ok, ex) -> {
if (ex != null) reservation.release(itemId, userId);
});
yield ResponseEntity.accepted()
.body(Map.of("orderId", event.orderId(), "status", "PENDING"));
}
};
}
}
Một số điểm cần để ý:
- Request “hết hàng” chỉ tốn một lần gọi Redis rồi trả về. Đây là đường đi của 98% request.
- Nếu gửi Kafka thất bại, ta gọi
releaseđể trả hàng về kho. Không có bước bù trừ này, hàng sẽ bị “giữ ma” mãi mãi. - Key của message là
itemId. Mọi đơn của cùng sản phẩm vào cùng partition, nên được xử lý theo thứ tự.
Producer nên bật acks=all và idempotence để không mất message:
spring.kafka.producer.acks=all
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
7.2. Consumer: DB là lưới an toàn
@Component
public class OrderConsumer {
private final JdbcTemplate jdbc;
public OrderConsumer(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
@KafkaListener(topics = "flash-sale-orders", groupId = "order-writer")
public void handle(OrderPlaced event) {
int inserted = jdbc.update("""
INSERT INTO flash_sale_order (id, item_id, user_id, status)
VALUES (?, ?, ?, 'PENDING')
ON CONFLICT DO NOTHING
""", event.orderId(), event.itemId(), event.userId());
if (inserted == 0) return; // message lặp lại, đã xử lý
int updated = jdbc.update("""
UPDATE flash_sale_item SET sold = sold + 1
WHERE id = ? AND sold < quantity
""", event.itemId());
if (updated == 0) {
throw new IllegalStateException("DB stock exhausted for item " + event.itemId());
}
}
}
ON CONFLICT DO NOTHING làm consumer idempotent. Kafka bảo đảm giao ít nhất một lần, nên cùng một message có thể đến hai lần. Lần thứ hai không chèn được dòng nào và kết thúc sớm.
Câu UPDATE ... WHERE sold < quantity là điều kiện nguyên tử ở tầng DB. Nếu Redis bị lệch (ví dụ ai đó nạp tay sai số), câu lệnh trả về 0. Transaction rollback, đơn không được tạo. Bạn nên cấu hình một error handler đẩy message lỗi sang dead-letter topic và hoàn kho Redis, thay vì retry vô hạn.
Vì consumer chỉ nhận khoảng 10.000 message và ghi tuần tự theo partition, DB chịu tải vài nghìn transaction mỗi giây. Mức này hoàn toàn bình thường.
8. Hết hạn thanh toán và hoàn kho
Một job định kỳ quét đơn quá hạn:
@Component
public class ExpiredOrderJob {
private final JdbcTemplate jdbc;
private final StockReservationService reservation;
public ExpiredOrderJob(JdbcTemplate jdbc, StockReservationService reservation) {
this.jdbc = jdbc;
this.reservation = reservation;
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expire() {
List<long[]> expired = jdbc.query("""
UPDATE flash_sale_order SET status = 'EXPIRED'
WHERE status = 'PENDING' AND created_at < now() - interval '10 minutes'
RETURNING item_id, user_id
""", (rs, i) -> new long[]{rs.getLong(1), rs.getLong(2)});
for (long[] row : expired) {
jdbc.update("UPDATE flash_sale_item SET sold = sold - 1 WHERE id = ?", row[0]);
reservation.release(row[0], row[1]);
}
}
}
UPDATE ... RETURNING chuyển trạng thái và lấy danh sách đơn trong một câu lệnh. Nếu hai instance chạy job cùng lúc, mỗi dòng chỉ được một instance cập nhật, vì điều kiện status = 'PENDING' không còn đúng sau lần đầu.
Lưu ý thứ tự: release Redis nằm trong transaction DB nhưng không thuộc transaction đó. Nếu transaction rollback sau khi đã release, Redis sẽ dư một đơn vị. Trong thực tế, bạn nên tách release ra sau commit, ví dụ bằng TransactionSynchronization.afterCommit. Cũng có thể phát event “OrderExpired” qua Kafka. Bản rút gọn ở đây chỉ để minh hoạ.
9. Kiểm thử: không bao giờ bán âm kho
Hành vi quan trọng nhất cần test là: 1.000 người tranh 100 sản phẩm thì đúng 100 người thành công. Ta dùng Testcontainers để chạy Redis thật.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class StockReservationServiceTest {
@Container
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Test
void whenThousandUsersCompeteForHundredItems_thenExactlyHundredSucceed() {
var factory = new LettuceConnectionFactory(
redisContainer.getHost(), redisContainer.getMappedPort(6379));
factory.afterPropertiesSet();
var template = new StringRedisTemplate(factory);
var service = new StockReservationService(template);
template.opsForValue().set("{item:1}:stock", "100");
var successes = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long userId = 0; userId < 1_000; userId++) {
long id = userId;
executor.submit(() -> {
if (service.reserve(1, id) == ReserveResult.SUCCESS) {
successes.incrementAndGet();
}
});
}
}
assertThat(successes.get()).isEqualTo(100);
assertThat(template.opsForValue().get("{item:1}:stock")).isEqualTo("0");
assertThat(service.reserve(1, 0)).isEqualTo(ReserveResult.ALREADY_PURCHASED);
}
}
Khối try-with-resources với virtual thread executor chờ mọi task xong rồi mới đi tiếp. Kết quả mong đợi:
successes = 100
stock = "0"
Nếu thay Lua script bằng GET rồi DECR riêng lẻ, test này sẽ thất bại không ổn định, và stock thường xuống số âm.
10. Các điểm khó và lỗi hay gặp
Hot key trên Redis. Mọi request cùng đập vào một key {item:1001}:stock. Một node Redis xử lý khoảng 100.000 lệnh/giây. Ở tải cao hơn, có hai cách xử lý:
- Chia kho (sharding stock): tách 10.000 thành 10 key
{item:1001:0}…{item:1001:9}, mỗi key 1.000, nằm trên các node khác nhau. Request chọn shard ngẫu nhiên, hết thì thử shard khác. Khi đó quy tắc “mỗi người 1 sản phẩm” cần định tuyến user cố định vào một shard theouserId % 10, hoặc kiểm tra ở tầng DB. - Cờ hết hàng cục bộ: khi Redis báo
SOLD_OUT, mỗi instance bật mộtAtomicBooleantrong bộ nhớ và từ chối ngay mà không gọi Redis nữa.
Tải đột biến trước cả Redis. Rate limit ở gateway, captcha hoặc “xếp hàng ảo” giúp cắt phần lớn bot. Trang sale nên là trang tĩnh trên CDN. Nút “Mua” chỉ được kích hoạt khi tới giờ, và URL mua chỉ được trả về lúc đó để giảm bấm sớm bằng script.
Redis mất dữ liệu. Nếu Redis failover và replica chưa kịp nhận lệnh DECR cuối, kho trên Redis có thể cao hơn thực tế. Lúc này điều kiện sold < quantity trong DB phát huy tác dụng. Đừng bao giờ bỏ lưới an toàn này.
Nhất quán cuối cùng. Người dùng nhận 202 nhưng đơn chưa có trong DB ngay. UI phải hiển thị trạng thái “đang xử lý” và poll, không được báo “mua thành công” ngay.
Lưu ý: Đừng dùng
SELECT ... FOR UPDATEtrên dòng tồn kho làm cơ chế chính cho flash sale. Nó đúng về mặt nhất quán, nhưng mọi transaction xếp hàng chờ cùng một row lock. Throughput sụp xuống vài trăm đến vài nghìn request/giây, connection pool cạn và kéo sập cả hệ thống bán hàng thường ngày.
Lưu ý: Kiến trúc Redis + Kafka chỉ đáng dùng khi tải thực sự lớn. Nếu đợt sale của bạn chỉ vài trăm request/giây, một câu
UPDATE ... WHERE sold < quantitytrong PostgreSQL là đủ. Nó đơn giản hơn nhiều và không có vấn đề đồng bộ giữa hai kho dữ liệu.
11. Kết luận
Thiết kế flash sale xoay quanh một nguyên tắc: lọc tải theo từng tầng và giữ một nguồn sự thật có ràng buộc chặt. Trong bài, bạn đã thấy:
- Redis Lua script kiểm tra và trừ kho nguyên tử, từ chối “hết hàng” chỉ trong một round-trip.
- Kafka làm bộ đệm để DB chỉ nhận đúng số đơn thành công.
- PostgreSQL với
UNIQUE,CHECKvàUPDATE ... WHERE sold < quantitylàm lưới an toàn chống bán âm kho và xử lý message lặp. - Cơ chế bù trừ khi gửi Kafka thất bại hoặc đơn quá hạn thanh toán, có chặn hoàn kho hai lần.
Khi áp dụng thực tế, hãy bắt đầu từ test tranh chấp như ở mục 9. Sau đó chạy load test với số liệu gần đỉnh thật, trước khi đợt sale đầu tiên mở cửa.