- Published on
Làm sao xử lý Inventory Reservation khi flash sale mà không bị oversell?
- Authors

- David Nguyen
Table of Contents
- 1. - Inventory Reservation là gì và vì sao flash sale làm nó "vỡ"?
- 2. - Chuẩn bị: schema và entity
- 3. - Cách 1: Đọc rồi trừ (naive)
- 4. - Cách 2: Atomic UPDATE có điều kiện
- 5. - Cách 3: Pessimistic lock
- 6. - Cách 4: Optimistic lock với @Version
- 7. - Cách 5: Redis + Lua script làm cổng chặn phía trước
- 8. - Cách 6: Reservation có TTL (giữ chỗ đúng nghĩa)
- 9. - Test: bắn 200 request vào 100 sản phẩm
- 10. - So sánh các cách tiếp cận
- 11. - Source Code
- 12. - Kết luận & tổng kết
1. - Inventory Reservation là gì và vì sao flash sale làm nó "vỡ"?
Anh em thử tưởng tượng 12h00 trưa, shop mở flash sale 100 chiếc tai nghe giá 1 đồng (ok, 99k cho thực tế). Có 20.000 người bấm "Mua ngay" trong cùng một giây. Hệ thống xử lý xong, và sáng hôm sau team CS báo: đã bán 137 đơn cho 100 cái tai nghe.
Đây là lỗi oversell - bán nhiều hơn số hàng đang có, và nó là bug concurrency: code chạy đúng khi test từng request một, sai khi nhiều request chạy cùng lúc.
Flash sale: 20.000 request cùng tranh một dòng tồn kho, cách làm sai bán lố, cách làm đúng bán đúng số lượng
Trước khi đi tiếp, mình chốt khái niệm cho rõ:
- Inventory (tồn kho): số lượng hàng có thể bán của một SKU.
- Inventory Reservation (giữ chỗ tồn kho): khi user bấm đặt hàng hoặc vào checkout, hệ thống "giữ" một số lượng hàng cho user đó trong một khoảng thời gian (ví dụ 10-15 phút) để họ thanh toán. Hàng đã giữ thì người khác không mua được nữa, dù đơn chưa trả tiền.
Nghe đơn giản, nhưng với hệ thống đông người dùng, mình phải giải quyết cùng lúc 4 vấn đề:
- Oversell: hai request cùng thấy "còn 1 cái" và cùng trừ.
- Hot row / hot key: hàng nghìn request cùng đập vào đúng một dòng dữ liệu của một SKU.
- Giữ chỗ mà không thanh toán: user giữ hàng rồi bỏ đi, hàng bị "kẹt" và không ai mua được (nhìn như hết hàng nhưng thực tế còn).
- Retry và lỗi giữa chừng: request timeout, client gọi lại, service crash sau khi trừ kho nhưng chưa tạo đơn. Trừ kho hai lần hay mất luôn một lần đều sai.
=> Trong bài viết này (thuộc series Design an e-commerce system), mình sẽ cùng anh em đi từ cách làm cơ bản nhất đến nâng cao, mỗi cách đều có code Java & Spring Boot, và cuối bài là bảng so sánh để anh em chọn. Bài này dành cho anh em từ mức beginner đến intermediate: biết Spring Boot và JPA cơ bản là đủ.
Note: Code bên dưới là các đoạn trích đủ để anh em copy và chạy thử trong project của mình. Anh em cũng có thể chạy thẳng project demo đầy đủ ở phần Source Code cuối bài.
2. - Chuẩn bị: schema và entity
Cả bài dùng chung một bảng inventory (ví dụ với PostgreSQL):
CREATE TABLE inventory (
sku VARCHAR(64) PRIMARY KEY,
available INT NOT NULL,
-- Chốt chặn cuối cùng: dù code sai thì DB vẫn không cho available âm
CONSTRAINT chk_inventory_available CHECK (available >= 0)
);
INSERT INTO inventory (sku, available) VALUES ('HEADPHONE-01', 100);
@Entity
@Table(name = "inventory")
@Getter
@Setter
public class Inventory {
@Id
private String sku;
private int available;
// Cố ý KHÔNG có @Version ở entity dùng chung này.
// Optimistic lock (cách 4) dùng một entity riêng, xem phần 6.
}
// Exception nghiệp vụ để controller trả về 409 Conflict
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String sku) {
super("SKU '" + sku + "' đã hết hàng");
}
}
Note: Để code gọn, mình bỏ phần import và dùng Lombok (@Getter, @Setter, @RequiredArgsConstructor). Các annotation JPA nằm trong package jakarta.persistence (Spring Boot 3 trở lên). Entity Inventory ở trên cố ý không có @Version: nếu có, Hibernate sẽ tự thêm AND version = ? vào câu UPDATE và cách 1 sẽ không còn oversell nữa, làm demo ở phần 3 và phần 9 sai mục đích. Mình chỉ đưa @Version vào ở cách 4.
3. - Cách 1: Đọc rồi trừ (naive)
Cách viết mà ai mới làm backend cũng sẽ nghĩ tới đầu tiên:
@Service
@RequiredArgsConstructor
public class NaiveInventoryService {
private final InventoryRepository inventoryRepository;
// Đừng dùng: check-then-act race condition
@Transactional
public void reserve(String sku, int qty) {
Inventory inv = inventoryRepository.findById(sku).orElseThrow();
if (inv.getAvailable() < qty) {
throw new OutOfStockException(sku);
}
inv.setAvailable(inv.getAvailable() - qty);
inventoryRepository.save(inv);
}
}
Nhìn logic thì hợp lý: còn đủ hàng thì trừ. Nhưng với hai request cùng mua khi available = 1 (xem timeline bên dưới), cả hai đều đọc được 1, cùng qua bước check và cùng ghi 0.
=> Hai đơn thành công cho 1 sản phẩm. Đây là lost update kết hợp với check-then-act, y hệt kiểu bug mình đã phân tích trong bài duplicate username. Với mức isolation mặc định của PostgreSQL là READ COMMITTED, mỗi câu SELECT chỉ thấy dữ liệu đã commit, nên cả hai transaction đều có thể đọc giá trị cũ như trên.
Cách 1: hai request cùng đọc available = 1, cả hai đều pass check và đều trừ kho, kết quả là oversell
4. - Cách 2: Atomic UPDATE có điều kiện
Thay vì đọc lên application để quyết định, mình đẩy luôn bước "check và trừ" xuống database trong một câu lệnh duy nhất:
public interface InventoryRepository extends JpaRepository<Inventory, String> {
// Trả về số dòng bị ảnh hưởng: 1 = trừ thành công, 0 = không đủ hàng
@Modifying
@Query("""
UPDATE Inventory i
SET i.available = i.available - :qty
WHERE i.sku = :sku
AND i.available >= :qty
""")
int decreaseIfEnough(@Param("sku") String sku, @Param("qty") int qty);
}
@Service
@RequiredArgsConstructor
public class AtomicInventoryService {
private final InventoryRepository inventoryRepository;
@Transactional
public void reserve(String sku, int qty) {
int updated = inventoryRepository.decreaseIfEnough(sku, qty);
if (updated == 0) {
throw new OutOfStockException(sku);
}
}
}
Vì sao cách này đúng? Câu UPDATE lấy row lock trên dòng của SKU, các request khác cùng SKU phải xếp hàng chờ. Khi đến lượt, ở mức READ COMMITTED PostgreSQL đánh giá lại điều kiện WHERE trên phiên bản dòng mới nhất, nên available >= :qty luôn được kiểm tra trên giá trị đã cập nhật. Không còn khoảng hở giữa "check" và "act" như cách 1.
=> Đây là cách mình khuyên anh em bắt đầu từ đây: ít code nhất, không cần lock ở application, và đủ cho phần lớn hệ thống. Cộng thêm CHECK (available >= 0) ở schema là lớp bảo vệ cuối nếu sau này có ai đó viết một câu UPDATE thiếu điều kiện.
Note: Các câu @Modifying như decreaseIfEnough chạy thẳng xuống database và bỏ qua persistence context của JPA. Có ba hệ quả anh em cần biết:
Nó không tăng
@Version, vì Hibernate không hề thấy entity nào bị sửa.Nếu trong cùng transaction anh em đã load
Inventorylên, object đó vẫn giữ giá trị cũ (stale) sau câuUPDATE. Đặt@Modifying(clearAutomatically = true)để xoá persistence context, hoặc đừng đọc lại entity đó trong cùng transaction. Xem thêm phần Modifying Queries trong tài liệu Spring Data JPA (lưu ýclearAutomaticallycũng bỏ luôn các thay đổi chưa flush).Vì vậy đừng trộn bulk update kiểu này với
@Versiontrên cùng một entity: version sẽ không còn phản ánh đúng các lần sửa. Đó là lý do cách 4 dùng entity riêng.
Note: Điểm yếu là hot row. Mọi request của cùng một SKU đều xếp hàng chờ trên một row lock, nên throughput của SKU đó bị chặn bởi tốc độ commit của database. Với flash sale cực lớn thì phần 7 và 8 mới là chỗ giải quyết.
Cách 2: kiểm tra và trừ kho nằm trong cùng một câu UPDATE, các request cùng SKU xếp hàng trên row lock
5. - Cách 3: Pessimistic lock
Nếu logic của anh em phức tạp hơn một phép trừ (ví dụ phải đọc nhiều dòng, áp dụng quy tắc rồi mới quyết định), câu UPDATE đơn giản không đủ. Lúc đó có thể khóa dòng trước rồi mới xử lý, bằng SELECT ... FOR UPDATE. Trong Spring Data JPA, mình khai báo qua @Lock trên repository method:
public interface InventoryRepository extends JpaRepository<Inventory, String> {
// Sinh ra SELECT ... FOR UPDATE, các transaction khác phải chờ
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Inventory i WHERE i.sku = :sku")
Optional<Inventory> findBySkuForUpdate(@Param("sku") String sku);
}
@Service
@RequiredArgsConstructor
public class PessimisticInventoryService {
private final InventoryRepository inventoryRepository;
@Transactional
public void reserve(String sku, int qty) {
// Từ đây tới commit, không ai khác sửa được dòng này
Inventory inv = inventoryRepository.findBySkuForUpdate(sku).orElseThrow();
if (inv.getAvailable() < qty) {
throw new OutOfStockException(sku);
}
inv.setAvailable(inv.getAvailable() - qty);
}
}
Lock được giữ tới khi transaction kết thúc. Nghĩa là mọi thứ nằm trong @Transactional (gọi service khác, gọi API ngoài...) đều kéo dài thời gian giữ lock. Nếu anh em lỡ gọi payment gateway trong transaction này thì cả SKU bị kẹt theo.
=> Pessimistic lock đúng và dễ hiểu, nhưng so với cách 2 nó tốn thêm một round-trip và giữ lock lâu hơn. Mình chỉ dùng khi thật sự cần đọc dữ liệu trước khi quyết định.
Cách 3: transaction A giữ lock tới khi commit, transaction B phải chờ rồi mới đọc được giá trị mới
6. - Cách 4: Optimistic lock với @Version
Ngược lại với pessimistic: không khóa, cứ đọc và xử lý, đến lúc ghi mới kiểm tra xem có ai sửa dòng này trong lúc mình làm không. JPA làm việc đó thông qua field @Version.
Mình thêm cột version vào bảng và map bằng một entity riêng trỏ cùng bảng inventory, để các cách khác (đặc biệt là cách 1 và các câu @Modifying) không bị ảnh hưởng:
ALTER TABLE inventory ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
@Entity
@Table(name = "inventory")
@Getter
@Setter
public class VersionedInventory {
@Id
private String sku;
private int available;
@Version
private long version;
}
public interface VersionedInventoryRepository extends JpaRepository<VersionedInventory, String> {
}
@Service
@RequiredArgsConstructor
public class OptimisticInventoryService {
private static final int MAX_RETRY = 3;
private final InventoryTxService txService;
public void reserve(String sku, int qty) {
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
txService.decrease(sku, qty);
return;
} catch (ObjectOptimisticLockingFailureException ex) {
// Có request khác sửa dòng trước mình, đọc lại rồi thử lần nữa
if (attempt == MAX_RETRY) {
throw ex;
}
}
}
}
}
@Service
@RequiredArgsConstructor
public class InventoryTxService {
private final VersionedInventoryRepository versionedRepository;
// Mỗi lần retry phải là một transaction mới, nên tách thành bean riêng
@Transactional
public void decrease(String sku, int qty) {
VersionedInventory inv = versionedRepository.findById(sku).orElseThrow();
if (inv.getAvailable() < qty) {
throw new OutOfStockException(sku);
}
inv.setAvailable(inv.getAvailable() - qty);
// Khi flush, Hibernate sinh: UPDATE ... WHERE sku = ? AND version = ?
// Nếu 0 dòng bị ảnh hưởng => ném lỗi optimistic locking
}
}
Note: Retry phải nằm ngoài transaction. Nếu để vòng for bên trong cùng một @Transactional thì transaction đó đã hỏng và lần thử sau vẫn đọc cùng dữ liệu cũ. Đó là lý do mình tách InventoryTxService thành bean riêng (gọi qua proxy mới có transaction mới).
Optimistic lock rất hợp khi xung đột hiếm, ví dụ sản phẩm bình thường. Còn trong flash sale, hàng nghìn request tranh cùng một dòng thì gần như request nào cũng đụng nhau, phần lớn bị retry rồi fail. Đã tốn công xử lý mà còn tạo thêm tải cho database.
=> Với flash sale, optimistic lock là lựa chọn không phù hợp cho đúng SKU nóng. Mình đưa vào bài để anh em hiểu vì sao nó hay được nhắc tới nhưng không cứu được trường hợp này.
Cách 4: request đến sau thấy version đã đổi, update không khớp dòng nào nên phải đọc lại và thử lại
7. - Cách 5: Redis + Lua script làm cổng chặn phía trước
Khi database trở thành nút cổ chai, ý tưởng là để Redis đứng trước và loại sớm những request chắc chắn thất bại. Với 20.000 người tranh 100 sản phẩm, 19.900 người còn lại không nên chạm tới database.
Redis xử lý lệnh trên một thread, và một Lua script được thực thi nguyên tử, nên script "đọc rồi trừ" bên dưới không bị chen ngang:
File src/main/resources/scripts/reserve_stock.lua:
-- KEYS[1] = stock:{sku}, ARGV[1] = qty
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
return -1 -- key chưa được warm-up
end
if stock < tonumber(ARGV[1]) then
return 0 -- không đủ hàng
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- trừ thành công
@Service
public class RedisStockGate {
private final StringRedisTemplate redis;
private final DefaultRedisScript<Long> reserveScript;
public RedisStockGate(StringRedisTemplate redis) {
this.redis = redis;
this.reserveScript = new DefaultRedisScript<>();
// Đọc file src/main/resources/scripts/reserve_stock.lua
this.reserveScript.setLocation(new ClassPathResource("scripts/reserve_stock.lua"));
this.reserveScript.setResultType(Long.class);
}
/** Nạp tồn kho từ DB lên Redis trước giờ mở bán */
public void warmUp(String sku, int available) {
redis.opsForValue().set(key(sku), String.valueOf(available));
}
public boolean tryReserve(String sku, int qty) {
Long result = redis.execute(reserveScript, List.of(key(sku)), String.valueOf(qty));
if (result == null || result == -1L) {
throw new IllegalStateException("Stock của " + sku + " chưa được warm-up");
}
return result == 1L;
}
/** Hoàn lại khi bước sau (ghi DB) thất bại */
public void rollback(String sku, int qty) {
redis.opsForValue().increment(key(sku), qty);
}
private String key(String sku) {
return "stock:" + sku;
}
}
Điểm quan trọng là Redis chỉ là cổng chặn, database vẫn là nguồn chân lý. Mình ghép với cách 2:
@Service
@RequiredArgsConstructor
public class GatedInventoryService {
private final RedisStockGate gate;
private final AtomicInventoryService dbService; // cách 2
public void reserve(String sku, int qty) {
if (!gate.tryReserve(sku, qty)) {
// Chặn ngay ở Redis, không chạm database
throw new OutOfStockException(sku);
}
try {
dbService.reserve(sku, qty); // atomic UPDATE vẫn là chốt chặn cuối
} catch (RuntimeException ex) {
gate.rollback(sku, qty); // ghi DB lỗi thì trả lại hàng ở Redis
throw ex;
}
}
}
Anh em cần lưu ý:
Redis có thể restart hoặc mất dữ liệu, nên số trên Redis có thể lệch DB. Vì vậy DB vẫn phải có atomic UPDATE và
CHECKconstraint để không bao giờ oversell, còn Redis lệch thì tệ nhất là bán thiếu và đối soát lại.Nếu app crash giữa lúc Redis đã trừ và DB chưa trừ, số trên Redis bị hụt. Cần job đối soát định kỳ giữa Redis và DB.
Nếu dùng Redis Cluster, tài liệu Redis yêu cầu mọi key mà script đụng tới phải được truyền qua
KEYS(script ở trên chỉ dùng đúng một keystock:{sku}nên thoả điều kiện), và script khai báo shebang#!thì không được truy cập các key thuộc nhiều hash slot khác nhau. Nếu sau này anh em mở rộng script sang nhiều key, hãy đọc kỹ phần này.
=> Cách này hấp thụ gần như toàn bộ lượng request "chắc chắn fail" bằng bộ nhớ, đổi lại hệ thống có thêm một thành phần cần vận hành và đối soát.
Cách 5: Redis chặn phần lớn request hết hàng, chỉ request thành công mới đi xuống database
8. - Cách 6: Reservation có TTL (giữ chỗ đúng nghĩa)
Các cách trên mới giải quyết bài toán "trừ kho không oversell". Nhưng reservation đúng nghĩa còn một vế nữa: user giữ hàng rồi không thanh toán thì sao? Nếu trừ kho vĩnh viễn ngay lúc bấm "Đặt hàng", hàng bị kẹt. Nếu không trừ thì lại oversell.
Lời giải là biến reservation thành một thực thể có vòng đời:
HELD: đã giữ chỗ, kho đã bị trừ, đang chờ thanh toán, có thời hạnexpires_at.CONFIRMED: đã thanh toán, giữ chỗ trở thành đơn hàng.RELEASED/EXPIRED: user huỷ hoặc quá hạn, trả hàng lại kho.
CREATE TABLE stock_reservation (
id UUID PRIMARY KEY,
order_id VARCHAR(64) NOT NULL UNIQUE, -- idempotency: một order chỉ giữ chỗ một lần
sku VARCHAR(64) NOT NULL,
qty INT NOT NULL,
status VARCHAR(16) NOT NULL, -- HELD | CONFIRMED | RELEASED | EXPIRED
expires_at TIMESTAMPTZ NOT NULL -- có múi giờ, ứng dụng làm việc với Instant (UTC)
);
CREATE INDEX idx_reservation_status_expires ON stock_reservation (status, expires_at);
@Entity
@Table(name = "stock_reservation")
@Getter
@Setter
public class StockReservation {
@Id
private UUID id;
@Column(name = "order_id", nullable = false, unique = true)
private String orderId;
private String sku;
private int qty;
// HELD | CONFIRMED | RELEASED | EXPIRED (có thể đổi sang enum nếu anh em thích)
private String status;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
}
Cột order_id UNIQUE chính là chỗ xử lý vấn đề retry. Bước kiểm tra findByOrderId vẫn là check-then-act, nên chốt chặn thật sự là constraint: INSERT thứ hai bị từ chối và cả transaction rollback, kho không bị trừ hai lần. Lỗi được bắt ở một facade bên ngoài transaction (cùng tinh thần "insert-and-catch" ở bài duplicate username).
public interface ReservationRepository extends JpaRepository<StockReservation, UUID> {
Optional<StockReservation> findByOrderId(String orderId);
List<StockReservation> findTop100ByStatusAndExpiresAtBefore(String status, Instant now);
// Chuyển trạng thái có điều kiện: chỉ một bên "thắng" khi confirm và expire đụng nhau
@Modifying
@Query("UPDATE StockReservation r SET r.status = :to WHERE r.id = :id AND r.status = :from")
int transition(@Param("id") UUID id, @Param("from") String from, @Param("to") String to);
}
@Service
@RequiredArgsConstructor
public class ReservationService {
private static final Duration HOLD_TIME = Duration.ofMinutes(10);
private final InventoryRepository inventoryRepository;
private final ReservationRepository reservationRepository;
@Transactional
public StockReservation reserve(String orderId, String sku, int qty) {
// Retry tuần tự của cùng một đơn: trả lại kết quả cũ, không trừ kho lần nữa
// (hai request đồng thời vẫn có thể lọt qua đây, constraint UNIQUE sẽ chặn ở bước save)
Optional<StockReservation> existing = reservationRepository.findByOrderId(orderId);
if (existing.isPresent()) {
return existing.get();
}
// Trừ kho bằng atomic UPDATE ở cách 2
if (inventoryRepository.decreaseIfEnough(sku, qty) == 0) {
throw new OutOfStockException(sku);
}
StockReservation r = new StockReservation();
r.setId(UUID.randomUUID());
r.setOrderId(orderId);
r.setSku(sku);
r.setQty(qty);
r.setStatus("HELD");
r.setExpiresAt(Instant.now().plus(HOLD_TIME));
// saveAndFlush để lỗi UNIQUE nổ ra ngay tại đây, trong transaction, thay vì lúc commit
return reservationRepository.saveAndFlush(r);
}
/** Gọi khi payment thành công */
@Transactional
public void confirm(UUID reservationId) {
int changed = reservationRepository.transition(reservationId, "HELD", "CONFIRMED");
if (changed == 0) {
throw new IllegalStateException("Reservation đã hết hạn hoặc bị huỷ");
}
}
/** Gọi khi user huỷ đơn hoặc job dọn dẹp phát hiện quá hạn */
@Transactional
public void release(StockReservation r, String finalStatus) {
// Chỉ khi chuyển trạng thái thành công mới trả hàng, nên chạy lại bao nhiêu lần cũng an toàn
if (reservationRepository.transition(r.getId(), "HELD", finalStatus) == 1) {
inventoryRepository.increase(r.getSku(), r.getQty());
}
}
}
Facade bên ngoài transaction, bắt lỗi trùng order_id và trả lại reservation đã có:
@Service
@RequiredArgsConstructor
public class ReservationFacade {
private final ReservationService reservationService;
private final ReservationRepository reservationRepository;
// Cố ý KHÔNG có @Transactional: transaction của reserve() đã rollback khi tới được đây
public StockReservation reserve(String orderId, String sku, int qty) {
try {
return reservationService.reserve(orderId, sku, qty);
} catch (DataIntegrityViolationException ex) {
// Request khác cùng orderId đã thắng, trả về kết quả của nó (kho chỉ bị trừ một lần)
return reservationRepository.findByOrderId(orderId).orElseThrow(() -> ex);
}
}
}
Retry cùng một đơn: request đến sau bị UNIQUE chặn, rollback hoàn lại kho, facade trả về reservation đã có
increase là một câu UPDATE cộng thêm đơn giản trong InventoryRepository:
@Modifying
@Query("UPDATE Inventory i SET i.available = i.available + :qty WHERE i.sku = :sku")
int increase(@Param("sku") String sku, @Param("qty") int qty);
Cuối cùng là job quét các reservation quá hạn và trả hàng về kho:
@Component
@RequiredArgsConstructor
public class ReservationExpiryJob {
private final ReservationRepository reservationRepository;
private final ReservationService reservationService;
// Cần @EnableScheduling ở một class @Configuration
@Scheduled(fixedDelay = 30_000)
public void releaseExpired() {
List<StockReservation> expired =
reservationRepository.findTop100ByStatusAndExpiresAtBefore("HELD", Instant.now());
for (StockReservation r : expired) {
reservationService.release(r, "EXPIRED");
}
}
}
Job expiry: chuyển trạng thái có điều kiện trước, chỉ bên thắng mới được trả hàng về kho
Hai chi tiết nhỏ mà quan trọng:
Câu
UPDATE ... WHERE status = 'HELD'đóng vai trò giống atomic UPDATE ở cách 2, nhưng áp lên trạng thái, nên không có chuyện vừa thanh toán xong vừa bị trả hàng. Lưu ý làconfirm()ở trên không kiểm traexpires_at: nếu thanh toán thành công lúc reservation đã quá hạn nhưng job chưa kịp quét, đơn vẫn được chốt. Mình chọn ưu tiên đơn đã trả tiền; nếu anh em muốn chặt hơn thì thêm điều kiệnAND expires_at > :nowvào câutransition, đổi lại user đã thanh toán có thể bị từ chối và phải hoàn tiền.Nhiều instance cùng chạy job vẫn an toàn nhờ điều kiện trên, chỉ tốn công làm thừa. Ở quy mô lớn anh em có thể dùng Redis key với TTL hoặc delayed message để thay cho polling.
QUESTION: Cách 6 có thay thế cách 2 không?
=> Không, nó xây trên cách 2. Bước trừ kho bên trong reserve() vẫn là atomic UPDATE (hoặc cổng Redis của cách 5 đặt phía trước). Reservation chỉ thêm vào vòng đời, thời hạn và tính idempotent.
Note: Với một SKU cực nóng, row của cách 2 vẫn là điểm nghẽn. Hướng nâng cao hơn nữa là chia tồn kho thành nhiều bucket (ví dụ 100 sản phẩm chia thành 10 dòng, mỗi dòng 10 cái), request được route vào một bucket ngẫu nhiên và thử bucket khác khi hết. Nó giảm tranh chấp trên một dòng nhưng làm logic "còn bao nhiêu hàng" phức tạp hơn, nên chỉ nên làm khi đo được rằng một row thật sự là nút cổ chai.
Cách 6: reservation là một state machine, HELD chỉ có thể đi tiếp một lần sang CONFIRMED hoặc RELEASED/EXPIRED
9. - Test: bắn 200 request vào 100 sản phẩm
Viết code xong thì phải thử. Test dưới đây bắn 200 thread cùng mua 1 sản phẩm của SKU chỉ có 100 cái, dùng CountDownLatch để các thread xuất phát cùng lúc:
@SpringBootTest
class InventoryConcurrencyTest {
@Autowired AtomicInventoryService service; // thay bằng NaiveInventoryService để so sánh
@Autowired InventoryRepository inventoryRepository;
@Autowired JdbcTemplate jdbcTemplate;
// Đưa kho về 100 trước mỗi lần chạy để các lần test không ảnh hưởng nhau
@BeforeEach
void resetStock() {
jdbcTemplate.update("UPDATE inventory SET available = 100 WHERE sku = 'HEADPHONE-01'");
}
@Test
void shouldNotOversell() throws Exception {
int threads = 200;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger success = new AtomicInteger();
AtomicInteger soldOut = new AtomicInteger();
AtomicInteger errors = new AtomicInteger();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < threads; i++) {
futures.add(pool.submit(() -> {
start.await(); // chờ tín hiệu để cùng chạy
try {
service.reserve("HEADPHONE-01", 1);
success.incrementAndGet();
} catch (OutOfStockException e) {
soldOut.incrementAndGet();
} catch (Exception e) {
// Lỗi khác (hết connection, optimistic lock sau khi retry hết lượt, vi phạm CHECK...)
errors.incrementAndGet();
}
return null;
}));
}
start.countDown();
for (Future<?> f : futures) f.get();
pool.shutdown();
int left = inventoryRepository.findById("HEADPHONE-01").orElseThrow().getAvailable();
System.out.printf("success=%d, soldOut=%d, errors=%d, available=%d%n",
success.get(), soldOut.get(), errors.get(), left);
// Bất biến quan trọng nhất: số đơn thành công và số hàng còn lại phải khớp với tồn kho ban đầu
assertThat(success.get() + left).isEqualTo(100);
assertThat(left).isGreaterThanOrEqualTo(0);
}
}
Note: HikariCP mặc định chỉ có 10 connection (maximumPoolSize), trong khi test có 200 thread. Các thread còn lại sẽ xếp hàng chờ connection, test vẫn chạy được nhưng chậm hơn. Anh em có thể tăng spring.datasource.hikari.maximum-pool-size khi chạy test để mô phỏng tranh chấp thật hơn. Vì đã bắt cả Exception chung và đếm vào errors, các lỗi ngoài ý muốn sẽ không làm test chết lặng lẽ; với cách 4, anh em sẽ thấy errors khác 0 vì request hết lượt retry.
Kết quả mong đợi (mình chưa chạy test này, đây là suy ra từ logic của từng cách):
Với cách 2, đúng 100 request thành công, 100 request nhận
OutOfStockException,errorsbằng 0 vàavailablevề 0.Với
NaiveInventoryServiceở cách 1 (nhớ dùng entityInventorykhông có@Version),successcó thể lớn hơn 100 vàavailablekhông khớp với số đơn đã bán, nên assertionsuccess + left == 100sẽ fail. Con số cụ thể thay đổi mỗi lần chạy vì nó phụ thuộc vào cách các thread đan xen nhau. Đó chính là oversell.
10. - So sánh các cách tiếp cận
| Cách tiếp cận | Chống oversell | Chịu tải cao (hot SKU) | Độ phức tạp | Giữ chỗ có thời hạn | Dùng khi |
|---|---|---|---|---|---|
| 1. Đọc rồi trừ | Không | Không | Thấp | Không | Không bao giờ dùng cho hàng có giới hạn |
| 2. Atomic UPDATE | Có | Một phần (xếp hàng trên 1 row lock) | Thấp | Không | Mặc định, điểm bắt đầu cho hầu hết hệ thống |
| 3. Pessimistic lock | Có | Không (giữ lock lâu hơn) | Trung bình | Không | Logic phức tạp, cần đọc trước khi quyết định |
| 4. Optimistic lock | Có | Không (retry bão hoà khi xung đột nhiều) | Trung bình | Không | Xung đột hiếm, sản phẩm bình thường |
| 5. Redis Lua + DB | Có (nhờ DB là chốt chặn) | Có | Cao | Không | Flash sale, cần chặn sớm request hết hàng |
| 6. Reservation có TTL | Có (xây trên cách 2 hoặc 5) | Một phần (tuỳ tầng trừ kho bên dưới) | Cao | Có | Có bước thanh toán giữa lúc đặt và lúc chốt đơn |
Ma trận so sánh nhanh: xanh là đáp ứng tốt, vàng là một phần hoặc có điều kiện, đỏ là không đáp ứng hoặc phức tạp
=> Cách 6 không đứng một mình mà là lớp bổ sung vòng đời lên trên cách 2 hoặc cách 5, nên cột "Chịu tải cao" của nó phụ thuộc vào tầng trừ kho phía dưới.
11. - Source Code
- Mình có code demo đầy đủ cho cả 6 cách trong bài, kèm test bắn 200 request và Redis Lua script. Anh em có thể tham khảo tại đây:
12. - Kết luận & tổng kết
Quay lại câu hỏi đầu bài: làm sao để 20.000 người tranh 100 sản phẩm mà không bán lố?
Gốc của oversell là check-then-act: đọc ở application rồi mới ghi. Bất kỳ cách nào để hai bước đó tách rời nhau đều có race condition.
Atomic UPDATE có điều kiện (cách 2) là nền tảng: gộp check và trừ vào một câu lệnh, kèm
CHECK (available >= 0)làm chốt chặn cuối. Đa số hệ thống dừng ở đây là đủ.Pessimistic và optimistic lock là công cụ cho logic phức tạp hơn, nhưng đều không phải câu trả lời cho SKU bị tranh chấp nặng.
Redis Lua giúp chặn sớm request chắc chắn thất bại, đổi lại phải vận hành thêm và đối soát với DB.
Reservation có TTL giải quyết vế còn lại của bài toán: hàng bị giữ mà không thanh toán, retry và idempotency.
Lời khuyên của mình: đừng nhảy ngay tới Redis hay bucket, cứ đi theo sơ đồ dưới đây.
Bắt đầu với cách 2, thêm reservation khi có bước thanh toán, chỉ thêm Redis khi đo được database là nút cổ chai
Bài toán tương tự về nguyên tắc "đẩy đảm bảo đúng đắn xuống tầng dữ liệu" anh em có thể đọc thêm ở đây:
Hẹn gặp lại anh em ở những dự án tiếp theo – Happy Coding!