背景

我们有一个业务场景是给学生发布考试,发布的过程不复杂,就是一个老师传递一些考试相关的参数过来,服务器自动给所有学生生成一份任务,但是在学生上交的时候会有个问题,就是成百上千的学生一起上交,会有并发流量的问题。

这里由于我们的考试可能会设计多个班级的联考,乃至一个学校或多个学校的联考,因为上交成绩单是一个比较集中的时间段,因此这里需要考虑的是服务器能承受的最大QPS以及学校的流量峰值。

这里比较特殊的是需要考虑学校的流量峰值,我们不希望因为考试把学校网络给整瘫痪了。

功能实现

鉴于上面两种情况,我首先想到的是限流,因此就有了第一种方案

方案一

采用阿里的Sentinel或者Guava的RateLimiter限流器,限流器的好处在于能对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

这里不去研究限流采用的是滑动窗口法,还是漏桶算法或是令牌桶算法,因为这些都能达到我们的目的,而且框架已经封装的很好。

限流的大小如何去确定呢,我们可以根据实际情况计算得出,int maxQps=学校最大允许上传流量*某个百分比/每份作业大小(我们每份作业按50KB算),这里的百分比是控制流量的大小,可以设定为80%或90%。

客户端请求时,超过maxQps的请求就return false,然后客户端做延时重试上传。

image-20191108142240385

下面是流程图:

image-20191108140227469

现在看上去已经很完美了,把限流器一加,就已经达到了控制QPS的目的,但其实这里有个很大的问题,服务器的QPS是控制住了,但是学校的流量并没有控制住,学校每次都是把所有作业数据请求过来,然后才知道这次请求是true还是false的,这样还是会导致学校流量瘫痪。

因此就有了下面的第二种方案

方案二

在HTTP跨域请求中,在正式跨域之前,浏览器会根据需要发起一次预检(也就是option请求,这次请求是不带任何数据的),用来让服务端返回允许的方法(如get、post),被跨域访问的Origin(来源或者域),还有是否需要Credentials(认证信息)等。当然跨域的PreFlight也有个前提,就是用了自定义的请求头,不过这些都不是重点,这里我引用这个option请求知识点的目的在于,我们是否也可以运用这种思想。

第一步,客户端先发送一次类似option的PreFlight请求去获取token,这个token就是是否能进行第二次请求的令牌。

第二步,服务器接受到请求,根据限流器返回结果(true或false)。

第三步,客户端根据返回的结果,来决定是否进行真实的请求,如果是false则需要重试。

第四步,假设客户端已经获取到令牌,然后发送真实的请求。

流程图:

image-20191108140411909

通过上面的操作,我们可以一直限制请求个数在maxQps范围内,因为永远最多只有maxQps的令牌可以通过申请,也就只有maxQps的客户端可以发送出真实请求,达到了控制学校流量的目的。

这样看上去依旧很完美,然而很不幸,这个方案还是有很大的问题:

image-20191111105032334

假定我们设置的令牌桶(或者滑动窗口)大小为5,则必然会出现真实请求和PreFlight请求同时出现在桶里的情况,结果可能是一个桶里面的请求大部分都是这样PreFlight请求,就会导致真实请求数量达不到预期的结果,使QPS大大下降。

而且如果PreFlight请求和真实请求是同一个接口也不利于接口参数的判断。

造成这个结果的原因是因为真实请求和PreFlight请求同时出现在一个桶里,那我们把他们分开,于是就有了方案三。

方案三

在这里,我把PreFlight请求单独设立一个getToken接口,和上交考试的接口分开,然后PreFlight请求设立一个限流器,称作A限流器。

第一步,客户端调用getToken接口,获取令牌。

第二步,服务器根据限流器返回结果(true或false)。

第三步,客户端根据返回的结果,来决定是否进行真实的请求,如果是false则需要重试。

第四步,假设客户端已经获取到令牌,然后发送真实的请求。

image-20191111104407599

这里的操作步骤和上面是一样的,无非就是把Token请求接口给单独分离,这样不会影响真实请求的发送,并且实现了限制客户端每秒能获取到的令牌数量,也就实现了限制真实请求的QPS。

(2)获取Token接口由于限流是针对一个学校的流量的,所以这里还得考虑多个学校联考时,各个学校Token的获取不能受干扰,这样就得给每个学校设置一个限流器。

我查阅了阿里的Sentinel根据接口限流的功能,Sentinel根据可以实现根据不同接口限流,但是需要给固定的接口设置限流规则,也就是这接口一开始就定义好的,如果是接口里有路径参数(如/v1/homework/token/{schoolId}),就会视为不同接口,也就需要配置不同的规则。所以这种方案是不符合我们的场景的,所以我自己摸索了一套方法用于给每个学校设定限流器。

大体思路就是每次调用Token请求时,我都会创建一个这个学校的规则,并且和已经存在的规则去做对比,如果已存在规则里有这个学校的规则,就不添加,不然就添加到规则里,并重载到FlowRuleManager。代码如下:

/**
* 限流器规则 
*/
private Set<FlowRule> rules = new ConcurrentSkipListSet<>((rule1, rule2) -> Objects.equals(rule1, rule2) ? 0 : 1);
/**
* 获取是否能上交考试作业的令牌 
* 
* @return 
*/
public Boolean getToken(Integer schoolId) {
String resourceName = ConstantUtil.Sentinel.EXAM_RULE_PREFIX + schoolId;
FlowRule rule = new FlowRule(resourceName);
rule.setCount(100);
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
//每次进来都创建规则,如果这个学校没在限流列表,那就加入规则列表,并重载规则
  if (rules.add(rule)) {
    FlowRuleManager.loadRules(Lists.newArrayList(rules));
  }    
 return SphO.entry(resourceName);
}

这里限流器规则rules需要用Set存储,防止重复(因为Sentinel规则是List的,不能去重,所以需要我们自己去重),Set我选择了ConcurrentSkipListSet来存储,效率较高,而且是线程安全的,不会有并发问题。

(3)接下来为了不影响QPS性能,我们在上交考试模块增加了一个RabbitMQ,能增加QPS的同时,也能减少数据库的压力,所以最后的逻辑图是这样的:

image-20191111112355989

这里稍微提一下,RabbitMQ我会增加一个死信队列,用于保存推送失败时数据的保存,死信队列的数据我会保存到dead_exam_data表,当线上出现问题时,可以通过手动调用/v1/dead/exam接口,把死信数据提取出来,重新保存到HomeworkDetail作业表。