業務場景
一般來說,電商平台涉及到減庫存的場景為:提交訂單--收銀台支付,這里會有減庫存時機問題,主流使用第三種方案。
- 下單減庫存。即提交訂單后就用商品總庫存-訂單庫存數量。用事務控制訂單生成和庫存更新,不會存在超賣問題。但是這里有個問題,下單后並不一定付款,如果存在惡意刷單會影響正常交易,且事務內生成訂單且更新庫存,業務量大會有性能問題。
- 付款減庫存。提交訂單后,並不扣減庫存,直到支付成功后真正扣減庫存。但是這里也有個問題,成功下單的用戶,到支付時沒有庫存可用,導致交易失敗。
- 預扣庫存。提交訂單后,庫存保留一定時間,比如10分鍾,超過這個時間庫存釋放。付款時真正完成庫存扣減。這種方案並未完全解決超賣和刷單問題
庫存扣減
庫存扣減准確,支持高並發,滿足高可用性(這個可以從保證整條鏈路上不存在單點,做兜底方案考慮,這里不做重點討論)
這里需要關注以下點
- 庫存剩余數量需大於扣減數量,庫存不能扣成負數
- 扣減多條sku,需要保證原子性,一條扣減失敗,全部回滾
- 有庫存扣減才能加庫存
- 保證冪等性
實現方案
實現思路總體分為兩大類,第一種是基於Mysql,另一種基於緩存實現比如Redis
1.基於緩存實現
適合於高流量,對庫存准確性要求不是非常高的場景下使用。
利用redis的incrby特性扣減庫存。但是需要考慮緩存丟失恢復場景。舉例一個發獎場景,Redis初始庫存 = 總庫存數量 - 已發放獎勵數量,如果使用異步發獎,需要等待MQ中發獎消息消費完畢才能重新初始化Redis庫存。否則會有不一致問題。
- 使用lua實現扣減邏輯
- 分布式環境下需使用分布式鎖控制只有一個服務初始化庫存,且需要注意初始化時機。
- 不具備數據庫事務特性(比如批量扣減,只能將扣減的sku打包在lua腳本內,lua腳本循環做扣減,雖然保證原子性但是中途扣減失敗無法回滾。假如最后一條sku扣減失敗,結果返回失敗,但是之前sku扣減成功的無法回滾)所以需要有對賬定時任務,定期保證最終一致性。
- 可以采用redis集群模式分攤數據
static { /** * * @desc 扣減庫存Lua腳本 * 庫存(stock)-1:表示不限庫存 * 庫存(stock)0:表示沒有庫存 * 庫存(stock)大於0:表示剩余庫存 * * @params 庫存key * @return * -3:庫存未初始化 * -2:庫存不足 * -1:不限庫存 * 大於等於0:剩余庫存(扣減之后剩余的庫存) * redis緩存的庫存(value)是-1表示不限庫存,直接返回1 */ StringBuilder sb = new StringBuilder(); sb.append("if (redis.call('exists', KEYS[1]) == 1) then"); sb.append(" local stock = tonumber(redis.call('get', KEYS[1]));"); sb.append(" local num = tonumber(ARGV[1]);"); sb.append(" if (stock == -1) then"); sb.append(" return -1;"); sb.append(" end;"); sb.append(" if (stock >= num) then"); sb.append(" return redis.call('incrby', KEYS[1], 0 - num);"); sb.append(" end;"); sb.append(" return -2;"); sb.append("end;"); sb.append("return -3;"); STOCK_LUA = sb.toString(); }
2.基於數據庫實現
1)方案1
- 基於樂觀鎖保證並發扣減下數據正確性
- 基於事務特性,保證批量扣減下,部分扣減失敗,全部回滾
- 庫存扣減和庫存流水在同一個事務內
- 庫存流水需要記錄業務流水號,當庫存歸還場景下需要攜帶扣減業務流水號。
- 需要冪等控制
//避免扣成負數
update inventory set leaved_amount = leaved_amount - #{count} where sku_id='123' and leaved_amount >= #{count}
2)方案2
使用這種方案可以很大程度緩解庫存校驗和查詢庫存時的性能問題,但會帶來庫存實時性的問題,即redis和mysql主節點一致性問題。
3)總結:
使用數據庫方案簡單高效,基於數據庫ACID特性很容易保證不出現超賣和並發下庫存准確性。
缺點:性能瓶頸在mysql主節點的寫入,