博客 / 詳情

返回

Redis應用實戰 - 秒殺場景(Node.js版本)

寫在前面

公司隨着業務量的增加,最近用時幾個月時間在項目中全面接入Redis,開發過程中發現市面上缺少具體的實戰資料,尤其是在Node.js環境下,能找到的資料要麼過於簡單入門,要麼名不副實,大部分都是屬於初級。因此決定把公司這段時間的成果進行分享,會用幾篇文章詳細介紹Redis的幾個使用場景,期望大家一起學習、進步。
下面就開始第一篇,秒殺場景。

業務分析

實際業務中,秒殺包含了許多場景,具體可以分為秒殺前、秒殺中和秒殺後三個階段,從開發角度具體分析如下:

  1. 秒殺前:主要是做好緩存工作,以應對用户頻繁的訪問,因為數據是固定的,可以把商品詳情頁的元素靜態化,然後用CDN或者是瀏覽器進行緩存。
  2. 秒殺中:主要是庫存查驗,庫存扣減和訂單處理,這一步的特點是

    • 短時間內大量用户同時進行搶購,系統的流量突然激增,服務器壓力瞬間增大(瞬時併發訪問高)
    • 請求數量大於商品庫存,比如10000個用户搶購,但是庫存只有100
    • 限定用户只能在一定時間段內購買
    • 限制單個用户購買數量,避免刷單
    • 搶購是跟數據庫打交道,核心功能是下單,庫存不能扣成負數
    • 對數據庫的操作讀多寫少,而且讀操作相對簡單
  3. 秒殺後:主要是一些用户查看已購訂單、處理退款和處理物流等等操作,這時候用户請求量已經下降,操作也相對簡單,服務器壓力不大。

根據上述分析,本文把重點放在秒殺中的開發講解,其他部分感興趣的小夥伴可以自己搜索資料,進行嘗試。

開發環境

數據庫:Redis 3.2.9 + Mysql 5.7.18
服務器:Node.js v10.15.0
測試工具:Jmeter-5.4.1

實戰

數據庫準備


如圖所示,Mysql中需要創建三張表,分別是

  • 產品表,用於記錄產品信息,字段分別為Id、名稱、縮略圖、價格和狀態等等
  • 秒殺活動表,用於記錄秒殺活動的詳細信息,字段分別為Id、參與秒殺的產品Id、庫存量、秒殺開始時間、秒殺結束時間和秒殺活動是否有效等等
  • 訂單表,用於記錄下單後的數據,字段分別為Id、訂單號、產品Id、購買用户Id、訂單狀態、訂單類型和秒殺活動Id等等

下面是創建sql語句,以供參考

CREATE TABLE `seckill_goods` (
    `id` INTEGER NOT NULL auto_increment,
    `fk_good_id` INTEGER,
    `amount` INTEGER,
    `start_time` DATETIME,
    `end_time` DATETIME,
    `is_valid` TINYINT ( 1 ),
    `comment` VARCHAR ( 255 ),
    `created_at` DATETIME NOT NULL,
    `updated_at` DATETIME NOT NULL,
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `orders` (
    `id` INTEGER NOT NULL auto_increment,
    `order_no` VARCHAR ( 255 ),
    `good_id` INTEGER,
    `user_id` INTEGER,
    `status` ENUM ( '-1', '0', '1', '2' ),
    `order_type` ENUM ( '1', '2' ),
    `scekill_id` INTEGER,
    `comment` VARCHAR ( 255 ),
    `created_at` DATETIME NOT NULL,
    `updated_at` DATETIME NOT NULL,
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `goods` (
    `id` INTEGER NOT NULL auto_increment,
    `name` VARCHAR ( 255 ),
    `thumbnail` VARCHAR ( 255 ),
    `price` INTEGER,
    `status` TINYINT ( 1 ),
    `stock` INTEGER,
    `stock_left` INTEGER,
    `description` VARCHAR ( 255 ),
    `comment` VARCHAR ( 255 ),
    `created_at` DATETIME NOT NULL,
    `updated_at` DATETIME NOT NULL,
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;

產品表在此次業務中不是重點,以下邏輯都以id=1的產品為示例,請悉知。
秒殺活動表中創建一條庫存為200的記錄,作為秒殺測試數據,參考下面語句:

INSERT INTO `redis_app`.`seckill_goods` (
    `id`,
    `fk_good_id`,
    `amount`,
    `start_time`,
    `end_time`,
    `is_valid`,
    `comment`,
    `created_at`,
    `updated_at` 
)
VALUES
    (
        1,
        1,
        200,
        '2020-06-20 00:00:00',
        '2023-06-20 00:00:00',
        1,
        '...',
        '2020-06-20 00:00:00',
        '2021-06-22 10:18:16' 
    );

秒殺接口開發

首先,説一下Node.js中的具體開發環境:

  • web框架使用Koa2
  • mysql操作使用基於promiseNode.js ORM工具Sequelize
  • redis操作使用ioredis
  • 封裝ctx.throwException方法用於處理錯誤,封裝ctx.send方法用於返回正確結果,具體實現參考文末完整代碼

其次,分析一下接口要處理的邏輯,大概步驟和順序如下:

  1. 基本參數校驗
  2. 判斷產品是否加入了搶購
  3. 判斷秒殺活動是否有效
  4. 判斷秒殺活動是否開始、結束
  5. 判斷秒殺商品是否賣完
  6. 獲取登錄用户信息
  7. 判斷登錄用户是否已搶到
  8. 扣庫存
  9. 下單

最後,根據分析把以上步驟用代碼進行初步實現,如下:

// 引入moment庫處理時間相關數據
const moment = require('moment');
// 引入數據庫model文件
const seckillModel = require('../../dbs/mysql/models/seckill_goods');
const ordersModel = require('../../dbs/mysql/models/orders');
// 引入工具函數或工具類
const UserModule = require('../modules/user');
const { random_String } = require('../../utils/tools/funcs');

class Seckill {
  /**
   * 秒殺接口
   * 
   * @method post
   * @param good_id 產品id
   * @param accessToken 用户Token
   * @param path 秒殺完成後跳轉路徑
   */
  async doSeckill(ctx, next) {
    const body = ctx.request.body;
    const accessToken = ctx.query.accessToken;
    const path = body.path;

    // 基本參數校驗
    if (!accessToken || !path) { return ctx.throwException(20001, '參數錯誤!'); };
    // 判斷此產品是否加入了搶購
    const seckill = await seckillModel.findOne({
      where: {
        fk_good_id: ctx.params.good_id,
      }
    });
    if (!seckill) { return ctx.throwException(30002, '該產品並未有搶購活動!'); };
    // 判斷是否有效
    if (!seckill.is_valid) { return ctx.throwException(30003, '該活動已結束!'); };
    // 判單是否開始、結束
    if(moment().isBefore(moment(seckill.start_time))) {
      return ctx.throwException(30004, '該搶購活動還未開始!');
    }
    if(moment().isAfter(moment(seckill.end_time))) {
      return ctx.throwException(30005, '該搶購活動已經結束!');
    }
    // 判斷是否賣完
    if(seckill.amount < 1) { return ctx.throwException(30006, '該產品已經賣完了!'); };

    //獲取登錄用户信息(這一步只是簡單模擬驗證用户身份,實際開發中要有嚴格的accessToken校驗流程)
    const userInfo = await UserModule.getUserInfo(accessToken);
    if (!userInfo) { return ctx.throwException(10002, '用户不存在!'); };

    // 判斷登錄用户是否已搶到(一個用户針對這次活動只能購買一次)
    const orderInfo = await ordersModel.findOne({
      where: {
        user_id: userInfo.id,
        seckill_id: seckill.id,
      },
    });
    if (orderInfo) { return ctx.throwException(30007, '該用户已搶到該產品,無需再搶!'); };

    // 扣庫存
    const count = await seckill.decrement('amount');
    if (count.amount <= 0) { return ctx.throwException(30006, '該產品已經賣完了!'); };

    // 下單
    const orderData = {
      order_no: Date.now() + random_String(4), // 這裏就用當前時間戳加4位隨機數作為訂單號,實際開發中根據業務規劃邏輯 
      good_id: ctx.params.good_id,
      user_id: userInfo.id,
      status: '1', // -1 已取消, 0 未付款, 1 已付款, 2已退款
      order_type: '2', // 1 常規訂單 2 秒殺訂單
      seckill_id: seckill.id, // 秒殺活動id
      comment: '', // 備註
    };
    const order = ordersModel.create(orderData);

    if (!order) { return ctx.throwException(30008, '搶購失敗!'); };

    ctx.send({
      path,
      data: '搶購成功!'
    });

  }

}

module.exports = new Seckill();

至此,秒殺接口用傳統的關係型數據庫就實現完成了,代碼並不複雜,註釋也很詳細,不用特別的講解大家也都能看懂,那它能不能正常工作呢,答案顯然是否定的
通過Jmeter模擬以下測試:

  • 模擬5000併發下2000個用户進行秒殺,會發現mysql報出timeout錯誤,同時seckill_goodsamount字段變成負數,orders表中同樣產生了多於200的記錄(具體數據不同環境下會有差異),這就代表產生了超賣,跟秒殺規則不符
  • 模擬10000併發下單個用户進行秒殺,orders表中產生了多於1條的記錄(具體數據不同環境下會有差異),這就説明一個用户針對這次活動買了多次,跟秒殺規則不符

分析下代碼會發現這其中的問題:

  • 步驟2,判斷此產品是否加入了搶購

    直接在mysql中查詢,因為是在秒殺場景下,併發會很高,大量的請求到數據庫,顯然mysql是扛不住的,畢竟mysql每秒只能支撐千級別的併發請求

  • 步驟7,判斷登錄用户是否已搶到

    在高併發下同一個用户上個訂單還沒有生成成功,再次判斷是否搶到依然會判斷為否,這種情況下代碼並沒有對扣減和下單操作做任何限制,因此就產生了單個用户購買多個產品的情況,跟一個用户針對這次活動只能購買一次的要求不符

  • 步驟8,扣庫存操作

    假設同時有1000個請求,這1000個請求在步驟5判斷產品是否秒殺完的時候查詢到的庫存都是200,因此這些請求都會執行步驟8扣減庫存,那庫存肯定會變成負數,也就是產生了超賣現象

解決方案

經過分析得到三個問題需要解決:

  1. 秒殺數據需要支持高併發訪問
  2. 一個用户針對這次活動只能購買一次的問題,也就是限購問題
  3. 減庫存不能扣成負數,訂單數不能超過設置的庫存數,也就是超賣問題

Redis作為內存型數據庫,本身高速處理請求的特性可以支持高併發。針對超賣,也就是庫存扣減變負數情況,Redis可以提供Lua腳本保證原子性和分佈式鎖兩個解決高併發下數據不一致的問題。針對一個用户只能購買一次的要求,Redis的分佈式鎖可以解決問題。
因此,可以嘗試用Redis解決上述問題,具體操作:

  • 為了支撐大量高併發的庫存查驗請求,需要用Redis保存秒殺活動數據(即seckill_goods表數據),這樣一來請求可以直接從Redis中讀取庫存並進行查詢,完成查詢之後如果還有庫存餘量,就直接從Redis中扣除庫存
  • 扣減庫存操作在Redis中進行,但是因為Redis扣減這一操作是分為讀和寫兩個步驟,也就是必須先讀數據進行判斷再執行減操作,因此如果對這兩個操作沒有做好控制,就導致數據被改錯,依然會出現超賣現象,為了保證併發訪問的正確性需要使用原子操作解決問題,Redis提供了使用Lua腳本包含多個操作來實現原子性的方案
    以下是Redis官方文檔對Lua腳本原子性的解釋

    Atomicity of scripts
    Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed. This semantic is similar to the one of MULTI / EXEC. From the point of view of all the other clients the effects of a script are either still not visible or already completed.
  • 使用Redis實現分佈式鎖,對扣庫存和寫訂單操作進行加鎖,以保證一個用户只能購買一次的問題。

接入Redis

首先,不再使用seckill_goods表,新增秒殺活動邏輯變為在Redis中插入數據,類型為hash類型,key規則為seckill_good_ + 產品id,現在假設新增一條keyseckill_good_1的記錄,值為

{
    amount: 200,
    start_time: '2020-06-20 00:00:00',
    end_time: '2023-06-20 00:00:00',
    is_valid: 1,
    comment: '...',
  }

其次,創建lua腳本保證扣減操作的原子性,腳本內容如下

if (redis.call('hexists', KEYS[1], KEYS[2]) == 1) then
  local stock = tonumber(redis.call('hget', KEYS[1], KEYS[2]));
  if (stock > 0) then
    redis.call('hincrby',  KEYS[1], KEYS[2], -1);
    return stock
  end;
  return 0
end;

最後,完成代碼,完整代碼如下:

// 引入相關庫
const moment = require('moment');
const Op = require('sequelize').Op;
const { v4: uuidv4 } = require('uuid');
// 引入數據庫model文件
const seckillModel = require('../../dbs/mysql/models/seckill_goods');
const ordersModel = require('../../dbs/mysql/models/orders');
// 引入Redis實例
const redis = require('../../dbs/redis');
// 引入工具函數或工具類
const UserModule = require('../modules/user');
const { randomString, checkObjNull } = require('../../utils/tools/funcs');
// 引入秒殺key前綴
const { SECKILL_GOOD, LOCK_KEY } = require('../../utils/constants/redis-prefixs');
// 引入避免超賣lua腳本
const { stock, lock, unlock } = require('../../utils/scripts');

class Seckill {
  async doSeckill(ctx, next) {
    const body = ctx.request.body;
    const goodId = ctx.params.good_id;
    const accessToken = ctx.query.accessToken;
    const path = body.path;

    // 基本參數校驗
    if (!accessToken || !path) { return ctx.throwException(20001, '參數錯誤!'); };
    // 判斷此產品是否加入了搶購
    const key = `${SECKILL_GOOD}${goodId}`;
    const seckill = await redis.hgetall(key);
    if (!checkObjNull(seckill)) { return ctx.throwException(30002, '該產品並未有搶購活動!'); };
    // 判斷是否有效
    if (!seckill.is_valid) { return ctx.throwException(30003, '該活動已結束!'); };
    // 判單是否開始、結束
    if(moment().isBefore(moment(seckill.start_time))) {
      return ctx.throwException(30004, '該搶購活動還未開始!');
    }
    if(moment().isAfter(moment(seckill.end_time))) {
      return ctx.throwException(30005, '該搶購活動已經結束!');
    }
    // 判斷是否賣完
    if(seckill.amount < 1) { return ctx.throwException(30006, '該產品已經賣完了!'); };

    //獲取登錄用户信息(這一步只是簡單模擬驗證用户身份,實際開發中要有嚴格的登錄註冊校驗流程)
    const userInfo = await UserModule.getUserInfo(accessToken);
    if (!userInfo) { return ctx.throwException(10002, '用户不存在!'); };

    // 判斷登錄用户是否已搶到
    const orderInfo = await ordersModel.findOne({
      where: {
        user_id: userInfo.id,
        good_id: goodId,
        status: { [Op.between]: ['0', '1'] },
      },
    });
    if (orderInfo) { return ctx.throwException(30007, '該用户已搶到該產品,無需再搶!'); };
    
    // 加鎖,實現一個用户針對這次活動只能購買一次
    const lockKey = `${LOCK_KEY}${userInfo.id}:${goodId}`; // 鎖的key有用户id和商品id組成
    const uuid = uuidv4();
    const expireTime = moment(seckill.end_time).diff(moment(), 'minutes'); // 鎖存在時間為當前時間和活動結束的時間差
    const tryLock = await redis.eval(lock, 2, [lockKey, 'releaseTime', uuid, expireTime]);
    
    try {
      if (tryLock === 1) {
        // 扣庫存
        const count = await redis.eval(stock, 2, [key, 'amount', '', '']);
        if (count <= 0) { return ctx.throwException(30006, '該產品已經賣完了!'); };

        // 下單
        const orderData = {
          order_no: Date.now() + randomString(4), // 這裏就用當前時間戳加4位隨機數作為訂單號,實際開發中根據業務規劃邏輯 
          good_id: goodId,
          user_id: userInfo.id,
          status: '1', // -1 已取消, 0 未付款, 1 已付款, 2已退款
          order_type: '2', // 1 常規訂單 2 秒殺訂單
          // seckill_id: seckill.id, // 秒殺活動id, redis中不維護秒殺活動id
          comment: '', // 備註
        };
        const order = ordersModel.create(orderData);

        if (!order) { return ctx.throwException(30008, '搶購失敗!'); };
      }
    } catch (e) {
      await redis.eval(unlock, 1, [lockKey, uuid]);
      return ctx.throwException(30006, '該產品已經賣完了!');
    }

    ctx.send({
      path,
      data: '搶購成功!'
    });
  }

}

module.exports = new Seckill();

這裏代碼主要做個四個修改:

  1. 步驟2,判斷產品是否加入了搶購,改為去Redis中查詢
  2. 步驟7,判斷登錄用户是否已搶到,因為不在維護搶購活動id,所以改為使用用户id、產品id和狀態status判斷
  3. 步驟8,扣庫存,改為使用lua腳本去Redis中扣庫存
  4. 對扣庫存和寫入數據庫操作進行加鎖

訂單的操作仍然在Mysql數據庫中進行,因為大部分的請求都在步驟5被攔截了,剩餘請求Mysql是完全有能力處理的。

再次通過Jmeter進行測試,發現訂單表正常,庫存量扣減正常,説明超賣問題和限購已經解決。

其他問題

  1. 秒殺場景的其他技術
    基於Redis支持高併發、鍵值對型數據庫和支持原子操作等特點,案例中使用Redis來作為秒殺應對方案。在更復雜的秒殺場景下,除了使用Redis外,在必要的的情況下還需要用到其他一些技術:

    • 限流,用漏斗算法、令牌桶算法等進行限流
    • 緩存,把熱點數據緩存到內存裏,儘可能緩解數據庫訪問的壓力
    • 削峯,使用消息隊列和緩存技術使瞬間高流量轉變成一段時間的平穩流量,比如客户搶購成功後,立即返回響應,然後通過消息隊列異步處理後續步驟,發短信,寫日誌,更新一致性低的數據庫等等
    • 異步,假設商家創建一個只針對粉絲的秒殺活動,如果商家的粉絲比較少(假設小於1000),那麼秒殺活動直接推送給所有粉絲,如果用户粉絲比較多,程序立刻推送給排名前1000的用户,其餘用户採用消息隊列延遲推送。(1000這個數字需要根據具體情況決定,比如粉絲數2000以內的商家佔99%,只有1%的用户粉絲超過2000,那麼這個值就應該設置為2000)
    • 分流,單台服務器不行就上集羣,通過負載均衡共同去處理請求,分散壓力

    這些技術的應用會讓整個秒殺系統更加完善,但是核心技術還是Redis,可以説用好Redis實現的秒殺系統就足以應對大部分場景。

  2. Redis健壯性
    案例使用的是單機版Redis,單節點在生產環境基本上不會使用,因為

    • 不能達到高可用
    • 即便有着AOF日誌和RDB快照的解決方案以保證數據不丟失,但都只能放在master上,一旦機器故障,服務就無法運行,而且即便採取了相應措施仍不可避免的會造成數據丟失。

    因此,Redis的主從機制和集羣機制在生產環境下是必須的。

  3. Redis分佈式鎖的問題

    • 單點分佈式鎖,案例提到的分佈式鎖,實際上更準確的説法是單點分佈式鎖,是為了方便演示,但是,單點Redis分佈式鎖是肯定不能用在生產環境的,理由跟第2點類似
    • 以主從機制(多機器)為基礎的分佈式鎖,也是不夠的,因為redis在進行主從複製時是異步完成的,比如在clientA獲取鎖後,主redis複製數據到從redis過程中崩潰了,導致鎖沒有複製到從redis中,然後從redis選舉出一個升級為主redis,造成新的主redis沒有clientA設置的鎖,這時clientB嘗試獲取鎖,並且能夠成功獲取鎖,導致互斥失效。

    針對以上問題,redis官方設計了Redlock,在Node.js環境下對應的資源庫為node-redlock,可以用npm安裝,至少需要3個獨立的服務器或集羣才能使用,提供了非常高的容錯率,在生產環境中應該優先採用此方案部署。

總結

秒殺場景的特點可以總結為瞬時併發訪問、讀多寫少、限時和限量,開發中還要考慮避免超賣現象以及類似黃牛搶票的限購問題,針對以上特點和問題,分析得到開發的原則是:數據寫入內存而不是寫入硬盤,異步處理而不是同步處理,扣庫存操作原子執行以及對單用户購買進行加鎖,而Redis正好是符合以上全部特點的工具,因此最終選擇Redis來解決問題。

秒殺場景是一個在電商業務中相對複雜的場景,此篇文章只是介紹了其中最核心的邏輯,實際業務可能更加複雜,但只需要在此核心基礎上進行擴展和優化即可。
秒殺場景的解決方案不僅僅適合秒殺,類似的還有搶紅包、搶優惠券以及搶票等等,思路都是一致的。
解決方案的思路還可以應用在單獨限購、第二件半價以及控制庫存等等諸多場景,大家要靈活運用。

項目地址

https://github.com/threerocks/redis-seckill

參考資料

https://time.geekbang.org/column/article/307421
https://redis.io/topics/distlock

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.