1. 背景
公司里面有个客成的项目,里面涉及到一个需求,就是用户在我们的网站上做了哪些操作之后,可以将线索推送到我们的客成系统中,由系统分配给对应的客成人员进行跟进,那这里就有一个线索如何分配的问题。整个分配的流程包含了用户行为的过滤、用户会员生命周期过滤、客成小组过滤、线索分配等流程,流程比较复杂。这里要提到的就是线索分配流程如何实现的。
对于线索分配,这里经过了两次演化,由最开始的随机分配,到中间的加权随机分配,到最后的加权轮询分配。随机分配的问题是在于线索分配不均,我们在后续加上权重之后先采用的加权随机分配,但是这个分配方式在所有人权重都一样的时候,又降级成了随机分配,还是分配不均。所以在最后,选择了加权轮询分配算法。
这里采用的是类似于Nginx中的平滑轮询算法,这种算法在线索量不大的时候,任然可以保证低权重的对象有被选中的机会。
2.加权轮询
2.1.实现思路
实现思路稍微有一点绕,但是在理解了之后也会感觉比较简单。
我们给需要轮询的对象定义两个属性:weight
, currentWeight
,以及一个全局属性totalWeight
。
- weight:对象分配的权重
- currentWeight:对象当前应该被分配的权重,这个权重会在每次分配的时候变化
- totalWeight:所有待分配对象的权重之和
每个对象的初始currentWeight
为0,进入分配之后会循环所有的对象,将currentWeight
加上它自己的weight
权重,循环完一次之后,对比所有对象找到currentWeight
最大的那个,这个对象就是当次分配选中的对象。
到这里,如果不做其他处理的话,每次选中的都是同一个对象,所以我们在对象被选中之后,需要处理currentWeight
的值,让其他的对象有机会的被选中,这里处理的方法是将currentWeight
减去 totalWeight
,这么处理的话,就让对象权重与总权重之间出现了比例关系。
下面通过一个表格来体现一下这个思路,我们假设有3个对象A、B、C,权重大小分别为3:1:1
。
可以看到,前5次分配中,A分配了3次,B和C各分配了1次,5次之后currentWeight
又回到了原点,以此类推。
2.2.实现代码
首先,需要一个分配对象的实体:
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class RoundRobinModel {
private Long id;
private Long weight;
private Long currentWeight;
}
这里的id主要是一个标识字段,在我的项目中用来表示的是需要分配的客成人员对象,可以根据需要修改成其他的标识。
后续需要做算法实现和封装,按照上面的列出的表格,写出代码:
/**
* 加权轮询选择
*
* @param roundRobinModels 加权轮询对象
* @return 被选中的对象
*/
public RoundRobinModel select(List<RoundRobinModel> roundRobinModels) {
if (CollectionUtils.isEmpty(roundRobinModels)) {
return null;
}
// 总权重
Long totalWeight = 0L;
// 最大权重对象
RoundRobinModel maxWeightModel = null;
for (RoundRobinModel roundRobinModel : roundRobinModels) {
totalWeight += roundRobinModel.getWeight();
// 当前权重累加
roundRobinModel.setCurrentWeight(roundRobinModel.getCurrentWeight() + roundRobinModel.getWeight());
// 选出当前权重最大的对象
if (maxWeightModel == null || maxWeightModel.getCurrentWeight() < roundRobinModel.getCurrentWeight()) {
maxWeightModel = roundRobinModel;
}
}
// 最大权重对象权重减去总权重
maxWeightModel.setCurrentWeight(maxWeightModel.getCurrentWeight() - totalWeight);
return maxWeightModel;
}
从上面的代码可以看到,使用这个算法时传入的是一个列表 List<RoundRobinModel> roundRobinModels
,需要需要将每次操作的结果存储起来,这样才能实现轮询的效果,否则的话,每次循环所有对象的currentWeight
都是0,也就是每次都会选中同一个对象。
在生产环境中,为了服务的高可用,往往会部署两个或两个以上的节点。要做到每个服务节点的权重对象同步,我们这里需要将权重对象存储到中间件中,可以是数据库中,也可以是缓存中间件中,我这里选择的是Redis。
其次,在分配阶段为了避免多个节点同时处理导致分配不均的情况,还需要对分配的代码加上分布式锁。
最后,需要考虑存储的权重对象对应的权重,有没有人在做配置的时候修改了,如果修改了,需要重新获取缓存。
综上,一个完整的工具类代码如下:
import com.alibaba.excel.util.StringUtils;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.List;
/**
* 加权轮询算法,采用Nginx中的平滑轮询算法
* 平滑:数据量小的时候,低权重的对象有更多被选中的机会,而不是由权重高的先占用,在数据量起来后,总体趋于一个按权重比例分配的规律
*/
@Slf4j
@Component
public class WeightedRoundRobinHelper {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
/**
* 获取加权轮询算法返回的权重对象,包含处理数据库和缓存数据,以及调用加权轮询算法
*
* @param lockKey 加权轮询分布式锁key
* @param roundRobinModelKey 加权轮询对象存储key
* @param roundRobinModels 加权轮询算法参数
* @return 加权轮询算法返回的权重对象
*/
public RoundRobinModel getRoundRobinModel(String lockKey, String roundRobinModelKey, List<RoundRobinModel> roundRobinModels) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock();
try {
// 从缓存中获取轮询对象
String redisValue = stringRedisTemplate.opsForValue().get(roundRobinModelKey);
if (StringUtils.isNotBlank(redisValue)) {
// 从缓存中获取分配人列表
List<RoundRobinModel> roundRobinModelsByCache = JSON.parseArray(redisValue, RoundRobinModel.class);
// 对比缓存中的分配人列表和参数中的分配人列表是否一致,包括weight是否一致,一致则使用缓存中的分配人列表(用于人为修改了分配权重的情况)
if (roundRobinModelsByCache.size() == roundRobinModels.size()) {
boolean isSame = true;
for (int i = 0; i < roundRobinModels.size(); i++) {
if (!roundRobinModels.get(i).getId().equals(roundRobinModelsByCache.get(i).getId())
|| !roundRobinModels.get(i).getWeight().equals(roundRobinModelsByCache.get(i).getWeight())) {
isSame = false;
break;
}
}
if (isSame) {
roundRobinModels = roundRobinModelsByCache;
}
}
}
// 调用加权轮询,获取分配人
RoundRobinModel roundRobinModel = new WeightedRoundRobinHelper().select(roundRobinModels);
// 更新缓存
stringRedisTemplate.opsForValue().set(roundRobinModelKey, JSON.toJSONString(roundRobinModels));
return roundRobinModel;
} catch (Exception e) {
log.error("加权轮询算法获取分配人列表异常", e);
return null;
} finally {
lock.unlock();
}
}
/**
* 加权轮询选择
*
* @param roundRobinModels 加权轮询对象
* @return 被选中的对象
*/
public RoundRobinModel select(List<RoundRobinModel> roundRobinModels) {
if (CollectionUtils.isEmpty(roundRobinModels)) {
return null;
}
// 总权重
Long totalWeight = 0L;
// 最大权重对象
RoundRobinModel maxWeightModel = null;
for (RoundRobinModel roundRobinModel : roundRobinModels) {
totalWeight += roundRobinModel.getWeight();
// 当前权重累加
roundRobinModel.setCurrentWeight(roundRobinModel.getCurrentWeight() + roundRobinModel.getWeight());
// 选出当前权重最大的对象
if (maxWeightModel == null || maxWeightModel.getCurrentWeight() < roundRobinModel.getCurrentWeight()) {
maxWeightModel = roundRobinModel;
}
}
// 最大权重对象权重减去总权重
maxWeightModel.setCurrentWeight(maxWeightModel.getCurrentWeight() - totalWeight);
return maxWeightModel;
}
}
2.3. 测试
@RestController
@RequestMapping("/roundRobin")
public class RoundRobinController {
@Resource
private WeightedRoundRobinHelper weightedRoundRobinHelper;
@GetMapping("/test")
public void testRoundRobin() {
RoundRobinModel A = new RoundRobinModel(1L, 3L, 0L);
RoundRobinModel B = new RoundRobinModel(2L, 1L, 0L);
RoundRobinModel C = new RoundRobinModel(3L, 1L, 0L);
List<RoundRobinModel> roundRobinModels = Arrays.asList(A, B, C);
String lockKey = "myRoundRobinLock";
String roundRobinModelKey = "myRoundRobinModelKey";
for (int i = 0; i < 5; i++) {
RoundRobinModel chosenModel = weightedRoundRobinHelper.getRoundRobinModel(lockKey, roundRobinModelKey, roundRobinModels);
System.out.println(chosenModel.getId());
}
}
}
运行测试代码,得到的结果与预期相符:
1
2
1
3
1
3.总结
本章主要是在说加权轮询算法在业务上的应用,有类似的业务都可以参考这个代码进行编写。代码算法参考的事Nginx的平滑轮询算法,这也是学习框架原理带来的一些好处。