Redis应用实战 - 秒杀场景(Node.js版本)
来源:SegmentFault
时间:2023-01-13 11:16:55 155浏览 收藏
有志者,事竟成!如果你在学习数据库,那么本文《Redis应用实战 - 秒杀场景(Node.js版本)》,就很适合你!文章讲解的知识点主要包括MySQL、分布式、Redis、Node.js、秒杀,若是你对本文感兴趣,或者是想搞懂其中某个知识点,就请你继续往下看吧~
写在前面
公司随着业务量的增加,最近用时几个月时间在项目中全面接入
CREATE TABLE `seckill_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;
产品表在此次业务中不是重点,以下逻辑都以
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' );
秒杀接口开发
首先,说一下
// 引入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
至此,秒杀接口用传统的关系型数据库就实现完成了,代码并不复杂,注释也很详细,不用特别的讲解大家也都能看懂,那它能不能正常工作呢,答案显然是否定的
通过
{ amount: 200, start_time: '2020-06-20 00:00:00', end_time: '2023-06-20 00:00:00', is_valid: 1, comment: '...', }
其次,创建
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
这里代码主要做个四个修改:
- 步骤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
以上就是《Redis应用实战 - 秒杀场景(Node.js版本)》的详细内容,更多关于mysql的资料请关注golang学习网公众号!
-
499 收藏
-
220 收藏
-
286 收藏
-
244 收藏
-
235 收藏
-
368 收藏
-
475 收藏
-
266 收藏
-
273 收藏
-
283 收藏
-
210 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 507次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 497次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习