写在前面

公司随着业务量的增加,最近用时几个月时间在项目中全面接入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

实战

数据库准备

tables
如图所示,Mysql中需要创建三张表,分别是

  • 产品表,用于记录产品信息,字段分别为Id、名称、缩略图、价格和状态等等
  • 秒杀活动表,用于记录秒杀活动的详细信息,字段分别为Id、参与秒杀的产品Id、库存量、秒杀开始时间、秒杀结束时间和秒杀活动是否有效等等
  • 订单表,用于记录下单后的数据,字段分别为Id、订单号、产品Id、购买用户Id、订单状态、订单类型和秒杀活动Id等等

下面是创建sql语句,以供参考

1
2
3
4
5
6
7
8
9
10
11
12
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;
1
2
3
4
5
6
7
8
9
10
11
12
13
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;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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的记录,作为秒杀测试数据,参考下面语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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. 下单

最后,根据分析把以上步骤用代码进行初步实现,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 引入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的记录,值为

1
2
3
4
5
6
7
{
amount: 200,
start_time: '2020-06-20 00:00:00',
end_time: '2023-06-20 00:00:00',
is_valid: 1,
comment: '...',
}

其次,创建lua脚本保证扣减操作的原子性,脚本内容如下

1
2
3
4
5
6
7
8
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;

最后,完成代码,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// 引入相关库
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