背景
我们有一个业务场景是给学生发布考试,发布的过程不复杂,就是一个老师传递一些考试相关的参数过来,服务器自动给所有学生生成一份任务,但是在学生上交的时候会有个问题,就是成百上千的学生一起上交,会有并发流量的问题。
这里由于我们的考试可能会设计多个班级的联考,乃至一个学校或多个学校的联考,因为上交成绩单是一个比较集中的时间段,因此这里需要考虑的是服务器能承受的最大QPS以及学校的流量峰值。
这里比较特殊的是需要考虑学校的流量峰值,我们不希望因为考试把学校网络给整瘫痪了。
功能实现
鉴于上面两种情况,我首先想到的是限流,因此就有了第一种方案
方案一
采用阿里的Sentinel或者Guava的RateLimiter限流器,限流器的好处在于能对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。
这里不去研究限流采用的是滑动窗口法,还是漏桶算法或是令牌桶算法,因为这些都能达到我们的目的,而且框架已经封装的很好。
限流的大小如何去确定呢,我们可以根据实际情况计算得出,int maxQps=学校最大允许上传流量*某个百分比/每份作业大小(我们每份作业按50KB算),这里的百分比是控制流量的大小,可以设定为80%或90%。
客户端请求时,超过maxQps的请求就return false,然后客户端做延时重试上传。
下面是流程图:
现在看上去已经很完美了,把限流器一加,就已经达到了控制QPS的目的,但其实这里有个很大的问题,服务器的QPS是控制住了,但是学校的流量并没有控制住,学校每次都是把所有作业数据请求过来,然后才知道这次请求是true还是false的,这样还是会导致学校流量瘫痪。
因此就有了下面的第二种方案
方案二
在HTTP跨域请求中,在正式跨域之前,浏览器会根据需要发起一次预检(也就是option请求,这次请求是不带任何数据的),用来让服务端返回允许的方法(如get、post),被跨域访问的Origin(来源或者域),还有是否需要Credentials(认证信息)等。当然跨域的PreFlight也有个前提,就是用了自定义的请求头,不过这些都不是重点,这里我引用这个option请求知识点的目的在于,我们是否也可以运用这种思想。
第一步,客户端先发送一次类似option的PreFlight请求去获取token,这个token就是是否能进行第二次请求的令牌。
第二步,服务器接受到请求,根据限流器返回结果(true或false)。
第三步,客户端根据返回的结果,来决定是否进行真实的请求,如果是false则需要重试。
第四步,假设客户端已经获取到令牌,然后发送真实的请求。
流程图:
通过上面的操作,我们可以一直限制请求个数在maxQps范围内,因为永远最多只有maxQps的令牌可以通过申请,也就只有maxQps的客户端可以发送出真实请求,达到了控制学校流量的目的。
这样看上去依旧很完美,然而很不幸,这个方案还是有很大的问题:
假定我们设置的令牌桶(或者滑动窗口)大小为5,则必然会出现真实请求和PreFlight请求同时出现在桶里的情况,结果可能是一个桶里面的请求大部分都是这样PreFlight请求,就会导致真实请求数量达不到预期的结果,使QPS大大下降。
而且如果PreFlight请求和真实请求是同一个接口也不利于接口参数的判断。
造成这个结果的原因是因为真实请求和PreFlight请求同时出现在一个桶里,那我们把他们分开,于是就有了方案三。
方案三
在这里,我把PreFlight请求单独设立一个getToken接口,和上交考试的接口分开,然后PreFlight请求设立一个限流器,称作A限流器。
第一步,客户端调用getToken接口,获取令牌。
第二步,服务器根据限流器返回结果(true或false)。
第三步,客户端根据返回的结果,来决定是否进行真实的请求,如果是false则需要重试。
第四步,假设客户端已经获取到令牌,然后发送真实的请求。
这里的操作步骤和上面是一样的,无非就是把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的同时,也能减少数据库的压力,所以最后的逻辑图是这样的:
这里稍微提一下,RabbitMQ我会增加一个死信队列,用于保存推送失败时数据的保存,死信队列的数据我会保存到dead_exam_data表,当线上出现问题时,可以通过手动调用/v1/dead/exam接口,把死信数据提取出来,重新保存到HomeworkDetail作业表。