秒殺系統(tǒng)如何防止超賣?

以下文章來源于公眾號后端技術(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)如下圖:

image

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ù)渗常,開始前我清空了)

image

在JMeter里啟動1000個線程壮不,無延遲同時訪問接口。模擬1000個人皱碘,搶購100個產(chǎn)品的場景询一。點擊啟動:

image

你猜會賣出多少個呢,先想一想癌椿。健蕊。。

答案是:

賣出了14個踢俄,庫存減少了14個缩功,但是每個請求Spring都處理了,創(chuàng)建了1000個訂單都办。

image

我這里該夸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個訂單叁扫。我們沒有超賣,可喜可賀涛目。

image

由于并發(fā)訪問的原因峡竣,很多線程更新庫存失敗了靠抑,所以在我們這種設(shè)計下,1000個人真要是同時發(fā)起購買适掰,只有39個幸運兒能夠買到東西颂碧,但是我們防止了超賣。

手速快未必好类浪,還得看運氣呀载城!

image

OK,今天先到這里费就,之后我們繼續(xù)一步步完善這個簡易的秒殺系統(tǒng)诉瓦,它總有從樹苗變成大樹的那一天!

參考

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末力细,一起剝皮案震驚了整個濱河市睬澡,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌眠蚂,老刑警劉巖煞聪,帶你破解...
    沈念sama閱讀 221,406評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異逝慧,居然都是意外死亡昔脯,警方通過查閱死者的電腦和手機啄糙,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,395評論 3 398
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來云稚,“玉大人隧饼,你說我怎么就攤上這事【渤拢” “怎么了燕雁?”我有些...
    開封第一講書人閱讀 167,815評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長窿给。 經(jīng)常有香客問我贵白,道長,這世上最難降的妖魔是什么崩泡? 我笑而不...
    開封第一講書人閱讀 59,537評論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮猬膨,結(jié)果婚禮上角撞,老公的妹妹穿的比我還像新娘。我一直安慰自己勃痴,他們只是感情好谒所,可當我...
    茶點故事閱讀 68,536評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著沛申,像睡著了一般劣领。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上铁材,一...
    開封第一講書人閱讀 52,184評論 1 308
  • 那天尖淘,我揣著相機與錄音,去河邊找鬼著觉。 笑死村生,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的饼丘。 我是一名探鬼主播趁桃,決...
    沈念sama閱讀 40,776評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼肄鸽!你這毒婦竟也來了卫病?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,668評論 0 276
  • 序言:老撾萬榮一對情侶失蹤典徘,失蹤者是張志新(化名)和其女友劉穎蟀苛,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體烂斋,經(jīng)...
    沈念sama閱讀 46,212評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡屹逛,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,299評論 3 340
  • 正文 我和宋清朗相戀三年础废,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片罕模。...
    茶點故事閱讀 40,438評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡评腺,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出淑掌,到底是詐尸還是另有隱情蒿讥,我是刑警寧澤,帶...
    沈念sama閱讀 36,128評論 5 349
  • 正文 年R本政府宣布抛腕,位于F島的核電站芋绸,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏担敌。R本人自食惡果不足惜摔敛,卻給世界環(huán)境...
    茶點故事閱讀 41,807評論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望全封。 院中可真熱鬧马昙,春花似錦、人聲如沸刹悴。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,279評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽土匀。三九已至子房,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間就轧,已是汗流浹背证杭。 一陣腳步聲響...
    開封第一講書人閱讀 33,395評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留钓丰,地道東北人躯砰。 一個月前我還...
    沈念sama閱讀 48,827評論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像携丁,于是被迫代替她去往敵國和親琢歇。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,446評論 2 359

推薦閱讀更多精彩內(nèi)容