秒杀系统 - 简单理论实现

秒杀系统中,操作一般都是比较复杂的,而且并发量特别高。比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在存在刷单行为,记录用户操作日志等等。

而且我们熟悉的秒杀都是分时间段的,比如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<Date> getDateMenus(){
        // 定义一个List<Date>集合,存储所有时间段
        List<Date> dates = new ArrayList<Date>();
        // 循环12次 - 每个秒杀时间段都是2小时,如12:00 - 14:00
        Date date = toDayStartHour(new Date());  // 获取凌晨时间
        for (int i = 0; i <12 ; i++) {
            // 每次递增2小时,将每次递增的时间存入到List<Date>集合中
            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<Date> dateMenus = new ArrayList<Date>();
        // 一次只显示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<Date> 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> 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 <len ; i++) {
            ids[i]=id;
        }
        return ids;
    }
}

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<SeckillGoods> 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<String, String> 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<String,String> 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());
            }
        }
    }
}

 

整个流程就是这样,具体实现可以参考某马的青橙商城,我是根据他的源码写的本文。主要是想熟悉下流程,中间具体业务还得根据自己的项目需求来进行。 文章写得有点久,就没有检查,可能里面存在错误,由于太困,也就没打算检查。如果有朋友有耐心看到本文,希望可以给予一些指点和帮助,因为我不是很清楚实际业务中秒杀系统是否是这样做的,毕竟我也是根据别人源码所提取的。

发布了100 篇原创文章 · 获赞 25 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_40885085/article/details/104567404