秒杀下单流程步骤:
秒杀订单大多数流程跟普通订单是一样的部分就不多赘述了,然后我们考虑到秒杀订单的性能,下单时秒杀订单信息和库存都是存于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中秒杀商品的限购判断、库存扣减和订单的保存,我们来看几段重点代码
-- 限购
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
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);
...
}
所以在用户查看订单列表时的不会出现数据不一致问题,最后秒杀订单也无论怎样都会落库到数据库,保证了数据的最终一致性。