关于redis:Redis应用实战-秒杀场景Nodejs版本

6次阅读

共计 10634 个字符,预计需要花费 27 分钟才能阅读完成。

写在后面

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

这里代码次要做个四个批改:

  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

正文完
 0