首頁  >  文章  >  web前端  >  淺談使用nodejs設計一個秒殺系統的方法

淺談使用nodejs設計一個秒殺系統的方法

青灯夜游
青灯夜游轉載
2021-04-21 09:45:332984瀏覽

這篇文章要跟大家介紹一下使用nodejs設計一個秒殺系統的方法。有一定的參考價值,有需要的朋友可以參考一下,希望對大家有幫助。

淺談使用nodejs設計一個秒殺系統的方法

對前端來說,「並發」場景很少遇到,本文將從常見的秒殺場景,來講講一個真實線上的node應用程式遇到「並發」將會用到什麼技術。本文範例程式碼資料庫基於MongoDB,快取基於Redis。 【相關推薦:《nodejs 教學》】

場景一:領券


規則:一個使用者只能領取一張券。

首先我們的思路是,用一個records表來保存用戶的領券記錄,用戶領券時在該表查詢是否已領取。

records架構如下

new Schema({
  // 用户id
  userId: {
    type: String,
    required: true,
  },
});

業務流程也很簡單:

淺談使用nodejs設計一個秒殺系統的方法

#MongoDB實作

範例程式碼如下:

  async grantCoupon(userId: string) {
    const record = await this.recordsModel.findOne({
      userId,
    });
    if (record) {
      return false;
    } else {
      this.grantCoupon();
      this.recordModel.create({
        userId,
      });
    }
  }

postman測試一下,好像沒問題。然後我們考慮並發場景,例如「使用者」並不會乖乖的點一下按鈕等待發券,而是快速點擊,又或者使用工具並發請求領券接口,我們的程式會出問題麼? (並發問題前端可以用loading來規避,但是介面必要攔截住,防止駭客攻擊)

結果是,使用者可能會領取到多張券。問題就出在查詢records新增領券記錄,這兩步是分開進行的,也就是存在一個時間點:查詢到用戶A無領券記錄,發券後A用戶又請求一次接口,此時records表資料插入操作還未完成,導致重複發放問題。

解決也很容易,就是如何讓查詢和插入語句一起執行,消除中間的非同步過程。 mongoose為我們提供了findOneAndUpdate,即尋找並修改,下面看一下改寫後的語句:

async grantCoupon(userId: string) {
  const record = await this.recordModel.findOneAndUpdate({
    userId,
  }, {
    $setOnInsert: {
      userId,
    },
  }, {
    new: false,
    upsert: true,
  });
  if (! record) {
    this.grantCoupon();
  }
}

實際上這是一個mongo的原子操作,第一個參數是查詢語句,查詢userId的條目,第二個參數$setOnInsert表示新增的時候插入的字段,第三個參數upsert=true表示如果查詢的條目不存在,將新建它,new=false表示返回查詢的條目而不是修改後的條目。那我們只用判斷查詢的record不存在,就執行發放邏輯,而插入語句是和查詢語句一起執行的。即使此時有並發請求進來,下一次查詢是在上次插入語句之後了。

原子(atomic),本意是指「不能被進一步分割的粒子」。原子操作意味著“不可被中斷的一個或一系列操作”,兩個原子操作不可能同時作用於同一個變數。

Redis實作

不只MongoDB,redis也很適合這個邏輯,下面用redis實作一下:

async grantCoupon(userId: string) {
  const result = await this.redis.setnx(userId, 'true');
  if (result === 1) {
    this.grantCoupon();
  }
}

同樣setnx是redis的一個原子操作,表示:如果key沒有值,則將值設為進去,如果已有值就不做處理,提示失敗。這裡只是示範並發處理,實際線上服務還需要考慮:

  • key值不能與其他應用程式衝突使用,如套用名稱功能名稱userId
  • 服務下線後redis的key需要清理,或是直接在setnx第三個參數加上過期時間
  • redis資料只在記憶體中,發券記錄需要入庫保存

##場景二:庫存限制


#規則:券總庫存一定,單一使用者不限領取數量

有了上面的範例,類似並發也很好實現,直接上程式碼

MongoDB實作

使用

stocks表來記錄券的發放數量,當然我們需要一個couponId欄位去標識這條記錄

表格結構:

new Schema({
  /* 券标识 */
  couponId: {
    type: String,
    required: true,
  },
  /* 已发放数量 */
  count: {
    type: Number,
    default: 0,
  },
});

發放邏輯:

async grantCoupon(userId: string) {
  const couponId = 'coupon-1'; // 券标识
  const total = 100; // 总库存
  const result = await this.stockModel.findOneAndUpdate({
    couponId,
  }, {
    $inc: {
      count: 1,
    },
    $setOnInsert: {
      couponId,
    },
  }, {
    new: true, // 返回modify后结果
    upsert: true, // 不存在则新增
  });
  if (result.count <= total) {
    this.grantCoupon();
  }
}

# Redis實作

incr: 原子操作,將key的值1,如果值不存在,將初始化為0;

async grantCoupon(userId: string) {
  const total = 100; // 总库存
  const result = await this.redis.incr(&#39;coupon-1&#39;);
  if (result <= total) {
    this.grantCoupon();
  }
}

思考一個問題,庫存全部消耗完之後,

count欄位還會增加麼?應該如何優化?

場景三:用戶領券限制庫存限制


#規則:一個用戶只能領一張券,總庫存有限制

解析

單獨去解決“一個用戶只能領一張”或“總庫存限制”,我們都可以用原子操作去處理,當有兩個條件,那是否可以實現一個,類似原子操作將“一個用戶只能領一張”和“總庫存限制”合併操作,或者說是更類似於數據庫的“事務”

数据库事务( transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成

mongoDB已经从4.0开始支持事务,但这里作为演示,我们还是使用代码逻辑来控制并发

业务逻辑:

淺談使用nodejs設計一個秒殺系統的方法

代码:

async grantCoupon(userId: string) {
  const couponId = &#39;coupon-1&#39;;// 券标识
  const totalStock = 100;// 总库存
  // 查询用户是否已领过券
  const recordByFind = await this.recordModel.findOne({
    couponId,
    userId,
  });
  if (recordByFind) {
    return &#39;每位用户只能领一张&#39;;
  }
  // 查询已发放数量
  const grantedCount = await this.stockModel.findOne({
    couponId,
  });
  if (grantedCount >= totalStock) {
    return &#39;超过库存限制&#39;;
  }
  // 原子操作:已发放数量+1,并返回+1后的结果
  const result = await this.stockModel.findOneAndUpdate({
    couponId,
  }, {
    $inc: {
      count: 1,
    },
    $setOnInsert: {
      couponId,
    },
  }, {
    new: true, // 返回modify后结果
    upsert: true, // 如果不存在就新增
  });
  // 根据+1后的的结果判断是否超出库存
  if (result.count > totalStock) {
    // 超出后执行-1操作,保证数据库中记录的已发放数量准确。
    this.stockModel.findOneAndUpdate({
      couponId,
    }, {
      $inc: {
        count: -1,
      },
    });
    return &#39;超过库存限制&#39;;
  }
  // 原子操作:records表新增用户领券记录,并返回新增前的查询结果
  const recordBeforeModify = await this.recordModel.findOneAndUpdate({
    couponId,
    userId,
  }, {
    $setOnInsert: {
      userId,
    },
  }, {
    new: false, // 返回modify后结果
    upsert: true, // 如果不存在就新增
  });
  if (recordBeforeModify) {
    // 超出后执行-1操作,保证数据库中记录的已发放数量准确。
    this.stockModel.findOneAndUpdate({
      couponId,
    }, {
      $inc: {
        count: -1,
      },
    });
    return &#39;每位用户只能领一张&#39;;
  }
  // 上述条件都满足,才执行发放操作
  this.grantCoupon();
}

其实我们可以舍去前两部查询records记录和查询库存数量,结果并不会出问题。从数据库优化来说,显然更改比查询更耗时,而且库存有限,最终库存消耗完,后面请求都会在前两步逻辑中走完。

  • 什么情况下会走到第3步的左分支?

场景举例:库存仅剩1个,此时用户A和用户B同时请求,此时A稍快一点,库存+1后=100,B库存+1=101;

  • 什么情况下会走到第4步的左分支?

场景举例:A用户同时发出两个请求,库存+1后均小于100,则稍快的一次请求会成功,另一个会查询到已有领券记录

  • 思考:什么情况下会出现,先请求的用户没抢到券,反而靠后的用户能抢到券?

库存还剩4个,A用户发起大量请求,最终导致数据库记录的已发放库存大于100,-1操作还全部执行完成,而此时B、C、D用户也同时请求,则会返回超出库存,待到库存回滚操作完成,E、F、G用户后续请求的反而显示还有库存,成功抢到券,当然这只是理论上可能存在的情况。

总结


设计一个秒杀系统,其实还要考虑很多情况。如大型电商的秒杀活动,一次有几万的并发请求,服务器可能都支撑不住,可能会再网关层直接舍弃部分用户请求,减少服务器压力,或结合kafka消息队列,或使用动态扩容等技术。

更多编程相关知识,请访问:编程入门!!

以上是淺談使用nodejs設計一個秒殺系統的方法的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除