1秒杀订单设计.md 14 KB

秒杀下单流程步骤:

  1. 用户点击“立即购买”进入到“确认订单”页面,
  2. 在“确认订单”页面选择收货地址,重新计算运费、订单价格
  3. 提交订单,选择支付方式进行支付
  4. 支付完毕

秒杀订单大多数流程跟普通订单是一样的部分就不多赘述了,然后我们考虑到秒杀订单的性能,下单时秒杀订单信息和库存都是存于redis,所以这里着重讲一下涉及到的redis流程。

第一步:

用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面,相关url/p/seckill/orderPath/p/seckill/{orderPath}/confirm

public class SeckillOrderParam extends OrderParam {

    @NotNull(message = "秒杀商品skuId不能为空")
    @Schema(description = "秒杀商品skuId" , requiredMode = Schema.RequiredMode.REQUIRED)
    private Long seckillSkuId;
}

这一步请求参数跟订单立即购买是一样的,多个了秒杀skuId。

第二步:

用户点击提交订单,进行提交订单,相关url/p/seckill/{orderPath}/submit

    @PostMapping("/{orderPath}/submit")
    @Operation(summary = "提交订单" , description = "提交之后返回订单号")
    @Parameter(name = "orderPath", description = "订单路径" , required = true)
    public ServerResponseEntity<OrderNumbersDto> submitOrders(@PathVariable("orderPath") String orderPath,
                                                        @Valid @RequestBody SubmitSeckillOrderParam seckillOrderParam) {
        String userId = SecurityUtils.getUser().getUserId();
        ServerResponseEntity<ShopCartOrderMergerDto> 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,里面这行也就是提交订单的核心代码:

// 创建订单相关信息
// 扣除库存,并保存订单数据到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方法可以看这行代码

// 使用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<String> cancel(@PathVariable("orderNumber") String orderNumber) {
    Order order = orderService.getOne(new LambdaQueryWrapper<Order>().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<String> noticeOrder(PayInfoResultBO payInfoResultBO, PayInfo payInfo, Integer paySysType) {

        // 获取订单之前是否进行过支付,如果进行过则本次支付将直接退款
        String[] orderNumbers = payInfo.getOrderNumbers().split(StrUtil.COMMA);
        Arrays.sort(orderNumbers);
        List<String> 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<Order> orders;
        try {
            multiLock.lock();
            List<Order> 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<MyOrderDto> myOrderDtoIpage = myOrderService.pageMyOrderByParams(page, userId, status, orderName, orderTimeStatus, orderType, orderNumber, shopId,orderMold);

这个方法下面先查询了redis中的订单数据,然后查询数据库中的订单数据,最后分页合并返回给用户,所以用户就能看到所有订单数据了

@Override
public IPage<MyOrderDto> pageMyOrderByParams(Page<MyOrderDto> 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<MyOrderDto> 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<MyOrderDto> myOrderDtoList = orderMapper.listMyOrderByParams(pageAdapter, userId, status, orderName,
            orderTimeStatus, preTime, orderType, orderNumber, shopId, orderMold);
    ...
}

所以在用户查看订单列表时的不会出现数据不一致问题,最后秒杀订单也无论怎样都会落库到数据库,保证了数据的最终一致性。