秒杀系统中,操作一般都是比较复杂的,而且并发量特别高。比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在存在刷单行为,记录用户操作日志等等。
而且我们熟悉的秒杀都是分时间段的,比如12-14点,14-16点。
那我们就可以根据时间段,将每个时间段的商品信息(含开始时间和结束时间)集合存入到Redis缓存中去。这里我们约定商品对象为SeckillGoods。
// 使用Hash存储,Key为标识时间的,如SeckillGoods_202002291400,即2020年2月29日14点整(商品秒杀开始时间)
// 次级Hash,商品id为key,商品对象为value
redisTemplate.boundHashOps("SeckillGoods_" + time).put(seckillGoods.getId(),seckillGoods);
此时我们点击立即抢购就可以通过从Redis中根据商品id查询到商品信息,渲染到商品详情页面上。
redisTemplate.boundHashOps("SeckillGoods_" + time).get(id);
在页面详情中,我们就可以通过点击立即秒杀进行秒杀商品。秒杀商品这个操作可能面临哪些问题呢?
因为并发量特别高,所以采用多线程下单。同时我们需要保证用户抢单的公平性,我们就可以记录用户抢单数据,将其存入Redis缓存队列中,多线程从队列中取出信息进行消费即可。存数抢单数据的时候,采用左边存储,右边取出的方式,即先进先出原则。
同时要下单,我们必须要保证用户是登录了的,如果没登录则需要先登录才能秒杀。用户已登录,则还需要判断当前用户是否多次下单(原则上,秒杀只允许下单一次,不允许多次提交订单/排队)。此时需要解决的就是怎么判断用户有没有多次下单。这里可以通过Redis的自增特性来解决。
// Redis自增特性
// incr(key,value):让自定key的值自增value,返回自增后的值,这是一个单线程操作
// 第一次:incr(username,1) -> 1
// 第二次:incr(username,1) -> 2
Long userQueueCount = redisTemplate.boundHashOps("UserQueueCount").increment(username,1);
第一次下单时,自增后的值是1,第二次下单时,自增后的值是2…… 依次类推
因此解决办法来了,我们每次判断这个自增后的值是否大于1,大于1则表明用户已经下过单/排过队了,就不允许再次下单即可。
如果此时是第一次下单,则可以进行下一步:创建排队信息、将排队信息存入到Redis缓存队列中、调用多线程进行抢单。
这里我们统一排队信息对象为SeckillStatus,该对象包含用户名、创建时间、秒杀状态(1.排队中 2.等待支付 3.支付超时 4.秒杀失败 5.支付完成)、秒杀商品id、应付金额、订单号、商品时间段等信息。
// 创建排队信息 - 用户名、创建时间、状态码、商品所在抢购时间段
SeckillStatus seckillStatus = new SeckillStatus(username,new Date(),1,time);
// 将排队信息存入到Redis缓存队列中 - 左存
redisTemplate.boundHashOps("SeckillQueue").leftPush(seckillStatus);
完成上诉步骤,我们就可以调用多线程异步抢单的工作了。
在异步抢单中,我们首先就是从Redis缓存队列中取出排队信息。
// 取出排队信息 - 右取
SeckillStatus seckillStauts = (SeckillStatus) redisTemplate.boundListOps("SeckillOrderQueue").rightPop();
此时,面临一个问题,因为多线程抢单,那么用户怎么知道他抢没抢到呢?因此我们还应该要在前端使用一个定时器每隔1秒发送一个异步请求取查询一次订单状态。
后端查询状态的代码怎么写?
回到之前,我们将排队信息存入到Redis缓存队列那里。我们应该在将排队信息存入到Redis缓存队列之后,将这个排队信息(因为这个排队信息包含订单状态)以key为用户名,value为seckillStatus对象的方式存入一份到Redis中,当多线程异步抢单中(不管抢单成功,还是抢单失败的时候),我们都要对状态进行更新。
// 将排队信息(排队信息对象含状态)存入到Redis中一份
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);
这个步骤完成之后,才能进行调用异步抢单任务。
此时,我们又将面临一个问题,那就是并发超卖现象。何为并发超卖现象呢?
多个人在同一时刻抢购同一商品,都会同时判断是否还有库存,而此时库存只剩一个,这几个人都会判断出有库存,然后就会接着处理后面的业务,就会出现超卖现象。即本来明明只有1个库存了,你却给我卖出去10几个。
如何解决这个问题呢?同样,我们可以利用Redis缓存队列来实现。给每一件商品创建一个独立的商品个数队列。比如说:A商品有2个,A商品的id为1002。创建一个队列,key为SeckillGoodsCountList_1002,往这个队列中塞入两次1002这个ID。这个步骤应该在什么时候做呢?这个步骤应该在根据每个时间段的商品信息集合存入到Redis缓存中的时候去做。
// 库存数组大小
int len = seckillGoods.getStockCount();
Long[] ids = new Long[len];
// for循环将商品id填入
for(int i = 0; i < len; i++){
ids[i] = seckillGoods.getId();
}
// 存入Redis缓存队列中
redisTemplate.boundListOps("SeckillGoodsCountList_" + seckillGoods.getId()).leftPushAll(ids);
每次给用户下单的时候,先从队列中取数据,如果能取到数据,则表明有库存,如果取不到,则表明没有库存,这样就可以防止超卖问题产生了。
// 获取队列中的商品id
Object sid = redisTemplate.boundListOps("SeckillGoodsCountList_" + id).rightPop();
if(sid == null){
// 商品售罄,处理售罄情况的业务
// 首先需要清空这个排队信息
// 清空排队信息,这个队列是用来判断是否重复下单的,商品都售罄了自然也就清理了
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());
// 清空状态信息,这个队列是用来保存订单状态的,商品都售罄了自然也就清理了
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
// ...其他业务
}
抢单成功,则需要创建一个订单信息SeckillOrder,含订单id,秒杀商品id、支付金额、用户id、商家id、创建时间、支付时间、支付状态、收货人地址、收货人电话、收货人姓名等信息。
// 创建订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId()); // 订单编号
seckillOrder.setSeckillId(id); // 商品id
seckillOrder.setMoney(goods.getCostPrice()); // 支付金额
seckillOrder.setUserId(username); // 用户名
seckillOrder.setSellerId(goods.getSellerId()); // 商家id
seckillOrder.setCreateTime(new Date()); // 创建时间
seckillOrder.setStatus("0"); // 状态 - 未支付
redisTemplate.boundHashOps("SeckillOrder").put(username,seckillOrder);
那么此时问题又来了,当用户抢单成功,那商品库存是不是会削减1个?如果我们从Redis中取出商品信息,将库存-1,然后再存入Redis,这样可行吗?事实上这种方案是不可行的。如果某一时刻,十几个用户抢单成功,他们同时从Redis中取出商品信息(库存),假如此时库存为50,那么这十几个用户取出来的都是50,50 – 1 = 49。将这个49更新到Redis实际是不对的,因为这十几个用户都抢单成功了,那么剩余库存应该是50减去这十几个抢单成功后的数量。即我明明卖出去10个,库存本应还剩40个,你这么给我一整,我咋还剩49个库存呢?显然是不对的。
怎么做呢?同样我们可以通过Redis的自增特性来做,因为它是单线程的。我们可以在根据每个时间段的商品信息集合存入到Redis缓存中的时候去做这个事情。创建一个商品库存缓存。
// 以id为key,库存为value
redisTemplate.boundHashOps("SeckillGoodsCount").put(seckillGoods.getId(),seckillGoods.getStockCount());
而在抢单成功之后,使用自增特性,让库存-1。
// 每次抢单成功之后,库存自减1
Long surplusCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillGoods.getId(), -1);
// 更新查询到的商品的库存
seckillGoods.setStockCount(surplusCount.intValue());
// 此时就根据库存的剩余量来判断是否同步到MySQL中去
if(setStockCount <= 0){
// 商品库存<=0,同步到MySQL,同时清理Redis缓存
seckillGoodsDao.updateByPrimaryKeySelective(seckillGoods);
// 清理Redis缓存
redisTemplate.boundHashOps("SeckillGoods_"+time).delete(seckillGoods.getId());
}else{
// 将数据同步到Redis中
redisTemplate.boundHashOps("SeckillGoods_" + time).put(id,goods);
}
此时抢单完成,更新状态为待支付(2)。
// 变更抢单状态
seckillStauts.setOrderId(seckillOrder.getId()); // 订单id
seckillStauts.setMoney(seckillOrder.getMoney().floatValue()); // 订单金额
seckillStauts.setStatus(2); // 抢单成功,待支付
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStauts);
此时因为前端的定时器一直在查询状态,如果查询到状态是待支付,则会跳转到支付页面(将订单相关信息附带过去)。跳转到支付页面之后,此时前端用定时器每3秒中向后台发送一次请求用于判断当前用户订单是否完成支付,如果完成了支付,则需要清理掉排队信息,并且需要修改订单状态信息。
前端一旦查询到订单已支付,则就去修改订单信息。
// 根据用户名查询订单数据
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(username);
if(seckillOrder != null){
// 修改订单 -> 持久化到MySQL中
seckillOrder.setPayTime(new Date()); // 支付时间
seckillOrder.setStatus("1"); // 状态 - 已支付
seckillOrderDao.insertSelective(seckillOrder); // 持久化到MySQL中
// 清除Redis中的订单
redisTemplate.boundHashOps("SeckillOrder").delete(username);
// 清除用户排队信息 - 用于判断是否重复下单的那个队列
redisTemplate.boundHashOps("UserQueueCount").delete(username);
// 清除排队状态存储信息
redisTemplate.boundHashOps("UserQueueStatus").delete(username);
}
此时,新问题又来了。用户每次下单后,不一定会立即支付,甚至有可能不支付,那么此时我们需要删除用户下的订单,并回滚库存。这里我们可以采用MQ的延时消息实现,每次用户下单的时候,如果订单创建成功,则立即发送一个延时消息到MQ中,等待消息被消费的时候,先检查对应订单是否下单支付成功,如果支付成功,会在MySQ中生成一个订单,如果没有支付,则Redis中还有该订单信息的存在,需要删除该订单信息以及用户排队信息,并恢复库存。
延时消息,将seckillStatus发送出去。半小时后监听到消息,也就是seckillStatus。如果seckillStatus不为null,则从Redis中根据seckillStatus获取用户名,判断Redis中是否存在这个订单,存在的话,则表明没有支付(因为支付成功,会清理Redis中的数据)。
// 判断Redis中是否存在对应的订单
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(seckillStatus.getUsername());
如果存在,则需要删除订单信息,并且回滚库存。
// 删除用户订单
redisTemplate.boundHashOps("SeckillOrder").delete(seckillOrder.getUserId());
// 查询出商品数据
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_"+seckillStatus.getTime()).get(seckillStatus.getGoodsId());
if(goods == null){ // 说明Redis中已经卖完了
// 只能从数据库中加载数据
goods = seckillGoodsMapper.selectByPrimaryKey(seckillStatus.getGoodsId());
}
// 递增库存 incr
Long seckillGoodsCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillStatus.getGoodsId(), 1);
goods.setStockCount(seckillGoodsCount.intValue());
// 将商品数据同步到Redis
redisTemplate.boundHashOps("SeckillGoods_"+seckillStatus.getTime()).put(seckillStatus.getGoodsId(),goods);
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillStatus.getGoodsId()).leftPush(seckillStatus.getGoodsId());
// 清理用户抢单排队信息
// 清理重复排队标识
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());
// 清理排队状态存储信息
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
整个秒杀过程,需要注意的就是多线程下单、防止秒杀重复排队、并发超卖问题、超时支付库存回滚问题。
本文是在看了某马的青橙商城秒杀阶段所写,因此下面附上完整代码。
1、时间相关的工具类
这个类的主要作用就是获取秒杀时间段、在某个时间基础上增加多少分钟/小时、获取秒杀页面上显示的秒杀时间段集合、时间格式转换。
/**
* 时间工具类
*/
public class DateUtil {
/***
* 从yyyy-MM-dd HH:mm格式转成yyyyMMddHH格式
* @param dateStr
* @return
*/
public static String formatStr(String dateStr){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");
try {
Date date = simpleDateFormat.parse(dateStr);
simpleDateFormat = new SimpleDateFormat("yyyyMMddHH");
return simpleDateFormat.format(date);
} catch (ParseException e) {
e.printStackTrace();
}
return null;
}
/***
* 获取指定日期的凌晨00:00
* 如2020-02-27 10:19,它的凌晨时间是2020-02-27 00:00
* @return
*/
public static Date toDayStartHour(Date date){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date start = calendar.getTime();
return start;
}
/***
* 在某个时间基础上递增N分钟
* @param date 时间
* @param minutes 增加时间(分)
* @return
*/
public static Date addDateMinutes(Date date,int minutes){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.MINUTE, minutes);// 24小时制
date = calendar.getTime();
return date;
}
/***
* 在某个时间基础上递增N小时
* @param date 时间
* @param hour 增加时间(时)
* @return
*/
public static Date addDateHour(Date date,int hour){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.HOUR, hour);// 24小时制
date = calendar.getTime();
return date;
}
/***
* 获取时间菜单
* @return
*/
public static List getDateMenus(){
// 定义一个List集合,存储所有时间段
List dates = new ArrayList();
// 循环12次 - 每个秒杀时间段都是2小时,如12:00 - 14:00
Date date = toDayStartHour(new Date()); // 获取凌晨时间
for (int i = 0; i <12 ; i++) {
// 每次递增2小时,将每次递增的时间存入到List集合中
dates.add(addDateHour(date,i * 2));
}
// 判断当前时间属于哪个时间范围
Date now = new Date();
for (Date cdate : dates) {
// 开始时间 <= 当前时间 < 开始时间+2小时
if(cdate.getTime() <= now.getTime() && now.getTime() < addDateHour(cdate,2).getTime()){
// 当前时间段的开始时间,如2:53,开始时间则是2:00
now = cdate;
break;
}
}
// 当前需要显示的时间菜单
List dateMenus = new ArrayList();
// 一次只显示5个时间段
for (int i = 0; i < 5 ; i++) {
dateMenus.add(addDateHour(now,i * 2));
}
return dateMenus;
}
/***
* 时间转成yyyyMMddHH格式
* @param date
* @return
*/
public static String date2Str(Date date){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddHH");
return simpleDateFormat.format(date);
}
}
2、秒杀商品初始化 - 任务调度,定时加载秒杀商品
这里主要是将MySQL中的秒杀商品信息、商品库存队列、商品库存值(商品具体的库存数量)存入到Redis。
/**
* 秒杀商品初始化任务调度 - Redis缓存
*/
@Component
public class SeckillGoodsTask {
// SpringDataRedis中的RedisTemplate
@Autowired
private RedisTemplate redisTemplate;
// 秒杀商品Dao,使用的tkMybatis
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
/**
* 每30秒执行一次
*/
@Scheduled(cron = "0/15 * * * * ?")
public void loadGoods(){
// 1 查询所有时间区间 - 如12-14,14-16
List dateMenus = DateUtil.getDateMenus();
// 循环时间区间,查询每个时间区间的秒杀商品
for (Date startTime : dateMenus) {
Example example = new Example(SeckillGoods.class);
Example.Criteria criteria = example.createCriteria();
// 2.1 商品必须审核通过
criteria.andEqualTo("status","1");
// 2.2 库存 > 0
criteria.andGreaterThan("stockCount",0);
// 2.3 秒杀开始时间 >= 当前循环的时间区间的开始时间
criteria.andGreaterThanOrEqualTo("startTime",startTime);
// 2.4 秒杀结束时间 < 当前循环的时间区间的开始时间+2小时
criteria.andLessThan("endTime",DateUtil.addDateHour(startTime,2));
// 2.5 过滤Redis中已经存在的该区间的秒杀商品
Set keys = redisTemplate.boundHashOps("SeckillGoods_" + DateUtil.date2Str(startTime)).keys();
// 如果Redis中存在,就不用查询
if(keys != null && keys.size() > 0){
criteria.andNotIn("id",keys);
}
// 2.6 执行查询
List seckillGoods =seckillGoodsMapper.selectByExample(example);
// 3 将秒杀商品存入到Redis缓存
for (SeckillGoods seckillGood : seckillGoods) {
// ★要秒杀商品完整数据加入到Redis缓存
redisTemplate.boundHashOps("SeckillGoods_"+DateUtil.date2Str(startTime)).put(seckillGood.getId(),seckillGood);
// 剩余库存个数 seckillGood.getStockCount() = 5
// 创建独立队列:存储商品剩余库存
// SeckillGoodsList_110:
// [110,110,110,110,110]
Long[] ids = pushIds(seckillGood.getStockCount(), seckillGood.getId()); // 组装商品ID,将商品ID组装成数组
// ★创建独立库存队列
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillGood.getId()).leftPushAll(ids);
// ★创建自定key的值,保存商品库存值 - 用于初始保存库存数量,下单减少库存,回滚增加库存所用
redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillGood.getId(),seckillGood.getStockCount());
}
}
}
/**
* 组装商品ID,将商品ID组装成数组
* @param len:商品剩余个数
* @param id:商品ID
* @return
*/
public Long[] pushIds(int len,Long id){
Long[] ids = new Long[len];
for (int i = 0; i
3、秒杀页面数据请求 - 请求数据展示
该类主要用于页面查询某个时间段的秒杀商品集合、某个商品详情
public class SeckillGoodsServiceImpl implements SeckillGoodsService {
@Autowired
private RedisTemplate redisTemplate;
/****
* 根据商品ID查询商品详情
* @param time 商品秒杀时区
* @param id 商品ID
* @return
*/
@Override
public SeckillGoods one(String time, Long id) {
return (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_"+time).get(id);
}
/***
* 根据时间区间查询秒杀商品列表
* @param time 时间段,即某个秒杀时间段的开始时间
* @return
*/
@Override
public List list(String time) {
// 组装key
String key = "SeckillGoods_"+time;
return redisTemplate.boundHashOps(key).values();
}
}
4、商品详情页立即秒杀业务 - 下单前的业务
在该业务前还应该判断是否已经登录,没有登录则需要先登录。如果登录了,则进行下单前的业务。
主要作用是判断用户是否重复下单、是否还有库存(有没有必要去排队)、创建排队信息、调用多线程抢单任务等
/***
* 下单实现
* @param id 商品ID
* @param time 商品时区 - 开始时间
* @param username 用户名
* @return
*/
@Override
public Boolean add(Long id, String time, String username) {
// Redis自增特性
// incr(key,value):让指定key的值自增value->返回自增的值->单线程操作
// 第1次: incr(username,1)->1
// 第2次: incr(username,1)->2
// 利用自增,如果用户多次提交或者多次排队,则递增值>1
Long userQueueCount = redisTemplate.boundHashOps("UserQueueCount").increment(username, 1);
if(userQueueCount > 1){ // 如果用户多次排队,自增的值肯定大于1
System.out.println("重复抢单.....");
// 100:错误状态码,重复排队,前端就可以通过错误码来进行不同的处理
throw new RuntimeException("100");
}
// 减少无效排队 - 秒杀商品库存为0了,就没有必要排队了
Long size = redisTemplate.boundListOps("SeckillGoodsCountList_" + id).size();
if(size <= 0){
// 101:表示没有库存,前端就可以通过错误码来进行不同的处理
throw new RuntimeException("101");
}
// 创建队列所需的排队信息 - 用户名、创建时间、状态(排队中)、商品时间段(开始时间)
SeckillStatus seckillStatus = new SeckillStatus(username,new Date(),1,id, time);
// 将排队信息存入到Redis缓存队列中 - 该队列作用是为了多线程下单消费
redisTemplate.boundListOps("SeckillOrderQueue").leftPush(seckillStatus);
// 将排队信息存入到Redis缓存中,key为用户名 - 该队列作用是为了根据用户名来查询这个排队信息的状态
// 状态:1:排队中,2:秒杀成功等待支付,3:支付超时,4:秒杀失败,5:支付完成
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);
// 异步操作调用 - 多线程下单,多线程里面才是真正做下单操作的
multiThreadingCreateOrder.createOrder();
return true;
}
5、多线程抢单 - 多线程做的任务
主要作用是判断是否当前库存能否抢单成功、抢单成功之后应该创建订单信息、抢单之后同步信息
/**
* 多线程任务 - 多线程抢单
*/
@Component
public class MultiThreadingCreateOrder {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
@Autowired
private IdWorker idWorker;
@Autowired
private RabbitTemplate rabbitTemplate;
/**
*
*/
@Async
public void createOrder() {
try {
// 从Redis排队队列中获取排队信息
SeckillStatus seckillStauts = (SeckillStatus) redisTemplate.boundListOps("SeckillOrderQueue").rightPop();
// 用户抢单数据 - 用户名、商品秒杀时间段、商品ID
String username = seckillStauts.getUsername();
String time = seckillStauts.getTime();
Long id = seckillStauts.getGoodsId();
// 获取Redis库存队列
Object sid = redisTemplate.boundListOps("SeckillGoodsCountList_" + id).rightPop();
if (sid == null) { // 取出来的为空,则表明商品暂时售罄
// 清理相关排队信息
clearQueue(seckillStauts);
// 这里应该抛出一个售罄的异常,方便前端根据状态码有不同的处理
return;
}
// 查询商品详情
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + time).get(id);
if (goods != null && goods.getStockCount() > 0) { // 如果
// 创建订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId()); // 订单编号
seckillOrder.setSeckillId(id); // 商品id
seckillOrder.setMoney(goods.getCostPrice()); // 应付金额
seckillOrder.setUserId(username); // 用户名
seckillOrder.setSellerId(goods.getSellerId()); // 商家id
seckillOrder.setCreateTime(new Date()); // 订单创建时间
seckillOrder.setStatus("0"); // 订单状态 - 未付款
redisTemplate.boundHashOps("SeckillOrder").put(username, seckillOrder);
// 库存削减 - Redis库存自减,保证库存数量是对的,因为Redis自增特性是单线程,安全的
Long surplusCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(goods.getId(), -1);
// 将新的库存信息设置到商品对象中去
goods.setStockCount(surplusCount.intValue());
// 商品库存=0 -> 将数据同步到MySQL,并清理Redis缓存
if (surplusCount <= 0) {
// 修改MySQL中的数据 - 商品售罄,应该同步到MySQL持久层
seckillGoodsMapper.updateByPrimaryKeySelective(goods);
// 清理Redis缓存 - 商品售罄,则应该移除
redisTemplate.boundHashOps("SeckillGoods_" + time).delete(id);
} else {
// 未售罄 - 将数据同步到Redis,维持最新商品信息(含库存)
redisTemplate.boundHashOps("SeckillGoods_" + time).put(id, goods);
}
// 变更Redis中的抢单状态 - 从排队到抢单成功未支付状态
seckillStauts.setOrderId(seckillOrder.getId()); // 订单Id
seckillStauts.setMoney(seckillOrder.getMoney().floatValue()); // 应付金额
seckillStauts.setStatus(2); // 抢单成功,待支付
redisTemplate.boundHashOps("UserQueueStatus").put(username, seckillStauts);
// 发送MQ消息
sendDelayMessage(seckillStauts);
}
System.out.println("----正在执行----");
} catch (Exception e) {
e.printStackTrace();
}
}
/***
* 清理用户排队信息
* @param seckillStauts 商品信息
*/
private void clearQueue(SeckillStatus seckillStauts) {
// 清理重复排队标识队列
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStauts.getUsername());
// 清理排队状态标识
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStauts.getUsername());
}
/***
* 延时消息发送
* @param seckillStatus
*/
public void sendDelayMessage(SeckillStatus seckillStatus) {
rabbitTemplate.convertAndSend(
"exchange.delay.order.begin", // 交换器
"delay", // 延迟消息
JSON.toJSONString(seckillStatus), // 发送数据
new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 消息有效期30分钟
message.getMessageProperties().setExpiration(String.valueOf(10000 * 60 * 30));
return message;
}
});
}
}
6、前端:点击立即秒杀之后的前端业务 - 定时器,定时查询排队状态
主要作用是在点击秒杀之后,前端应该在一段时间里向后端发起请求,查询排队状态,根据返回的状态码,来做不同的操作:提示抢单失败、提示售罄、成功跳转支付页面、排队状态继续接着查询、超时提示服务器繁忙
/**
* 查询订单抢单状态 - 点击立即秒杀即调用
*/
queryStatus:function () {
// 120秒查询抢购信息
let count = 120;
// 定时查询 -> window.setInterval()
let queryClock = window.setInterval(function () {
// 时间递减
count--;
// 根据状态判断对应操作
axios.get('/seckill/order/query.do').then(function (response) {
// 403->未登录->登录
if(response.data.code === 403){
location.href='/redirect/back.do';
}else if(response.data.code === 1){
// 1->排队中->继续定时查询
app.msg='正在排队....'+count;
}else if(response.data.code === 2){
// 2->抢单成功
app.msg='抢单成功,即将进入支付!';
// 跳转到支付页->携带订单号+订单金额
location.href='/pay.html?orderId='+response.data.other.orderId+'&money='+response.data.other.money*100;
}else{
// 0 -> 抢单失败
if(response.data.code==0){
// 停止查询
window.clearInterval(queryClock);
app.msg='服务器繁忙,请稍后再试!';
}else if(response.data.code==404){
// 404 -> 找不到数据
app.msg='抢单失败,稍后再试!';
}
//停止查询
window.clearInterval(queryClock);
}
});
if(count <= 0){ // 定时结束
// 停止查询
window.clearInterval(queryClock);
app.msg='服务器繁忙,请稍后再试!';
}
},1000);
}
7、前端:进入支付页面之后,显示订单信息,选择支付方式(比如微信支付) ,则跳转到微信支付的页面。
在微信支付页面则需要创建一个付款二维码、创建一个定时器隔一段时间查询一次是否已经支付、已经支付则跳转至支付成功页面并且更新订单状态。
// 创建支付二维码
var qrcode = new QRCode(document.getElementById("qrcode"), {
width : 200,
height : 200
});
new Vue({
el: '#app',
data(){
return {
orderId:"", // 订单编号
money:"", // 支付金额
timer: null, // 定时器
}
},
methods:{
/**
* 创建二维码
*/
createNative(){
let orderId = getQueryString("orderId"); //获取订单Id
// 请求生成二维码的信息 - 怎么生成的二维码业务需根据实际情况做
axios.get(`/pay/createNative.do?orderId=${orderId}`).then(response => {
if(response.data.out_trade_no!=null){
qrcode.makeCode(response.data.code_url); // 生成二维码
this.orderId= response.data.out_trade_no; // 订单id
this.money= response.data.total_fee; // 应付金额
this.setTimer(); // 设置定时器
}else{
// 准确的说应该提示没有权限或其他操作
// 应该根据返回结果码来做,如果订单不存在,则应该是无权限的
// 如果订单存在,但已经支付,则应该是提示已支付信息的
// 具体业务具体做,这里不细谈,只谈个流程
location.href='payfail.html'; // 跳转支付失败页面
}
});
},
/**
* 查询支付状态
*/
queryPayStatus(){
axios.get(`/pay/queryPayStatus.do?orderId=${this.orderId}`).then(response => {
if(response.data.result_code=='SUCCESS'){ // 有成功的返回结果
if(response.data.trade_state=='SUCCESS'){ // 交易状态为成功
location.href='paysuccess.html'; // 跳转支付成功页面
}
}else{
location.href='payfail.html'; // 跳转支付失败页面
}
});
},
/**
* 设置定时器
*/
setTimer() {
if(this.timer == null) {
this.timer = setInterval( () => {
console.log('开始定时...每过3秒执行一次')
this.queryPayStatus()//查询支付状态
}, 3000)
}
}
},
created(){
// 进入该页面则就要创建支付二维码和清空定时器
this.createNative();
clearInterval(this.timer);
this.timer = null;
},
destroyed: function () {
// 每次离开当前界面时,清除定时器
clearInterval(this.timer);
this.timer = null;
},
});
8、前端定时器一直在查询是否已经支付 - 支付之后的业务,跳转支付成功页面
该方法作用是返回支付信息(是否已经支付成功)
@Override
public Map queryPayStatus(String orderId) {
Map param=new HashMap();
param.put("appid", appid); // 公众账号ID
param.put("mch_id", partner);// 商户号
param.put("out_trade_no", orderId);// 订单号
param.put("nonce_str", WXPayUtil.generateNonceStr());// 随机字符串
String url="https://api.mch.weixin.qq.com/pay/orderquery";
try {
String xmlParam = WXPayUtil.generateSignedXml(param, partnerkey);
HttpClient client=new HttpClient(url);
client.setHttps(true);
client.setXmlParam(xmlParam);
client.post();
String result = client.getContent();
Map map = WXPayUtil.xmlToMap(result);
System.out.println(map);
return map;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
流程如下:
9、监听延时消息,根据延时消息查询订单状态,如果30分钟后未支付,则需要关闭微信支付,且要删除该订单信息以及用户排队信息,并恢复库存。
/**
* 延时信息监听器
*/
public class OrderMessageListener implements MessageListener {
// 注入相关类
@Autowired
private RedisTemplate redisTemplate;
@Reference
private WeixinPayService weixinPayService;
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
/***
* 消息监听
* @param message
*/
@Override
public void onMessage(Message message) {
String content = new String(message.getBody());
System.out.println("监听到的消息:" + content);
// 回滚操作
rollbackOrder(JSON.parseObject(content,SeckillStatus.class));
}
/*****
* 订单回滚操作
* @param seckillStatus
*/
public void rollbackOrder(SeckillStatus seckillStatus){
if(seckillStatus == null){
return;
}
// 判断Redis中是否存在对应的订单
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.boundHashOps("SeckillOrder").get(seckillStatus.getUsername());
// 如果存在,开始回滚 - 因为不存在只会在支付成功后同步到MySQL,并会清理Redis中的缓存
if(seckillOrder!=null){
// 1.关闭微信支付
Map map = weixinPayService.closePay(seckillStatus.getOrderId().toString());
// 关闭微信支付成功
if(map.get("return_code").equals("SUCCESS") && map.get("result_code").equals("SUCCESS")){
// 2.删除Redis中的用户订单
redisTemplate.boundHashOps("SeckillOrder").delete(seckillOrder.getUserId());
// 3.查询出商品数据
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + seckillStatus.getTime()).get(seckillStatus.getGoodsId());
if(goods == null){ // 如果为null,说明Redis中库存为0,已经被移除
// 数据库中加载数据
goods = seckillGoodsMapper.selectByPrimaryKey(seckillStatus.getGoodsId());
}
// 4.递增库存1 incr
Long seckillGoodsCount = redisTemplate.boundHashOps("SeckillGoodsCount").increment(seckillStatus.getGoodsId(), 1);
goods.setStockCount(seckillGoodsCount.intValue());
// 5.将商品数据同步到Redis
redisTemplate.boundHashOps("SeckillGoods_" + seckillStatus.getTime()).put(seckillStatus.getGoodsId(),goods);
redisTemplate.boundListOps("SeckillGoodsCountList_"+seckillStatus.getGoodsId()).leftPush(seckillStatus.getGoodsId());
// 6.清理用户抢单排队信息
// 清理重复排队标识
redisTemplate.boundHashOps("UserQueueCount").delete(seckillStatus.getUsername());
// 清理排队状态存储信息
redisTemplate.boundHashOps("UserQueueStatus").delete(seckillStatus.getUsername());
}
}
}
}
整个流程就是这样,具体实现可以参考某马的青橙商城,我是根据他的源码写的本文。主要是想熟悉下流程,中间具体业务还得根据自己的项目需求来进行。 文章写得有点久,就没有检查,可能里面存在错误,由于太困,也就没打算检查。如果有朋友有耐心看到本文,希望可以给予一些指点和帮助,因为我不是很清楚实际业务中秒杀系统是否是这样做的,毕竟我也是根据别人源码所提取的。