大廠防止商品超賣的五種方案!
前言
"快看我們的秒殺系統(tǒng)!庫存顯示-500了!"
3年前的這個電話讓我記憶猶新。
當時某電商大促,我們自認為完美的分布式架構,在0點整瞬間被擊穿。
數(shù)據(jù)庫連接池耗盡,庫存表出現(xiàn)負數(shù),客服電話被打爆...
1.為什么會發(fā)生超賣?
首先我們一起看看為什么會發(fā)送超賣?
(1)數(shù)據(jù)庫的"最后防線"漏洞
我們用下面的列子,給大家介紹一下商品超賣是如何發(fā)生的。
-- 危險的更新語句
UPDATE product SET stock = stock -1
WHERE id=123 AND stock>0;
上面這條看似安全的SQL,在并發(fā)場景下可能變成下圖這樣的:
請求1和請求2都將庫存更新成9。
根本原因:數(shù)據(jù)庫的更新操作不是原子性校驗,多個事務可能同時通過stock>0的條件檢查。
(2)超賣的本質
商品超賣的本質是:多個請求同時穿透緩存,同一時刻讀取到相同庫存值,最終在數(shù)據(jù)庫層發(fā)生覆蓋。
就像100個人同時看上一件衣服,都去試衣間前看了眼牌子,出來時都覺得自己應該拿到那件衣服。
2.防止超賣的方案
(1)數(shù)據(jù)庫樂觀鎖
數(shù)據(jù)庫樂觀鎖的核心原理是通過版本號控制并發(fā)。
例如下面這樣的:
UPDATE product
SET stock = stock -1, version=version+1
WHERE id=123 AND version=#{currentVersion};
Java的實現(xiàn)代碼如下:
@Transactional
public boolean deductStock(Long productId) {
Product product = productDao.selectForUpdate(productId);
if (product.getStock() <= 0) return false;
int affected = productDao.updateWithVersion(
productId,
product.getVersion(),
product.getStock()-1
);
return affected > 0;
}
基于數(shù)據(jù)庫樂觀鎖方案的架構圖如下:
優(yōu)缺點分析:
優(yōu)點 | 缺點 |
無需額外中間件 | 高并發(fā)時DB壓力大 |
實現(xiàn)簡單 | 可能出現(xiàn)大量更新失敗 |
適用場景:日訂單量1萬以下的中小系統(tǒng)。
(2)Redis原子操作
Redis原子操作的核心原理是使用:Redis + Lua腳本。
核心代碼如下:
// Lua腳本保證原子性
String lua = "if redis.call('get', KEYS >= ARGV[1] then " +
"return redis.call('decrby', KEYS[1], ARGV " +
"else return -1 end";
public boolean preDeduct(String itemId, int count) {
RedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(itemId), count);
return result != null && result >= 0;
}
該方案的架構圖如下:
性能對比:
- 單節(jié)點QPS:數(shù)據(jù)庫方案500 vs Redis方案8萬
- 響應時間:<1ms vs 50ms+
(3)分布式鎖
目前最常用的分布式鎖的方案是Redisson。
下面是Redisson的實現(xiàn):
RLock lock = redisson.getLock("stock_lock:"+productId);
try {
if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
// 執(zhí)行庫存操作
}
} finally {
lock.unlock();
}
注意事項
- 鎖粒度要細化到商品級別
- 必須設置等待時間和自動釋放
- 配合異步隊列使用效果更佳
該方案的架構圖如下:
(4)消息隊列削峰
可以使用 RocketMQ的事務消息。
核心代碼如下:
// RocketMQ事務消息示例
TransactionMQProducer producer = new TransactionMQProducer("stock_group");
producer.setExecutor(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg) {
// 扣減數(shù)據(jù)庫庫存
return LocalTransactionState.COMMIT_MESSAGE;
}
});
該方案的架構圖如下:
技術指標:
- 削峰能力:10萬QPS → 2萬TPS
- 訂單處理延遲:<1秒(正常時段)
(5)預扣庫存
預扣庫存是防止商品超賣的終極方案。
核心算法如下:
// Guava RateLimiter限流
RateLimiter limiter = RateLimiter.create(1000); // 每秒1000個令牌
public boolean preDeduct(Long itemId) {
if (!limiter.tryAcquire()) return false;
// 寫入預扣庫存表
preStockDao.insert(itemId, userId);
return true;
}
該方案的架構圖如下:
性能數(shù)據(jù):
- 百萬級并發(fā)支撐能力
- 庫存準確率99.999%
- 訂單處理耗時200ms內
3.避坑指南
(1)緩存與數(shù)據(jù)庫不一致
某次大促因緩存未及時失效,導致超賣1.2萬單。
錯誤示例如下:
// 錯誤示例:先刪緩存再寫庫
redisTemplate.delete("stock:"+productId);
productDao.updateStock(productId, newStock); // 存在并發(fā)寫入窗口
(2)未考慮庫存回滾
秒殺取消后,忘記恢復庫存,引發(fā)后續(xù)超賣。
正確做法是使用事務補償。
例如下面這樣的:
@Transactional
public void cancelOrder(Order order) {
stockDao.restock(order.getItemId(), order.getCount());
orderDao.delete(order.getId());
}
庫存回滾和訂單刪除,在同一個事務中。
(3)鎖粒度過大
鎖粒度過大,全局限流導致10%的請求被誤殺。
錯誤示例如下:
// 錯誤示例:全局限鎖
RLock globalLock = redisson.getLock("global_stock_lock");
總結
其實在很多大廠中,一般會將防止商品超賣的多種方案組合使用。
架構圖如下:
通過組合使用:
- Redis做第一道防線(承受80%流量)
- 分布式鎖控制核心業(yè)務邏輯
- 預扣庫存+消息隊列保證最終一致性
實戰(zhàn)經(jīng)驗:某電商在2023年雙11中:
- Redis集群承載98%請求
- 分布式鎖攔截異常流量
- 預扣庫存保證最終準確性
系統(tǒng)平穩(wěn)支撐了每秒12萬次秒殺請求,0超賣事故發(fā)生!
記住:沒有銀彈方案,只有適合場景的組合拳!