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ĩaAi dùng?
Physical StockSố hàng thực tế trong kho (đếm được)Warehouse team
ReservedHàng đã “giữ chỗ” cho đơn hàng đang xử lý nhưng chưa xuất khoAllocation Engine
Safety StockDự 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 ngayStorefront (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ĩaMụ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 TurnoverSố 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.