高可用服务设计之二:Rate limiting 限流与降级

时间:2024-08-18 22:38:02

高可用服务设计之二:Rate limiting 限流与降级

nginx限制请求之一:(ngx_http_limit_conn_module)模块

nginx限制请求之二:(ngx_http_limit_req_module)模块

nginx限制请求之三:Nginx+Lua+Redis 对请求进行限制

nginx限制请求之四:目录进行IP限制

分布式限流之一:redis+lua 实现分布式令牌桶,高并发限流

服务容灾

为了避免出现服务的雪崩,我们需要对服务做容灾处理。

常规的服务容灾处理思路有:

  • 资源隔离
  • 超时设定
  • 服务降级
  • 服务限流

其中每种思路又可以有不同的解决方案。

比如资源隔离可以通过将不同的服务发布在独立的docker容器或服务器中,这样即使一个服务出现问题,也不会殃及池鱼。

服务降级和服务限流可以通过前端nginx+lua来实现,当服务处理延迟或宕机时,nginx可以直接返回固定的降级/失败响应,已快速跳过问题服务。

一、限流

在开发高并发系统时,有很多手段保护系统,比如缓存降级限流。缓存的目的是提升系统访问速度和增大系统处理能力,可谓是抗高并发的银弹。而降级是当服务出问题或者影响到核心流程的性能,需要暂时屏蔽掉,待高峰过去或者问题解决后再打开的场景。而有些场景并不能用缓存和降级来解决,比如稀缺资源(秒杀、抢购)、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页)等。因此,需要有一种手段来限制这些场景下的并发/请求量,这种手段就是限流。

限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的请求进行限速来保护系统,一旦达到限速速率则可以拒绝服务(定向到错误页或告知资源没有了)、排队或等待(比如秒杀、评论、下单)、降级(返回兜底数据或者默认数据,如商品详情页库存默认有货)。在压测时,我们能找出每个系统的处理峰值,然后通过设定峰值阈值,当系统过载时,通过拒绝过载的请求来保障系统可用。另外,也可以根据系统的吞吐量、响应时间、可用率来动态调整限流阈值。

一般开发高并发系统场景的限流有:限制总并发数(比如数据库连接池、线程池)、限制瞬时并发数(如Nginx的limit_conn模块,用来限制瞬间并发连接数)、限制时间窗口内的平均速率(如Guava的RateLimiter、Nginx的limit_req模块,用来限制每秒的平均速率),以及限制远程接口调用速率、限制MQ的消费速率等。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。

Rate limiting 在 Web 架构中非常重要,是互联网架构可靠性保证重要的一个方面。

从最终用户访问安全的角度看,设想有人想暴力碰撞网站的用户密码;或者有人攻击某个很耗费资源的接口;或者有人想从某个接口大量抓取数据。大部分人 都知道应该增加 Rate limiting,做请求频率限制。从安全角度,这个可能也是大部分能想到,但不一定去做的薄弱环节。

从整个架构的稳定性角度看,一般 SOA 架构的每个接口的有限资源的情况下,所能提供的单位时间服务能力是有限的。假如超过服务能力,一般会造成整个接口服务停顿,或者应用 Crash,或者带来连锁反应,将延迟传递给服务调用方造成整个系统的服务能力丧失。有必要在服务能力超限的情况下 Fail Fast。

另外,根据排队论,由于 API 接口服务具有延迟随着请求量提升迅速提升的特点,为了保证 SLA 的低延迟,需要控制单位时间的请求量。这也是 Little’s law 所说的。

高可用服务设计之二:Rate limiting 限流与降级

1.1、限流算法

常见的限流算法有:令牌桶、漏桶。计数器也可以用来进行粗暴限流实现。

限流算法之漏桶算法、令牌桶算法

令牌桶算法

令牌桶算法,是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:

  • 假设限制2r/s,则按照500毫秒的固定速率往桶内添加令牌。
  • 桶中最多存放b个令牌,当桶满时,新添加的令牌会被丢弃或拒绝。
  • 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上。
  • 如果桶中的令牌不足n个,则不会删除令牌,且该数据包被限流(要么丢弃,要么在缓冲区等待)。

漏桶算法

漏桶作为计量工具时,可以用于流量整形和流量控制,漏桶算法的描述如下:

  • 一个固定容量的漏桶,按照常量固定速率流出水滴。
  • 如果桶是空的,则不需流出水滴。
  • 可以以任意速率流入水滴到漏桶。
  • 如果流入水滴超过了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。

令牌桶和漏桶对比

  • 令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时,则拒绝新的请求。
  • 漏桶则是按照常量固定速率流出请求,请求流入速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝。
  • 令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿多个令牌),并允许一定程序的突发流量。
  • 漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率。
  • 令牌桶允许一定程序的突发,而漏桶主要目的是平滑流入速率。
  • 两个算法实现可以一样,但是方向是相反的,对于相同的参数得到的限流效果是一样的。

常见的限流方式有:限制总并发数(数据库连接池、线程池)、限制瞬时并发数(如Nginx的limit_conn模块)、限制时间窗口的平均速率(如Guava的RateLimiter、Nginx的limit_req模块)、限制远程接口的调用速率限制MQ的消费速率等。从应用的层面上来讲,又可以分为:接入层限流应用层限流分布式限流等。

1.2、应用级限流

1.2.1、限制总并发/连接/请求数

对于一个应用系统来说,一定会有极限并发/请求数,即总有一个TPS/QPS阈值,如果超了阈值,则系统就会不影响用户请求或响应得非常慢。因此,我们最好进行过载保护,以防止大量请求涌入击垮系统。

如Tomcat的Connector中的以下几个参数:

  • acceptCount:如果Tomcat的线程都忙于响应,新来的连接将会进入队列,如果超出队列大小,则会拒绝连接。
  • maxConnections:瞬时最大连接数,超出的会排队等待。
  • maxThreads:Tomcat能启动用来处理请求的最大线程数,如果请求处理量一直远远大于线程数,则会引起响应变慢甚至会僵死。

类似于Tomcat配置最大连接数等参数,Redis和MySQL也有相关的配置。

如MQ(max_connections)、Redis(tcp-backlog)都会有类似的限制连接数的配置。

1.2.2、限制总资源数

如果有的资源是稀缺资源(如数据库连接、线程),而且可能有多个系统都会去使用它,那么需要加以限制。可以使用池化技术来限制总资源数,如连接池、线程池。假设分配给每个应用的数据库连接是100,那么本应用最多可以使用100个资源,超出则可以等待或者抛异常。

1.2.3、限制某个接口的总并发/请求数

如果接口可能会有并发流量,但又担心访问量太大造成奔溃,那么久需要限制这个接口的总并发/请求数了。因为粒度比较细,可以为每个接口设置相应的阈值。可以使用Java中的AtomicLong或者Semaphore进行限流。Hystrix在信号量模式下也使用Semaphore限制每个接口的总请求数。

一种实现方式如下:

try {
if (atomic.incrementAndGet() > 限流数) {
//拒绝请求
}
//处理请求
} finally {
atomic.decrementAndGet();
}

1.2.4、限流接口每秒的请求数

限制每秒的请求数,可以使用Guava的Cache来存储计数器,设置过期时间为2S(保证能记录1S内的计数)。下面代码使用当前时间戳的秒数作为key进行统计,这种限流的方式也比较简单。

LoadingCache<Long, AtomicLong> counter =
CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long aLong) throws Exception {
return new AtomicLong(0);
}
}); long limit = 1000;
while (true) {
long currentSeconds = System.currentTimeMillis() / 1000;
if (counter.get(currentSeconds).incrementAndGet() > limit) {
logger.info("被限流了:{}", currentSeconds);
continue;
}
//业务处理
}

上面介绍的“限制某个接口的总并发/请求数”和"限流接口每秒的请求数"限流方案都是对于单机接口的限流,当系统进行多机部署时,就无法实现整体对外功能的限流了。当然这也看具体的应用场景,如果平行的应用服务器需要共享限流阀值指标,可以使用Redis作为共享的计数器。

1.2.5、平滑限流某个接口的请求数

Guava RateLimiter提供的令牌桶算法可用于平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

1.2.5.1、平滑突发限流(SmoothBursty)

平滑突发限流顾名思义,就是允许突发的流量进入,后面再慢慢的平稳限流。下面给出几个Demo

# 创建了容量为5的桶,并且每秒新增5个令牌,即每200ms新增一个令牌
RateLimiter limiter = RateLimiter.create(5);
while (true) {
// 获取令牌(可以指定一次获取的个数),获取后可以执行后续的业务逻辑
System.out.println(limiter.acquire());
}

上面代码执行结果如下所示:

0.0
0.188216
0.191938
0.199089
0.19724
0.19997

上面while循环中执行的limiter.acquire(),当没有令牌时,此方法会阻塞。实际应用当中应当使用tryAcquire()方法,如果获取不到就直接执行拒绝服务。

下面在介绍一下中途休眠的场景:

RateLimiter limiter = RateLimiter.create(2);
System.out.println(limiter.acquire());
Thread.sleep(1500L);
while (true) {
System.out.println(limiter.acquire());
}

上面代码执行结果如下:

0.0
0.0
0.0
0.0
0.499794
0.492334

从上面结果可以看出,当线程休眠时,会囤积令牌,以给后续的acquire()使用。但是上面的代码只能囤积1S的令牌(也就是2个),当睡眠时间超过1.5S时,执行结果还是相同的。

1.2.5.2、平滑预热限流(SmoothWarmingUp)

平滑突发限流有可能瞬间带来了很大的流量,如果系统扛不住的话,很容易造成系统挂掉。这时候,平滑预热限流便可以解决这个问题。创建方式:

// permitsPerSecond表示每秒钟新增的令牌数,warmupPeriod表示从冷启动速率过渡到平均速率所需要的时间间隔
RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)

例如:

RateLimiter limiter = RateLimiter.create(5, 1000, TimeUnit.MILLISECONDS);
for (int i = 1; i < 5; i++) {
System.out.println(limiter.acquire());
}
Thread.sleep(1000L);
for (int i = 1; i < 50; i++) {
System.out.println(limiter.acquire());
}

执行结果如下:

0.0
0.513566
0.353789
0.215167
0.0
0.519854
0.359071
0.219118
0.197874
0.197322
0.197083
0.196838

上面结果可以看出来,平滑预热限流的耗时是慢慢趋*均值的。

节流

有时候我们想在特定时间窗口内对重复的相同事件最多只处理一次,或者想限制多个连续相同事件最小执行时间间隔,那么可使用节流(Throttle)实现,其防止多个相同事件连续重复执行。节流主要有如下几种用法:throttleFirst、throttleLast、throttleWithTimeout。

1.3、限流方法总结

常见的 Rate limiting 的实现方式

1.3.1、在Proxy层限流

Proxy 层的实现,针对部分 URL 或者 API 接口进行访问频率限制

1.3.1.1、Nginx 中限流

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

server {
    location /search/ {
        limit_req zone=one burst=5;
    }

详细参见:《Nginx模块 ngx_http_limit_req_module 限制请求速率

1.3.1.2、Haproxy 提供限流

详细参见:Haproxy Rate limit 模块

1.3.2、Java、Scala JVM 系应用层实现

例如:在提供给业务方的Controller层进行控制。

1.3.2.1使用guava提供工具库里的RateLimiter类(内部采用令牌捅算法实现)进行限流

<!--核心代码片段-->
private RateLimiter rateLimiter = RateLimiter.create(400);//400表示每秒允许处理的量是400
if(rateLimiter.tryAcquire()) {
//短信发送逻辑可以在此处 }

Google Guava 提供了一个 RateLimiter 实现。使用方式简单明了,在自己的应用中简单封装即可,放到 HTTP 服务或者其他逻辑接口调用的前端。

详细参见:Google Guava RateLimiter

1.3.2.2使用Java自带delayqueue的延迟队列实现(编码过程相对麻烦,此处省略代码)

1.3.2.3使用Redis实现,存储两个key,一个用于计时,一个用于计数。请求每调用一次,计数器增加1,若在计时器时间内计数器未超过阈值,则可以处理任务

if(!cacheDao.hasKey(API_WEB_TIME_KEY)) {
cacheDao.putToValue(API_WEB_TIME_KEY,0,(long)1, TimeUnit.SECONDS);
} if(cacheDao.hasKey(API_WEB_TIME_KEY)&&cacheDao.incrBy(API_WEB_COUNTER_KEY,(long)1) > (long)400) {
LOGGER.info("调用频率过快");
}
//短信发送逻辑

这个在 Redis 官方文档有非常详细的实现。一般适用于所有类型的应用,比如 PHP、Python 等等。Redis 的实现方式可以支持分布式服务的访问频率的集中控制。Redis 的频率限制实现方式还适用于在应用中无法状态保存状态的场景。

具体实现

A、在Controller层设置两个全局key,一个用于计数,另一个用于计时

//用于计时
private static final String API_WEB_TIME_KEY = "time_key";
//用于计数
private static final String API_WEB_COUNTER_KEY = "counter_key";

2、对时间key的存在与否进行判断,并对计数器是否超过阈值进行判断

//1秒的时间已经过了,重新对time_key赋值,同时初始化计数变量
if(!cacheDao.hasKey(API_WEB_TIME_KEY)) {
cacheDao.putToValue(API_WEB_TIME_KEY,0,(long)1, TimeUnit.SECONDS);
cacheDao.putToValue(API_WEB_COUNTER_KEY,0,(long)2, TimeUnit.SECONDS);//时间到就重新初始化为0
} //如果大于最大请求数量,直接打logger,返回
if(cacheDao.hasKey(API_WEB_TIME_KEY)&&cacheDao.incrBy(API_WEB_COUNTER_KEY,(long)1) > (long)400) {
LOGGER.info("调用频率过快");
return;
} //短信发送逻辑

1.3.2.4通过Hytrix限流

见《服务容错保护断路器Hystrix之一:入门示例介绍(springcloud引入Hystrix的两种方式)

1.3.3、数据库限流

数据库连接数的限制

1.4、分布式限流

分布式限流最关键的是要将限流服务做成原子化,而解决方案可以使用Redis+Lua或者Nginx+Lua技术进行实现,通过这两种技术可以实现高并发和高性能。

见《分布式限流之一:redis+lua 实现分布式令牌桶,高并发限流

2 降级

当访问量剧增、服务出现问题(如响应时间长或者不响应)或非核心服务影响到核心服务的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键参数进行自动降级,也可以配合开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。降级也需要根据系统的吞吐量、响应时间、可用率等条件进行手工降级或自动降级。

降级预案

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保车,从而梳理出哪些必须誓死保护,哪些可以降级。比如,可以参考日志级别设置预案:

  • 一般:比如,有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级。
  • 警告:有些服务在一段时间内成功率有波动(如在95%~100%之间),可以自动降级或人工降级并发送告警。
  • 错误:比如,可用率低于90%,或者数据库连接池用完了,或者访问量突然猛增到系统能承受的最大阈值,此时可以根据情况自动降级或人工降级。
  • 严重错误:比如,因为特殊原因数据出现错误,此时需要紧急人工降级。

降级分类

  • 降级按照是否自动化可分为:自动开关降级和人工开关降级。
  • 降级按照功能可分为:读服务降级和写服务降级。
  • 降级按照处于的系统层次可分为:多级降级。

降级的功能点主要从服务器端链路考虑,即根据用户访问的服务调用链路来梳理哪里需要降级。

  • 页面降级。
  • 页面片段降级。
  • 页面异步请求降级。
  • 服务功能降级。
  • 读降级。比如多级缓存模式,如果后端服务有问题,则可以降级为只读缓存,这种方式是用于对读一致性要求不高的场景。
  • 写降级。比如秒杀抢购我们可以只进行Cache的更新,然后异步扣减库存到DB,保证最终一致性即可,此时可以将DB降级为Cache。
  • 爬虫降级。
  • 风控降级。

自动开关降级

自动降级是根据系统负载、资源使用情况、SLA等指标进行降级。

  • 超时降级
  • 统计失败次数降级
  • 故障降级
  • 限流降级

人工开关降级

比如,上线新功能时进行灰度测试,当新服务有问题时通过开关切换回老服务。

自动降级

我们这边将系统遇到“危险”时采取的整套应急方案和措施统一称为降级或服务降级。想要帮助服务做到自动降级,需要先做到如下几个步骤:

  1. 可配置的降级策略:降级策略=达到降级的条件+降级后的处理方案,策略一定得可配置,因为不同的服务对服务的质量定义不一样,降级的方案也将不一样。
  2. 可识别的降级边界:一定要精确的知道需要对谁进行降级,可以是一个对外服务、对下游的一个依赖或者是内部一段处理逻辑。降级边界主要用来植入降级逻辑。
  3. 数据采集:是否达到降级条件依赖于采集的数据,这些数据可以是当前某段时间的数据,也可以是很长一段时间的历史数据。
  4. 行为干预:进入降级状态后将会对正常的业务流程产生干预,可能是限流、熔断,也可能是同步流程变为异步流程等(比如发送MQ的变成oneway的形式)等。
  5. 结果干预:是返回null,还是默认值,还是流程上的同步改异步等。
  6. 快速恢复:即如何从降级状态变回正常状态,这也需要达到某些条件。

3 超时与重试机制

如果应用不设置超时,可能会导致请求响应慢,慢请求累积导致连锁效应,甚至造成应用雪崩。而有些中间件或框架在超时后会进行重试(如设置超时自动重试两次),读服务天然适合重试,但写服务大多不能重试(如写订单,如果写服务是幂等的,则重试是允许的),重试次数太多会导致多倍请求流量,即模拟了DDoS攻击,后果可能是灾难。因此,务必设置合理的重试机制,并且应该和熔断、快速失败机制配合。在进行代码Review时,一定记得Review超时与重试机制。

对于非幂等写服务应避免重试,或者考虑提前生成唯一流水号来保证写服务操作通过判断流水号来实现幂等操作。

在进行数据库/缓存服务器操作时,要经常检查慢查询,慢查询通常是引起服务出问题的罪魁祸首。也要考虑在超时严重时,直接将该服务降级,待该服务修复后再取消降级。

4 回滚机制

回滚是指当程序或数据出错时,将程序或数据恢复到最近的一个正确版本的行为。通过回滚机制可保证系统在某些场景下的高可用。常见的回滚如下:

  • 事务回滚
  • 代码库回滚
  • 部署版本回滚
  • 数据版本回滚
  • 静态资源版本回滚。

5 压测与预案

在大促来临之前,研发人员需要对现有系统进行梳理,发现系统瓶颈和问题,然后进行系统调优来提升系统的健壮性和处理能力。一般通过系统压测来发现系统瓶颈和问题,然后进行系统优化和容灾(系统参数调整、单机房容灾、多机房容灾等)。

系统压测

压测一般是指性能压力测试,用来评估系统的稳定性和性能,通过压测数据进行系统容量评估,从而决定是否需要进行扩容或缩容。

压测之前要有压测方案(如压测接口、并发量、压测策略[突发、逐步加压、并发量]、压测指标[机器负载、QPS/TPS]、响应时间[平均、最小、最大]、成功率、相关参数[JVM参数、压缩参数]等),最后根据压测报告分析的结果进行系统优化和容灾。