寫在前面
公司隨着業務量的增加,最近用時幾個月時間在項目中全面接入Redis,開發過程中發現市面上缺少具體的實戰資料,尤其是在Node.js環境下,能找到的資料要麼過於簡單入門,要麼名不副實,大部分都是屬於初級。因此決定把公司這段時間的成果進行分享,會用幾篇文章詳細介紹Redis的幾個使用場景,期望大家一起學習、進步。
下面就開始第一篇,秒殺場景。
業務分析
實際業務中,秒殺包含了許多場景,具體可以分為秒殺前、秒殺中和秒殺後三個階段,從開發角度具體分析如下:
- 秒殺前:主要是做好緩存工作,以應對用户頻繁的訪問,因為數據是固定的,可以把商品詳情頁的元素靜態化,然後用
CDN或者是瀏覽器進行緩存。 -
秒殺中:主要是庫存查驗,庫存扣減和訂單處理,這一步的特點是
- 短時間內大量用户同時進行搶購,系統的流量突然激增,服務器壓力瞬間增大(瞬時併發訪問高)
- 請求數量大於商品庫存,比如10000個用户搶購,但是庫存只有100
- 限定用户只能在一定時間段內購買
- 限制單個用户購買數量,避免刷單
- 搶購是跟數據庫打交道,核心功能是下單,庫存不能扣成負數
- 對數據庫的操作讀多寫少,而且讀操作相對簡單
- 秒殺後:主要是一些用户查看已購訂單、處理退款和處理物流等等操作,這時候用户請求量已經下降,操作也相對簡單,服務器壓力不大。
根據上述分析,本文把重點放在秒殺中的開發講解,其他部分感興趣的小夥伴可以自己搜索資料,進行嘗試。
開發環境
數據庫: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框架使用Koa2mysql操作使用基於promise的Node.jsORM工具Sequelizeredis操作使用ioredis庫- 封裝
ctx.throwException方法用於處理錯誤,封裝ctx.send方法用於返回正確結果,具體實現參考文末完整代碼
其次,分析一下接口要處理的邏輯,大概步驟和順序如下:
- 基本參數校驗
- 判斷產品是否加入了搶購
- 判斷秒殺活動是否有效
- 判斷秒殺活動是否開始、結束
- 判斷秒殺商品是否賣完
- 獲取登錄用户信息
- 判斷登錄用户是否已搶到
- 扣庫存
- 下單
最後,根據分析把以上步驟用代碼進行初步實現,如下:
// 引入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_goods表amount字段變成負數,orders表中同樣產生了多於200的記錄(具體數據不同環境下會有差異),這就代表產生了超賣,跟秒殺規則不符 - 模擬10000併發下單個用户進行秒殺,
orders表中產生了多於1條的記錄(具體數據不同環境下會有差異),這就説明一個用户針對這次活動買了多次,跟秒殺規則不符
分析下代碼會發現這其中的問題:
-
步驟2,判斷此產品是否加入了搶購
直接在
mysql中查詢,因為是在秒殺場景下,併發會很高,大量的請求到數據庫,顯然mysql是扛不住的,畢竟mysql每秒只能支撐千級別的併發請求 -
步驟7,判斷登錄用户是否已搶到
在高併發下同一個用户上個訂單還沒有生成成功,再次判斷是否搶到依然會判斷為否,這種情況下代碼並沒有對扣減和下單操作做任何限制,因此就產生了單個用户購買多個產品的情況,跟一個用户針對這次活動只能購買一次的要求不符
-
步驟8,扣庫存操作
假設同時有1000個請求,這1000個請求在步驟5判斷產品是否秒殺完的時候查詢到的庫存都是200,因此這些請求都會執行步驟8扣減庫存,那庫存肯定會變成負數,也就是產生了超賣現象
解決方案
經過分析得到三個問題需要解決:
- 秒殺數據需要支持高併發訪問
- 一個用户針對這次活動只能購買一次的問題,也就是限購問題
- 減庫存不能扣成負數,訂單數不能超過設置的庫存數,也就是超賣問題
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,現在假設新增一條key為seckill_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();
這裏代碼主要做個四個修改:
- 步驟2,判斷產品是否加入了搶購,改為去
Redis中查詢 - 步驟7,判斷登錄用户是否已搶到,因為不在維護搶購活動
id,所以改為使用用户id、產品id和狀態status判斷 - 步驟8,扣庫存,改為使用
lua腳本去Redis中扣庫存 - 對扣庫存和寫入數據庫操作進行加鎖
訂單的操作仍然在Mysql數據庫中進行,因為大部分的請求都在步驟5被攔截了,剩餘請求Mysql是完全有能力處理的。
再次通過Jmeter進行測試,發現訂單表正常,庫存量扣減正常,説明超賣問題和限購已經解決。
其他問題
-
秒殺場景的其他技術
基於Redis支持高併發、鍵值對型數據庫和支持原子操作等特點,案例中使用Redis來作為秒殺應對方案。在更復雜的秒殺場景下,除了使用Redis外,在必要的的情況下還需要用到其他一些技術:- 限流,用漏斗算法、令牌桶算法等進行限流
- 緩存,把熱點數據緩存到內存裏,儘可能緩解數據庫訪問的壓力
- 削峯,使用消息隊列和緩存技術使瞬間高流量轉變成一段時間的平穩流量,比如客户搶購成功後,立即返回響應,然後通過消息隊列異步處理後續步驟,發短信,寫日誌,更新一致性低的數據庫等等
- 異步,假設商家創建一個只針對粉絲的秒殺活動,如果商家的粉絲比較少(假設小於1000),那麼秒殺活動直接推送給所有粉絲,如果用户粉絲比較多,程序立刻推送給排名前1000的用户,其餘用户採用消息隊列延遲推送。(1000這個數字需要根據具體情況決定,比如粉絲數2000以內的商家佔99%,只有1%的用户粉絲超過2000,那麼這個值就應該設置為2000)
- 分流,單台服務器不行就上集羣,通過負載均衡共同去處理請求,分散壓力
這些技術的應用會讓整個秒殺系統更加完善,但是核心技術還是
Redis,可以説用好Redis實現的秒殺系統就足以應對大部分場景。 -
Redis健壯性
案例使用的是單機版Redis,單節點在生產環境基本上不會使用,因為- 不能達到高可用
- 即便有着
AOF日誌和RDB快照的解決方案以保證數據不丟失,但都只能放在master上,一旦機器故障,服務就無法運行,而且即便採取了相應措施仍不可避免的會造成數據丟失。
因此,
Redis的主從機制和集羣機制在生產環境下是必須的。 -
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