前言
最近在解決一套老電商系統(tǒng)的庫存"超賣"問題孔庭。一直以為超賣問題忿偷,最難解決的是庫存扣減趣些,實則不然仿荆,我們的系統(tǒng)在解決了庫存扣減問題之后,還會一直有“超賣”現(xiàn)象坏平?這一切的背后到底是道德的淪喪拢操,還是人性的扭曲,歡迎收看本期走近科學(xué)
本文帶你解決以下電商場景問題
- 保證庫存線程安全的扣減
- 防止庫存的多次扣減功茴、回滾
- 超時未支付被取消的訂單(取消會回滾庫存)庐冯, 如果收到了支付回調(diào)怎么辦
如何線程安全的扣減庫存
先來說說庫存扣減的問題孽亲,這是我們原來老系統(tǒng)的邏輯坎穿,注意!這里是錯誤的示例
// 以下是偽代碼,錯誤的示例
// 查詢出Goods對象
$goods = selectGoodsById($id);
if ($goods->num - $order_num > 0) {
// 計算出扣減后的庫存
$goods->num = $goods->num - $order_num;
// 保存
save($goods);
}
上述代碼犯了大忌玲昧,并發(fā)情況會導(dǎo)致多個線程讀到相同的庫存數(shù)栖茉,然后扣減,然后保存到DB孵延,下面我們來說下正確的姿勢
正確的做法
利用MySQL update 會持有當(dāng)前記錄鎖的特點吕漂,保證線程安全的扣減
SQL 示例:
update kucun set num = num - ? where id = ? and num - ? >= 0
我們的這條記錄根據(jù)主鍵更新,當(dāng)事務(wù)A update 這條記錄時尘应,會持有當(dāng)前記錄的鎖惶凝,當(dāng)事務(wù)A未提交時,其他想要更新這條記錄的事務(wù)只能等待鎖釋放
關(guān)于MySQL update 鎖的細(xì)節(jié)犬钢,本文不討論苍鲜,可以參考MySQL文檔
https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
雖然MySQL可以保證數(shù)據(jù)的準(zhǔn)確性,但是大并發(fā)量場景下玷犹,大量的鎖競爭混滔,導(dǎo)致庫存的扣減可能成為系統(tǒng)性能的瓶頸
使用 Redis 庫存扣減
使用Redis的優(yōu)勢很多,單線程的文件事件處理器保證了并發(fā)下可以線程的安全扣減歹颓、回滾庫存坯屿, 以及Redis高性能。
雖然Redis解決了線程安全和性能的問題巍扛,但是Redis并不能做到像MySQL那樣一條SQL命令完成庫存扣減领跛,我們需要先讀出已有庫存,再和當(dāng)前下單庫存做一個判斷是否可以庫存扣減撤奸。所以最佳的實現(xiàn)方案是通過Redis 執(zhí)行l(wèi)ua腳本隔节,保證整個邏輯處理期間,不會有其他客戶端插進(jìn)來
/**
*
* 扣減庫存Lua腳本
* 庫存(stock)-1:表示不限庫存
* 庫存(stock)0:表示沒有庫存
* 庫存(stock)大于0:表示剩余庫存
*
* @params 庫存key
* @return
* -3:庫存未初始化
* -2:庫存不足
* -1:不限庫存
* 大于等于0:剩余庫存(扣減之后剩余的庫存)
*/
const SUB_STOCK_LUA = "
if (redis.call('exists', KEYS[1]) == 1) then
local stock = tonumber(redis.call('get', KEYS[1]));
local num = tonumber(ARGV[1]);
if (stock == -1) then
return -1;
end;
if (stock >= num) then
return redis.call('incrby', KEYS[1], 0 - num);
end;
return -2;
end;
return -3;
";
注意:
當(dāng)對一個訂單中的 good_list 扣減庫存的時候寂呛,需要注意怎诫,當(dāng)某一個商品庫存扣減失敗時,之前的扣減的商品庫存需要回滾贷痪。這會涉及到對redis的多次操作幻妓,你可以把整體邏輯寫到一個lua腳本中
使用Redis 做庫存扣減會有一個問題(偽代碼如下),Redis數(shù)據(jù)和MySQL數(shù)據(jù)并不能保證強一致性劫拢,因為Redis的數(shù)據(jù)相當(dāng)于直接寫進(jìn)去了肉津,如果在需要回滾的時候,Redis不可用了導(dǎo)致數(shù)據(jù)無法回滾舱沧,最終會造成MySQL沒有寫入訂單數(shù)據(jù)妹沙,Redis卻扣減了庫存
try {
$db->beginTransaction();
$db->saveOrder();
$redis->reduceStock();
$db->commit();
} catch (Exception $e) {
$db->rollback();
$redis->rollbackStock();
}
這種情況并沒有什么好的解決辦法,這是一個幾率非常小的故障熟吏,首先我們肯定要盡可能的保證Redis的高可用性距糖,其次在發(fā)生這種情況后玄窝,我們要想辦法恢復(fù)Redis中的數(shù)據(jù),例如我們可以在整個邏輯之后悍引,選擇異步的方式(例如MQ)向MySQL中同步庫存恩脂,當(dāng)發(fā)生故障后,以MySQL數(shù)據(jù)為準(zhǔn)恢復(fù)數(shù)據(jù)
所以Redis是一把雙刃劍趣斤,提升性能的同時俩块,也帶來了問題
AliSQL
這是后來我在網(wǎng)上看到的方案,AliSQL 是阿里自研 MySQL 分支浓领,AliSQL 針對并發(fā)修改同一記錄的情況玉凯,使用數(shù)據(jù)庫層面的緩沖隊列,避免大量爭鎖的代價联贩。感興趣的同學(xué)可以試下(阿里云MySQL 8.0 集成了這一功能)壮啊,如果AliSQL解決了性能問題的話,那么這個方案相比Redis要更好
關(guān)于庫存多次扣減的問題
當(dāng)訂單的提交和庫存的扣減同步進(jìn)行的時候撑蒜,不需要考慮這個問題歹啼。
舉例:訂單系統(tǒng)生成訂單之后,通過MQ通知庫存系統(tǒng)座菠,庫存系統(tǒng)異步扣減庫存狸眼,這個時候庫存系統(tǒng)可能會多次消費,這個時候就需要考慮這個問題了浴滴。
或者我們上面說的通過MQ同步MySQL庫存也需要考慮可能發(fā)生多次扣減
解決方案如圖拓萌,通過訂單做為唯一索引保證流水記錄的唯一性,從而保證只能有一次成功的扣減
庫存回滾問題
多數(shù)博客對于超賣的講解只在于庫存的扣減升略,但是庫存扣減安全了微王,真的就可以保證不超賣嗎?我們的系統(tǒng)在解決了庫存扣減問題后品嚣,還是出現(xiàn)成交訂單 > 庫存的問題炕倘,為此我也是絞盡腦汁,抓破了頭
在對下單進(jìn)行壓力測試之后翰撑,我堅信下單不會出現(xiàn)超賣的問題罩旋,后來我懷疑問題出在了庫存回滾,如果一個訂單回滾了兩次庫存(取消超時未支付訂單的線程和用戶線程同時取消一個訂單)眶诈,同樣也會出現(xiàn)超賣的現(xiàn)象涨醋。
解決方法:
和防止多次扣減一樣,采用寫入訂單回滾流水的方式逝撬,個人認(rèn)為這種方法比較加鎖要好浴骂,數(shù)據(jù)有跡可循
超時未支付被取消的訂單收到了支付回調(diào)
在解決了庫存回滾問題之后,超賣問題還沒有解決宪潮,最后通過日志定位到了這個問題溯警。
問題描述:用戶在系統(tǒng)即將自動取消訂單的前一瞬間完成了支付趣苏,系統(tǒng)取消了該訂單并回滾了庫存,同時系統(tǒng)收到了該訂單的支付回調(diào)愧膀,該訂單的狀態(tài)更改為已支付,因為不該出現(xiàn)的庫存回滾導(dǎo)致了“超賣”
下面說下我們的解決方案谣光,以微信支付為例
我們的系統(tǒng)在提交訂單之后檩淋,會調(diào)用微信的統(tǒng)一下單接口,這時候微信收到了我們的商戶訂單號(微信已經(jīng)生成訂單)萄金,用戶選擇不支付蟀悦。超時自動取消邏輯處理之前,先調(diào)用微信的關(guān)閉訂單接口氧敢,如果關(guān)閉成功日戈,則這個時候用戶后續(xù)無法對該訂單發(fā)起支付。如果返回訂單已支付孙乖,則無需處理該訂單浙炼,該訂單會收到微信支付的回調(diào)
參考
http://www.reibang.com/p/76bc0e963172
https://www.zhihu.com/question/268937734
https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_3