微服务商城系统(十五)秒杀基础
文章目录
- 一、秒杀业务分析
- 1、需求
- 2、表结构说明
- 3、秒杀需求分析
- 二、秒杀商品压入缓存
- 1、搭建秒杀服务工程
- 2、定时任务
- 3、秒杀商品压入缓存实现
- 三、秒杀频道页
- 四、下单实现
- 五、多线程抢单
- 1、异步实现
- 2、排队下单
- 3、下单状态查询
- 六、总结
代码链接: https://github.com/betterGa/ChangGou
一、秒杀业务分析
1、需求
所谓 “秒杀”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲,就是 网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
秒杀商品通常有两种限制:库存限制、时间限制。
✨ 需求:
(1)录入秒杀商品数据,主要包括:商品标题、原价、秒杀价、商品图片、介绍、秒杀时段等信息
(2)秒杀频道首页列出秒杀商品(进行中的)点击秒杀商品图片跳转到秒杀商品详细页。
(3)商品详细页显示秒杀商品信息,点击立即抢购实现秒杀下单,下单时扣减库存。当库存为 0 或不在活动期范围内时无法秒杀。
(4)秒杀下单成功,直接跳转到支付页面(微信扫码),支付成功,跳转到成功页,填写收货地址、电话、收件人等信息,完成订单。
(5)当用户秒杀下单 5 分钟内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。
2、表结构说明
秒杀商品信息表:
CREATE TABLE `tb_seckill_goods` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`sup_id` bigint(20) DEFAULT NULL COMMENT 'spu ID',`sku_id` bigint(20) DEFAULT NULL COMMENT 'sku ID',`name` varchar(100) DEFAULT NULL COMMENT '标题',`small_pic` varchar(150) DEFAULT NULL COMMENT '商品图片',`price` decimal(10,2) DEFAULT NULL COMMENT '原价格',`cost_price` decimal(10,2) DEFAULT NULL COMMENT '秒杀价格',`create_time` datetime DEFAULT NULL COMMENT '添加日期',`check_time` datetime DEFAULT NULL COMMENT '审核日期',`status` char(1) DEFAULT NULL COMMENT '审核状态,0未审核,1审核通过,2审核不通过',`start_time` datetime DEFAULT NULL COMMENT '开始时间',`end_time` datetime DEFAULT NULL COMMENT '结束时间',`num` int(11) DEFAULT NULL COMMENT '秒杀商品数',`stock_count` int(11) DEFAULT NULL COMMENT '剩余库存数',`introduction` varchar(2000) DEFAULT NULL COMMENT '描述',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
秒杀订单表:
CREATE TABLE `tb_seckill_order` (`id` bigint(20) NOT NULL COMMENT '主键',`seckill_id` bigint(20) DEFAULT NULL COMMENT '秒杀商品ID',`money` decimal(10,2) DEFAULT NULL COMMENT '支付金额',`user_id` varchar(50) DEFAULT NULL COMMENT '用户',`create_time` datetime DEFAULT NULL COMMENT '创建时间',`pay_time` datetime DEFAULT NULL COMMENT '支付时间',`status` char(1) DEFAULT NULL COMMENT '状态,0未支付,1已支付',`receiver_address` varchar(200) DEFAULT NULL COMMENT '收货人地址',`receiver_mobile` varchar(20) DEFAULT NULL COMMENT '收货人电话',`receiver` varchar(20) DEFAULT NULL COMMENT '收货人',`transaction_id` varchar(30) DEFAULT NULL COMMENT '交易流水',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
3、秒杀需求分析
秒杀技术实现核心思想是运用缓存 减少数据库瞬间的访问压力,读取商品详细信息时运用缓存,当用户点击抢购时减少缓存中的库存数量,当库存数为 0 时或活动期结束时,同步到数据库。 产生的秒杀预订单也不会立刻写到数据库中,而是先写到缓存,当用户付款成功后再写入数据库。
当然,上面实现的思路只是一种最简单的方式,并未考虑其中一些问题,例如并发状况容易产生的问题。我们看看下面这张思路更严谨的图:
二、秒杀商品压入缓存
这里秒杀商品列表和秒杀商品详情都是从 Redis 中取出来的,所以我们首先要将符合参与秒杀的商品定时查询出来,并将数据存入到 Redis 缓存中。
数据存储类型我们可以选择 Hash 类型。
秒杀分页列表这里可以通过 redisTemplate.boundHashOps(key).values() 获取结果数据。
秒杀商品详情,可以通过 redisTemplate.boundHashOps(key).get(key) 获取详情。
1、搭建秒杀服务工程
我们首先搭建一个秒杀服务工程,然后按照上面步骤实现。
搭建 changgou-service-seckill 和 changgou-service-seckill-api,作为秒杀工程的服务提供工程。
(1)导入依赖:
<dependencies><dependency><groupId>com.changgou</groupId><artifactId>changgou-service-seckill-api</artifactId><version>1.0-SNAPSHOT</version></dependency>
</dependencies>
(2)application.yml 配置
server:port: 18093
spring:application:name: seckilldatasource:driver-class-name: com.mysql.jdbc.Driverurl: jdbc:mysql://192.168.211.132:3306/changgou_seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTCusername: rootpassword: 123456rabbitmq:host: 192.168.211.132 #mq的服务器地址username: guest #账号password: guest #密码main:allow-bean-definition-overriding: true
eureka:client:service-url:defaultZone: http://127.0.0.1:7001/eurekainstance:prefer-ip-address: true
feign:hystrix:enabled: true
#hystrix 配置
hystrix:command:default:execution:timeout:#如果enabled设置为false,则请求超时交给ribbon控制enabled: trueisolation:thread:timeoutInMilliseconds: 10000strategy: SEMAPHORE
(3)导入生成文件
使用代码生成器,生成 dao、Pojo,并导入到工程中。
(4)启动类
@SpringBootApplication
@EnableEurekaClient
@MapperScan(basePackages = {"com.changgou.seckill.dao"})
@EnableScheduling
public class SeckillApplication {public static void main(String[] args) {SpringApplication.run(SeckillApplication.class,args);}@Beanpublic IdWorker idWorker(){return new IdWorker(1,1);}
}
2、定时任务
采用 Spring 的定时任务,定时将在秒杀时间段内的商品查询出来再存入到 Redis 缓存。
定时任务相关的配置,配置步骤如下:
(1)在定时任务类的指定方法上加上 @Scheduled 开启定时任务
(2)定时任务表达式:使用 cron 属性来配置定时任务执行时间
创建 com.changgou.seckill.timer.SeckillGoodsPushTask 类,并在类中加上定时任务执行方法,代码如下:
@Component
public class SeckillGoodsPushTask {/***** 每30秒执行一次*/@Scheduled(cron = "0/30 * * * * ?")public void loadGoodsPushRedis(){System.out.println("task demo");}
}
- 定时任务常用时间表达式
CronTrigger 配置完整格式为:[秒][分][小时][日][月][周][年]
使用说明:
通配符说明:
*
表示所有值. 例如:在分的字段上设置*
,表示每一分钟都会触发。?
表示不指定值。使用的场景为不需要关心当前设置这个字段的值,?
只有 日 和 周 可以用。
例如:要在每月的 10 号触发一个操作,但不关心是周几,所以需要周位置的那个字段设置为"?
" 具体设置为0 0 0 10 * ?
。-
表示区间。例如 在小时上设置 “10-12”,表示 10,11,12 点都会触发。,
表示指定多个值,例如在周字段上设置 “MON,WED,FRI” 表示周一,周三和周五触发/
用于递增触发。如在秒上面设置 “5/15” 表示从 5 秒开始,每增 15 秒触发 (5,20,35,50)。 在月字段上设置 ‘1/3’ 所示每月 1 号开始,每隔三天触发一次。L
表示最后的意思。在日字段设置上,表示当月的最后一天(依据当前月份,如果是二月还会依据是否是润年[leap]), 在周字段上表示星期六,相当于 “7” 或 “SAT”。如果在 “L” 前加上数字,则表示该数据的最后一个。例如在周字段上设置"6L"这样的格式,则表示“本月最后一个星期五"W
表示离指定日期的最近那个工作日(周一至周五). 例如在日字段上设置 “15W”,表示离每月15号最近的那个工作日触发。如果 15 号正好是周六,则找最近的周五(14号)触发, 如果15号是周未,则找最近的下周一(16号)触发.如果 15 号正好在工作日(周一至周五),则就在该天触发。如果指定格式为 “1W”,它则表示每月 1 号往后最近的工作日触发。如果1号正是周六,则将在3号下周一触发。(注,W
"前只能设置具体的数字,不允许区间-
).#
序号(表示每月的第几个周几),例如在周字段上设置"6#3"表示在每月的第三个周六.注意如果指定 “#5”,正好第五周没有周六,则不会触发该配置(用在母亲节和父亲节再合适不过了) ;
常用表达式:
0 0 10,14,16 * * ? 每天上午10点,下午2点,4点
0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
0 0 12 ? * WED 表示每个星期三中午12点
"0 0 12 * * ?" 每天中午12点触发
"0 15 10 ? * *" 每天上午10:15触发
"0 15 10 * * ?" 每天上午10:15触发
"0 15 10 * * ? *" 每天上午10:15触发
"0 15 10 * * ? 2005" 2005年的每天上午10:15触发
"0 * 14 * * ?" 在每天下午2点到下午2:59期间的每1分钟触发
"0 0/5 14 * * ?" 在每天下午2点到下午2:55期间的每5分钟触发
"0 0/5 14,18 * * ?" 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发
"0 0-5 14 * * ?" 在每天下午2点到下午2:05期间的每1分钟触发
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午2:10和2:44触发
"0 15 10 ? * MON-FRI" 周一至周五的上午10:15触发
"0 15 10 15 * ?" 每月15日上午10:15触发
"0 15 10 L * ?" 每月最后一日的上午10:15触发
"0 15 10 ? * 6L" 每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6L 2002-2005" 2002年至2005年的每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6#3" 每月的第三个星期五上午10:15触发
3、秒杀商品压入缓存实现
数据检索条件分析
按照 2.1 中的几个步骤实现将秒杀商品从数据库中查询出来,并存入到 Redis 缓存
(1)查询 活动没结束的所有秒杀商品
① 计算秒杀时间段
② 状态必须为审核通过 status=1
③ 商品库存个数 >0
④ 活动没有结束 endTime>=now()
⑤ 在 Redis 中没有该商品的缓存
⑥ 执行查询获取对应的结果集
(2)将活动没有结束的秒杀商品入库
这里会涉及到时间操作,所以这里提前准备了一个时间工具包DateUtil。时间菜单分析
将商品数据从数据库中查询出来,并存入 Redis 缓存,但页面每次显示的时候,只显示当前正在秒杀以及往后延时 2个小时、4个小时、6个小时、8个小时 的秒杀商品数据。我们要做的是:
(1)求出整个时间菜单
(2)确定每个时间菜单的区间值
(3)根据菜单时间的区间值,求对应的秒杀商品数据
(4)将秒杀商品数据存到 Redis 中,使用 hash 类型,namespace 为时间,key 为商品 id,value 为 seckillgoods 商品信息。时间菜单计算
可以先求出当前时间的凌晨,然后每 2 个小时后作为下一个抢购的开始时间,这样可以分出 12 个抢购时间段,如下:
00:00-02:00
02:00-04:00
04:00-06:00
06:00-08:00
08:00-10:00
10:00-12:00
12:00-14:00
14:00-16:00
16:00-18:00
18:00-20:00
20:00-22:00
22:00-00:00
而现实的菜单只需要计算出当前时间在哪个时间段范围,该时间段范围就属于正在秒杀的时间段,而后面即将开始的秒杀时间段的计算也就出来了,可以在当前时间段基础之上+2小时、+4小时、+6小时、+8小时。
关于时间菜单的运算,在给出的 DateUtil 包里已经实现,代码如下:
/**** 获取时间菜单* @return*/
public static List<Date> getDateMenus(){//定义一个List<Date>集合,存储所有时间段List<Date> dates = getDates(12);//判断当前时间属于哪个时间范围Date now = new Date();for (Date cdate : dates) {//开始时间<=当前时间<开始时间+2小时if(cdate.getTime()<=now.getTime() && now.getTime()<addDateHour(cdate,2).getTime()){now = cdate;break;}}//当前需要显示的时间菜单List<Date> dateMenus = new ArrayList<Date>();for (int i = 0; i <5 ; i++) {dateMenus.add(addDateHour(now,i*2));}return dateMenus;
}/**** 指定时间往后 N 个时间间隔* @param hours* @return*/
public static List<Date> getDates(int hours) {List<Date> dates = new ArrayList<Date>();//循环12次Date date = toDayStartHour(new Date()); //凌晨for (int i = 0; i <hours ; i++) {//每次递增2小时,将每次递增的时间存入到List<Date>集合中dates.add(addDateHour(date,i*2));}return dates;
}
使用主方法进行测试:
public static void main(String[] args) {List<Date> dateMenus = getDateMenus();for (Date dateMenu : dateMenus) {SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyyMMddHH");System.out.println(simpleDateFormat.format(dateMenu));}
}
运行结果:
- 查询秒杀商品导入 Reids
写个定时任务,查询从当前时间开始,往后延续 4 个时间菜单间隔,也就是一共只查询 5 个时间段抢购商品数据,并压入缓存,实现代码如下:
@Component
public class SeckillGoodsPushTask {@Autowiredprivate SeckillGoodsMapper seckillGoodsMapper;@Autowiredprivate RedisTemplate redisTemplate;/*** 每 5 秒执行一次*/@Scheduled(cron = "0/60 * * * * ?")public void loadGoodsPushRedis() {//System.out.println("task demo");// 查询符合当前参与秒杀活动的时间清单List<Date> dateMenus = DateUtil.getDateMenus();// 遍历时间清单for (Date datemenus : dateMenus) {// 求时间的字符串格式String timeSpace = DateUtil.data2str(datemenus, "yyyyMMddHH");System.out.println(timeSpace);/** 满足以下条件,将商品读入 Redis 缓存1、秒杀商品库存stock_count >02、审核状态为通过 status3、开始时间start_time <= 当前时间 && 当前时间+2 < 结束时间 end_time*/// 构建条件Example example = new Example(SeckillGoods.class);Example.Criteria criteria = example.createCriteria();criteria.andGreaterThan("stockCount", 0);criteria.andEqualTo("status", "1");criteria.andGreaterThanOrEqualTo("startTime", datemenus);criteria.andLessThan("endTime", DateUtil.addDateHour(datemenus, 2));// 查询数据List<SeckillGoods> seckillGoods = seckillGoodsMapper.selectByExample(example);// 存入 Redisfor (SeckillGoods seckillgood : seckillGoods) {// namespace是当前时间,key是商品id,value是商品信息redisTemplate.boundHashOps("SeckillGoods_"+timeSpace).put(seckillgood.getId(), seckillgood);}}}
}
这时数据库中有数据:
运行程序,Redis 数据如下:
(注意到,namespace有奇怪的前缀,key 和 value 是编码的形式,需要解决一下 Redis 乱码问题:
提供 Redis 配置类:
@Configuration
public class RedisConfig {@Bean(name = "redisTemplate")public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {RedisTemplate<Object, Object> template = new RedisTemplate<>();RedisSerializer<String> redisSerializer = new StringRedisSerializer();template.setConnectionFactory(factory);//key序列化方式template.setKeySerializer(redisSerializer);//value序列化template.setValueSerializer(redisSerializer);//value hashmap序列化template.setHashValueSerializer(redisSerializer);//key haspmap序列化template.setHashKeySerializer(redisSerializer);return template;}
}
改为 String 类型:
redisTemplate.boundHashOps("SeckillGoods_" + timeSpace).put(seckillgood.getId().toString(), JSON.toJSONString(seckillgood));
再看 Redis :
还有一个问题,Java 的时间总比数据库的早 8 个小时,所以需要在项目中设置 jdbc 驱动的 url serverTimezone=Asia/Shanghai
)
因为定时查询商品的,所以对于 Redis 中已经存在的商品,需要进行判断,不重复添加:
三、秒杀频道页
秒杀频道首页,显示正在秒杀的和未开始秒杀的商品(已经开始或者还没开始,未结束的秒杀商品)
- 秒杀时间菜单
如上图,时间菜单需要根据当前时间动态加载,时间菜单的计算上面功能中已经实现,在 DateUtil 工具包中。我们只需要将时间菜单获取,然后响应到页面,页面根据对应的数据显示即可。
在 SeckillGoodsController 中提供方法:
@GetMapping("/menus")public Result<List<Date>> menus(){List<Date> dates= DateUtil.getDateMenus();return new Result<>(true,StatusCode.OK,"查询秒杀时间菜单成功");}
测试:
- 秒杀频道页
秒杀频道页是指将对应时区的秒杀商品从 Reids 缓存中查询出来,并到页面显示。对应时区秒杀商品存储的时候以 Hash 类型进行了存储,每次用户在前端点击对应时间菜单的时候,可以将时间菜单的开始时间以 yyyyMMddHH 格式提交到后台,后台根据时间格式查询出对应时区秒杀商品信息。 - 控制层
修改 com.changgou.seckill.controller.SeckillGoodsController,并添加秒杀商品查询方法,代码如下:
- 控制层
@RestController
@RequestMapping("/seckillGoods")
@CrossOrigin
public class SeckillGoodsController {@Autowired
private SeckillGoodsService seckillGoodsService;@GetMapping(value = "/list")public Result<List<SeckillGoods>> list(String time) {List<SeckillGoods> list = seckillGoodsService.list(time);return new Result(true, StatusCode.OK, "秒杀商品列表查询成功", list);}
- 业务层
创建 com.changgou.seckill.service.SeckillGoodsService,添加根据时区查询秒杀商品的方法:
public interface SeckillGoodsService {/**** 获取指定时间对应的秒杀商品列表* @param key*/List<SeckillGoods> list(String time);
}
实现:
@Service
public class SeckillGoodsServiceImpl implements SeckillGoodsService {@Autowiredprivate RedisTemplate redisTemplate;/**** Redis 中根据 Key 获取秒杀商品列表* @param key* @return*/@Overridepublic List<SeckillGoods> list(String time) {return redisTemplate.boundHashOps("SeckillGoods_"+time).values();}
}
测试:
秒杀详情页
通过秒杀频道页点击请购按钮,会跳转到商品秒杀详情页,秒杀详情页需要根据商品 ID 查询商品详情,我们可以在频道页点击秒杀抢购的时候将 ID 一起传到后台,然后根据 ID 去 Redis 中查询详情信息。- 业务层
在 com.changgou.seckill.service.SeckillGoodsService,中添加查询秒杀商品详情的方法,需要提供时间 和 商品 ID:
- 业务层
SeckillGoods one(String time,Long id);
实现:
@Overridepublic SeckillGoods one(String time, Long id) {return JSON.parseObject((String) redisTemplate.boundHashOps("SeckillGoods_"+time).get(String.valueOf(id)),SeckillGoods.class);}
(因为前面把 Redis 里的 key、value 都转化成 String 类型了,所以这里查询的时候也需要转化回去)
- 控制层
@GetMapping(value = "/one")public Result one(Date time,Long id){SeckillGoods seckillGoods = seckillGoodsService.one(time, id);return new Result(true,StatusCode.OK,"查询秒杀商品信息成功",seckillGoods);}
测试:
四、下单实现
用户下单,从控制层->Service层->Dao层,所以我们先把 dao 创建好,再创建 service 层,再创建控制层。
用户下单,为了提升下单速度,我们将订单数据存入到 Redis 缓存中,如果用户支付了,则将 Reids 缓存中的订单存入到 MySQL 中,并清空 Redis 缓存中的订单。
- 业务层
创建 com.changgou.seckill.service.SeckillOrderService,并在接口中增加下单方法,代码如下:
public interface SeckillOrderService {Boolean add(Long id, String time, String username);
}
实现:
@Overridepublic boolean add(String time, Long id, String username) {// 查询秒杀商品String nameSpace = "SeckillGoods_" + time;SeckillGoods seckillGoods = JSON.parseObject((String) redisTemplate.boundHashOps(nameSpace).get(id.toString()),SeckillGoods.class);// 判断有无库存if (seckillGoods == null || seckillGoods.getStockCount() <= 0) {throw new RuntimeException("已售罄!");}// 创建订单对象SeckillOrder seckillOrder = new SeckillOrder();seckillOrder.setSeckillId(id);seckillOrder.setMoney(seckillGoods.getCostPrice());seckillOrder.setUserId(username);seckillOrder.setCreateTime(new Date());// 订单状态:未支付seckillOrder.setStatus("0");// 将订单对象存储到 Redis 中// 一个用户只允许有一个未支付秒杀订单redisTemplate.boundHashOps("SeckillOrder").put(username, seckillOrder.toString());// 库存递减seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);// 如果商品是最后一个,需要将 Redis 中将该商品删除,并将数据同步到 Mysqlif (seckillGoods.getStockCount() <= 0) {seckillGoodsMapper.updateByPrimaryKey(seckillGoods);redisTemplate.boundHashOps(nameSpace).delete(id);} else {// 同步数据到 RedisredisTemplate.boundHashOps(nameSpace).put(id.toString(), JSON.toJSONString(seckillGoods));}return true;}
- 控制层
@RequestMapping(value = "/add")public Result add(String time,Long id){String username="jia";seckillOrderService.add(time,id,username);return new Result(true,StatusCode.OK,"下单成功");}
上述功能完成了秒杀抢单操作,但没有解决并发相关的问题,例如并发、超卖现象,这块甚至有可能产生雪崩问题。
测试:
五、多线程抢单
使用多线程+队列削峰:
在实际秒杀中,操作一般都是比较复杂的,而且并发量特别高,比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在存在刷单行为,记录用户操作日志等。
下订单这里,我们一般采用多线程下单,但多线程中我们又需要保证用户抢单的公平性,也就是先抢先下单。我们可以这样实现,用户进入秒杀抢单,如果用户符合抢单资格,只需要记录用户抢单数据,存入队列,多线程从队列中进行消费即可,使用 Redis 实现队列, 存入队列采用左压,多线程下单采用右取的方式。
1、异步实现
要想使用 Spring 的 异步操作(底层是多线程),需要先开启异步操作,用 @EnableAsync 注解开启,然后在对应的异步方法上添加注解 @Async 即可。
创建 com.changgou.seckill.task.MultiThreadingCreateOrder 类,在类中创建一个 createOrder 方法,并在方法上添加 @Async,代码如下:
@Component
public class MultiThreadingCreateOrder {/**** 多线程下单操作*/@Asyncpublic void createOrder(){try {System.out.println("准备执行....");Thread.sleep(20000);System.out.println("开始执行....");} catch (InterruptedException e) {e.printStackTrace();}}
}
可以看到, createOrder 方法进行了休眠阻塞操作,我们在下单的方法 add 里 调用 createOrder 方法,并在调用后和整个方法结束前分别打印 “1” 和 ”2“,如果下单的方法没有阻塞,继续执行,说明属于异步操作,如果阻塞了,说明没有执行异步操作。
运行结果:
可以看到,实现了多线程的效果。接下来,需要将之前下单的 add 方法里的逻辑挪到多线程的方法 createOrder 中:
可以看到,username、time、id 的值写si。
运行起来是没问题的。
2、排队下单
(1)排队信息封装
用户每次下单的时候,我们可以创建一个队列进行排队,然后采用多线程的方式创建订单,排队我们可以采用 Redis 的队列实现。 排队信息中需要有用户抢单的商品信息,主要包含商品 ID,商品抢购时间段,用户登录名。我们可以设计个 Javabean,如下:
public class SeckillStatus implements Serializable {//秒杀用户名private String username;//创建时间private Date createTime;//秒杀状态 1:排队中,2:秒杀等待支付,3:支付超时,4:秒杀失败,5:支付完成private Integer status;//秒杀的商品IDprivate Long goodsId;//应付金额private Float money;//订单号private Long orderId;//时间段private String time;public SeckillStatus() {}public SeckillStatus(String username, Date createTime, Integer status, Long goodsId, String time) {this.username = username;this.createTime = createTime;this.status = status;this.goodsId = goodsId;this.time = time;}//get、set...略
}
(2)排队实现
我们可以将秒杀抢单信息存入到 Redis 中,这里采用 List 方式存储,List 本身是一个队列,用户点击抢购的时候,就将用户抢购信息存入到 Redis 中,代码如下:
多线程下单:
运行,可以看到,会先入队,生成 namespace 为 SeckillOrderQueue 的记录:
10s 后:
可以看到,用户的抢单请求出队了,并且生成了 namespace 为 SeckillOrder 的记录,而且 SeckillGoods_xxx 记录的库存也发生了变化,源码:
至此,只是生成了 Redis 中的记录,并没有真正下单,所以我们把 ”下单成功“ 改成 ”正在排队“。
3、下单状态查询
按照上面的流程,虽然可以实现用户下单异步操作,但是并不能确定下单是否成功,所以我们需要做一个页面判断,每过 1 秒钟查询一次下单状态,多线程下单的时候,需要修改抢单状态,支付的时候,清理抢单状态(未实现)。
(1)下单更新抢单状态
用户每次点击抢购的时候,如果排队成功,则将用户抢购状态存储到 Redis 中,多线程抢单的时候,如果抢单成功,则更新抢单状态。
修改 SeckillOrderServiceImpl 的 add 方法,记录状态,代码如下:
修改 MultiThreadingCreateOrder 类的 createOrder() 方法:
@Asyncpublic void createOrder() {try {System.out.println("准备执行下单......");// 睡眠Thread.sleep(10000);// 从队列中获取用户排队信息,先抢先下单SeckillStatus seckillStatus= JSON.parseObject((String) redisTemplate.boundListOps("SeckillOrderQueue").rightPop(),SeckillStatus.class);if(seckillStatus==null){return;}// 抢单所属时间段String time = seckillStatus.getTime();// 商品idLong id = seckillStatus.getGoodsId();// 用户名String username = seckillStatus.getUsername();// 查询秒杀商品String nameSpace = "SeckillGoods_" + time;SeckillGoods seckillGoods = JSON.parseObject((String) redisTemplate.boundHashOps(nameSpace).get(id.toString()),SeckillGoods.class);// 判断有无库存if (seckillGoods == null || seckillGoods.getStockCount() <= 0) {throw new RuntimeException("已售罄!");}// 创建订单对象SeckillOrder seckillOrder = new SeckillOrder();seckillOrder.setId(idWorker.nextId());seckillOrder.setSeckillId(id);seckillOrder.setMoney(seckillGoods.getCostPrice());seckillOrder.setUserId(username);seckillOrder.setCreateTime(new Date());// 订单状态:未支付seckillOrder.setStatus("0");// 将订单对象存储到 Redis 中// 一个用户只允许有一个未支付秒杀订单redisTemplate.boundHashOps("SeckillOrder").put(username, JSON.toJSONString(seckillOrder));// 库存递减seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);// 如果商品是最后一个,需要将 Redis 中将该商品删除,并将数据同步到 Mysqlif (seckillGoods.getStockCount() <= 0) {seckillGoodsMapper.updateByPrimaryKey(seckillGoods);redisTemplate.boundHashOps(nameSpace).delete(id);} else {// 同步数据到 RedisredisTemplate.boundHashOps(nameSpace).put(id.toString(), JSON.toJSONString(seckillGoods));/*** 更新订单状态*/seckillStatus.setOrderId(seckillOrder.getId());// 支付金额seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));// 待付款seckillStatus.setStatus(2);redisTemplate.boundHashOps("UserQueueStatus").put(username,JSON.toJSONString(seckillStatus));}System.out.println("10 秒钟后下单完成!");} catch (InterruptedException e) {e.printStackTrace();}}
主要添加了逻辑:
seckillOrder.setId(idWorker.nextId());
/*** 更新订单状态*/seckillStatus.setOrderId(seckillOrder.getId());// 支付金额seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));// 待付款seckillStatus.setStatus(2);
- 业务层:
/**** 根据用户名查询订单* @param username* @return*/SeckillStatus queryStatus(String username);
实现:
@Overridepublic SeckillStatus queryStatus(String username) {return JSON.parseObject((String) redisTemplate.boundHashOps("UserQueueStatus").get(username),SeckillStatus.class);}
- 控制层:
@RequestMapping(value = "/query")public Result queryStatus() {String username = "jia";SeckillStatus seckillStatus = seckillOrderService.queryStatus(username);if (seckillStatus == null) {return new Result(false, StatusCode.NOTFOUNDERROR, "查询抢单失败");}return new Result(true, StatusCode.OK, "查询抢单成功", seckillStatus);}
测试:
六、总结
(1)秒杀是商家为了促销,组织的限时抢购活动。
秒杀技术实现核心思想是运用缓存减少数据库瞬间的访问压力。
读取秒杀活动时间段内的商品详细信息时,访问缓存;当用户抢购成功时,减少缓存中的库存数量;当库存数为 0 时或秒杀活动期结束时,将缓存中的商品删除。
用户抢购成功时,产生的秒杀预订单不会立刻写到数据库中,而是先写到缓存,当用户成功付款后,才写入数据库。缓存使用 Redis 数据库。
(2)查询活动时间段内的所有秒杀商品,需要满足以下条件:
① 状态必须为审核通过: status=1
② 商品库存个数 >0
③ 活动时间:startTime >= 当前时间, endTime < 当前时间+2 ,比如说商品的秒杀时间设置startTime 和 endTime 是 11:00 ~ 11:30,当前时间段是 10:00 ~ 12:00,是需要将商品在时间段内展示出来的。
使用 @Scheduled 开启定时任务,每 60 s 查询一次,从当前时间所在时间段算起,查询 5 个时间段内秒杀活动的商品数据。以 SeckillGoods_[time] 为 namespace,key 是商品的id,value是商品对象。还需要去重,也就是说,已经在缓存中的 key,不需要再存了,逻辑是先遍历 key,然后使用 SQL 语句,查询出条件为 id not in keys 的记录。、
(3)用户抢购成功时,产生的秒杀预订单不会立刻写到数据库中,而是先写到缓存,用户下单时,是先到缓存中查询秒杀商品,如果无库存,会提示“已售罄”,然后创建 SeckillOrder 订单对象,设置id、金额、用户id、创建时间、订单状态(未支付)、并把订单对象存入缓存,namespace 为 SeckillOrder,key 为 用户名,value 为订单对象,然后商品库存递减,如果商品是最后一个,也就是说递减后数目 <(这里可以小于吗???)=0 时,需要把商品从 SeckillGoods_[time] 中移除,也就是说,商品不再作秒杀活动了。
使用 @Async 开启多线程,用户抢单入队,多线程从队列中消费,队列使用 Redis 的 List 实现,左进右出,实现“先抢先下单”。封装排队信息 SeckillStatus,包括 username、createTime、status(排队中;等待支付;支付超时;秒杀失败;支付完成)、goodsId 等属性。
可以在消费的 createOrder 方法中,sleep 10s,这样就能看到先入队,再出队的效果。list 名为 SeckillOrderQueue 。 入队后,生成 namespace 为 UserQueueStatus ,key 为 username,value 为 SeckillStratus
的记录,用于记录抢单成功的订单状态,后续可以进行查询。(否则多线程就消费,出队了,查询不到了)
微服务商城系统(十五)秒杀基础相关推荐
- springcloud 整合 gateway_GitHub上最火的SpringCloud微服务商城系统项目,附全套教程
项目介绍 mall-swarm是一套微服务商城系统,采用了 Spring Cloud Greenwich.Spring Boot 2.MyBatis.Docker.Elasticsearch等核心技术 ...
- mall-swarm是一套微服务商城系统
介绍: mall-swarm是一套微服务商城系统,采用了 Spring Cloud Hoxton & Alibaba.Spring Boot 2.3.Oauth2.MyBatis.Elasti ...
- mall-swarm微服务商城系统
mall-swarm是一套微服务商城系统,采用了 Spring Cloud 2021 & Alibaba.Spring Boot 2.7.Oauth2.MyBatis.Docker.Elast ...
- 微服务商城系统(十六)秒杀核心
代码链接: https://github.com/betterGa/ChangGou 文章目录 一.防止秒杀重复排队 二. 并发超卖问题解决 三. 订单支付 1.实现根据不同类型订单识别不同操作队列 ...
- 微服务商城系统(十四)微信支付
文章目录 一.支付微服务 1.微信支付 API 2.HttpClient 工具类 3.支付微服务搭建 二.微信支付二维码生成 三.检测支付状态 四.内网穿透 五.支付结果通知 1.支付结果回调通知 2 ...
- 微服务商城系统(十) Spring Security Oauth2 + JWT 用户认证
文章目录 一.用户认证分析 1.认证 与 授权 2.单点登录 3.第三方账号登录 4.第三方认证 5.认证技术方案 6.Security Oauth 2.0 入门 7. 资源服务授权 (1)资源服务授 ...
- 微服务商城系统(一)框架搭建、商品微服务搭建
文章目录 一.预备 1.微服务 2.缓存 3.通用Mapper 和 PageHelper 4.持久化 5.电商模式 二.系统设计 三.框架搭建 1.环境准备 2.项目结构介绍 3.公共工程搭建 (1) ...
- SpringCloud Alibaba 微服务架构(十五)- 一文详解 Nacos 高可用特性
前言 服务注册发现是一个经久不衰的话题,Dubbo 早期开源时默认的注册中心 Zookeeper 最早进入人们的视线,并且在很长一段时间里,人们将注册中心和 Zookeeper 划上了等号,可能 Zo ...
- 微服务商城系统(六)商品搜索 SpringBoot 整合 Elasticsearch
文章目录 一.Elasticsearch 和 IK 分词器的安装 二.Kibana 使用 三.数据导入 Elasticsearch 1.SpringData Elasticsearch 介绍 2.搜索 ...
- 微服务商城系统 实战记录 用户、商家、后台管理员注册与登录功能实现
代码见 https://github.com/betterGa/ChangGou 文章目录 一.用户注册 1.使用 ajax (POST 方法) 2.使用 thymeleaf 3.解决跨域问题 二.用 ...
最新文章
- AI如何帮助我们理解意识——麻省理工最新大脑研究
- util.Date与sql.Date的相互转换以及区别
- 【Visual C++】一些开发心得与调试技巧
- 网络:TCP/UDP
- kiss原则包括什么_和女孩牵手与kiss的具体方法
- 【机器视觉】 dev_update_var算子
- 织梦 css里的图片标签,织梦{dede:field.body /}中用CSS的expression参数控制图片大小
- 列表生成式(List)
- 前后端分离 与 不分离
- ASS 字幕格式规范
- C语言求若干个数的均值和方差
- win7家庭版如何升级到专业版和旗舰版
- 宇视摄像头安装——筒机安装
- Nexus私服的下载、安装、启动、配置教程
- golang读取conf文件的两种方式(ini和Viper)
- 最邻近差值算法(nearest)和双线性插值算法(bilinear)
- 台大李宏毅课程笔记3——New Optimization for Deep Learning深度学习新优化
- 英文中 vi和vt的区别
- Ubuntu 16.04 源码编译安装GPU tensorflow(二)
- Python股票监控机器人,加强版!
热门文章
- HazelEngine 学习记录 - Layers
- MacOS提示“Developer tools access“需要控制另一个进程,以便继续调试
- 初二计算机听课笔记,初二物理上听课记录20篇
- hdu2859Phalanx
- WinDBG技巧:this指针的常见误区 (ECX寄存器存放this指针)
- Numpy-如何对数组进行切割
- 侯宁彬出席“春风拂槛”唐文化论坛并发表主题演讲
- 独家 | Fomo 3D 沦陷?为何又是 DDoS攻击?来听听区块链安全大牛的深度解析
- 基于mysql 批量插入100w测试数据
- 认识SlackwareLinux及制作系统安装磁片之关於bootdisk(转)