在开发高并发系统时有三把利器用来保护系统:缓存
、降级
和 限流
。
-
缓存
:目的是提升系统访问速度和增大系统能处理的容量,可谓是抗高并发流量的银弹; -
降级
:当服务出问题或者影响到核心流程的性能则需要暂时屏蔽掉,待高峰或者问题解决后再打开; -
限流
:有些场景并不能用缓存和降级来解决,比如稀缺资源(秒杀、抢购)、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页),因此需有一种手段来限制这些场景的并发/请求量。
1 互联网雪崩效应解决方案
-
服务降级
:在高并发的情况, 防止用户一直等待,直接返回一个友好的错误提示给客户端。 -
服务熔断
:在高并发的情况,一旦达到服务最大的承受极限,直接拒绝访问,使用服务降级。 -
服务隔离
:使用服务隔离方式解决服务雪崩效应。 -
服务限流
:在高并发的情况,一旦服务承受不了使用服务限流机制(计时器(滑动窗口计数)、漏桶算法、令牌桶(Restlimite))
2 高并发限流解决方案
高并发限流解决方案限流算法(令牌桶
、漏桶
、计数器
)、应用层解决限流
(Nginx)
3 限流算法
常见的限流算法有:令牌桶、漏桶。计数器也可以进行粗暴限流实现。
3.1 计数器算法
3.1.1 计数器
它是限流算法中最简单最容易的一种算法,比如我们要求某一个接口,1分钟内的请求不能超过10次,我们可以在开始时设置一个计数器,每次请求,该计数器+1;如果该计数器的值大于10并且与第一次请求的时间间隔在1分钟内,那么说明请求过多,如果该请求与第一次请求的时间间隔大于1分钟,并且该计数器的值还在限流范围内,那么重置该计数器。
如果大量的流量,在临界聚集的时候,比如 59s 访问 10 次请求,61s 的时候访问 10 次请求,那么在 2s 内就发生了 20 次请求,而不满足计数器的 10 个(传统方式限制不了),所以我们可以采用滑动窗口方式解决计数器临界问题
/**
* 功能说明: 纯手写计数器方式<br>
*/
public class LimitService {
private int limtCount = 60;// 限制最大访问的容量
AtomicInteger atomicInteger = new AtomicInteger(0); // 每秒钟 实际请求的数量
private long start = System.currentTimeMillis();// 获取当前系统时间
private int interval = 60;// 间隔时间60秒
public boolean acquire() {
long newTime = System.currentTimeMillis();
if (newTime > (start + interval)) {
// 判断是否是一个周期
start = newTime;
atomicInteger.set(0); // 清理为0
return true;
}
atomicInteger.incrementAndGet();// i++;
return atomicInteger.get() <= limtCount;
}
static LimitService limitService = new LimitService();
public static void main(String[] args) {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
for (int i = 1; i < 100; i++) {
final int tempI = i;
newCachedThreadPool.execute(new Runnable() {
public void run() {
if (limitService.acquire()) {
System.out.println("你没有被限流,可以正常访问逻辑 i:" + tempI);
} else {
System.out.println("你已经被限流呢 i:" + tempI);
}
}
});
}
}
}
3.1.2 滑动窗口计数
滑动窗口计数有很多使用场景,比如说限流防止系统雪崩。相比计数实现,滑动窗口实现会更加平滑,能自动消除毛刺。
滑动窗口原理是在每次有访问进来时,先判断前 N 个单位时间内的总访问量是否超过了设置的阈值,并对当前时间片上的请求数 +1。
3.2 令牌桶算法
令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:
- 假设限制
2r/s
,则按照500毫秒
的固定速率往桶中添加令牌; - 桶中最多存放
b
个令牌,当桶满时,新添加的令牌被丢弃或拒绝; - 当一个
n
个字节大小的数据包到达,将从桶中删除n
个令牌,接着数据包被发送到网络上; - 如果桶中的令牌不足
n
个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。
令牌桶算法的优点
能够接受突然的高并发请求
3.2.1 使用 RateLimiter
实现令牌桶限流
RateLimiter
是 guava
提供的基于令牌桶算法的实现类,可以非常简单的完成限流特技,并且根据系统的实际情况来调整生成 token
的速率。
通常可应用于抢购限流防止冲垮系统;限制某接口、服务单位时间内的访问量,譬如一些第三方服务会对用户访问量进行限制;限制网速,单位时间内只允许上传下载多少字节等。
下面来看一些简单的实践,需要先引入 guava
的 maven
依赖。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>25.1-jre</version>
</dependency>
</dependencies>
使用 RateLimiter
实现令牌桶算法:
package com.snow.limit.controller;
import com.google.common.util.concurrent.RateLimiter;
import com.snow.limit.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* 功能说明:使用 RateLimiter 实现令牌桶算法
*
*/
@RestController
public class IndexController {
@Autowired
private OrderService orderService;
// 解释:1.0 表示 每秒生成1个令牌存放在桶中
RateLimiter rateLimiter = RateLimiter.create(1.0);
// 下单请求
@RequestMapping("/order")
public String order() {
// 1.限流判断 --- 正常应该放在网关中
// 如果在500秒内 没有获取到令牌的话,则会一直等待
System.out.println("生成令牌等待时间:" + rateLimiter.acquire());
boolean acquire = rateLimiter.tryAcquire(500, TimeUnit.MILLISECONDS);
if (!acquire) {
System.out.println("当前访问人数过多,请稍后再试!");
return "当前访问人数过多,请稍后再试!";
}
// 2.如果没有达到限流的要求,直接调用订单接口
boolean isOrderAdd = orderService.addOrder();
if (isOrderAdd) {
return "恭喜您,抢购成功!";
}
return "抢购失败!";
}
}
测试:发现高并发访问,控制台打印:
生成令牌等待时间:0.0
db....正在操作订单表数据库...
生成令牌等待时间:0.902096
当前访问人数过多,请稍后再试!
生成令牌等待时间:1.80186
当前访问人数过多,请稍后再试!
生成令牌等待时间:2.701986
当前访问人数过多,请稍后再试!
生成令牌等待时间:3.601859
当前访问人数过多,请稍后再试!
生成令牌等待时间:4.501148
当前访问人数过多,请稍后再试!
生成令牌等待时间:5.40092
当前访问人数过多,请稍后再试!
生成令牌等待时间:6.300638
当前访问人数过多,请稍后再试!
生成令牌等待时间:7.2005
当前访问人数过多,请稍后再试!
生成令牌等待时间:8.10059
当前访问人数过多,请稍后再试!
ratelimiter
实现服务降级
3.2.2 为什么要实现服务限流
例如:秒杀抢购、服务安全、雪崩效应等。
过滤流量的突然增大。
限流的最终目的:保护服务。
限流算法:
计数器算法
(传统计数器缺点:临界问题 —> 滑动计数器)令牌桶算法
漏桶算法
应用层限流
(Nginx,属于运维配置)
3.3 漏桶算法
漏桶作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:
- 一个固定容量的漏桶,按照常量
固定速率流出
水滴; - 如果桶是空的,则不需流出水滴;
- 可以以任意速率流入水滴到漏桶;
- 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。
令牌桶和漏桶对比:
- 令牌桶是按照
固定速率往桶中添加令牌
,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求; - 漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;
- 令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;
- 漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;
- 令牌桶
允许一定程度的突发
,而漏桶主要目的是平滑流入速率
;
两个算法实现可以一样,但是方向是相反的,对于相同的参数得到的限流效果是一样的。
另外有时候我们还使用计数器来进行限流,主要用来限制总并发数,比如数据库连接池、线程池、秒杀的并发数;只要全局总请求数或者一定时间段的总请求数设定的阀值则进行限流,是简单粗暴的总数量限流,而不是平均速率限流。
一个固定的漏桶,以常量固定的速率流出水滴。
- 如果桶中没有水滴的话,则不会流出水滴
- 如果流入的水滴超过桶中的流量,则流入的水滴可能会发生溢出,溢出的水滴请求是无法访问的,直接调用服务降级方法,桶中的容量是不会发生变化。
3.4 漏桶算法与令牌桶算法区别
主要区别在于“漏桶算法”能够强行限制数据的传输速率,而“令牌桶算法”在能够限制数据的平均传输速率外,还允许某种程度的突发传输。
在“令牌桶算法”中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,因此它 适合于具有突发特性的流量
。
- 令牌桶:
允许一定程度的突发
- 漏桶:
平滑流入速率
4 手写 ratelimiter
限流注解框架
在需要限流的方法上添加注解
网关:一般是拦截所有的接口
我们只是需要对 秒杀抢购
、大流量访问接口
才会实现限流。
ratelimiter
限流注解框架实现思路:
- 自定义一个注解
- 整合
SpringAOP
- 使用环绕通知,执行服务降级
4.1 添加依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>25.1-jre</version>
</dependency>
<!-- springboot 整合AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
4.2 自定义注解
package com.snow.limit.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 功能说明: 自定义服务限流注解框架 原理:参考:RateLimiter <br>
*
*/
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExtRateLimiter {
/**
* 以每秒为单位固定的速率值往令牌桶中添加令牌
*/
double permitsPerSecond();
/**
* 在规定的毫秒数中,如果没有获取到令牌的话,则直接走服务降级处理
*/
long timeout();
}
4.3 封装 RateLimiter
自定义注解封装 RateLimiter
实例:
// 以每秒添加1个令牌到令牌桶中
@ExtRateLimiter(permitsPerSecond = 1.0, timeout = 500)
@RequestMapping("/findIndex")
public String findIndex() {
System.out.println("findIndex" + System.currentTimeMillis());
return "findIndex" + System.currentTimeMillis();
}
4.4 编写 AOP
package com.snow.limit.aop;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.snow.limit.annotation.ExtRateLimiter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.google.common.util.concurrent.RateLimiter;
/**
* 功能说明:使用AOP环绕通知判断拦截所有springmvc 请求,判断请求方法上是否存在ExtRateLimiter <br>
* 1.判断请求方法上是否有@ExtRateLimiter<br>
* 2.如果方法上存在@ExtRateLimiter注解话<br>
* 3.使用反射技术获取@ExtRateLimiter注解方法上的参数<br>
* 4.调用原生RateLimiter代码创建令牌桶<br>
* 5.如果获取令牌超时的,直接调用服务降级方法(需要自己定义)<br>
* 6.如果能够获取令牌的话,直接进入实际请求方法。<br>
* AOP创建方式有两种 注解版本和XML方式<br>
*
*/
@Aspect
@Component
public class RateLimiterAop {
// 定义一个 HashMap,将相同的请求放在同一个桶中
private Map<String, RateLimiter> rateHashMap = new ConcurrentHashMap<>();
/**
* 定义切入点 拦截 com.snow.limit.controller 包下面的类
*/
@Pointcut("execution(public * com.snow.limit.controller.*.*(..))")
public void rlAop() {
}
/**
* 使用AOP环绕通知判断拦截所有springmvc 请求,判断请求方法上是否存在ExtRateLimiter注解
*
* @param proceedingJoinPoint
* @return
* @throws Throwable
*/
@Around("rlAop()")
public Object doBefore(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
// 1.如果请求方法上存在@ExtRateLimiter注解的话
Method sinatureMethod = getSinatureMethod(proceedingJoinPoint);
if (sinatureMethod == null) {
// 直接报错
return null;
}
// 2.使用java的反射机制获取拦截方法上自定义注解的参数
ExtRateLimiter extRateLimiter = sinatureMethod.getDeclaredAnnotation(ExtRateLimiter.class);
if (extRateLimiter == null) {
// 直接进入实际请求方法中
return proceedingJoinPoint.proceed();
}
double permitsPerSecond = extRateLimiter.permitsPerSecond(); // 获取 permitsPerSecond 参数
long timeout = extRateLimiter.timeout(); // 获取 timeout 参数
// 3.调用原生的RateLimiter创建令牌,保证每个请求对应都是单例的RateLimiter
// /index---RateLimiter /order --RateLimiter 使用hashMap key为 请求的url地址##
// 相同的请求在同一个桶
String requestURI = getRequestURI();
RateLimiter rateLimiter = null;
if (rateHashMap.containsKey(requestURI)) {
// 如果在hashMap URL 能检测到RateLimiter
rateLimiter = rateHashMap.get(requestURI);
} else {
// 如果在hashMap URL 没有检测到RateLimiter 添加新的RateLimiter
rateLimiter = RateLimiter.create(permitsPerSecond);
rateHashMap.put(requestURI, rateLimiter);
}
// 4.获取令牌桶中的令牌,如果没有有效期获取到令牌的话,则直接调用本地服务降级方法,不会进入到实际请求方法中。
boolean tryAcquire = rateLimiter.tryAcquire(timeout, TimeUnit.MILLISECONDS);
if (!tryAcquire) {
// 服务降级
fallback();
return null;
}
// 5.获取令牌桶中的令牌,如果能在有效期获取令牌到令的话,则直接进入到实际请求方法中。
// 直接进入实际请求方法中
return proceedingJoinPoint.proceed();
}
private void fallback() throws IOException {
System.out.println("进入“服务降级”,请稍后再试!");
// 在AOP编程中获取响应
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletResponse response = attributes.getResponse();
response.setHeader("Content-type", "text/html;charset=UTF-8");
PrintWriter writer = response.getWriter();
try {
writer.println("进入“服务降级”,请稍后再试!");
} catch (Exception e) {
} finally {
writer.close();
}
}
// 获取请求的 URI
private String getRequestURI() {
return getRequest().getRequestURI();
}
// 获取请求
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes.getRequest();
}
// 获取到AOP拦截的方法
private Method getSinatureMethod(ProceedingJoinPoint proceedingJoinPoint) {
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
// 获取到AOP拦截的方法
Method method = signature.getMethod();
return method;
}
}
4.5 运行效果
启动服务,浏览器访问:http://127.0.0.1:8080/findIndex,快速刷新请求可以看到:
5 应用级限流
5.1 限流总并发/连接/请求数
对于一个应用系统来说一定会有极限并发/请求数,即总有一个 TPS/QPS
阀值,如果超了阀值则系统就会不响应用户请求或响应的非常慢,因此我们最好进行过载保护,防止大量请求涌入击垮系统。
如果你使用过 Tomcat
,其 Connector
其中一种配置有如下几个参数:
acceptCount
:如果 Tomcat 的线程都忙于响应,新来的连接会进入队列排队,如果超出排队大小,则拒绝连接;maxConnections
:瞬时最大连接数,超出的会排队等待;maxThreads
:Tomcat能启动用来处理请求的最大线程数,如果请求处理量一直远远大于最大线程数则可能会僵死。
详细的配置请参考官方文档。另外如 MySQL(如max_connections)、Redis(如tcp-backlog)都会有类似的限制连接数的配置。
5.2 限流总资源数
如果有的资源是稀缺资源(如数据库连接、线程),而且可能有多个系统都会去使用它,那么需要限制应用;可以使用池化技术来限制总资源数:连接池、线程池。比如分配给每个应用的数据库连接是100,那么本应用最多可以使用100个资源,超出了可以等待或者抛异常。
5.3 限流某个接口的总并发/请求数
如果接口可能会有突发访问情况,但又担心访问量太大造成崩溃,如抢购业务;这个时候就需要限制这个接口的总并发/请求数总请求数了;因为粒度比较细,可以为每个接口都设置相应的阀值。可以使用Java中的 AtomicLong
进行限流:
适合对业务无损的服务或者需要过载保护的服务进行限流,如抢购业务,超出了大小要么让用户排队,要么告诉用户没货了,对用户来说是可以接受的。而一些开放平台也会限制用户调用某个接口的试用请求量,也可以用这种计数器方式实现。这种方式也是简单粗暴的限流,没有平滑处理,需要根据实际情况选择使用;
5.4 限流某个接口的时间窗请求数
即一个时间窗口内的请求数,如想限制某个接口/服务每秒/每分钟/每天的请求数/调用量。如一些基础服务会被很多其他系统调用,比如商品详情页服务会调用基础商品服务调用,但是怕因为更新量比较大将基础服务打挂,这时我们要对每秒/每分钟的调用量进行限速;一种实现方式如下所示:
5.5 平滑限流某个接口的请求数
之前的限流方式都不能很好地应对突发请求,即瞬间请求可能都被允许从而导致一些问题;因此在一些场景中需要对突发请求进行整形,整形为平均速率请求处理(比如5r/s,则每隔200毫秒处理一个请求,平滑了速率)。这个时候有两种算法满足我们的场景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法实现,可直接拿来使用。
Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。
5.6 接入层限流
接入层通常指请求流量的入口,该层的主要目的有:负载均衡、非法请求过滤、请求聚合、缓存、降级、限流、A/B测试、服务质量监控等等,可以参考笔者写的《使用Nginx+Lua(OpenResty)开发高性能Web应用》。
对于Nginx接入层限流可以使用Nginx自带了两个模块:连接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module。还可以使用OpenResty提供的Lua限流模块lua-resty-limit-traffic进行更复杂的限流场景。
limit_conn用来对某个KEY对应的总的网络连接数进行限流,可以按照如IP、域名维度进行限流。limit_req用来对某个KEY对应的请求的平均速率进行限流,并有两种用法:平滑模式(delay)和允许突发模式(nodelay)。
5.7 ngx_http_limit_conn_module
limit_conn
是对某个 KEY 对应的总的网络连接数进行限流。可以按照IP来限制IP维度的总连接数,或者按照服务域名来限制某个域名的总连接数。但是记住不是每一个请求连接都会被计数器统计,只有那些被Nginx处理的且已经读取了整个请求头的请求连接才会被计数器统计。