Thiết kế giỏ hàng và đặt hàng cho sàn thương mại điện tử
1. Tổng quan
Giỏ hàng và đặt hàng là hai chức năng mà khách hàng dùng nhiều nhất trên một sàn thương mại điện tử. Chúng cũng dễ gây sự cố nhất: bán vượt tồn kho, trừ tiền hai lần, đơn hàng “treo” khi có flash sale.
Bài này đi qua một thiết kế hoàn chỉnh theo dạng case-study. Thiết kế gồm yêu cầu, mô hình dữ liệu, API, luồng xử lý và các điểm khó. Mỗi điểm khó đi kèm cách giải bằng Spring Boot 3, PostgreSQL, Redis và Kafka. Đọc xong, bạn có một khung thiết kế để áp dụng hoặc mang đi phỏng vấn system design.
2. Yêu cầu bài toán
2.1. Yêu cầu chức năng
- Khách hàng thêm, sửa, xoá sản phẩm (SKU) trong giỏ. Giỏ được giữ qua nhiều phiên đăng nhập.
- Khi checkout, hệ thống tính lại giá và kiểm tra tồn kho, sau đó tạo đơn ở trạng thái
PENDING_PAYMENT. - Đơn chưa thanh toán sau 15 phút sẽ bị huỷ và trả hàng về kho.
- Sau khi thanh toán thành công, hệ thống phát sự kiện cho kho vận, email và điểm thưởng.
2.2. Quy mô giả định
- 10 triệu người dùng hoạt động hằng tháng.
- Thao tác giỏ hàng: trung bình 2.000 req/s, đỉnh 20.000 req/s.
- Đặt hàng: trung bình 200 đơn/s. Khi flash sale có thể lên 5.000 đơn/s, dồn vào vài chục SKU.
2.3. Ràng buộc
- Không bán vượt kho. Đây là ràng buộc cứng.
- Không tạo trùng đơn khi client gửi lại request do timeout hoặc người dùng bấm hai lần.
- Đơn đã tạo thì sự kiện phải được phát đi, kể cả khi Kafka đang tạm gián đoạn.
- Giỏ hàng được phép nhất quán “gần đúng”. Đơn hàng và tồn kho thì phải nhất quán chặt.
Ràng buộc cuối quyết định kiến trúc. Giỏ hàng đọc ghi nhiều và ít quan trọng nên đặt trên Redis. Đơn hàng và tồn kho cần transaction nên đặt trên PostgreSQL.
3. Mô hình dữ liệu
Giỏ hàng trên Redis dùng một Hash cho mỗi khách hàng:
key: cart:{customerId}
field: sku
value: quantity
TTL: 30 ngày
Giỏ chỉ lưu SKU và số lượng, không lưu giá. Giá có thể đổi bất cứ lúc nào, nên hệ thống luôn tính lại giá lúc checkout.
Phần dữ liệu cần transaction nằm trên PostgreSQL:
CREATE TABLE inventory (
sku VARCHAR(64) PRIMARY KEY,
available INT NOT NULL CHECK (available >= 0),
reserved INT NOT NULL DEFAULT 0
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
total_amount BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (customer_id, idempotency_key)
);
CREATE TABLE order_items (
order_id BIGINT REFERENCES orders(id),
sku VARCHAR(64) NOT NULL,
quantity INT NOT NULL,
unit_price BIGINT NOT NULL,
PRIMARY KEY (order_id, sku)
);
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
);
Một vài điểm đáng chú ý trong schema này:
CHECK (available >= 0)là lớp bảo vệ cuối cùng. Nếu code có lỗi, database vẫn chặn được tồn kho âm.UNIQUE (customer_id, idempotency_key)chặn việc tạo trùng đơn ngay ở tầng dữ liệu.order_items.unit_pricelà snapshot giá tại thời điểm đặt. Đơn cũ không bị ảnh hưởng khi giá sản phẩm thay đổi.- Tiền lưu bằng
BIGINTtheo đơn vị nhỏ nhất (đồng). Không dùngDOUBLE.
4. Thiết kế API
GET /api/cart -> xem giỏ (kèm giá hiện tại)
PUT /api/cart/items/{sku} -> body {"quantity": 2}, đặt số lượng
DELETE /api/cart/items/{sku} -> xoá SKU khỏi giỏ
POST /api/orders -> header Idempotency-Key, tạo đơn từ giỏ
GET /api/orders/{id} -> xem trạng thái đơn
PUT với số lượng tuyệt đối là idempotent. Gọi lại nhiều lần vẫn cho cùng kết quả. Nếu thiết kế kiểu POST /add với +1, mỗi lần client retry sẽ cộng thêm một sản phẩm.
POST /api/orders không idempotent về bản chất, nên client phải sinh một Idempotency-Key (UUID) cho mỗi lần bấm “Đặt hàng”. Khi retry, client gửi lại đúng key đó.
5. Giỏ hàng trên Redis
Thêm dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Service giỏ hàng:
@Service
public class CartService {
private static final Duration CART_TTL = Duration.ofDays(30);
private static final int MAX_ITEMS = 100;
private final StringRedisTemplate redis;
public CartService(StringRedisTemplate redis) {
this.redis = redis;
}
public void setQuantity(long customerId, String sku, int quantity) {
String key = "cart:" + customerId;
HashOperations<String, String, String> ops = redis.opsForHash();
if (quantity <= 0) {
ops.delete(key, sku);
return;
}
if (!ops.hasKey(key, sku) && ops.size(key) >= MAX_ITEMS) {
throw new IllegalStateException("Giỏ hàng đã đủ " + MAX_ITEMS + " sản phẩm");
}
ops.put(key, sku, String.valueOf(quantity));
redis.expire(key, CART_TTL);
}
public Map<String, Integer> getItems(long customerId) {
Map<String, String> raw = redis.<String, String>opsForHash().entries("cart:" + customerId);
return raw.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> Integer.parseInt(e.getValue())));
}
}
Giải thích:
ops.put(key, sku, ...)ghi một field trong Hash với độ phức tạp O(1). Hai thiết bị cùng sửa hai SKU khác nhau không ghi đè lên nhau. Nếu bạn lưu cả giỏ thành một chuỗi JSON thì sẽ bị ghi đè.expiređược gia hạn ở mỗi lần ghi. Giỏ chỉ hết hạn sau 30 ngày không có thao tác nào.MAX_ITEMSgiới hạn kích thước giỏ để tránh key quá lớn do bot.
Lệnh hasKey rồi size không atomic. Hai request song song có thể vượt giới hạn 1–2 item. Với giỏ hàng, sai lệch nhỏ như vậy chấp nhận được. Bạn không nên áp dụng mức “gần đúng” này cho tồn kho.
Lưu ý: Redis không phải nguồn dữ liệu bền vững tuyệt đối. Hãy bật AOF (
appendfsync everysec) để giới hạn mất mát ở mức khoảng một giây. Mất một giây dữ liệu giỏ hàng là chấp nhận được, còn mất đơn hàng thì không.
6. Luồng đặt hàng
Luồng POST /api/orders đi qua các bước sau:
1. Đọc giỏ từ Redis
2. Lấy giá hiện tại từ Catalog
3. BEGIN transaction (PostgreSQL)
3a. Insert orders với idempotency_key -> nếu trùng, trả đơn cũ
3b. Trừ kho từng SKU (conditional update) -> thiếu hàng thì ROLLBACK
3c. Insert order_items (snapshot giá)
3d. Insert outbox_events "OrderCreated"
4. COMMIT
5. Xoá giỏ hàng (best-effort)
6. Relay đọc outbox -> publish Kafka
6.1. Chống bán vượt kho bằng conditional update
Cách sai hay gặp là đọc tồn kho, so sánh trong Java rồi mới ghi xuống. Giữa lúc đọc và lúc ghi, một thread khác có thể đã mua mất món hàng. Cách đúng là gộp phép kiểm tra và phép trừ vào một câu lệnh SQL:
@Repository
public class InventoryRepository {
private final JdbcClient jdbc;
public InventoryRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public boolean reserve(String sku, int quantity) {
int updated = jdbc.sql("""
UPDATE inventory
SET available = available - :qty,
reserved = reserved + :qty
WHERE sku = :sku AND available >= :qty
""")
.param("qty", quantity)
.param("sku", sku)
.update();
return updated == 1;
}
}
Database khoá dòng trong lúc UPDATE chạy. Request thứ hai phải chờ request thứ nhất xong, sau đó đánh giá lại điều kiện available >= :qty trên giá trị mới. Nếu không đủ hàng, updated == 0 và method trả về false. Bạn không cần SELECT ... FOR UPDATE hay version column.
6.2. Idempotency và transaction
@Service
public class OrderService {
private final JdbcClient jdbc;
private final InventoryRepository inventory;
public OrderService(JdbcClient jdbc, InventoryRepository inventory) {
this.jdbc = jdbc;
this.inventory = inventory;
}
@Transactional
public long placeOrder(long customerId, String idempotencyKey, List<OrderLine> lines) {
Optional<Long> existing = jdbc.sql("""
INSERT INTO orders (customer_id, idempotency_key, status, total_amount, expires_at)
VALUES (:c, :k, 'PENDING_PAYMENT', :total, now() + interval '15 minutes')
ON CONFLICT (customer_id, idempotency_key) DO NOTHING
RETURNING id
""")
.param("c", customerId).param("k", idempotencyKey)
.param("total", lines.stream().mapToLong(OrderLine::subtotal).sum())
.query(Long.class).optional();
if (existing.isEmpty()) {
return jdbc.sql("SELECT id FROM orders WHERE customer_id = :c AND idempotency_key = :k")
.param("c", customerId).param("k", idempotencyKey)
.query(Long.class).single();
}
long orderId = existing.get();
for (OrderLine line : lines.stream().sorted(Comparator.comparing(OrderLine::sku)).toList()) {
if (!inventory.reserve(line.sku(), line.quantity())) {
throw new OutOfStockException(line.sku());
}
jdbc.sql("INSERT INTO order_items VALUES (:o, :s, :q, :p)")
.param("o", orderId).param("s", line.sku())
.param("q", line.quantity()).param("p", line.unitPrice())
.update();
}
jdbc.sql("""
INSERT INTO outbox_events (aggregate_id, event_type, payload)
VALUES (:id, 'OrderCreated', CAST(:payload AS jsonb))
""")
.param("id", orderId)
.param("payload", "{\"orderId\":" + orderId + ",\"customerId\":" + customerId + "}")
.update();
return orderId;
}
}
public record OrderLine(String sku, int quantity, long unitPrice) {
long subtotal() { return quantity * unitPrice; }
}
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String sku) { super("Hết hàng: " + sku); }
}
Những điểm then chốt:
ON CONFLICT ... DO NOTHING RETURNING idchỉ trả về id khi insert thành công. Nếu key đã tồn tại, kết quả rỗng. Khi đó service đọc lại đơn cũ và trả về, nên client retry vẫn nhận đúng một đơn.OutOfStockExceptionlàRuntimeException, vì vậy@Transactionalrollback toàn bộ: đơn, các SKU đã trừ trước đó và outbox. Không có trạng thái “nửa vời”.- Danh sách SKU được sắp xếp trước khi trừ kho. Hai đơn cùng chứa SKU A và B sẽ luôn khoá A trước rồi đến B, nhờ vậy tránh deadlock.
- Nếu request đầu bị rollback, key cũng bị rollback theo. Client retry với cùng key sẽ thực hiện lại từ đầu, đây đúng là hành vi mong muốn.
6.3. Transactional outbox
Nếu gọi kafkaTemplate.send() bên trong transaction, bạn gặp một trong hai lỗi. Kafka gửi thành công nhưng DB rollback thì sinh ra sự kiện “ma”. DB commit nhưng Kafka lỗi thì sự kiện bị mất. Outbox giải quyết bằng cách ghi sự kiện vào cùng transaction với đơn hàng, sau đó một relay đẩy sự kiện sang Kafka.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {
private final JdbcClient jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() throws Exception {
List<OutboxEvent> events = jdbc.sql("""
SELECT id, aggregate_id, event_type, payload::text AS payload
FROM outbox_events WHERE published = false
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""").query(OutboxEvent.class).list();
for (OutboxEvent e : events) {
kafka.send("orders", String.valueOf(e.aggregateId()), e.payload()).get();
jdbc.sql("UPDATE outbox_events SET published = true WHERE id = :id")
.param("id", e.id()).update();
}
}
}
record OutboxEvent(long id, long aggregateId, String eventType, String payload) {}
@Scheduled chỉ chạy khi ứng dụng đã bật @EnableScheduling.
FOR UPDATE SKIP LOCKEDcho phép chạy nhiều instance relay song song mà không xử lý trùng một dòng.- Key của message là
aggregateId. Mọi sự kiện của cùng một đơn rơi vào cùng partition và giữ đúng thứ tự. .get()chờ Kafka xác nhận rồi mới đánh dấupublished. Nếu relay chết giữa chừng, sự kiện có thể được gửi lại lần nữa. Outbox đảm bảo at-least-once, nên consumer phải idempotent, chẳng hạn lưuorderIdđã xử lý.
6.4. Hết hạn thanh toán
Một job định kỳ huỷ các đơn quá hạn và trả hàng về kho:
WITH expired AS (
UPDATE orders SET status = 'CANCELLED'
WHERE status = 'PENDING_PAYMENT' AND expires_at < now()
RETURNING id
)
UPDATE inventory i
SET available = i.available + oi.quantity,
reserved = i.reserved - oi.quantity
FROM order_items oi
WHERE oi.order_id IN (SELECT id FROM expired) AND oi.sku = i.sku;
Điều kiện status = 'PENDING_PAYMENT' giải quyết tranh chấp với callback thanh toán. Callback cũng phải chuyển trạng thái bằng UPDATE ... WHERE status = 'PENDING_PAYMENT'. Bên nào cập nhật trước thì thắng, bên còn lại cập nhật 0 dòng. Nếu thanh toán về sau khi đơn đã bị huỷ, hệ thống hoàn tiền thay vì “hồi sinh” đơn.
Câu lệnh trên giả định mỗi SKU chỉ xuất hiện một lần trong mỗi đơn, điều mà khoá chính (order_id, sku) đã đảm bảo. Nếu một đợt huỷ có nhiều đơn cùng chứa một SKU, PostgreSQL chỉ áp dụng một dòng join cho mỗi dòng inventory. Khi đó bạn cần gộp trước bằng GROUP BY sku và SUM(quantity).
7. Tải đột biến: flash sale
Khi 5.000 request/s cùng tranh một SKU, tất cả xếp hàng chờ khoá một dòng trong inventory. Database bị nghẽn, latency tăng vọt và kéo theo các SKU khác. Cách giải là chặn bớt lưu lượng trước khi chạm DB, bằng một bộ đếm tồn kho trên Redis chạy script Lua:
@Component
public class FlashSaleGate {
private static final RedisScript<Long> TRY_ACQUIRE = RedisScript.of("""
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[1])
if stock >= qty then
redis.call('DECRBY', KEYS[1], qty)
return 1
end
return 0
""", Long.class);
private final StringRedisTemplate redis;
public FlashSaleGate(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(String sku, int quantity) {
Long ok = redis.execute(TRY_ACQUIRE, List.of("flash:stock:" + sku), String.valueOf(quantity));
return ok != null && ok == 1L;
}
public void release(String sku, int quantity) {
redis.opsForValue().increment("flash:stock:" + sku, quantity);
}
}
Redis chạy script Lua một cách atomic. Không có lệnh nào chen vào giữa GET và DECRBY. Với 1.000 suất và 50.000 request, chỉ khoảng 1.000 request lọt qua cổng để vào OrderService. 49.000 request còn lại nhận ngay HTTP 409 “Hết suất” mà không tốn một truy vấn DB nào.
Luồng ghép lại như sau:
tryAcquire (Redis) --false--> 409 Sold out
|true
placeOrder (PostgreSQL, conditional update vẫn giữ nguyên)
|exception
release (Redis)
Redis chỉ là bộ lọc. Nguồn sự thật vẫn là conditional update trong PostgreSQL. Nếu Redis lệch số, ví dụ vì restart hoặc vì release không chạy, hậu quả xấu nhất là bán thiếu vài suất chứ không bán vượt.
Một số biện pháp bổ trợ khác:
- Rate limit theo customer để chặn bot spam.
- Hàng đợi: đẩy request thắng cổng vào Kafka, consumer tạo đơn với tốc độ DB chịu được, client poll
GET /api/orders/{id}. Cách này đổi latency lấy độ ổn định. - Tách SKU nóng sang một bảng hoặc instance riêng để không ảnh hưởng đơn thường.
8. Kiểm thử tranh chấp tồn kho
Test quan trọng nhất là chứng minh hệ thống không bán vượt kho khi có nhiều thread cùng mua.
@JdbcTest
@Import(InventoryRepository.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class InventoryRepositoryTest {
@Autowired JdbcClient jdbc;
@Autowired InventoryRepository inventory;
@BeforeEach
void setUp() {
jdbc.sql("""
CREATE TABLE IF NOT EXISTS inventory (
sku VARCHAR(64) PRIMARY KEY,
available INT NOT NULL CHECK (available >= 0),
reserved INT NOT NULL DEFAULT 0)
""").update();
jdbc.sql("DELETE FROM inventory").update();
jdbc.sql("INSERT INTO inventory (sku, available) VALUES ('IPHONE-16', 5)").update();
}
@Test
void givenStockOf5_when20ConcurrentBuyers_thenExactly5Succeed() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Boolean>> results = IntStream.range(0, 20)
.mapToObj(i -> executor.submit(() -> inventory.reserve("IPHONE-16", 1)))
.toList();
long success = 0;
for (Future<Boolean> f : results) {
if (f.get()) success++;
}
assertThat(success).isEqualTo(5);
}
Integer left = jdbc.sql("SELECT available FROM inventory WHERE sku = 'IPHONE-16'")
.query(Integer.class).single();
assertThat(left).isZero();
}
}
@Transactional(propagation = NOT_SUPPORTED)tắt transaction mặc định của@JdbcTest. Nếu không tắt, dữ liệu insert trongsetUpchưa được commit và các thread khác không nhìn thấy.- Test dùng virtual thread của Java 21 để tạo 20 request đồng thời.
- Database nhúng mặc định của
@JdbcTest(H2) đủ cho kiểm tra này. Để testON CONFLICTvàSKIP LOCKED, hãy dùng PostgreSQL thật qua Testcontainers.
Kết quả mong đợi:
success = 5, available = 0
Nếu thay câu UPDATE bằng kiểu “SELECT rồi so sánh trong Java”, test này sẽ fail ngẫu nhiên, có lần success lên tới 7 hoặc 8.
9. Lỗi hay gặp và khi nào không nên dùng
- Lưu giá trong giỏ rồi dùng luôn giá đó khi checkout. Kẻ xấu có thể giữ giá cũ hoặc sửa payload. Luôn lấy giá từ server.
- Trừ kho ngay khi thêm vào giỏ. Người dùng bỏ giỏ rất nhiều, nên tồn kho sẽ bị “giam” vô ích. Chỉ giữ hàng lúc checkout và luôn có hạn.
- Gọi Kafka hoặc dịch vụ thanh toán bên trong
@Transactional. Transaction dài làm giữ khoá lâu, đồng thời vẫn không đảm bảo nhất quán. Hãy dùng outbox. - Coi Redis là nguồn sự thật cho tồn kho. Redis chỉ nên làm lớp lọc.
- Dùng distributed transaction (2PC/XA) giữa các service. Cách này phức tạp và chậm. Hãy dùng saga dựa trên sự kiện cùng các bước bù trừ (huỷ đơn, hoàn kho).
Lưu ý: Nếu sàn của bạn chỉ có vài chục đơn mỗi phút, một database duy nhất với conditional update và idempotency key là đủ. Redis gate, hàng đợi Kafka và tách SKU nóng chỉ đáng thêm vào khi số liệu đo được cho thấy DB thực sự nghẽn.
10. Kết luận
Bài viết đã thiết kế giỏ hàng và luồng đặt hàng theo từng lớp. Giỏ hàng nằm trên Redis Hash vì cần nhanh và chấp nhận nhất quán gần đúng. Đơn hàng và tồn kho nằm trên PostgreSQL vì cần nhất quán chặt.
Ba kỹ thuật cốt lõi giải quyết các điểm khó:
- Conditional update chống bán vượt kho.
- Idempotency key cùng ràng buộc
UNIQUEchống tạo trùng đơn. - Transactional outbox đảm bảo không mất sự kiện.
Khi có flash sale, một cổng Redis chạy script Lua lọc bớt lưu lượng trước khi chạm database, trong khi database vẫn giữ vai trò nguồn sự thật. Bạn có thể bắt đầu với phiên bản đơn giản nhất và thêm từng lớp khi tải thực tế đòi hỏi.