写在前面 公司随着业务量的增加,最近用时几个月时间在项目中全面接入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
语句,以供参考
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
操作使用基于promise
的Node.js
ORM
工具Sequelize
redis
操作使用ioredis
库
封装ctx.throwException
方法用于处理错误,封装ctx.send
方法用于返回正确结果,具体实现参考文末完整代码
其次,分析一下接口要处理的逻辑,大概步骤和顺序如下:
基本参数校验
判断产品是否加入了抢购
判断秒杀活动是否有效
判断秒杀活动是否开始、结束
判断秒杀商品是否卖完
获取登录用户信息
判断登录用户是否已抢到
扣库存
下单
最后,根据分析把以上步骤用代码进行初步实现,如下:
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 const moment = require ('moment' );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 { 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 , '该产品已经卖完了!' ); }; 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 ), good_id : ctx.params.good_id, user_id : userInfo.id, status : '1' , order_type : '2' , seckill_id : seckill.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
的记录,值为
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' );const seckillModel = require ('../../dbs/mysql/models/seckill_goods' );const ordersModel = require ('../../dbs/mysql/models/orders' );const redis = require ('../../dbs/redis' );const UserModule = require ('../modules/user' );const { randomString, checkObjNull } = require ('../../utils/tools/funcs' );const { SECKILL_GOOD, LOCK_KEY } = require ('../../utils/constants/redis-prefixs' );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} ` ; 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 ), good_id : goodId, user_id : userInfo.id, status : '1' , order_type : '2' , 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