Redis应用实战|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
实战 数据库准备 Redis应用实战|Redis应用实战 - 秒杀场景(Node.js版本)
文章图片

如图所示,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操作使用基于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 = https://www.it610.com/article/{ 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 = https://www.it610.com/article/{ 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应用实战|Redis应用实战 - 秒杀场景(Node.js版本)】因此,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

    推荐阅读