好好学习,天天向上
本文已收录至我的Github仓库
DayDayUP:
http://github.com/RobodLee/DayDayUP
,欢迎Star
畅购商城(一):环境搭建畅购商城(二):分布式文件系统FastDFS
畅购商城(三):商品管理畅购商城(四):Lua、OpenResty、Canal实现广告缓存与同步畅购商城(五):Elasticsearch实现商品搜索畅购商城(六):商品搜索畅购商城(七):Thymeleaf实现静态页
畅购商城(八):微服务网关和JWT令牌畅购商城(九):Spring Security Oauth2畅购商城(十):购物车畅购商城(十一):订单畅购商城(十二):接入微信支付
畅购商城(十三):秒杀系统「上」
流程分析
上面这张图是整个秒杀系统的流程。简单介绍一下:
秒杀是一个并发量很大的系统,数据吞吐量都很大,MySQL的数据是保存在硬盘中的,数据吞吐的能力满足不了整个秒杀系统的需求。为了提高系统的访问速度,我们定时将秒杀商品从MySQL加载进Redis,因为Redis的数据是保存在内存中的,速度非常快,可以满足很高的吞吐量。
用户访问秒杀系统,请求到了OpenResty,OpenResty从Redis中加载秒杀商品,然后用户来到了秒杀列表页。当用户点击某个秒杀商品时,OpenResty再从Redis中加载秒杀商品详情信息,接着用户就来到了秒杀商品详情页。
当进入到商品详情页之后用户就可以点击下单了,点击下单的时候,OpenResty会检查商品是否还有库存,没有库存就下单失败。有库存的话还需要检查一下用户是否登录,没有登录的话再到OAuth2.0认证服务那边去登录,登录成功后再进入到秒杀微服务中,开始正式的下单流程。
理论上这时候还要对用户进行一些合法性检测,比如账号是否异常等,但是这太耗时了,为了减少系统响应的时间,用户检测这一步先省略。直接让用户进行排队,排队就是将用户id和商品id存入Redis队列,成功排队后给用户返回一个 “正在排队”的信息。
当排队成功后就开启多线程抢单,为每个排队的用户分配一个线程。在排队用户自己的线程中开始检测账号的状态是否正常,然后从Redis中检测库存时候足够,当所有条件都满足的时候,下单成功,将订单信息存入Redis。并将Redis中的排队信息从“排队中”改为“待支付”,这样前端在查询状态的时候就知道可以开始支付了,然后跳转到支付页面进行支付。当用户支付成功后,将抢单信息从Redis中删除,并同步到MySQL中。
最后一个问题,有的用户成功抢单后并不去付款,所以我们需要定时去处理未支付的订单。方案和上一篇文章中提到的一样,使用RabbitMQ死信队列。在抢单成功后将订单id、用户id和商品id存到RabbitMQ的队列1,设置半个小时后过期,过期后将信息发送给队列2,我们去监听队列2。当监听到队列2中的消息的时候,说明半个小时已经到了,这时候我们再去Redis中查询订单的状态,如果已经支付了就不去管它;如果没有支付就向微信服务器发送请求关闭支付,然后回滚库存,并将Redis中的抢单信息删除。
这样整个秒杀流程就结束了。
定时任务
怎么搭建秒杀微服务就不记录了,没什么好说的,秒杀微服务名为changgou-service-seckill。定时任务我也是第一次接触,所以在这里记录一下。
首先在启动类上添加一个注解**@EnableScheduling去开始对定时任务的支持。然后创建一个类SeckillGoodsPushTask**,在这个类上添加@Component注解,将其注入Spring容器。然后再添加一个方法,加上**@Scheduled**注解,声明这个方法是一个定时任务。
/**
* SeckillGoodsPushTask
* 定时将秒杀商品加载到redis中
*/
@Scheduled(cron = "0/5 * * * * ?")
public void loadGoodsPushRedis() {
List<Date> dateMenu = DateUtil.getDateMenus();
for (Date date : dateMenu) {
date.setYear(2019-1900); //2019-6-1 为了方便测试
date.setMonth(6-1);
date.setDate(1);
String dateString = SystemConstants.SEC_KILL_GOODS_PREFIX +DateUtil.data2str(date,"yyyyMMddHH");
BoundHashOperations boundHashOperations = redisTemplate.boundHashOps(dateString);
Set<Long> keys = boundHashOperations.keys(); //获取Redis中已有的商品的id集合
List<SeckillGoods> seckillGoods;
//将秒杀商品的信息从数据库中加载出来
if (keys!=null && keys.size()>0) {
seckillGoods = mapper.findSeckillGoodsNotIn(date,keys);
} else {
seckillGoods = mapper.findSeckillGoods(date);
}
//遍历秒杀商品集合,将商品依次放入Redis中
for (SeckillGoods seckillGood : seckillGoods) {
boundHashOperations.put(seckillGood.getId(),seckillGood);
}
}
}
----------------------------------------------------------------------------------------------------------------
@Repository("seckillGoodsMapper")
public interface SeckillGoodsMapper extends Mapper<SeckillGoods> {
//查找符合条件的秒杀商品
@Select("SELECT" +
" * " +
" FROM " +
" tb_seckill_goods " +
" WHERE " +
" status = 1 " +
" AND stock_count > 0 " +
" AND start_time >= #{date} " +
" AND end_time < DATE_ADD(#{date},INTERVAL 2 HOUR)")
List<SeckillGoods> findSeckillGoods(@Param("date") Date date);
//查询出符合条件的秒杀商品,排除之前已存入的
@SelectProvider(type = SeckillGoodsMapper.SeckillProvider.class, method = "findSeckillGoodsNotIn")
List<SeckillGoods> findSeckillGoodsNotIn(@Param("date") Date date, @Param("keys") Set<Long> keys);
class SeckillProvider {
public String findSeckillGoodsNotIn(@Param("date") Date date, @Param("keys") Set<Long> keys) {
StringBuilder sql = new StringBuilder("SELECT" +
" * " +
" FROM " +
" tb_seckill_goods " +
" WHERE " +
" status = 1 " +
" AND stock_count > 0 " +
" AND start_time >= ");
sql.append("").append(date.toLocaleString()).append("")
.append(" AND end_time < DATE_ADD(")
.append("").append(date.toLocaleString()).append("")
.append(" ,INTERVAL 2 HOUR) ")
.append(" AND id NOT IN (");
for (Long key : keys) {
sql.append(key).append(",");
}
sql.deleteCharAt(sql.length() - 1).append(")");
System.out.println(sql.toString());
return sql.toString();
}
}
}
(cron = "0/5 * * * * ?")中几个参数分别代表秒-分-时-日-月-周-年。年可以省略,所以是6个。*表示所有值,比如 “分” 是*就代表每分钟都执行。?表示不需要关心这个值是多少。/表示递增触发,0/5表示从0秒开始每5秒触发一次。所以这段代码配置的就是每5秒执行一次定时任务。
上面这段代码的意思是:将MySQL中的秒杀商品放入Redis,为了避免添加重复的商品,先获取Redis中已有商品的id集合,然后在查询数据库的时候将已有的排除掉。redis中存入商品的键为秒杀开始的时间,例如 "2020100110"表示2020年10月1日10点,获取时间菜单用的是资料提供的一个工具类DateUtil。DateUtil的代码不难,我就不介绍了,开调试模式跟着走一遍就能看懂。为了方便测试,我将日期定在了2019年6月1日,实际开发中应该用当前日期。
秒杀频道页
将商品加载到Redis中后就可以开始下单流程了,首先需要有个秒杀频道页,就是将对应时间段的秒杀商品加载到页面上展示出来。前端将当前时间的字符串(yyyyMMddHH)传到后端,后端从Redis中查询出对应的商品返回到前端,前端进行展示。
// SeckillGoodsController
//根据时间段(2019090516) 查询该时间段的所有的秒杀的商品
@GetMapping("/list")
public Result<List<SeckillGoods>> list(@RequestParam("time") String time){
List<SeckillGoods> list = seckillGoodsService.list(time);
return new Result<>(true,StatusCode.OK,"查询成功",list);
}
-----------------------------------------------------------------------------------
// SeckillGoodsServiceImpl
@Override
public List<SeckillGoods> list(String time) {
return redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX+time).values();
}
代码很简单,就是根据键将商品从Redis中查询出来。
秒杀商品详情页
当用户点击秒杀频道页的商品后,就会进入到秒杀商品详情页。前端将当前时间段和商品的id传到后端,后端从Redis中将商品信息查询出来,然后返回给前端进行展示。
// SeckillGoodsController
//根据时间段 和秒杀商品的ID 获取商品的数据
@GetMapping("/one")
public Result<SeckillGoods> one(String time,Long id){
SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
return new Result<>(true,StatusCode.OK,"查询商品数据成功",seckillGoods);
}
------------------------------------------------------------------------------------------
// SeckillGoodsServiceImpl
@GetMapping("/one")
public Result<SeckillGoods> one(String time,Long id){
SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
return new Result<>(true,StatusCode.OK,"查询商品数据成功",seckillGoods);
}
多线程抢单
上面两个小节内容都不多,现在正式进入下单的流程。因为在秒杀环境中,并发量都很大,如果只开一个线程的话,用户不知道要等到猴年马月,所以为每个下单的用户分配一个线程去进行处理是比较妥当的。
要在SpringBoot中开启多线程,首先在启动类上添加一个注解**@EnableAsync**去开启对异步任务的支持。
//SeckillOrderController
//下单
@RequestMapping("/add")
public Result<Boolean> add(String time,Long id){
//1.获取当前登录的用户的名称
String username ="robod";//测试用写死
boolean flag = seckillOrderService.add(id, time, username);
return new Result(true,StatusCode.OK,"排队中。。。",flag);
}
前端将时间段和商品的id传进来,用户名暂时写死,方便测试。
// SeckillOrderServiceImpl
@Override
public boolean add(Long id, String time, String username) {
SeckillStatus seckillStatus = new SeckillStatus(username,LocalDateTime.now(),1,id,time);
//将seckillStatus存入redis队列
redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).leftPush(seckillStatus);
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY).put(username,seckillStatus);
multiThreadingCreateOrder.createOrder();
return true;
}
在这段代码中,先根据已有的信息创建了一个SeckillStatus对象,这个类中存放了秒杀的一些状态信息。然后将seckillStatus放入redis队列中,如果及时地处理订单系统响应速度就会变慢,所以先创建一个SeckillStatus放入redis,然后调用**multiThreadingCreateOrder.createOrder()**去开启一个线程处理订单。
@Component
public class MultiThreadingCreateOrder {
…………
//异步抢单
@Async //声明该方法是个异步任务,另开一个线程去运行
public void createOrder() {
//从redis队列中取出seckillStatus
SeckillStatus seckillStatus = (SeckillStatus)
redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).rightPop();
BoundHashOperations seckillGoodsBoundHashOps =
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + seckillStatus.getTime());
//从redis中查询出秒杀商品
SeckillGoods seckillGoods = (SeckillGoods)seckillGoodsBoundHashOps.get(seckillStatus.getGoodsId());
if (seckillGoods == null || seckillGoods.getStockCount() <=0 ) {
throw new RuntimeException("已售罄");
}
//创建秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setSeckillId(seckillGoods.getId());
seckillOrder.setMoney(seckillGoods.getCostPrice());
seckillOrder.setUserId(seckillStatus.getUsername());
seckillOrder.setCreateTime(LocalDateTime.now());
seckillOrder.setStatus("0");
//将秒杀订单存入redis,键为用户名,确保一个用户只有一个秒杀订单
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_ORDER_KEY)
.put(seckillStatus.getUsername(),seckillOrder);
//减库存,如果库存没了就从redis中删除,并将库存数据写到MySQL中
seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
if (seckillGoods.getStockCount() <= 0) {
seckillGoodsBoundHashOps.delete(seckillStatus.getGoodsId());
seckillGoodsMapper.updateByPrimaryKeySelective(seckillGoods);
} else {
seckillGoodsBoundHashOps.put(seckillStatus.getGoodsId(),seckillGoods);
}
//下单成功,更改seckillstatus的状态,再存入redis中
seckillStatus.setOrderId(seckillOrder.getId());
seckillStatus.setMoney(Float.valueOf(seckillGoods.getCostPrice()));
seckillStatus.setStatus(2); //等待支付
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY)
.put(seckillStatus.getUsername(),seckillStatus);
}
}
在这个方法上添加了一个**@Async注解,说明该方法是个异步任务,每次执行该方法的时候都会另开一个线程去运行。之前不是将订单存入redis队列中了吗,现在从redis队列中取出。然后根据商品id查询出商品信息。接着进行库存判断,如果没有商品或者库存没了说明已经卖完了,抛出已售罄的异常。如果有库存的话,就创建一个秒杀订单,将status置为0表示未支付**。 然后将订单存入redis中,这样订单就算创建完成了。成功创建订单后就应该减去相应的库存。如果减完库存后发现库存没了,说明最后一件商品已经卖完了,这时候就可以将redis中的该商品删除,并更新到MySQL中。
最后修改seckillstatus的内容,并更新到redis中。之前没说把seckillstatus存入redis的作用,其实它的作用就是供前端查询订单状态。
既然是查询订单状态,得提供一个接口吧
// SeckillOrderController
//查询当前登录的用户的抢单信息(状态)
@GetMapping("/query")
public Result<SeckillStatus> queryStatus(String username) {
SeckillStatus seckillStatus = seckillOrderService.queryStatus(username);
if (seckillStatus == null) {
return new Result<>(false,StatusCode.NOT_FOUND_ERROR,"未查询到订单信息");
}
return new Result<>(true,StatusCode.OK,"订单查询成功",seckillStatus);
}
-------------------------------------------------------------------------------------------
//SeckillOrderServiceImpl
@Override
public SeckillStatus queryStatus(String username) {
return (SeckillStatus) redisTemplate.boundHashOps(SystemConstants.SEC_KILL_USER_STATUS_KEY).get(username);
}
前端将用户名传入进来,然后查询订单状态,如果查询出来的状态是待支付的话,就可以进入支付流程了。
总结
好了,这篇文章到这里就结束了,主要介绍了一下秒杀的流程,然后实现了定时任务,秒杀频道页,秒杀商品详情页和多线程抢单的功能。这个秒杀系统还没有结束,还存在很多问题,在下一篇文章中,将会修改现有的问题并继续完善秒杀的流程。让我们下期再见!
码字不易,可以的话,给我来个点赞,收藏,关注
代码: