以下文章來源于公眾號后端技術(shù)漫談 欲账,
作者蠻三刀把刀
前言
本文主要是通過實際代碼講解屡江,幫助你一步步搭建一個簡易的秒殺系統(tǒng)。從而快速的了解秒殺系統(tǒng)的主要難點赛不,并且迅速上手實際項目惩嘉。
我對秒殺系統(tǒng)文章的規(guī)劃:
從零開始打造簡易秒殺系統(tǒng):樂觀鎖防止超賣
從零開始打造簡易秒殺系統(tǒng):令牌桶限流
從零開始打造簡易秒殺系統(tǒng):Redis 緩存
從零開始打造簡易秒殺系統(tǒng):消息隊列異步處理訂單
…
秒殺系統(tǒng)
秒殺系統(tǒng)介紹
秒殺系統(tǒng)相信網(wǎng)上已經(jīng)介紹了很多了,我也不想黏貼很多定義過來了踢故。
廢話少說文黎,秒殺系統(tǒng)主要應(yīng)用在商品搶購的場景,比如:
電商搶購限量商品
賣周董演唱會的門票
火車票搶座
…
秒殺系統(tǒng)抽象來說就是以下幾個步驟:
用戶選定商品下單
校驗庫存
扣庫存
創(chuàng)建用戶訂單
用戶支付等后續(xù)步驟…
聽起來就是個用戶買商品的流程而已嘛殿较,確實耸峭,所以我們?yōu)樯兑f他是個專門的系統(tǒng)呢。淋纲。
為什么要做所謂的“系統(tǒng)”
如果你的項目流量非常小劳闹,完全不用擔心有并發(fā)的購買請求,那么做這樣一個系統(tǒng)意義不大。
但如果你的系統(tǒng)要像12306那樣本涕,接受高并發(fā)訪問和下單的考驗业汰,那么你就需要一套完整的流程保護措施,來保證你系統(tǒng)在用戶流量高峰期不會被搞掛了菩颖。(就像12306剛開始網(wǎng)絡(luò)售票那幾年一樣)
這些措施有什么呢:
嚴格防止超賣:庫存100件你賣了120件样漆,等著辭職吧
防止黑產(chǎn):防止不懷好意的人群通過各種技術(shù)手段把你本該下發(fā)給群眾的利益全收入了囊中。
保證用戶體驗:高并發(fā)下晦闰,別網(wǎng)頁打不開了放祟,支付不成功了,購物車進不去了呻右,地址改不了了跪妥。這個問題非常之大,涉及到各種技術(shù)窿冯,也不是一下子就能講完的骗奖,甚至根本就沒法講完。
我們先從“防止超賣”開始吧
畢竟醒串,你網(wǎng)頁可以卡住执桌,最多是大家沒參與到活動,上網(wǎng)口吐芬芳芜赌,罵你一波仰挣。但是你要是賣多了,本該拿到商品的用戶可就不樂意了缠沈,輕則投訴你膘壶,重則找漏洞起訴賠償。讓你吃不了兜著走洲愤。
不能再說下去了颓芭,我這篇文章可是打著實戰(zhàn)文章的名頭,為什么我老是要講廢話啊啊啊啊啊啊柬赐。
上代碼亡问。
說好的做“簡易”的秒殺系統(tǒng),所以我們只用最簡單的SpringBoot項目
建立“簡易”的數(shù)據(jù)庫表結(jié)構(gòu)
一開始我們先來張最最最簡易的結(jié)構(gòu)表肛宋,參考了crossoverjie的秒殺系統(tǒng)文章州藕。
等未來我們需要解決更多的系統(tǒng)問題,再擴展表結(jié)構(gòu)酝陈。
一張庫存表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 '創(chuàng)建時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
通過HTTP接口發(fā)起一次購買請求
代碼中我們采用最傳統(tǒng)的Spring MVC+Mybaits的結(jié)構(gòu)
結(jié)構(gòu)如下圖:
Controller層代碼
提供一個HTTP接口: 參數(shù)為商品的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("創(chuàng)建訂單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);
//創(chuàng)建訂單
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;
}
發(fā)起并發(fā)購買請求
我們通過JMeter(https://jmeter.apache.org/) 這個并發(fā)請求工具來模擬大量用戶同時請求購買接口的場景沉帮。
注意:POSTMAN并不支持并發(fā)請求锈死,其請求是順序的贫堰,而JMeter是多線程請求。希望以后PostMan能夠支持吧馅精,畢竟JMeter還在倔強的用Java UI框架严嗜。畢竟是親兒子呢粱檀。
如何通過JMeter進行壓力測試洲敢,請參考下文,講的非常入門但詳細茄蚯,包教包會:
https://www.cnblogs.com/stulzq/p/8971531.html
我們在表里添加一個Iphone压彭,庫存100。(請忽略訂單表里的數(shù)據(jù)渗常,開始前我清空了)
在JMeter里啟動1000個線程壮不,無延遲同時訪問接口。模擬1000個人皱碘,搶購100個產(chǎn)品的場景询一。點擊啟動:
你猜會賣出多少個呢,先想一想癌椿。健蕊。。
答案是:
賣出了14個踢俄,庫存減少了14個缩功,但是每個請求Spring都處理了,創(chuàng)建了1000個訂單都办。
我這里該夸Spring強大的并發(fā)處理能力嫡锌,還是該罵MySQL已經(jīng)是個成熟的數(shù)據(jù)庫,卻都不會自己鎖庫存琳钉?
避免超賣問題:更新商品庫存的版本號
為了解決上面的超賣問題势木,我們當然可以在Service層給更新表添加一個事務(wù),這樣每個線程更新請求的時候都會先去鎖表的這一行(悲觀鎖)歌懒,更新完庫存后再釋放鎖啦桌。可這樣就太慢了歼培,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);
//創(chuàng)建訂單
int id = createOrder(stock);
return stock.getCount() - (stock.getSale()+1);
}
private void saleStockOptimistic(Stock stock) {
LOGGER.info("查詢數(shù)據(jù)庫,嘗試更新庫存");
int count = stockService.updateStockByOptimistic(stock);
if (count == 0){
throw new RuntimeException("并發(fā)更新庫存失敗,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菌仁,如果是,扣減庫存静暂,成功搶購济丘。如果發(fā)現(xiàn)version變了,則不更新數(shù)據(jù)庫洽蛀,返回搶購失敗摹迷。
發(fā)起并發(fā)購買請求
這次,我們能成功嗎郊供?
再次打開JMeter峡碉,把庫存恢復(fù)為100,清空訂單表驮审,發(fā)起1000次請求鲫寄。
這次的結(jié)果是:
賣出去了39個攒驰,version更新為了39,同時創(chuàng)建了39個訂單叁扫。我們沒有超賣,可喜可賀涛目。
由于并發(fā)訪問的原因峡竣,很多線程更新庫存失敗了靠抑,所以在我們這種設(shè)計下,1000個人真要是同時發(fā)起購買适掰,只有39個幸運兒能夠買到東西颂碧,但是我們防止了超賣。
手速快未必好类浪,還得看運氣呀载城!
OK,今天先到這里费就,之后我們繼續(xù)一步步完善這個簡易的秒殺系統(tǒng)诉瓦,它總有從樹苗變成大樹的那一天!