写在后面

公司随着业务量的减少,最近用时几个月工夫在我的项目中全面接入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 `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 = {      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 0end;

最初,实现代码,残缺代码如下:

// 引入相干库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