At 2 AM, your phone buzzes. A support screenshot: three customers placed orders for the same hoodie at the same time, but there are only two left in the warehouse. Oversold.
It wasn't the first time. The inventory data from your own warehouse and the 1688 drop-ship warehouse are like clocks in two different time zones—one is accurate, the other is always five minutes behind. And five minutes is enough for three proxy buyers to pay simultaneously.
The Problem: Stale Inventory Data Leads to Overselling
Anyone who runs cross-border proxy knows that the inventory API from 1688 doesn't return real-time stock—it returns the "orderable quantity". A supplier might have already sold out, but the API still says "in stock". Even worse, suppliers can modify product links at any time—specifications, prices, inventory—changing on a whim.
Most developers think of scheduled sync: pull inventory from 1688 every five minutes and write it into a local database. But there's a hidden trap: any order requests that come in during the sync window see old data. If three requests arrive at the same time, all pass the inventory check, and only when deducting do you realize there are only two left.
Even more insidious: during sales events, 1688's inventory API returns inflated numbers—suppliers deliberately set high stock to attract traffic. Your local sync says "100 pieces", but only 20 are actually orderable.
Why Common Solutions Fall Short
Many people say "add a Redis cache with a short TTL". But caching only speeds up reads; it doesn't solve the latency from the data source itself. Others use WebSocket for real-time push, but 1688 doesn't offer a WebSocket interface—only REST APIs.
The real problem is: inventory data from upstream inherently has sync delay, and order placement is concurrent. Without a proper locking mechanism and compensation strategy, overselling is inevitable.
How We Solved It
In Taocarts, we adopted a two‑layer approach: local cache + proactive invalidation for queries, and distributed lock + async reconciliation for deductions.
Real-Time Inventory Query Architecture
The query side doesn't do real‑time sync—that's too slow. We maintain a local inventory table and pull a full snapshot from 1688 every five minutes. But the key change is: after every successful order, we immediately invalidate the cache for that product and trigger an incremental sync.
// Inventory query service (simplified)
class InventoryService
{
private CacheInterface $cache;
private InventorySync $sync;
private LoggerInterface $logger;
public function getAvailableStock(string $skuId, string $supplierId): int
{
$cacheKey = "stock:{$supplierId}:{$skuId}";
// First, try reading from cache
$stock = $this->cache->get($cacheKey);
if ($stock !== null) {
return (int) $stock;
}
// Cache miss, read from local database
$stock = $this->getLocalStock($skuId, $supplierId);
if ($stock === null) {
// Not in local DB either, trigger a sync
$stock = $this->sync->syncSingle($skuId, $supplierId);
if ($stock === null) {
$this->logger->warning("库存数据缺失", ['sku' => $skuId]);
return 0;
}
}
// Write to cache with a TTL of 60 seconds
$this->cache->set($cacheKey, $stock, 60);
return $stock;
}
private function getLocalStock(string $skuId, string $supplierId): ?int
{
// Read from MySQL using an indexed query
$row = DB::table('inventory_snapshots')
->where('sku_id', $skuId)
->where('supplier_id', $supplierId)
->first();
return $row ? $row->stock : null;
}
}
Inventory Deduction During Order Placement
Querying is just the first step; the real challenge is preventing concurrent deductions. We use a Redis distributed lock to serialize deduction operations for the same SKU.
// Inventory deduction in the order service (critical path)
class OrderService
{
private InventoryService $inventory;
private RedisLock $lock;
private LoggerInterface $logger;
public function placeOrder(OrderRequest $request): OrderResult
{
$skuId = $request->skuId;
$quantity = $request->quantity;
$lockKey = "order_lock:{$skuId}";
// Acquire distributed lock with a 3-second timeout
$token = $this->lock->acquire($lockKey, 3);
if (!$token) {
$this->logger->warning("获取锁超时", ['sku' => $skuId]);
return OrderResult::fail('System busy, please try again later');
}
try {
// Re-query the latest stock (now serialized)
$available = $this->inventory->getAvailableStock($skuId, $request->supplierId);
if ($available < $quantity) {
return OrderResult::fail('Insufficient inventory');
}
// Local deduction (deduct locally first, then sync to 1688 asynchronously)
$affected = DB::table('inventory_snapshots')
->where('sku_id', $skuId)
->where('stock', '>=', $quantity)
->decrement('stock', $quantity);
if ($affected === 0) {
// Optimistic lock failed, indicates concurrent deduction
$this->logger->warning("乐观锁扣减失败", ['sku' => $skuId]);
return OrderResult::fail('Insufficient inventory');
}
// Create the order
$order = $this->createOrder($request);
// Invalidate the cache
$this->inventory->invalidateCache($skuId, $request->supplierId);
// Asynchronously trigger the actual purchase on 1688 (just mark it here)
$this->dispatchPurchaseJob($order);
return OrderResult::success($order);
} finally {
$this->lock->release($lockKey, $token);
}
}
}
Hidden Gotchas: Edge Cases with 1688 Inventory
The code above looks straightforward, but after hitting real issues we discovered several undocumented pitfalls:
SKU mapping: 1688 SKU IDs and your local SKU IDs are not one‑to‑one. Different variants of the same product (color, size) may have different product IDs on 1688. You need a mapping table, and it can break when suppliers modify a link. We run a mapping validation task every 30 minutes.
Orderable quantity vs. actual inventory: The
quantityfield returned by 1688's API is the "orderable quantity", not physical stock. A supplier may set "inventory 999" but only have 20 that can actually be shipped. Our countermeasure: immediately after placing an order, call 1688's "order check" API (not a real order) to verify it can really be purchased. If the check fails, we roll back the local inventory and notify support.Cache stampede during sales events: On Singles' Day, 1688's API slows down. When caches expire, a flood of requests hits the database. We added a Bloom filter to return 0 immediately for non‑existent SKUs, preventing a cache avalanche.
The Results
After this solution went live, the oversell rate dropped from ~3% to nearly zero. The only oversell incident occurred because a supplier changed a link during a sync window, breaking the mapping—so we later added proactive detection of supplier link changes.
The 2 AM alerts never went off again. Support finally got a good night's sleep.
Closing Thoughts
Real‑time inventory is a system‑level challenge—throwing Redis at it won't cut it. You need to understand the semantics of upstream APIs, the granularity of concurrency control, and compensation mechanisms. If your system also integrates with 1688 or other platforms, how do you handle inventory sync delays? Have you encountered any even stranger edge cases? I'd love to hear your stories.
Top comments (0)