Tồn kho, chống bán vượt, giữ hàng, distributed lockStock, overselling, reservations, distributed locks
Order Service điều phối saga: giữ hàng ở Inventory Service, gọi thanh toán, rồi xác nhận hoặc bù trừ. Inventory DB là nguồn sự thật; Redis chỉ lọc bớt request lúc flash sale. Bật “Flash sale” hoặc “Thanh toán lỗi” để xem các nhánh còn lại.The Order Service runs the saga: reserve in the Inventory Service, take payment, then confirm or compensate. The inventory database is the source of truth; Redis only sheds load during a flash sale. Turn on “Flash sale” or “Payment fails” to see the other paths.
1Khách đặt 3 tai nghe SKU-1001. Order Service tạo đơn PENDING và bắt đầu saga.A customer orders 3 headsets, SKU-1001. The Order Service creates a PENDING order and starts the saga.
Hai request cùng đọc available = 1 rồi cùng trừ. Dùng một câu UPDATE … SET available = available - n WHERE available >= n và kiểm tra số dòng bị đổi; thêm CHECK (available >= 0) làm lưới an toàn.Two requests read available = 1 and both decrement. Use one UPDATE … SET available = available - n WHERE available >= n, check the affected row count, and add CHECK (available >= 0) as a safety net.
Reservation có expires_at gắn với đơn. Expirer quét PENDING quá hạn bằng SKIP LOCKED, trả lại available và báo Order Service huỷ đơn.Each reservation has an expires_at tied to its order. The expirer picks stale PENDING rows with SKIP LOCKED, returns the units to available and tells the Order Service to cancel.
Hàng nghìn transaction chờ khoá cùng một dòng stock_level. Đặt Redis DECRBY (Lua) phía trước để chỉ khoảng số lượng hàng đi tới database; database vẫn kiểm tra lại. Có thể chia tồn thành nhiều bucket để giảm tranh chấp.Thousands of transactions queue on the lock of one stock_level row. Put a Redis DECRBY (Lua) in front so only about as many requests as units reach the database, which still re-checks. Splitting stock into buckets reduces contention further.
Không có transaction chung giữa Inventory và Payment. Saga phát lệnh bù trừ ReleaseReservation; lệnh idempotent nhờ điều kiện status = 'PENDING'. Sự kiện đi qua outbox để không mất.Inventory and Payment share no transaction. The saga issues a ReleaseReservation compensation, made idempotent by the status = 'PENDING' condition. Events go through an outbox so none are lost.
Hàng hỏng, mất, nhập sai. Mọi thay đổi đi qua bảng stock_movement chỉ thêm; job đối soát so tổng sổ với stock_level và với số kiểm kê của WMS, chênh lệch thành phiếu ADJUST có người duyệt.Damage, loss, mis-scans. Every change goes through an append-only stock_movement table; a reconciliation job compares the ledger sum with stock_level and with WMS counts, and turns differences into an approved ADJUST document.
Chọn kho theo quy tắc (gần nhất, đủ hàng, phí giao) bằng Specification. Giữ hàng ở từng kho theo thứ tự cố định (warehouse_id tăng dần) để tránh deadlock.Choose warehouses by rules (nearest, enough stock, shipping cost) through a Specification. Reserve in each warehouse in a fixed order (ascending warehouse_id) to avoid deadlocks.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
stock_level | sku, warehouse_id, on_hand, reserved, available, version | PK(sku, warehouse_id); CHECK (available >= 0 AND available = on_hand - reserved)PK(sku, warehouse_id); CHECK (available >= 0 AND available = on_hand - reserved) |
reservation | id, order_id, sku, warehouse_id, qty, status, expires_at | unique(order_id, sku, warehouse_id); PENDING → CONFIRMED · RELEASED · EXPIRED · SHIPPEDunique(order_id, sku, warehouse_id); PENDING → CONFIRMED · RELEASED · EXPIRED · SHIPPED |
stock_movement | id, sku, warehouse_id, delta, type, ref_type, ref_id, created_at | Sổ chỉ thêm: RECEIVE, RESERVE, RELEASE, SHIP, TRANSFER, ADJUSTAppend-only ledger: RECEIVE, RESERVE, RELEASE, SHIP, TRANSFER, ADJUST |
warehouse | id, code, region, priority, active | Dùng khi chọn kho cho đơnUsed when choosing a warehouse for an order |
outbox | id, topic, key, payload, published_at | StockReserved, StockReleased đi cùng transactionStockReserved and StockReleased committed in the same transaction |
Java: giữ hàng bằng UPDATE có điều kiện và bù trừ idempotent. Go: cửa Redis cho flash sale, database kiểm tra lại và trả token khi thất bại.Java: reserve with a conditional UPDATE and an idempotent compensation. Go: the Redis gate for a flash sale, with the database re-checking and the token returned on failure.
@Service
@RequiredArgsConstructor
public class InventoryService {
private final JdbcTemplate jdbc;
private final ReservationRepository reservations;
private final Clock clock;
@Transactional
public Reservation reserve(UUID orderId, String sku, long wh, int qty) {
int rows = jdbc.update("""
UPDATE stock_level
SET available = available - ?, reserved = reserved + ?
WHERE sku = ? AND warehouse_id = ? AND available >= ?""",
qty, qty, sku, wh, qty);
if (rows == 0) {
throw new OutOfStockException(sku, wh, qty);
}
movement(sku, wh, -qty, "RESERVE", orderId);
return reservations.save(Reservation.pending(orderId, sku, wh, qty,
clock.instant().plus(Duration.ofMinutes(15))));
}
@Transactional // saga compensation
public void release(UUID orderId) {
for (Reservation r : reservations.lockPendingByOrder(orderId)) {
jdbc.update("""
UPDATE stock_level
SET available = available + ?, reserved = reserved - ?
WHERE sku = ? AND warehouse_id = ?""",
r.getQty(), r.getQty(), r.getSku(), r.getWarehouseId());
r.markReleased();
movement(r.getSku(), r.getWarehouseId(), r.getQty(), "RELEASE", orderId);
}
}
private void movement(String sku, long wh, int delta, String type, UUID ref) {
jdbc.update("""
INSERT INTO stock_movement (sku, warehouse_id, delta, type, ref_id)
VALUES (?, ?, ?, ?, ?)""", sku, wh, delta, type, ref);
}
}var ErrSoldOut = errors.New("inventory: sold out")
// Runs atomically inside Redis: no other command interleaves.
var gate = redis.NewScript(`
local left = redis.call("DECRBY", KEYS[1], ARGV[1])
if left < 0 then
redis.call("INCRBY", KEYS[1], ARGV[1])
return -1
end
return left`)
type FlashSale struct {
rdb *redis.Client
db *pgxpool.Pool
}
func (f *FlashSale) Reserve(ctx context.Context, orderID uuid.UUID, sku string, wh int64, qty int) error {
key := "stock:" + sku
left, err := gate.Run(ctx, f.rdb, []string{key}, qty).Int()
if err != nil {
return fmt.Errorf("redis gate: %w", err)
}
if left < 0 {
return ErrSoldOut // rejected without touching PostgreSQL
}
err = pgx.BeginFunc(ctx, f.db, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE stock_level SET available = available - $1, reserved = reserved + $1
WHERE sku = $2 AND warehouse_id = $3 AND available >= $1`, qty, sku, wh)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrSoldOut // Redis said yes, the database says no
}
_, err = tx.Exec(ctx, `
INSERT INTO reservation (order_id, sku, warehouse_id, qty, status, expires_at)
VALUES ($1, $2, $3, $4, 'PENDING', now() + interval '15 minutes')`,
orderID, sku, wh, qty)
return err
})
if err != nil {
f.rdb.IncrBy(context.WithoutCancel(ctx), key, int64(qty)) // return the token
return err
}
return nil
}| Chỉ database (UPDATE có điều kiện)Database only (conditional UPDATE) | Redis pre-decrement + databaseRedis pre-decrement + database | |
|---|---|---|
| Hợp vớiGood for | Lưu lượng bình thường, hàng nghìn đơn mỗi phútNormal traffic, thousands of orders a minute | Flash sale, hàng chục nghìn request/giây vào vài SKUFlash sales, tens of thousands of requests per second on a few SKUs |
| Đúng đắnCorrectness | Một nguồn sự thật, không lệchOne source of truth, no drift | Redis có thể lệch, cần trả token và đối soátRedis can drift; needs token returns and reconciliation |
| Tải lên databaseDatabase load | Mọi request khoá cùng một dòngEvery request locks the same row | Chỉ khoảng số hàng còn lại tới databaseOnly about as many requests as units reach it |
| Vận hànhOperations | Đơn giảnSimple | Nạp bộ đếm trước giờ mở bán, theo dõi lệchPreload the counter before launch, watch for drift |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Lock thêm một hệ thống có thể hết hạn giữa chừng (GC pause, mạng chậm) và vẫn cần fencing token. Một câu UPDATE có điều kiện dùng chính khoá dòng của database, nguyên tử và không có khe hở.A lock adds a system whose lease can expire midway (GC pause, slow network) and still needs fencing tokens. A conditional UPDATE uses the database's own row lock, atomic with no gap.
Giữ hàng (available → reserved) lúc đặt đơn để không bán vượt. on_hand chỉ giảm khi hàng thật sự rời kho. Tách ba con số giúp biết bao nhiêu hàng đang trên kệ và bao nhiêu còn bán được.Reserve (available → reserved) at order time to prevent overselling. on_hand drops only when goods physically leave. Three numbers tell you what is on the shelf and what can still be sold.
Database thắng: trả lỗi hết hàng, cộng trả token Redis. Job đối soát định kỳ đặt lại bộ đếm Redis theo available trong database.The database wins: answer sold out and return the Redis token. A periodic reconciliation resets the Redis counter from available in the database.
Không, nếu chỉ release reservation đang PENDING dưới khoá dòng và đổi sang RELEASED trong cùng transaction. Lần thứ hai không tìm thấy dòng nào.No, as long as only a PENDING reservation is released under a row lock and flipped to RELEASED in the same transaction. The second delivery finds no row.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.