关于服务限流的一些思考

3,523

限流的价值感

限流必然是很有价值的,在系统资源不足时面对外部世界的不确定性(突发流量,超预期的用户)而形成的一种自我保护机制。 但是价值感是很低的,因为99.99%的时候系统总是工作在安全线之下,甚至一年到头都碰不到一次撞线的机会。这就好比法律,它始终存在,但是大部分时候对于大多数人它几乎不存在,或者说感知不到它的存在。

一个软件系统往往会存在很多隐藏的bug,最常用的功能bug往往很少。不常用的功能因为长时间不被人关注缺少重现的机会会一直隐藏在那里伺机爆发。而且随着软件系统的迭代更新,不受关注的功能极有可能被测试人员忽视从覆盖测试中被遗忘了。结果一旦遇到了突发的场景(墨菲定律),这一段被忽视的存在bug的代码逻辑被唤醒的,然后就会导致系统出错甚至奔溃。限流功能就是这些不被关注的功能之一。

为了解决限流的价值感问题,工程师们需要对限流功能进行周期性演练,需要使用单元测试和压力测试进行多次重演。总之就是想尽各种办法来触发限流功能撞线,以重现那一段生产环境几乎不会被执行的 else 逻辑。

限流算法

业界较为常见的两个基础限流算法是漏斗算法和令牌算法,这两个算法大同小异。漏斗算法可以理解为每一个请求都会消耗一定的空气,而漏斗里的空气是有限的,通过漏水的方式获得空气的速率也是有限的。令牌算法可以理解为每一个请求都会消耗一个令牌,而令牌桶生产令牌的能力是有限的,存放令牌的桶容量也是有限的。

生产环境的请求QPS在曲线上看是平滑的,因为大多数统计系统都采用了平滑算法(一段时间内的均值),但是在实际的运行过程中会有一定的随机性和波动性,会有突发的一些离群点,这个一般被称之为突发流量。

限流算法需要考虑这种突发流量,它应该可以短期内容忍,容忍也是有上限的,就是容忍时间必须很短。上面两个算法都具有这个容忍能力,这个容忍就体现在漏斗中空气的积蓄,令牌桶中令牌的积蓄,积蓄耗光就达到了容忍的上限。

分布式限流

如果你的应用是单个进程,那么限流就很简单,请求的计数算法都可以在内存里完成。限流算法几乎没有损耗,都是纯内存的计算。但是互联网世界的应用都是多节点的分布式的,每个节点的请求处理能力还不一定一样。我们需要考虑的是这多个节点的整体请求处理能力。

单个进程的处理能力是1w QPS 并不意味着整体的请求处理能力是 N * 1w QPS,因为整体的处理能力还会有共享资源的能力限制。这个共享资源一般是指数据库,也可以是同一台机器的多个进程共享的 CPU 和 磁盘 等资源,还有网络带宽因素也会制约整体的请求量。

这时候请求的计数算法就需要集中在一个地方(限流中间件)来完成。应用程序在处理请求之前都需要向这种集中管理器申请流量(空气、令牌桶)。 每一个请求都需要一次网络 IO,从应用程序到限流中间件之间。

比如我们可以使用 Redis + Lua 来实现这个限流功能,但是 Lua 的性能要比 C 弱很多,通常这个限流算法能达到 1w 左右的 QPS就到顶了。还可以使用 Redis-Cell 模块,其内部使用 Rust 实现,它能达到 5w 左右的 QPS 也就到极限了。这时候它们都进入了满负荷状态,但是在生产环境中我们不会希望它们一直满负荷工作。

那如何完成 10w QPS 的限流呢?

一个简单的想法就是将限流的 key 分桶,然后使用 Redis 集群来扩容,让限流的申请指令经过客户端的 hash 分桶后打散的集群的多个几点,借此分散压力。

那如何完成 百万 QPS (1M)的限流呢?

如果还使用上面的方法那需要的带宽资源和Redis实例也是惊人的。我们可能需要几十个 Redis 节点,外加上百M(1M * 20 字节 * 8) 的带宽来完成这个工作。

这时我们必须转换思路,不再使用这种集中管控的方式来工作了。就好比一个国家人太多了,就要分省、市、县来分别进行管理 —— 放权。

我们将整体的 QPS 按照权重分散多每个子节点,每个字节点在内存中进行单机限流。如果每个节点都是对等的,那么每个子节点就可以分得 1/n 的 QPS。

它的优点在于分散了限流压力,将 IO 操作变成纯内存计算,这样就可以很轻松地应对超高的 QPS 限流。但是这也增加了系统的复杂度,需要有一个集中的配置中心来向每个子节点来分发 QPS 阈值,需要每个应用字节点向这个配置中心进行注册,需要有一个配置管理后台来对系统的 QPS 分配进行管理。

如果没有一个完善易用成熟的开源软件的话,这样的一个控制中心服务和SDK往往需要一个小型团队来完成。这对于中小型企业来说往往是承受不起的,再想想限流的价值感如此之低,这种可能性就更加微乎其微了。