背景
目前前端领域的范围逐步纵深扩张,不仅仅是页面的交互,还会涉及到前后端胶水层的处理,前端技术项目的后端,以及OSS等业务逻辑的抽离,使用Node应用,可以帮助前端在不学习新语言的前提下有能力去构建开发完整的产品,开发域上得到了很大的扩展,但由于前端本身在后端能力上的欠缺,可能会带来新的问题
举例:前端想要去做前端与后端的胶水层,做一些数据处理以返回更适合前端渲染的数据结构,服务上线后,用户访问频次较高,虽然后端做了限流缓存逻辑,但请求到前端胶水层服务时,由于没有做相应的限流措施,服务挂掉,最终造成事故。
因此我们需要对于Node服务进行改造优化以保证其高可用性,本章首先从限流与缓存进行介绍,近期对mock平台的请求的优化涉及到了以上两类场景,下面将从实践落地的角度探索NodeJs的限流缓存方案。
业务背景
目前平台用户统一使用getMockData进行接口mock,随着用户量以及业务场景越来越多,不可避免会存在高频次请求的情况,目前每次该接口都会进行数据库的读写,这可能会造成数据库并发过多,影响平台其他业务的数据访问。
缓存前提 返回一致性:getMockData是对于接口的模拟,目前平台中绝大部分返回体是静态数据,适用于做请求缓存
优化顺序
服务端通常的请求优化顺序是先限流,后缓存,限流是将外部高并发的请求进行剔除,在中后台通常的场景下我们称并发量过高的的请求为无效请求或是恶意请求,可能是外部攻击,也可能是开发者代码出现死循环等问题导致,在经过限流过滤后的有效请求会进入缓存规则,服务端会将请求进行缓存(Redis,服务端内部缓存等),这对于低频次更新更频次的接口优化效果会更明显,请求速度会更快,数据库I/O频次大幅降低。
限流
常用算法调研对比
现在限流主流有4种算法
- 固定时间窗口算法
- 滑动窗口算法
- 令牌桶算法
- 漏桶算法
我们将4种方案进行了对比,下面简要介绍下4种算法的员力以及使用场景
时间窗口
首先维护一个计数器,将单位时间段当做一个窗口,计数器记录这个窗口接收请求的次数。
- 当次数少于限流阀值,就允许访问,并且计数器+1
- 当次数大于限流阀值,就拒绝访问。
- 当前的时间窗口过去之后,计数器清零。
假设单位时间是1分钟,限流阀值为5。在单位时间1分钟内,每来一个请求,计数器就加1,等到1分钟结束后,计数器清0,重新开始计数,如果计数器累加的次数超过限流阀值5,后续的请求全部拒绝
局限性:如果此时有用户在00:01:59发出5个请求,在00:02:01发出5个请求,普通时间窗口是没法检测到的,也就是说时间窗口配置是5次/min,但一些恶意请求在临界点是可以达到10次/min的
滑动时间窗口
滑动窗口限流解决固定窗口临界值的问题。它将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。
假设单位时间还是1min,滑动窗口算法把它划分为5个小周期,也就是滑动窗口(单位时间)被划分为5个小格子。每格表示12s。每过12s,时间窗口就会往右滑动一格。然后呢,每个小周期,都有自己独立的计数器,如果请求是00:02:30到达的,00:02:00~00:03:00对应的计数器就会加1。
我们来看下滑动窗口是如何解决临界问题的?
如上图,假设我们1min内的限流阀值还是5个请求,00:02:4800:03:48内(比如00:03:00的时候)来了5个请求。时间过了36s之后,又来5个请求,落在紫色格子里。如果是固定窗口算法,是不会被限流的,但是滑动窗口的话,每过一个小周期,它会右移一个小格。过了12s这个点后,会右移一小格,当前的单位时间段是3.244.24s,由于与之前的时间窗口有2个坑位重叠,只有3个坑位可用,剩下的2个请求就会触发限流,如上图灰色小格所示。
漏桶算法
漏桶算法面对限流,就更加的柔性,不存在直接的粗暴拒绝。
它的原理很简单,可以认为就是注水漏水的过程。往漏桶中以任意速率流入水,以固定的速率流出水。当水超过桶的容量时,会被溢出,也就是被丢弃。因为桶容量是不变的,保证了整体的速率。
- 流入的水滴,可以看作是访问系统的请求,这个流入速率是不确定的。
- 桶的容量一般表示系统所能处理的请求数。
- 如果桶的容量满了,就达到限流的阀值,就会丢弃水滴(拒绝请求)
- 流出的水滴,是恒定过滤的,对应服务按照固定的速率处理请求。
局限性:在正常流量的时候,系统按照固定的速率处理请求,是我们想要的。但是面对突发流量的时候,漏桶算法还是循规蹈矩地处理请求,这就不是我们想看到的。流量变突发时,我们希望系统尽量快点处理冗余请求,提升用户体验
令牌桶算法
面对突发流量的时候,我们可以使用令牌桶算法限流。
算法原理:
- 有一个令牌管理员,根据限流大小,定速往令牌桶里放令牌。
- 如果令牌数量满了,超过令牌桶容量的限制,那就丢弃。
- 系统在接受到一个用户请求时,都会先去令牌桶要一个令牌。如果拿到令牌,那么就处理这个请求的业务逻辑;
- 如果拿不到令牌,就直接拒绝这个请求。
算法对比
时间窗口算法控制粒度不太灵活,恶意请求掌握规则后还是会突破算法做的限制,漏桶算法在大流量到来时,会将漏桶灌满,这个算法没有将所有的冗余请求进行过滤,这样会导致下一次有效请求到来时算法还是会优先处理冗余请求,造成有效请求的’误伤‘。
令牌桶算法在冗余请求到来时,内部令牌桶会即刻清空,但是由于令牌是循环不断给的,这样下一次有效请求大概率会进入控制器进行业务处理。
下面会介绍该算法是如何在Moocake项目中落地的。
业务场景落地
定义限流粒度
限流粒度直接影响着服务的可用性,如果将粒度控制到项目全局,那么在一个接口超限,所有其他接口的访问都会受到影响,因此我们需要将限流规则控制在合适的范围,接口mocn场景下,我们希望做到尽可能的接口隔离,一个接口的超限不会影响到其他接口的访问。
令牌桶的key
既然将粒度控制在接口级别,那么令牌桶需要设置为一个接口对应一个桶,我们基于mock请求的三要素:
- 请求URL-url
- 请求方法-method
- 项目-spaceCode(用于定义在哪个业务项目下)
定义一个API的requestKey
为了尽量减少数据库与Redis等存储设施的I/O,我们将令牌桶维护在服务器内存中。
令牌放置任务定时取消
算法会为每一个请求分配requestKey,并生成对应的请求桶,同时会开启轮询放令牌任务,requestKey越来越多,为了避免起对应的轮询放令牌任务越来越多引起服务内存升高,中间件会定期清理对应key的放令牌任务。
限流整体流程
用户发起mock请求,服务端中间件生成对应requestKey,若不存在requestKey对应的请求桶,则生成新的令牌桶,并开启定时放令牌任务,同时正常走业务控制器,若存在对应请求桶,则进而判断桶中令牌是否被消耗光,若已被消耗光则返回超限错误,没有则正常走业务控制器
mock常见限流场景是针对单接口,必要时可以针对全局请求限流做兜底以避免服务请求超限
Node.JS代码如下
// 令牌桶映射let passKeyBucketMap = {};// 清除令牌桶任务let bucketCleartMap = {};export class RequestLimitMiddleware { resolve(): MidwayWebMiddleware { return async function requestHandleMiddleware(ctx: Context, next: Next): Promise<void> { try { const qps = 10; // 定时往桶内放令牌 function intervalPushPassKey(requestCacheKey) { passKeyBucketMap[requestCacheKey] = qps; let requestLimitInterval = setInterval(() => { passKeyBucketMap[requestCacheKey] = qps; }, 1000); // 清理循环(延时处理) bucketCleartMap[requestCacheKey] = setTimeout(() => { delete passKeyBucketMap[requestCacheKey]; clearInterval(requestLimitInterval); }, 1000 * 60 * 10); } const { params } = ctx.query; // 定义限流粒度 const requestCacheKey = `${params.xxx}-${params.xxxxx}`; // 若接口没有对应的桶,则生成,正常执行业务逻辑 if (!(requestCacheKey in passKeyBucketMap)) { intervalPushPassKey(requestCacheKey); passKeyBucketMap[requestCacheKey] = passKeyBucketMap[requestCacheKey] - 1; ctx.logger.info(`首次生成${actionName}请求桶 当前请求桶情况`, passKeyBucketMap); await next(); } else { // 桶内令牌剩余0,返回超限 if (passKeyBucketMap[requestCacheKey] === 0) { ctx.logger.info(`${actionName}请求桶已耗尽`); ctx.body = { code: 500, message: `当前接口${actionName}已超过调用限制` }; } else { // 桶内还有剩余令牌,正常执行请求逻辑 passKeyBucketMap[requestCacheKey] = passKeyBucketMap[requestCacheKey] - 1; ctx.logger.info('当前请求桶情况', passKeyBucketMap); await next(); } } } catch (e) { } }; }}
缓存
业务现状
用户请求的mock接口返回内容更新频次很低,每一次请求如果都从数据库中读取返回数据给到前端会造成I/O资源浪费,并且每一次的数据库读写都会增加请求时间,甚至堵塞请求服务,造成线上事故。
业务场景落地(基于redis)
定义CacheKey
与令牌桶的key的定义相同,保证接口级的缓存
定期清理
moocake接口与业务相关,用户写完需求后mock接口可能不再用到了,其对应的缓存数据也应被清除以避免redis资源浪费,我们需要对CacheKey对应的redis数据配置缓存时间
后台操作节点手动清除
涉及到mock接口的更新,场景的关闭/开启相关节点服务端需要手动清除CacheKey对应的redis,保证用户每次请求获取到更新后的数据。
限流+缓存自上而下
限流过滤冗余请求,缓存提升请求速度,一定程度保证了服务稳定运行
方案测试
PRE环境(缓存限流)与线上环境(无缓存限流)请求测试对比
以1s请求10次为例
原始状态 平均耗时187ms
新增请求缓存中间件 平均耗时57ms,在首次请求后,后续会取缓存
新增请求限流中间件,预设QPS限制为10次/s 请求频率增加至20次/s,也就是每50ms请求一次
在100次请求中,失败40次,成功60次,从图中可以看到成功与触发限流呈交错式分布,符合预期
总结
限流与缓存是提高NodeJs等后端服务可用性的常用措施,面对不同场景。需要在以上基础上更加细化,同时限流也不仅仅是服务端,还有网关限流,nginx限流。每个端上都有自己的优化策略。同时我们需要尤其警惕的是避免’误伤’,不要把’限流’变成了‘限制用户访问’