小編給大家分享一下如何使用nodejs設(shè)計(jì)一個(gè)秒殺系統(tǒng)的方法,希望大家閱讀完這篇文章之后都有所收獲,下面讓我們一起去探討吧!
創(chuàng)新互聯(lián)公司科技有限公司專業(yè)互聯(lián)網(wǎng)基礎(chǔ)服務(wù)商,為您提供遂寧服務(wù)器托管,高防服務(wù)器,成都IDC機(jī)房托管,成都主機(jī)托管等互聯(lián)網(wǎng)服務(wù)。
1、能夠嵌入動態(tài)文本于HTML頁面。2、對瀏覽器事件做出響應(yīng)。3、讀寫HTML元素。4、在數(shù)據(jù)被提交到服務(wù)器之前驗(yàn)證數(shù)據(jù)。5、檢測訪客的瀏覽器信息。6、控制cookies,包括創(chuàng)建和修改等。7、基于Node.js技術(shù)進(jìn)行服務(wù)器端編程。
對于前端來說,“并發(fā)”場景很少遇到,本文將從常見的的秒殺場景,來講講一個(gè)真實(shí)線上的node應(yīng)用遇到“并發(fā)”將會用到什么技術(shù)。本文示例代碼數(shù)據(jù)庫基于MongoDB,緩存基于redis。
規(guī)則:一個(gè)用戶只能領(lǐng)取一張券。
首先我們的思路是,用一個(gè)records表來保存用戶的領(lǐng)券記錄,用戶領(lǐng)券時(shí)在該表查詢是否已領(lǐng)取。
records結(jié)構(gòu)如下
new Schema({ // 用戶id userId: { type: String, required: true, }, });
業(yè)務(wù)流程也很簡單:
MongoDB實(shí)現(xiàn)
示例代碼如下:
async grantCoupon(userId: string) { const record = await this.recordsModel.findOne({ userId, }); if (record) { return false; } else { this.grantCoupon(); this.recordModel.create({ userId, }); } }
postman測試一下,好像沒問題。然后我們考慮并發(fā)場景,比如“用戶”并不會乖乖的點(diǎn)一下按鈕等待發(fā)券,而是快速點(diǎn)擊,又或者使用工具并發(fā)請求領(lǐng)券接口,我們的程序會出問題么?(并發(fā)問題前端可以用loading來規(guī)避,但是接口必要攔截住,防止黑客攻擊)
結(jié)果是,用戶可能會領(lǐng)取到多張券。問題就出在查詢r(jià)ecords
與新增領(lǐng)券記錄
,這兩步是分開進(jìn)行的,也就是存在一個(gè)時(shí)間點(diǎn):查詢到用戶A無領(lǐng)券記錄,發(fā)券后A用戶又請求一次接口,此時(shí)records表數(shù)據(jù)插入操作還未完成,導(dǎo)致重復(fù)發(fā)放問題。
解決也很容易,就是如何讓查詢和插入語句一起執(zhí)行,消除中間的異步過程。mongoose為我們提供了findOneAndUpdate
,即查找并修改,下面看一下改寫后的語句:
async grantCoupon(userId: string) { const record = await this.recordModel.findOneAndUpdate({ userId, }, { $setOnInsert: { userId, }, }, { new: false, upsert: true, }); if (! record) { this.grantCoupon(); } }
實(shí)際上這是一個(gè)mongo的原子操作,第一個(gè)參數(shù)是查詢語句,查詢userId的條目,第二個(gè)參數(shù)$setOnInsert表示新增的時(shí)候插入的字段,第三個(gè)參數(shù)upsert=true表示如果查詢的條目不存在,將新建它,new=false表示返回查詢的條目而不是修改后的條目。那我們只用判斷查詢的record不存在,就執(zhí)行發(fā)放邏輯,而插入語句是和查詢語句一起執(zhí)行的。即使此時(shí)有并發(fā)請求進(jìn)來,下一次查詢是在上次插入語句之后了。
原子(atomic),本意是指“不能被進(jìn)一步分割的粒子”。原子操作意味著“不可被中斷的一個(gè)或一系列操作”,兩個(gè)原子操作不可能同時(shí)作用于同一個(gè)變量。
Redis實(shí)現(xiàn)
不止MongoDB,redis也很適合這種邏輯,下面用redis實(shí)現(xiàn)一下:
async grantCoupon(userId: string) { const result = await this.redis.setnx(userId, 'true'); if (result === 1) { this.grantCoupon(); } }
同樣setnx是redis的一個(gè)原子操作,表示:如果key沒有值,則將值設(shè)置進(jìn)去,如果已有值就不做處理,提示失敗。這里只是演示并發(fā)處理,實(shí)際線上服務(wù)還需要考慮:
key值不能與其他應(yīng)用沖突使用,如應(yīng)用名稱+功能名稱+userId
服務(wù)下線后redis的key需要清理,或者直接在setnx第三個(gè)參數(shù)加上過期時(shí)間
redis數(shù)據(jù)只在內(nèi)存中,發(fā)券記錄需要入庫保存
規(guī)則:券總庫存一定,單個(gè)用戶不限領(lǐng)取數(shù)量
有了上面的示例,類似并發(fā)也很好實(shí)現(xiàn),直接上代碼
MongoDB實(shí)現(xiàn)
使用stocks
表來記錄券的發(fā)放數(shù)量,當(dāng)然我們需要一個(gè)couponId字段去標(biāo)識這條記錄
表結(jié)構(gòu):
new Schema({ /* 券標(biāo)識 */ couponId: { type: String, required: true, }, /* 已發(fā)放數(shù)量 */ count: { type: Number, default: 0, }, });
發(fā)放邏輯:
async grantCoupon(userId: string) { const couponId = 'coupon-1'; // 券標(biāo)識 const total = 100; // 總庫存 const result = await this.stockModel.findOneAndUpdate({ couponId, }, { $inc: { count: 1, }, $setOnInsert: { couponId, }, }, { new: true, // 返回modify后結(jié)果 upsert: true, // 不存在則新增 }); if (result.count <= total) { this.grantCoupon(); } }
Redis實(shí)現(xiàn)
incr: 原子操作,將key的值+1,如果值不存在,將初始化為0;
async grantCoupon(userId: string) { const total = 100; // 總庫存 const result = await this.redis.incr('coupon-1'); if (result <= total) { this.grantCoupon(); } }
思考一個(gè)問題,庫存全部消耗完后,count
字段還會增加么?應(yīng)該如何優(yōu)化?
規(guī)則:一個(gè)用戶只能領(lǐng)一張券,總庫存有限制
解析
單獨(dú)去解決“一個(gè)用戶只能領(lǐng)一張”或“總庫存限制”,我們都可以用原子操作去處理,當(dāng)有兩個(gè)條件,那是否可以實(shí)現(xiàn)一個(gè),類似原子操作將“一個(gè)用戶只能領(lǐng)一張”和“總庫存限制”合并操作,或者說是更類似于數(shù)據(jù)庫的“事務(wù)”
數(shù)據(jù)庫事務(wù)( transaction)是訪問并可能操作各種數(shù)據(jù)項(xiàng)的一個(gè)數(shù)據(jù)庫操作序列,這些操作要么全部執(zhí)行,要么全部不執(zhí)行,是一個(gè)不可分割的工作單位。事務(wù)由事務(wù)開始與事務(wù)結(jié)束之間執(zhí)行的全部數(shù)據(jù)庫操作組成
mongoDB已經(jīng)從4.0開始支持事務(wù),但這里作為演示,我們還是使用代碼邏輯來控制并發(fā)
業(yè)務(wù)邏輯:
代碼:
async grantCoupon(userId: string) { const couponId = 'coupon-1';// 券標(biāo)識 const totalStock = 100;// 總庫存 // 查詢用戶是否已領(lǐng)過券 const recordByFind = await this.recordModel.findOne({ couponId, userId, }); if (recordByFind) { return '每位用戶只能領(lǐng)一張'; } // 查詢已發(fā)放數(shù)量 const grantedCount = await this.stockModel.findOne({ couponId, }); if (grantedCount >= totalStock) { return '超過庫存限制'; } // 原子操作:已發(fā)放數(shù)量+1,并返回+1后的結(jié)果 const result = await this.stockModel.findOneAndUpdate({ couponId, }, { $inc: { count: 1, }, $setOnInsert: { couponId, }, }, { new: true, // 返回modify后結(jié)果 upsert: true, // 如果不存在就新增 }); // 根據(jù)+1后的的結(jié)果判斷是否超出庫存 if (result.count > totalStock) { // 超出后執(zhí)行-1操作,保證數(shù)據(jù)庫中記錄的已發(fā)放數(shù)量準(zhǔn)確。 this.stockModel.findOneAndUpdate({ couponId, }, { $inc: { count: -1, }, }); return '超過庫存限制'; } // 原子操作:records表新增用戶領(lǐng)券記錄,并返回新增前的查詢結(jié)果 const recordBeforeModify = await this.recordModel.findOneAndUpdate({ couponId, userId, }, { $setOnInsert: { userId, }, }, { new: false, // 返回modify后結(jié)果 upsert: true, // 如果不存在就新增 }); if (recordBeforeModify) { // 超出后執(zhí)行-1操作,保證數(shù)據(jù)庫中記錄的已發(fā)放數(shù)量準(zhǔn)確。 this.stockModel.findOneAndUpdate({ couponId, }, { $inc: { count: -1, }, }); return '每位用戶只能領(lǐng)一張'; } // 上述條件都滿足,才執(zhí)行發(fā)放操作 this.grantCoupon(); }
其實(shí)我們可以舍去前兩部查詢r(jià)ecords記錄和查詢庫存數(shù)量,結(jié)果并不會出問題。從數(shù)據(jù)庫優(yōu)化來說,顯然更改比查詢更耗時(shí),而且?guī)齑嬗邢?,最終庫存消耗完,后面請求都會在前兩步邏輯中走完。
什么情況下會走到第3步的左分支?
場景舉例:庫存僅剩1個(gè),此時(shí)用戶A和用戶B同時(shí)請求,此時(shí)A稍快一點(diǎn),庫存+1后=100,B庫存+1=101;
什么情況下會走到第4步的左分支?
場景舉例:A用戶同時(shí)發(fā)出兩個(gè)請求,庫存+1后均小于100,則稍快的一次請求會成功,另一個(gè)會查詢到已有領(lǐng)券記錄
思考:什么情況下會出現(xiàn),先請求的用戶沒搶到券,反而靠后的用戶能搶到券?
庫存還剩4個(gè),A用戶發(fā)起大量請求,最終導(dǎo)致數(shù)據(jù)庫記錄的已發(fā)放庫存大于100,-1操作還全部執(zhí)行完成,而此時(shí)B、C、D用戶也同時(shí)請求,則會返回超出庫存,待到庫存回滾操作完成,E、F、G用戶后續(xù)請求的反而顯示還有庫存,成功搶到券,當(dāng)然這只是理論上可能存在的情況。
看完了這篇文章,相信你對“如何使用nodejs設(shè)計(jì)一個(gè)秒殺系統(tǒng)的方法”有了一定的了解,如果想了解更多相關(guān)知識,歡迎關(guān)注創(chuàng)新互聯(lián)行業(yè)資訊頻道,感謝各位的閱讀!