一: 产品需求

四个实体,用户、订单、悦姐、店长,自动排单需要把用户下的订单替店长排到悦姐身上。

订单分为周期指定悦姐订单和单笔随机订单。

悦姐库存以半小时为单位,每半个小时算一个槽。

下单时间根据配置来,白天基本都可以下单。

目标: 在订单和悦姐之间寻找最佳匹配

模型:

悦姐: 库存、技能、家庭住址

订单:地址、技能、占时

硬性约束: 悦姐存在满足的库存和技能
软性约束: 得分(偏好,距离,库存利用率)

指标: 悦姐库存技能一定时排订单最多,或者总得分最高,单个门店或者所有门店作为维度

二: 算法及规则实现

定时任务: 每天凌晨按门店+日期的维度进行派单,多轮计算,每一轮取最高得分订单进行派单并在下一轮剔除,共需计算 订单.数量 轮次。

半小时优化: 对于在到门店但是还未派到悦姐身上的订单,进行半小时一次的优化,算法和凌晨定时任务类似。

实时到人: 用户下单后,根据规则实时计算门店悦姐得分,并派单到最高得分的悦姐。

凌晨的定时任务优势在于待排单池中订单多,悦姐身上订单少,每轮取最高分,那么每家门店的订单总得分,将是最高的。

半小时优化会把一些得分低的订单,和有库存的悦姐再进行一次匹配,让总的得分上升。

实时到人是按照用户下单的优先顺序进行派单,所以是实时最优不是一定范围内的最优

三: 自动排单的优化和思考

自动排单第一个版本,只有夜里进行推荐排单,一直优化到白天用户下单,实时分配。
单个订单选择最高得分悦姐,缺点很多,比如 得分受 订单+悦姐身上其他订单 影响,在悦姐身上订单有变化时,得分就会改变,所以最高得分,可能只是那一瞬间的最高得分。
这个优化算是将单个订单局部最优,优化为单个订单整体最优。
后序还可以把相关性很强的订单,作为一组,进行计算,而不是单个单个的订单进行计算。
在技术上,订单数据和悦姐库存,因为有实时性,所以没有缓存,后序可以优化为监听 binlog 实时更新缓存,优化下单时间。

半小时的定时任务优化排单是在白天,而白天用户和店长都会进行操作,所以在重新优化时,不能大面积的锁订单或者库存,这时采用了伪取消,即没有在数据库中改数据,而是在内存中计算时,计算悦姐库存不把这些需要重新优化的低分值订单考虑进去。

在计算悦姐得分时,有三种得分规则,一种是离家距离,一种是悦姐的库存利用率,还有一种是单与单之间距离。
具体得多少分,根据运营配置来获取。在计算规则得分的时候,需要空间换时间,把数据缓存成易于使用的方式,让查询变快。

四: 自动排单使用到的技术点

夜里的定时任务:因为门店之间是不共享悦姐的,所以悦姐库存是独立的,今天和明天的订单之间,也没有关系。所以以 门店+日期 的粒度进行多线程排单,避免了线程间共享数据,是线程安全的。

悦姐库存的锁:不管是哪种方式进行排单,都需要请求悦姐库存,而不同的方式之间,悦姐库存是共享的,这个时候就需要给悦姐库存加锁。如果锁整个门店的悦姐,因为用户下单、店长调整订单、定时任务半小时优化,都比较频繁,粒度就比较大了,因此把加锁延迟到排单入库的时候,入库之前如果拿不到最高分悦姐的库存的锁,就拿次高分,以此类推。

推荐排单日志:记录了订单信息和悦姐信息,有些是实时收集,在获取到信息进行计算时,塞入 ThreadLocal,计算完成入库后,发送事件到 Ringbuffer,在 handle 中异步再补全日志信息,生成排单日志。使用了 Disruptor 来做事件处理。

定时任务线程:使用 metrics + 继承 ThreadPoolExecutor 来监控线程池中任务的处理情况,指标有任务处理时间99 95 线、吞吐量等,并且把线程池的基本信息,比如核心线程数、最大线程数、任务数量、已完成任务数等等透传给外面。

规则计算:使用 QLExpress 作为规则引擎。把表达式缓存在数据库和内存中,达到可以实时调整的目的。

五: 监控与告警

周期计划浪费越界槽位监控:

除了单笔订单还有周期订单,周期订单指定了悦姐,到了配置的时间,自动生成订单,排给指定的悦姐。因为下单时,没有固定的时间,白天所有的时间都可以作为悦姐的开始时间,所以会有很大的库存浪费。基于此种场景,做了一个周期指定计划的检查,根据给定的门店,检查该门店悦姐浪费了多少个时间槽。

算法:计算浪费的时间槽,使用了类似搜索迭代算法。

1.检查悦姐开始计划与库存开始时间
2.检查悦姐身上计划与计划之间是否可以塞入计划
3.检查悦姐结束计划与库存结束时间

检查计划与计划之间是否可以塞入计划伪代码:

dynamicSlotPost(boolean isFirst, int start, int end, CalculationInfo ci, Set<CalculationInfo> paths) {
    // 是否越界
	if(isFirst && start >= end) return;

    // 如果开始时间槽加上计划的时长,大于结束时长,则代表不可以再塞入订单,收集并返回
	if(!isFirst && checkOverFlowPost(start+ci.getRecommendSlot(), end)) { 
	   path.add(ci.copy());
	   return;
	}
   
    //  本次计划开始时间槽和推荐时间信息
    CalculationOrderInfo coi = new CalculationOrderInfo();
    coi.setStartSlot(start);
    ci.getOrderInfos().add(coi);

    //  两小时的推荐订单
    if(isFirst && checkOverFlowPost(start + recommendSlot2, end)) return; // 第一次进来是否越界
    coi.setRecommendSlot(recommendSlot2); // 设置本次计划推荐时长
    checkAndRemove(ci, end);  // 如果ci中最后一个计划越界了,则剔除后继续迭代
    dynamicSlotPost(false, coi.getStartSlot()+recommendSlot2, end, ci.copy, paths);

    //  三小时的推荐订单
    if(isFirst && checkOverFlowPost(start + recommendSlot3, end)) return;
    coi.setRecommendSlot(recommendSlot2); // 设置本次计划推荐时长
    checkAndRemove(ci, end);  // 如果ci中最后一个计划越界了,则剔除后继续迭代
    dynamicSlotPost(false, coi.getStartSlot()+recommendSlot3, end, ci.copy, paths);

}

定时任务线程池监控:
通过继承 ThreadPoolExecutor,重写前置和后置拦截器,来监控任务的执行情况,并且在排单失败的时候,会把异常和订单相关信息通过邮件发送。

监控店长调单率:
通过每次推荐排单的数据库记录,来计算店长的调单率,监控调单率较高的店铺,与店长沟通,获取反馈,优化排单规则。

排单日志:
店长有时候会问为什么排给谁谁谁,并且运营也需要查看排单的计算情况。所以在每次排单的过程中,收集相关数据,比如订单的时间、地址等,和每个悦姐的得分、悦姐的库存、技能、以及被过滤的原因等信息,展示出来。

六: 总结

痛点,库存代码是遗留代码,设计不规范,有多种查询库存方式,订单表和悦姐库存占用表都被用来查询库存了,并且库存在内部使用的时候,没有统一的数据模型,比如库存服务 1 代表可用,规则服务 0 代表可用,推荐排单服务使用时,就必须转换格式。

还有规则服务也是两年前的废弃版本,现在重新启用,没有文档,只能一点一点研究,不过确实比从零开始写快一点。

复盘总结一下,前前后后走了好多弯路,有些是需求不完善,有些是实现的时候没想清楚应该怎么写,代码删了改改了删。