写在后面
公司随着业务量的减少,最近用时几个月工夫在我的项目中全面接入 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 `scekill_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'
);
秒杀接口开发
首先,说一下 NOdejs 中的具体开发环境:
web
框架应用Koa2
mysql
操作应用基于promise
的Node.js
ORM
工具Sequelize
redis
操作应用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