秒杀下单流程步骤: 1. 用户点击“立即购买”进入到“确认订单”页面, 2. 在“确认订单”页面选择收货地址,重新计算运费、订单价格 3. 提交订单,选择支付方式进行支付 4. 支付完毕 秒杀订单大多数流程跟普通订单是一样的部分就不多赘述了,然后我们考虑到秒杀订单的性能,下单时秒杀订单信息和库存都是存于redis,所以这里着重讲一下涉及到的redis流程。 ## 第一步: 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面,相关url`/p/seckill/orderPath`,`/p/seckill/{orderPath}/confirm` ```java public class SeckillOrderParam extends OrderParam { @NotNull(message = "秒杀商品skuId不能为空") @Schema(description = "秒杀商品skuId" , requiredMode = Schema.RequiredMode.REQUIRED) private Long seckillSkuId; } ``` 这一步请求参数跟订单立即购买是一样的,多个了秒杀skuId。 ## 第二步: 用户点击提交订单,进行提交订单,相关url`/p/seckill/{orderPath}/submit` ```java @PostMapping("/{orderPath}/submit") @Operation(summary = "提交订单" , description = "提交之后返回订单号") @Parameter(name = "orderPath", description = "订单路径" , required = true) public ServerResponseEntity submitOrders(@PathVariable("orderPath") String orderPath, @Valid @RequestBody SubmitSeckillOrderParam seckillOrderParam) { String userId = SecurityUtils.getUser().getUserId(); ServerResponseEntity dtoEntity = submitOrderManager.checkSubmitInfo(seckillOrderParam, userId); if (!dtoEntity.isSuccess()) { return ServerResponseEntity.transform(dtoEntity); } ShopCartOrderMergerDto dto = dtoEntity.getData(); dto.setRoomId(seckillOrderParam.getRoomId()); if (dto.getUserAddr() == null && !Objects.equals(dto.getMold(),1) && Objects.equals(dto.getDvyTypes().get(0).getDvyType(), DeliveryType.EXPRESS.getValue())) { // 请填写收货地址 throw new YamiShopBindException("yami.delivery.address"); } ShopCartOrderDto shopCartOrderDto = dto.getShopCartOrders().get(0); ShopCartItemDto shopCartItemDto = shopCartOrderDto.getShopCartItemDiscounts().get(0).getShopCartItems().get(0); seckillCacheManager.checkOrderPath(userId + shopCartItemDto.getProdId(), orderPath); // 秒杀活动信息(来自缓存) Seckill seckill = seckillService.getSeckillById(dto.getSeckillId()); // 判断秒杀是否已经下线 if (Objects.equals(seckill.getStatus(), SeckillEnum.INVALID.getValue()) || DateUtil.compare(seckill.getEndTime(), new Date()) < 0 || Objects.equals(seckill.getStatus(), SeckillEnum.OFFLINE.getValue())) { // 秒杀活动已结束 throw new YamiShopBindException("yami.seckill.has.ended"); } if (DateUtil.compare(seckill.getStartTime(), new Date()) > 0) { // 秒杀活动未开始 throw new YamiShopBindException("yami.seckill.not.start"); } String orderNumber = String.valueOf(segmentService.getDateFormatSegmentId(SegmentIdKey.ORDER)); shopCartOrderDto.setOrderNumber(orderNumber); seckillOrderParam.getOrderFlowLogParam().setIp(IpHelper.getIpAddr()); dto.setVirtualRemarkList(seckillOrderParam.getVirtualRemarkList()); dto.setUserId(userId); dto.setOrderFlowLogParam(seckillOrderParam.getOrderFlowLogParam()); seckillOrderService.submit(dto, seckill.getMaxNum()); // 使用redis队列方式入库 orderQueueManage.submitOrderToQueue(orderNumber, userId, OrderType.SECKILL.value(), Collections.singleton(dto.getShopCartOrders().get(0).getShopId()), dto.getOrderFlowLogParam()); return ServerResponseEntity.success(new OrderNumbersDto(orderNumber)); } ``` 首先我们看到提交订单的核心方法`submit`,里面这行也就是提交订单的核心代码: ```java // 创建订单相关信息 // 扣除库存,并保存订单数据到redis, 订单取消或者支付成功后,从redis迁移订单数据到mysql, 目的:提高订单并发 seckillCacheManager.decrSeckillSkuStocks(dto, maxNum); ``` 这行代码里面用`DecrementStock.lua`脚本处理redis中秒杀商品的限购判断、库存扣减和订单的保存,我们来看几段重点代码 ### 秒杀商品限购判断,查询redis中当前秒杀活动的key是否存在,存在则判断是否达到限购数量,达到则返回-2 ``` -- 限购 if maxNum ~= -1 then if (redis.call('exists', cachePrefix.."buy:"..activityId.."_"..userId) == 1) then -- 用户已购数量 boughtNum = tonumber(redis.call('get', cachePrefix.."buy:"..activityId.."_"..userId)) end if maxNum < (boughtNum + prodCount) then return "-2" end end ``` ### 秒杀商品库存扣减,判断是否满足库存扣减,不满足则返回-1库存不足,并且判断是否限购,限购则更新用户已购数量,最后添加保存锁定记录和扣减商品库存 ``` local mgetSkuKeys = {} local msetSkuKV = {} if prodCount > 0 then -- 下面通过get获取当前的库存数量 table.insert(mgetSkuKeys, cachePrefix.."stock:"..activityId.."_"..skuId) table.insert(mgetSkuKeys, cachePrefix.."stock:"..activityId.."_"..skuId.."_"..pointId) local stockStr = redis.call('mget', unpack(mgetSkuKeys)); local stock = tonumber(stockStr[1]) local pointStock = tonumber(stockStr[2]) if pointStock == nil or prodCount > pointStock then -- 库存不足返回-1 return "-1" end -- 批量更新剩余库存 -- 总库存 table.insert(msetSkuKV, cachePrefix.."stock:"..activityId.."_"..skuId) table.insert(msetSkuKV, stock - prodCount) -- 区域库存 table.insert(msetSkuKV, cachePrefix.."stock:"..activityId.."_"..skuId.."_"..pointId) table.insert(msetSkuKV, pointStock - prodCount) end -- 更改秒杀库存 -- 保存锁定记录 local lockKey = cachePrefix.."lock:"..orderNumber table.insert(msetSkuKV, lockKey) table.insert(msetSkuKV, tostring(prodCount)) -- 限购的秒杀商品才需要更新购买数量 if maxNum ~= -1 then -- 更新购买数量 table.insert(msetSkuKV, cachePrefix.."buy:"..activityId.."_"..userId) table.insert(msetSkuKV, boughtNum + prodCount) end redis.call('mset',unpack(msetSkuKV)) ``` ### 添加订单锁定明细记录和保存订单记录 ``` -- 添加锁定明细记录 - 用于订单创建失败时,解锁库存 key-订单编号_活动id_仓库id_skuId value-操作数量 --redis.call('rpush', cachePrefix.."seckill_order_lock_log", orderNumber.."_"..pointId.."_"..skuId.."_"..currentTime.."_"..activityId.."_"..prodCount.."_"..userId) redis.call('rpush', cachePrefix.."seckill_order_lock_log", orderNumber.."_"..currentTime.."_"..userId) -- 保存订单记录map key=userId value={(status+订单id):1未付款, (info+订单id):orderInfo} redis.call('HMSET',cachePrefix.."order:"..userId, "info:"..orderNumber, orderInfo) return "1" ``` 可以看到上面一整个lua脚本进行了库存判断、库存扣减、添加订单锁定记录、保存订单操作,只是订单是存在了redis中,那用户的订单查询是怎么进行的的呢? 我们回到`submit`方法可以看这行代码 ```java // 使用redis队列方式入库 orderQueueManage.submitOrderToQueue(orderNumber, userId, OrderType.SECKILL.value(), Collections.singleton(dto.getShopCartOrders().get(0).getShopId()), dto.getOrderFlowLogParam()); ``` 通过redis的队列将redis中的订单入库到数据库,不过这段是在上面一整个lua脚本完成后执行的,可能会因为各种网络错误导致没有入库,此时我们会在取消订单和支付成功后进行入库以确保数据一致性。 ## 取消订单 ### 用户主动取消订单 ``` @PutMapping("/cancel/{orderNumber}") public ServerResponseEntity cancel(@PathVariable("orderNumber") String orderNumber) { Order order = orderService.getOne(new LambdaQueryWrapper().eq(Order::getOrderNumber, orderNumber).eq(Order::getUserId, userId)); if (Objects.isNull(order)) { eventPublisher.publishEvent(new SaveSeckillOrderEvent(orderNumber, userId)); } ... } ``` ### 订单取消订单任务 ``` /** * 取消订单 */ @XxlJob("cancelSeckillOrder") public void cancelSeckillOrder() { log.info("先从Redis中将订单数据存储到MySQL中。。。"); seckillCacheManager.saveSeckillOrderInfo(); log.info("取消超时未支付的秒杀订单..."); seckillOrderService.cancelOrderUnpayOrderByTime(); } ``` ## 支付成功 ``` @Override @Transactional(rollbackFor = Exception.class) public ServerResponseEntity noticeOrder(PayInfoResultBO payInfoResultBO, PayInfo payInfo, Integer paySysType) { // 获取订单之前是否进行过支付,如果进行过则本次支付将直接退款 String[] orderNumbers = payInfo.getOrderNumbers().split(StrUtil.COMMA); Arrays.sort(orderNumbers); List orderNumberList = Arrays.asList(orderNumbers); // 取消订单和支付成功,同一时间只能执行一个 RLock[] locks = new RLock[orderNumberList.size()]; for (int i = 0; i < orderNumberList.size(); i++) { RLock lock = redissonClient.getLock(CacheNames.REDISSON_ORDER_LOCK + orderNumberList.get(i)); locks[i] = lock; } RLock multiLock = redissonClient.getMultiLock(locks); List orders; try { multiLock.lock(); List orderStatusList = orderService.getOrdersStatus(orderNumberList, payInfo.getUserId()); // 数据库中没有的订单,可能是秒杀订单, 先到redis中查询,如果是秒杀订单,则保存到mysql中 if (orderStatusList.size() == 1 && BooleanUtil.isTrue(orderStatusList.get(0).getCache())) { eventPublisher.publishEvent(new SaveSeckillOrderEvent(orderNumberList.get(0), payInfo.getUserId())); // 能从redis中获取到订单数据,代表时秒杀订单 if (CollUtil.isNotEmpty(orderStatusList)) { payInfo.setOrderType(OrderType.SECKILL.value()); } } } ... } ``` 可以看到`SaveSeckillOrderEvent`事件和`seckillCacheManager.saveSeckillOrderInfo()`方法会落库秒杀订单到数据库 最后如果提交订单时落库失败,在订单未支付和未取消时用户是怎么查询到redis的订单的?可以看这个`/p/myOrder/myOrderSearch`用户订单列表查询接口里面这行核心代码 ``` IPage myOrderDtoIpage = myOrderService.pageMyOrderByParams(page, userId, status, orderName, orderTimeStatus, orderType, orderNumber, shopId,orderMold); ``` 这个方法下面先查询了redis中的订单数据,然后查询数据库中的订单数据,最后分页合并返回给用户,所以用户就能看到所有订单数据了 ``` @Override public IPage pageMyOrderByParams(Page page, String userId, Integer status, String orderName, Integer orderTimeStatus, Integer orderType, String orderNumber, Long shopId, Integer orderMold) { int redisOrderTotal = 0; PageAdapter pageAdapter = new PageAdapter(page); List orderList = new ArrayList<>(); // 满足以下所有条件就从缓存中查询秒杀订单 // 1.订单状态:查询全部订单或者待支付订单 2.订单类型:查询全部订单类型或者秒杀订单 3.时间范围:不能是三个月之前(Redis订单缓存最多半个小时) boolean searchSeckillOrder = (Objects.isNull(status) || status == 0 || Objects.equals(status, OrderStatus.UNPAY.value())) && (Objects.isNull(orderType) || Objects.equals(orderType, OrderType.SECKILL.value())) && !Objects.equals(orderTimeStatus, OrderTimeStatusType.THREE_MONTHS_AGO.value()); if(searchSeckillOrder) { // 获取redis是否存在订单,并返回符合条件的redis订单总数 redisOrderTotal = getRedisOrderAndTotal(pageAdapter, userId, orderName, orderNumber, redisOrderTotal, orderList); int begin = 0; int size = pageAdapter.getSize(); // 如果redis有值,判断一下 // 比如redis有23条,一页10条 // 如果是第一页,redis这一页有10条,redisTotal > begin + size ,则数据库的size只需要拿0条 // 如果是第二页,redisTotal > begin + size ,则数据库的size只需要拿0条 // 如果是第三页,begin < redisTotal < begin + size ,20 < 23 < 20 + 10,则数据库的begin = 0, size只需要拿begin + size - redisTotal = 7条 // 如果是第四页,redisTotal < begin,20 < 30,则数据库的begin = begin - redisTotal = 30 - 23 = 7, size = 10条 if(redisOrderTotal > pageAdapter.getBegin()) { int endIndex = pageAdapter.getBegin() + pageAdapter.getSize(); size = redisOrderTotal < endIndex ? endIndex - redisOrderTotal : 0; } if(redisOrderTotal <= pageAdapter.getBegin()){ begin = pageAdapter.getBegin() - redisOrderTotal; } pageAdapter.setSize(size); // begin最小从0开始,如果是负数,begin = 0 pageAdapter.setBegin(begin); } List myOrderDtoList = orderMapper.listMyOrderByParams(pageAdapter, userId, status, orderName, orderTimeStatus, preTime, orderType, orderNumber, shopId, orderMold); ... } ``` 所以在用户查看订单列表时的不会出现数据不一致问题,最后秒杀订单也无论怎样都会落库到数据库,保证了数据的最终一致性。