本文主要从设计与原理方面分享优化过程中的思考,不涉及具体的代码实现。在分析过程中我会写一些当时思考的问题,在看后续答案时可以自己也先思考一下
老的限流方案
首先讲解一下原本网关限流功能的实现方案,省略其中的白名单,黑名单,令牌桶算法实现等一些细节
- 限流策略中包含多种策略,比如根据用户维度限流,ip维度限流,接口维度限流等,每种限流策略各自维护自己的计数。任意一个策略触发限流拒绝都会拒绝请求
- 每种限流策略都会设置好总允许的权重值,每次访问通过redis的lua脚本进行权重的扣除和增加,直到为0,扣除不了的则触发拒绝
- 简单讲一下限流使用的令牌桶,网关会在redis中记录剩余令牌数和上次补充令牌的时间点,redis的key是限流维度生成的唯一id,比如用户维度则是特殊前缀+用户id。假设我们有一个令牌桶,初始令牌数为10,每5秒钟补充5个令牌,桶的最大容量也是10个令牌。那么令牌使用情况如下图:
- 老限流的流程图:
限流存在的问题
总体来看,这个限流的方案还是比较简单的,当流量不大的时候也可以正常运行。
但是流量大的时候存在一些问题,大家可以先想一下有哪些问题?
- 一次请求由于需要从多个维度进行限流,所以会串行的多次访问redis,每次io都需要消耗时间
- lua脚本在redis中执行,redis的性能成为瓶颈,一方面是cpu高,另一方面redis的命令也是串行的,后面的命令需要等待前面的命令
- 即使权重已经扣除到0了,当请求过来时,仍然需要访问redis去判断是否可以通过
分析和优化思路
针对这3个问题,我们分析一下,都有哪些处理方案,以及这些方案的优缺点
第一个问题:多次串行IO
-
多线程并行访问
- 并行可以同时进行网络请求,虽然总次数没有少,但是总时间可以减少到最慢的那次请求的耗时。比如,每次1秒,串行访问3次,总耗时3秒,改为并行则只需要1秒。
- 串行改并行一般来说改动起来会小一些,比较好实现。可以节省时间。但是本质上没有减少资源消耗
- 同时由于本次请求的是redis,redis仍然需要进行串行执行
- 所以这个方案并不好
-
合并请求批量访问
- 在这个场景中,时间消耗分为,io耗时+redis执行耗时。
- 批量访问可以减少io耗时的部分,但是不会减少redis执行耗时。
- 总体来看这个方案可以减少总耗时,但是由于需要聚合请求,分发响应,因此代码改动会稍微大一些。
- 目前看是一个可行的方案
-
减少redis的访问
- 首先我们要分析redis的作用是什么,为什么我们需要访问redis?
- 由于网关是无状态的,限流是全局维度的,所以针对这个场景我们利用redis作为一个中心化的数据保存,也就是每个策略的权重数据。其次由于请求是并发的,所以我们利用了redis来保证权重加减的原子性。在这2个原因中的3个关键点是:数据保存,中心化和原子性。
- 数据保存通常来讲我们遵循(内存>redis>数据库)。那我们是否可以用内存代替redis实现数据保存?
- 如果网关只有一台机器,那么是可行的,但是现在由于多台机器,所以中心化这个关键点限制了我们把数据从redis改为内存。
- 那我们改进一下这个思路,能不能把部分数据改为内存?
- 数据是权重数据,本质上就是一个计数器。那我们将计数器的一部分值放到内存中,扣除完了之后再来redis扣除一次(这个思路其实就是java中的TLAB机制的启发)。
- 数据保存和中心化都没问题了,那原子性是否可以保证?
- 不同机器从redis中扣除这部分逻辑与之前是一致的,所以原子性没问题,保存在内存中的值,只会被单机操作一定可以保证原子性(最差就是加锁)。
- 那么最终分析之后这个方案也是可行的
- 首先我们要分析redis的作用是什么,为什么我们需要访问redis?
第二个问题: redis执行lua脚本成为瓶颈
- 优化lua脚本
- lua脚本实现的是令牌桶算法,在当前场景下没有发现优化空间,因此不考虑
- redis分片处理
- 不同的key路由到不同的redis中,可以解决瓶颈问题,方案可行,但是本质上没有减少资源消耗
- 减少redis访问
- 同上一个问题的解决方案,方案可行
第三个问题:权重扣除为0,仍然访问redis
- 还是先分析原因,为什么权重为0时我们需要访问redis?
- 因为内存中并不知道权重是否已经为0,这个数据只在redis中存在。
- 那我们有什么办法可以提前知道redis中权重是否为0?
- 好像没有办法
- 但是如果上一次请求返回了权重为0,那么这一次请求是否可以判断出redis中权重是否为0?
- 答案是部分可以。
- 利用令牌桶算法的特点,令牌桶每隔一定时间会增加令牌。如果当前权重已经是0,且在到需要增加令牌的时间之前,权重一定一直为0。
- 所以当上一次请求返回为0时,同时记录上一次补充令牌的时间点,那么就可以推算出在哪个时间点前,权重一直为0,那么此时就不需要再次访问redis。
- 通过这种方式可以极大的减少权重为0时的redis访问。不管总请求数是多少,在一个令牌补充周期内,每台机器只会在权重为0时访问一次redis。
- 这个问题的解决方案与上2个问题的解决方案同时兼容,因此也是可行的
优化后的方案
现在我们总结上面的问题解决方案:
- 可以看到减少redis访问这个方案可以同时解决第一个和第二个问题,并且真实的减少了资源消耗
- 第三个问题的解决方案也不冲突,因此优化后的限流方案改为如下流程:
新方案的优点
- 从原本一次请求多次redis访问变成了,多次请求一次redis操作
- 大部分扣除权重不经过网络,纯内存操作
- redis访问极大的减少,redis的消耗变小
- 权重扣完后不会再请求redis,避免恶意流量打垮redis
新方案有什么缺点?
- 限流的精确度变差,限流的边界值被模糊了
- 比如原本限流1w次,现在变成不到1w次就已经触发了限流
- 已经触发限流后,在补充令牌前,后面的请求可能又可以成功
- 这个缺点我认为是可以接受的,毕竟我们的限流没有必要那么的精确,可以容忍
- 提一个问题,假设一个令牌周期内限流1w次,新方案的限流最早从第多少次会触发第一次限流?和哪些因素有关?
优化后的效果
redis的cpu使用率从85% -> 5%
机器的cpu使用率从70% -> 50%
优化后的总结
- 优化的首要关键因素是能发现问题,也就是优化点
- 一般最直接的方式就是通过监控发现,所以监控很重要
- 业务的理解程度也很大程度上会影响你是否能发现问题
- 其次是问题的解决方案
- 解决方案跟个人经验有关系,但是大部分的问题的解决方案都是类似的,通过其他人的问题解决方案来积累经验我认为是最值的
- 一般来讲很难一下子就想到最优方案。想出一种方案后最好再思考一下,有哪些缺点,还有没有其他方案。比较多个方案选一个相对最优的方案
- 优化时常用的一些思路
- 通用方案不一定是最好的,可以利用一些功能的特点去针对性的优化
- 优化到一定程度后,一般很难做到十全十美,通过放弃一部分东西可以换取一些你更需要的东西,比如空间换时间
如果觉得本文的内容有不足之处,欢迎指出
总觉得自己写的文章语言都好生硬,不知道怎么改善