剖析微服务接口鉴权限流背后的数据结构和算法

211 阅读9分钟

剖析微服务接口鉴权限流背后的数据结构和算法

微服务是最近几年才兴起的概念。简单点讲,就是把复杂的大应用,解耦拆分成几个小的应用。这样做的好处有很多。比如,这样有利于团队组织架构的拆分,毕竟团队越大协作的难度越大;再比如,每个应用都可以独立运维,独立扩容,独立上线,各个应用之间互不影响。不用像原来那样,一个小功能上线,整个大应用都要重新发布

不过,有利就有弊。大应用拆分成微服务之后,服务之间的调用关系变得更复杂,平台的整体复杂熵升高,出错的概率、debug 问题的难度都高了好几个数量级。所以,为了解决这些问题,服务治理便成了微服务的一个技术重点。

所谓服务治理,简单点讲,就是管理微服务,保证平台整体正常、平稳地运行。服务治理涉及的内容比较多,比如鉴权、限流、降级、熔断、监控告警等等。这些服务治理功能的实现,底层依赖大量的数据结构和算法。

鉴权

image.png

要实现接口鉴权功能,我们需要事先将应用对接口的访问权限规则设置好。当某个应用访问其中一个接口的时候,我们就可以拿应用的请求 URL,在规则中进行匹配。如果匹配成功,就说明允许访问;如果没有可以匹配的规则,那就说明这个应用没有这个接口的访问权限,我们就拒绝服务。

如何实现快速鉴权?

接口的格式有很多,有类似 Dubbo 这样的 RPC 接口,也有类似 Spring Cloud 这样的HTTP 接口。不同接口的鉴权实现方式是类似的,我这里主要拿 HTTP 接口给你讲解。

1.如何实现精确匹配规则?

image.png

针对这种匹配模式,我们可以将每个应用对应的权限规则,存储在一个字符串数组中。当用户请求到来时,我们拿用户的请求 URL,在这个字符串数组中逐一匹配,匹配的算法就是我们之前学过的字符串匹配算法(比如 KMP、BM、BF 等)。

规则不会经常变动,所以,为了加快匹配速度,我们可以按照字符串的大小给规则排序,把它组织成有序数组这种数据结构。当要查找某个 URL 能否匹配其中某条规则的时候,我们可以采用二分查找算法,在有序数组中进行匹配。 而二分查找算法的时间复杂度是 O(logn)(n 表示规则的个数),这比起时间复杂度是O(n) 的顺序遍历快了很多。对于规则中接口长度比较长,并且鉴权功能调用量非常大的情况,这种优化方法带来的性能提升还是非常可观的 。

2.如何实现前缀匹配规则?

只要某条规则可以匹配请求 URL 的前缀,我们就说这条规则能够跟这个请求 URL 匹配

image.png

image.png

3.如何实现模糊匹配规则?

如果我们的规则更加复杂,规则中包含通配符,比如“**”表示匹配任意多个子目录,“ *”表示匹配任意一个子目录。只要用户请求 URL 可以跟某条规则模糊匹配,我们就说这条规则适用于这个请求。

image.png

实际上,我们可以结合实际情况,挖掘出这样一个隐形的条件,那就是,并不是每条规则都包含通配符,包含通配符的只是少数。于是,我们可以把不包含通配符的规则和包含通配符的规则分开处理。

我们把不包含通配符的规则,组织成有序数组或者 Trie 树(具体组织成什么结构,视具体的需求而定,是精确匹配,就组织成有序数组,是前缀匹配,就组织成 Trie 树),而这一部分匹配就会非常高效。剩下的是少数包含通配符的规则,我们只要把它们简单存储在一个 数组中就可以了。尽管匹配起来会比较慢,但是毕竟这种规则比较少,所以这种方法也是可以接受的。 当接收到一个请求 URL 之后,我们可以先在不包含通配符的有序数组或者 Trie 树中查找。如果能够匹配,就不需要继续在通配符规则中匹配了;如果不能匹配,就继续在通配符规则中查找匹配。

限流

所谓限流,顾名思义,就是对接口调用的频率进行限制。比如每秒钟不能超过 100 次调用,超过之后,我们就拒绝服务。限流的原理听起来非常简单,但它在很多场景中,发挥着重要的作用。比如在秒杀、大促、双 11、618 等场景中,限流已经成为了保证系统平稳运行的一种标配的技术解决方案。

按照不同的限流粒度,限流可以分为很多种类型。比如给每个接口限制不同的访问频率,或者给所有接口限制总的访问频率,又或者更细粒度地限制某个应用对某个接口的访问频率等等。

如何实现精准限流?

最简单的限流算法叫固定时间窗口限流算法。这种算法是如何工作的呢?首先我们需要选定一个时间起点,之后每当有接口请求到来,我们就将计数器加一。如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次访问请求),出现累加访问次数超过限流值的情况时,我们就拒绝后续的访问请求。当进入下一个时间窗口之后,计数器就清零重新计数。

image.png

这种基于固定时间窗口的限流算法的缺点是,限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。

假设我们的限流规则是每秒不能超过100次接口请求,第一个 1s 时间窗口内,100次接口请求都集中在最后 10ms 内。在第二个 1s 的时间窗口内,100 次接口请求都集中在最开始的 10ms 内。虽然两个时间窗口内流量都符合限流要求(≤100 个请求),但在两个时间窗口临界的 20ms 内,会集中有 200 次接口请求。固定时间窗口限流算法并不能对这种情况做限制,所以,集中在这 20ms 内的 200 次请求就有可能压垮系统。

image.png

为了解决这个问题,我们可以对固定时间窗口限流算法稍加改造。我们可以限制任意时间窗口(比如 1s)内,接口请求数都不能超过某个阈值( 比如 100 次)。因此,相对于固定时间窗口限流算法,这个算法叫滑动时间窗口限流算法。

流量经过滑动时间窗口限流算法整形之后,可以保证任意一个 1s 的时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑。那具体到实现层面,我们该如何来做呢?

我们假设限流的规则是,在任意 1s 内,接口的请求次数都不能大于 K 次。我们就维护一个大小为 K+1 的循环队列,用来记录 1s 内到来的请求。注意,这里循环队列的大小等于限流次数加一,因为循环队列存储数据时会浪费一个存储单元。

当有新的请求到来时,我们将与这个新请求的时间间隔超过 1s 的请求,从队列中删除。然后,我们再来看循环队列中是否有空闲位置。如果有,则把新请求存储在队列尾部(tail 指针所指的位置);如果没有,则说明这 1 秒内的请求次数已经超过了限流值 K,所以这个请求被拒绝服务。

任意 1s 内,接口的请求次数都不能大于 6 次。

image.png

即便滑动时间窗口限流算法可以保证任意时间窗口内,接口请求次数都不会超过最大限流值,但是仍然不能防止,在细时间粒度上访问过于集中的问题。 比如刚刚举的那个例子,第一个 1s 的时间窗口内,100 次请求都集中在最后 10ms 中,也就是说,基于时间窗口的限流算法,不管是固定时间窗口还是滑动时间窗口,只能在选定的时间粒度上限流,对选定时间粒度内的更加细粒度的访问频率不做限制。实际上,针对这个问题,还有很多更加平滑的限流算法,比如令牌桶算法、漏桶算法等。

思考

  1. 除了用循环队列来实现滑动时间窗口限流算法之外,我们是否还可以用其他数据结构来实现呢?请对比一下这些数据结构跟循环队列在解决这个问题时的优劣之处

优先队列,根据请求时间构建小根堆,最早请求时间放在堆顶,然后每次进来一个请求,就判断这个事件跟堆顶时间差是否小于15,并且堆的大小小于请求限制的次数,如果是就插入队列,如果不是就限制

  1. 分析一下鉴权那部分内容中,前缀匹配算法的时间复杂度和空间复杂度

假设有n个规则,每个规则的单词个数平均为m,则时间复杂度为O(mlogn), 空间复杂度O(nm) 时间复杂度分析下:平均搜索m层,每一层最多有n个单词,由于是采用有序数组存储,查找时间复杂度为O(logn),所以总的时间复杂度为O(m*logn)