Tại sao tồn kho là bài toán khó?
Tưởng tượng: kho còn 1 chiếc iPhone cuối cùng. Lúc 14:00:00.000, hai khách hàng ở hai thành phố khác nhau cùng bấm “Mua” đồng thời. Nếu hệ thống không xử lý đúng, cả hai đều được xác nhận → overselling → một khách sẽ bị hủy đơn → trải nghiệm tệ, mất khách.
Đây không phải vấn đề lý thuyết — đây là vấn đề xảy ra hàng ngày trên mọi sàn e-commerce.
Mô hình tồn kho: 4 loại số lượng
Với SKU: "IPHONE-16-256GB" tại Kho TP.HCM:
┌──────────────────────────────────────────────────────────┐
│ Physical Stock (Tồn kho vật lý): 100 │
│ ├── Reserved (Đã giữ cho đơn chưa giao): -15 │
│ ├── Safety Stock (Dự phòng tối thiểu): -5 │
│ │ │
│ └── Available to Sell (ATP): 80 │
│ (= Physical - Reserved - Safety) │
│ │
│ On-Order (Hàng đang nhập về): 50 │
│ Available to Promise (Tương lai): 130 │
│ (= ATP + On-Order) │
└──────────────────────────────────────────────────────────┘
| Loại | Ý nghĩa | Ai dùng? |
|---|---|---|
| Physical Stock | Số hàng thực tế trong kho (đếm được) | Warehouse team |
| Reserved | Hàng đã “giữ chỗ” cho đơn hàng đang xử lý nhưng chưa xuất kho | Allocation Engine |
| Safety Stock | Dự phòng tối thiểu — không bao giờ bán (phòng sai sót, hàng lỗi) | Inventory Manager |
| ATP (Available to Sell) | Số hàng thực sự có thể bán ngay | Storefront (hiển thị cho khách) |
Stock Reservation — Chống overselling
Luồng Reserve Stock
Khách bấm "Mua":
1. Order Service → Inventory Service: "Reserve 1 IPHONE-16 tại kho HCM"
2. Inventory Service:
BEGIN TRANSACTION
SELECT atp FROM inventory
WHERE sku = 'IPHONE-16' AND warehouse = 'HCM'
FOR UPDATE; -- Pessimistic lock
IF atp >= 1:
UPDATE inventory SET reserved = reserved + 1 WHERE ...
INSERT INTO reservations (order_id, sku, qty, expires_at)
VALUES ('ORD-001', 'IPHONE-16', 1, NOW() + INTERVAL '30 minutes');
RETURN: RESERVED
ELSE:
RETURN: OUT_OF_STOCK
COMMIT
3. Nếu RESERVED: tiếp tục thanh toán
Nếu OUT_OF_STOCK: thông báo hết hàng
Reservation Expiry — Tự hủy giữ chỗ
Nếu khách giữ hàng nhưng không thanh toán trong 30 phút, reservation tự động hết hạn:
-- Cron job chạy mỗi phút
UPDATE inventory
SET reserved = reserved - r.qty
FROM reservations r
WHERE r.sku = inventory.sku
AND r.warehouse = inventory.warehouse
AND r.status = 'ACTIVE'
AND r.expires_at < NOW();
UPDATE reservations
SET status = 'EXPIRED'
WHERE status = 'ACTIVE'
AND expires_at < NOW();
Database Schema cho Inventory
-- Bảng tồn kho chính
CREATE TABLE inventory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku VARCHAR(50) NOT NULL,
warehouse_id VARCHAR(20) NOT NULL,
physical_qty INT NOT NULL DEFAULT 0,
reserved_qty INT NOT NULL DEFAULT 0,
safety_stock INT NOT NULL DEFAULT 0,
-- Available to Sell = physical - reserved - safety
-- Computed column hoặc tính runtime
version BIGINT NOT NULL DEFAULT 1, -- Optimistic locking
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(sku, warehouse_id),
CONSTRAINT positive_physical CHECK (physical_qty >= 0),
CONSTRAINT positive_reserved CHECK (reserved_qty >= 0),
CONSTRAINT no_oversell CHECK (physical_qty - reserved_qty - safety_stock >= 0)
);
-- Bảng reservation (giữ chỗ)
CREATE TABLE stock_reservations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id VARCHAR(50) NOT NULL,
sku VARCHAR(50) NOT NULL,
warehouse_id VARCHAR(20) NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
-- 'ACTIVE', 'COMMITTED' (đã xuất kho), 'EXPIRED', 'CANCELLED'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
committed_at TIMESTAMPTZ
);
CREATE INDEX idx_reservation_expiry ON stock_reservations(status, expires_at)
WHERE status = 'ACTIVE';
Eventual Consistency trong tồn kho phân tán
Khi hệ thống có nhiều kho và nhiều service cùng đọc/ghi tồn kho, không thể dùng strong consistency (quá chậm). Giải pháp: Eventual Consistency với event-driven updates:
Luồng Event-Driven:
1. Order Service: "Đã confirm đơn ORD-001"
→ Publish event: order.confirmed {sku: IPHONE-16, qty: 1, warehouse: HCM}
2. Inventory Service (Consumer):
→ Nhận event → reservation status: ACTIVE → COMMITTED
→ physical_qty -= 1, reserved_qty -= 1
3. Warehouse Service (Consumer):
→ Nhận event → Tạo pick list cho nhân viên kho
4. Analytics Service (Consumer):
→ Nhận event → Cập nhật dashboard doanh số
Mỗi service cập nhật state riêng, async, eventually consistent.
Xử lý xung đột: Optimistic Locking
// Optimistic locking: kiểm tra version trước khi update
func (r *InventoryRepo) Reserve(ctx context.Context, sku, warehouse string, qty int) error {
for retries := 0; retries < 3; retries++ {
inv, err := r.GetBySKU(ctx, sku, warehouse)
if err != nil { return err }
atp := inv.PhysicalQty - inv.ReservedQty - inv.SafetyStock
if atp < qty {
return ErrOutOfStock
}
// Update with version check
result := r.db.Exec(`
UPDATE inventory
SET reserved_qty = reserved_qty + ?, version = version + 1
WHERE sku = ? AND warehouse_id = ? AND version = ?`,
qty, sku, warehouse, inv.Version)
if result.RowsAffected == 1 {
return nil // Success
}
// Version mismatch → ai đó đã update trước → retry
}
return ErrConcurrencyConflict
}
Inventory Sync giữa nhiều kênh bán
Khi bán trên cả Shopee, Lazada, và website riêng, cùng một SKU:
┌──────────┐
│ Inventory │ ← Source of truth
│ Service │
└─────┬─────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Shopee │ │ Lazada │ │Website │
│ Channel│ │Channel │ │Channel │
│ ATP: 80│ │ATP: 80 │ │ATP: 80 │
└────────┘ └────────┘ └────────┘
Vấn đề: Nếu Shopee bán 1, phải giảm ATP ở Lazada và Website ngay
→ Giải pháp: Event-driven sync hoặc Channel-specific allocation
Channel Allocation:
Shopee: max 40 (50%)
Lazada: max 24 (30%)
Website: max 16 (20%)
→ Mỗi kênh có "quota" riêng → Không xung đột
Metrics quan trọng
| Metric | Ý nghĩa | Mục tiêu |
|---|---|---|
| Stockout Rate | % thời gian SKU hết hàng | < 2% |
| Oversell Rate | % đơn bị hủy do bán vượt tồn | < 0.1% |
| Inventory Turnover | Số lần xoay vòng hàng tồn/năm | > 12 (monthly) |
| Reservation Expiry Rate | % reservation bị expire (khách không thanh toán) | < 15% |
| ATP Accuracy | Độ chính xác của số hàng có thể bán | > 99.5% |
Tiếp theo, chúng ta sẽ đi vào bộ não thực sự của hệ thống — các thuật toán phân bổ đơn hàng cho tài xế. Đọc tiếp Phần 3 — Thuật toán phân bổ: Assignment Problem, Bin Packing & VRP.