【秒殺系統】從零開始打造簡易秒殺系統(一):防止超賣
前言
大家好,好久不發文章了。(快一個月了- -)最近有很多學習的新知識想和大家分享,但無奈最近項目蠻忙的,很多文章寫了一半擱置在了筆記里,待以后慢慢補充發布。
本文主要是通過實際代碼講解,幫助你一步步搭建一個簡易的秒殺系統。從而快速的了解秒殺系統的主要難點,并且迅速上手實際項目。
我對秒殺系統文章的規劃:
- 從零開始打造簡易秒殺系統:樂觀鎖防止超賣
- 從零開始打造簡易秒殺系統:令牌桶限流
- 從零開始打造簡易秒殺系統:Redis 緩存
- 從零開始打造簡易秒殺系統:消息隊列異步處理訂單
- …
歡迎關注我的公眾號:后端技術漫談(二維碼見底部)
秒殺系統
秒殺系統介紹
秒殺系統相信網上已經介紹了很多了,我也不想黏貼很多定義過來了。
廢話少說,秒殺系統主要應用在商品搶購的場景,比如:
- 電商搶購限量商品
- 賣周董演唱會的門票
- 火車票搶座
- …
秒殺系統抽象來說就是以下幾個步驟:
- 用戶選定商品下單
- 校驗庫存
- 扣庫存
- 創建用戶訂單
- 用戶支付等后續步驟…
聽起來就是個用戶買商品的流程而已嘛,確實,所以我們為啥要說他是個專門的系統呢。。
為什么要做所謂的“系統”
如果你的項目流量非常小,完全不用擔心有并發的購買請求,那么做這樣一個系統意義不大。
但如果你的系統要像12306那樣,接受高并發訪問和下單的考驗,那么你就需要一套完整的流程保護措施,來保證你系統在用戶流量高峰期不會被搞掛了。(就像12306剛開始網絡售票那幾年一樣)
這些措施有什么呢:
- 嚴格防止超賣:庫存100件你賣了120件,等著辭職吧
- 防止黑產:防止不懷好意的人群通過各種技術手段把你本該下發給群眾的利益全收入了囊中。
- 保證用戶體驗:高并發下,別網頁打不開了,支付不成功了,購物車進不去了,地址改不了了。這個問題非常之大,涉及到各種技術,也不是一下子就能講完的,甚至根本就沒法講完。
我們先從“防止超賣”開始吧
畢竟,你網頁可以卡住,最多是大家沒參與到活動,上網口吐芬芳,罵你一波。但是你要是賣多了,本該拿到商品的用戶可就不樂意了,輕則投訴你,重則找漏洞起訴賠償。讓你吃不了兜著走。
不能再說下去了,我這篇文章可是打著實戰文章的名頭,為什么我老是要講廢話啊啊啊啊啊啊。
上代碼。
說好的做“簡易”的秒殺系統,所以我們只用最簡單的SpringBoot項目
建立“簡易”的數據庫表結構
一開始我們先來張最最最簡易的結構表,參考了crossoverjie的秒殺系統文章。
等未來我們需要解決更多的系統問題,再擴展表結構。
一張庫存表stock,一張訂單表stock_order
-- ----------------------------
-- Table structure for stock
-- ----------------------------
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock` (`id` int(11) unsigned NOT NULL AUTO_INCREMENT,`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名稱',`count` int(11) NOT NULL COMMENT '庫存',`sale` int(11) NOT NULL COMMENT '已售',`version` int(11) NOT NULL COMMENT '樂觀鎖,版本號',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ----------------------------
-- Table structure for stock_order
-- ----------------------------
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order` (`id` int(11) unsigned NOT NULL AUTO_INCREMENT,`sid` int(11) NOT NULL COMMENT '庫存ID',`name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名稱',`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '創建時間',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
通過HTTP接口發起一次購買請求
代碼中我們采用最傳統的Spring MVC+Mybaits的結構
結構如下圖:
Controller層代碼
提供一個HTTP接口: 參數為商品的Id
@RequestMapping("/createWrongOrder/{sid}")
@ResponseBody
public String createWrongOrder(@PathVariable int sid) {LOGGER.info("購買物品編號sid=[{}]", sid);int id = 0;try {id = orderService.createWrongOrder(sid);LOGGER.info("創建訂單id: [{}]", id);} catch (Exception e) {LOGGER.error("Exception", e);}return String.valueOf(id);
}
Service層代碼
@Override
public int createWrongOrder(int sid) throws Exception {//校驗庫存Stock stock = checkStock(sid);//扣庫存saleStock(stock);//創建訂單int id = createOrder(stock);return id;
}private Stock checkStock(int sid) {Stock stock = stockService.getStockById(sid);if (stock.getSale().equals(stock.getCount())) {throw new RuntimeException("庫存不足");}return stock;
}private int saleStock(Stock stock) {stock.setSale(stock.getSale() + 1);return stockService.updateStockById(stock);
}private int createOrder(Stock stock) {StockOrder order = new StockOrder();order.setSid(stock.getId());order.setName(stock.getName());int id = orderMapper.insertSelective(order);return id;
}
發起并發購買請求
我們通過JMeter(https://jmeter.apache.org/) 這個并發請求工具來模擬大量用戶同時請求購買接口的場景。
注意:POSTMAN并不支持并發請求,其請求是順序的,而JMeter是多線程請求。希望以后PostMan能夠支持吧,畢竟JMeter還在倔強的用Java UI框架。畢竟是親兒子呢。
如何通過JMeter進行壓力測試,請參考下文,講的非常入門但詳細,包教包會:
https://www.cnblogs.com/stulzq/p/8971531.html
我們在表里添加一個Iphone,庫存100。(請忽略訂單表里的數據,開始前我清空了)
在JMeter里啟動1000個線程,無延遲同時訪問接口。模擬1000個人,搶購100個產品的場景。點擊啟動:
你猜會賣出多少個呢,先想一想。。。
答案是:
賣出了14個,庫存減少了14個,但是每個請求Spring都處理了,創建了1000個訂單。
我這里該夸Spring強大的并發處理能力,還是該罵MySQL已經是個成熟的數據庫,卻都不會自己鎖庫存?
避免超賣問題:更新商品庫存的版本號
為了解決上面的超賣問題,我們當然可以在Service層給更新表添加一個事務,這樣每個線程更新請求的時候都會先去鎖表的這一行(悲觀鎖),更新完庫存后再釋放鎖。可這樣就太慢了,1000個線程可等不及。
我們需要樂觀鎖。
一個最簡單的辦法就是,給每個商品庫存一個版本號version字段
我們修改代碼:
Controller層
/*** 樂觀鎖更新庫存* @param sid* @return*/
@RequestMapping("/createOptimisticOrder/{sid}")
@ResponseBody
public String createOptimisticOrder(@PathVariable int sid) {int id;try {id = orderService.createOptimisticOrder(sid);LOGGER.info("購買成功,剩余庫存為: [{}]", id);} catch (Exception e) {LOGGER.error("購買失敗:[{}]", e.getMessage());return "購買失敗,庫存不足";}return String.format("購買成功,剩余庫存為:%d", id);
}
Service層
@Override
public int createOptimisticOrder(int sid) throws Exception {//校驗庫存Stock stock = checkStock(sid);//樂觀鎖更新庫存saleStockOptimistic(stock);//創建訂單int id = createOrder(stock);return stock.getCount() - (stock.getSale()+1);
}private void saleStockOptimistic(Stock stock) {LOGGER.info("查詢數據庫,嘗試更新庫存");int count = stockService.updateStockByOptimistic(stock);if (count == 0){throw new RuntimeException("并發更新庫存失敗,version不匹配") ;}
}
?
Mapper
<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock">update stock<set>sale = sale + 1,version = version + 1,</set>WHERE id = #{id,jdbcType=INTEGER}AND version = #{version,jdbcType=INTEGER}</update>
我們在實際減庫存的SQL操作中,首先判斷version是否是我們查詢庫存時候的version,如果是,扣減庫存,成功搶購。如果發現version變了,則不更新數據庫,返回搶購失敗。
發起并發購買請求
這次,我們能成功嗎?
再次打開JMeter,把庫存恢復為100,清空訂單表,發起1000次請求。
這次的結果是:
賣出去了39個,version更新為了39,同時創建了39個訂單。我們沒有超賣,可喜可賀。
由于并發訪問的原因,很多線程更新庫存失敗了,所以在我們這種設計下,1000個人真要是同時發起購買,只有39個幸運兒能夠買到東西,但是我們防止了超賣。
手速快未必好,還得看運氣呀!
OK,今天先到這里,之后我們繼續一步步完善這個簡易的秒殺系統,它總有從樹苗變成大樹的那一天!
源碼
我會隨著文章的更新,一直同步更新項目代碼,歡迎關注:
https://github.com/qqxx6661/miaosha
參考
- https://cloud.tencent.com/developer/article/1488059
- https://juejin.im/post/5dd09f5af265da0be72aacbd
- https://crossoverjie.top/%2F2018%2F05%2F07%2Fssm%2FSSM18-seconds-kill%2F
關注我
我是一名后端開發工程師。
主要關注后端開發,數據安全,物聯網,邊緣計算方向,歡迎交流。
各大平臺都可以找到我
- 微信公眾號:后端技術漫談
- Github:@qqxx6661
- CSDN:@Rude3knife
- 知乎:@后端技術漫談
- 簡書:@蠻三刀把刀
- 掘金:@蠻三刀把刀
原創博客主要內容
- 后端開發技術
- Java面試知識點
- 設計模式/數據結構
- LeetCode/劍指offer 算法題解析
- SpringBoot/SpringCloud入門實戰系列
- 數據分析/數據爬蟲
- 逸聞趣事/好書分享/個人生活
個人公眾號:后端技術漫談
如果文章對你有幫助,不妨收藏,轉發,在看起來~