互联网高并发解决方案(2)--高并发服务限流特技
RPC和本地JAVA调用的区别RPC远程调用:一般是可以跨平台使用的,采用Socket技术,只要语言支持socket技术就可以进行互相通信。其实就是socket+反射实现的。本地调用:只能支持Java语言与Java语言开发,使用虚拟机进行通讯。高并发服务的限流特技(解决高并发,请求多,用户等待时间长)雪崩效应:服务的请求数超过了服务器的最大线程数(线程池缓存队列满了)。默认的情况下,只有一个线程池
RPC和本地JAVA调用的区别
- RPC远程调用:一般是可以跨平台使用的,采用Socket技术,只要语言支持socket技术就可以进行互相通信。其实就是socket+反射实现的。
- 本地调用:只能支持Java语言与Java语言开发,使用虚拟机进行通讯。
高并发服务的限流特技(解决高并发,请求多,用户等待时间长)
- 雪崩效应:服务的请求数超过了服务器的最大线程数(线程池缓存队列满了)。默认的情况下,只有一个线程池维护所有的服务接口,如果大量的请求访问同一个接口,达到了服务器的默认极限,可能会导致其他的服务无法访问。
- 在开发高并发的系统的时候可以使用:缓存,降级,限流进行系统的保护。
(1)缓存:目的是提升系统的访问速度和增大系统能处理的容量。
(2)降级:当服务出现问题(到达访问的极限,超出服务器的最大请求数)的时候或者影响到核心的流程性能的时候就需要暂时的屏蔽掉,待高峰或者问题解决之后再打开。(发生服务熔断后就使用服务的降级,服务熔断是直接不执行,服务降级是发生错误返回一个用户的响应),提供fallBack方法进行返回用户一个友好的提示。针对的是高并发请求过多的时候,并不是服务报错的情况,服务报错是bug问题
(3)熔断:高并发的情况下,当服务不可用(就是到达了访问的极限),为了防止用户一直等待,直接拒绝访问(服务熔断可以理解为保险丝,电流过大保险丝熔断,这里是服务的请求数超出了阈值,拒绝访问。)
(3)限流:有些场景并不能使用缓存和降级来解决,比如秒杀和抢购,因此我们需要另一种手段去限制这些场景的并发请求量,这就是限流。 - 限流一般是在网关层面进行限制的(但是维护性不好)
- 主要这些是防止高并发的情况下,用户一直等待接口的返回,提供的做法。
- 服务降级和服务熔断针对于用户体验和高并发限流的,并不针对服务出错的问题(这是开发的bug),当并发量过大的时候,用户访问一直等待
互联网雪崩效应的解决方案
(1)服务降级:在高并发的情况下,防止用户一直等待,直接返回一个友好的错误提示给客户端。
(2)服务熔断:在高并发的情况下,一旦达到了服务最大的承受极限(超过了线程池的最大缓存队列),直接拒绝访问,或者使用服务降级给用户返回一个错误提示。
(3)服务隔离:使用服务隔离方式解决服务雪崩效应,有计数器(不常用)和线程池(让每个接口都有自己独立的线程池,而不是一个线程池控制所有的接口)的方式
(4)服务限流:在高并发的情况下,一旦服务承受不了,就可以使用服务限流的机制(计时器,漏桶算法,令牌桶)
高并发的限流解决方案
- 注意 不是所有的接口都去做限流,只有真正高并发,访问量大的接口才去做限流
- 高并发限流解决方案是使用限流算法,令牌桶,漏桶,计数器,应用层解决限流(Nginx)
限流算法的解决
- 常见的限流算法有:令牌桶,漏桶。计数器也可以进行粗暴限流实现。
(1)计数器
- 他是限流算法中最简单最容易的一种算法,比如我们要求某一个接口,1分钟内的请求不能超过10次,我们可以在开始的时候设置一个计数器,每次请求过来,计数器就+1;如果该计数器的值大于10并且与第一次的请求时间间隔在一分钟以内,那么说明请求过多;如果该请求与第一次的请求间隔大于1分钟,并且该计数器的值还在限流范围内,那么重置其计数器。(就是超过1分钟,计数器清0)
- 利用原子类来进行统计服务的实现次数
- 注意:这里有临界的问题,如果一分钟以内在1-58秒没有请求到达,但是在59s的时候,直接访问了10次,然后过了60s即1分钟,计数器清0,又可以继续访问,61s的时候又有10个请求到达,此时下一分钟的请求量达到阈值,不能访问,知道120s之后,才可以继续访问,此时只是在2s内访问了20个请求,并不等于每分钟支持10个请求。还是直接2秒访问了20次,并不是我们定义的要求一分钟访问10次。
(2)滑动窗口计数器
- 滑动窗口计数器有很多的使用场景,比如说限流防止系统雪崩。相比较计数器实现,滑动窗口实现会更加的平滑,能够自动的消除毛刺。
- 滑动窗口原理是在每次有访问进来的时候,先判断前N个单位时间内的总访问量是否超过了设置的阈值,并对当前时间片上的请求数+1。
- 每个格子都有自己的独立的原子计数器
- 如上图所示,将一分钟之内分为6个格子,每个格子的访问时间是10s,每个格子中都有自己独立的计数器,60s之内只能有10个请求
- 滑动窗口之所以能解决高并发的问题:滑动窗口分为10个格子,每隔10s滑动到下一个格子中,滑动会圈住6个格子(如图所示),6个格子表示1分钟之内访问的请求总数。这样就不会发生类似于计数器的临界现象,2s钟(59s和60s直接访问了20次)
- 多少秒内创建多少个格子是自己随机创建的,我们举得例子是1分钟之内创建了6个格子。
- 我们在多少秒内创建多少个格子,就会滑动多个个格子进行限制。
- 滑动的时候从第一个格子开始,逐一滑动。就是说我们在第一个格子(0-9s)的时候执行完后(第一次执行的范围是0-59s),直接向右滑动一个格子,范围变为了10s-69s。就是每次都逐一滑动一个格子
(3) 令牌桶算法(限制平均流入速率)
- 令牌桶是存放的token,就是所说的令牌
- 令牌桶分为两个动作
(1)以固定的速率,往桶中加入令牌-token(固定的速率其实是指的平均速度)
(2)客户端想要访问请求,先从桶里面获取对应的token(令牌) - 令牌获取成功后,从桶中删除。
- 在访问的时候开始放token,而不是服务启动的时候
- 如果客户端发送请求的速度大于往桶中放入令牌(token)的速度,那么有的请求会直接拿不到token,此时就会直接拒绝访问服务(做服务降级或者服务熔断)
- 如果客户端一直不访问,但是放入桶中的token是独立线程,此时桶中的token就会满。若桶中已经满了,则不会再接受新的令牌(token)
- 令牌桶算法是一个存放固定容量令牌的桶,按照固定的速率往桶里面添加令牌,令牌桶算法的描述如下:
- 假设限制2r/s,就是每秒钟加入2个令牌,取的是每秒的平均速率
- 相当于开启了独立的线程,以一个固定的速率往桶中存入token
- 桶中最多存放b个令牌,当桶满了的时候,新添加的令牌被丢弃或者拒绝
- 当一个n个字节的数据包到达的时候,将从桶中删除n个令牌,接着数据包被发送到网络上;如果桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。
- 说白了就是请求数大于桶中的令牌数,拒绝访问,做服务的降级或者熔断;如果请求数小于桶中的令牌数则允许访问,拿到令牌访问服务器;若桶满了则不会再接受新的令牌,不会将其放入桶中
基于ratelimter类实现令牌桶方式限流
- 先做限流处理,再做业务逻辑处理
- 通常情况下限流是放在网关中的,这样针对所有的接口对其限流,维护性不好,一般的限流是针对大流量接口的
- Ratelimter.create方法中需要传入参数,传入的参数是速率值,如果传入1.0表示的是每秒钟往桶中存入1个令牌
- Ratelimter是一个独立的线程,负责向桶中加入token
- ratelimter.acquire()表示的是客户端从桶中获取对应的令牌,返回的结果是double,表示的是从桶中拿到令牌的等待时间。(开始准备拿token,一直到拿到了token的等待时间);如果获取不到令牌的话,就会一直等待(通常我们不能这么做,应该是超过了一定时间我们对其做服务的降级,因此需要传递别的参数做限制)
- ratelimter.tryAcquire(时间,时间单位(ms,s)),返回的结果是boolean,true表示拿到了令牌:设置服务的降级处理,配置在规定时间内如果没有获取到令牌,直接走降级
- java代码:
(1)代码架构 - pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.xiyou</groupId>
<artifactId>token</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
</parent>
<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>
</project>
- 启动类:
package com.xiyou;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TokenApplication {
public static void main(String[] args) {
SpringApplication.run(TokenApplication.class, args);
}
}
- OrderService
package com.xiyou.service;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
/**
* 模拟添加订单操作
* @return
*/
public boolean addOrder(){
System.out.println("db......正在操作订单的数据库");
return true;
}
}
- IndexController
package com.xiyou.controller;
import com.google.common.util.concurrent.RateLimiter;
import com.xiyou.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
* 使用令牌桶的方式实现限流
*/
@RestController
public class IndexController {
@Autowired
private OrderService orderService;
/**
* 创建独立线程
* 默认传入一个参数,表示每秒往桶里放入多个令牌
*/
private RateLimiter rateLimiter = RateLimiter.create(1);
@GetMapping("/addOrder")
public String addOrder() {
// 返回的结果是double类型,这个结果表示从桶中拿到令牌的时间
// double acquire = rateLimiter.acquire();
// System.out.println("从桶中获取的令牌的时间是: " + acquire);
// 该方法表示如果500ms内没有获取到令牌的话,则直接走服务的降级
// 返回的结果是一个boolean值,若返回true 则代表获取成功
boolean tryAcquire = rateLimiter.tryAcquire(1, TimeUnit.MILLISECONDS);
// 如果为false表示的是没有拿到 相当于做了服务降级
if (!tryAcquire) {
System.out.println("别抢了,再抢也是等待");
return "没有拿到令牌";
}
// 业务逻辑处理
boolean result = orderService.addOrder();
if (result) {
System.out.println("抢购成功");
return "成功在500ms内拿去到令牌";
}
return "没有拿到令牌";
}
}
- 传统的方式整合RateLimiter有一个很大的缺点就是代码的重复量特别大,而且其本身是不支持注解的
- 一般来说限流的代码是放到网关中,但是这就是针对了所有的接口都做了限流(如果接口的访问高并发不多的时候,不建议这么做,这么做的维护性不是很强 ,有时候还会影响整体的性能的),限流的代码其实最好还是放到高并发的接口中的。
- 因此其实我们最好实现注解版本实现特定接口的限流,传统的rateLimiter是不支持注解的方式的。
rateLimiter注解版本技术点分析
(1)自己定义一个注解
(2)SpringBoot中整合SpringAOP,其AOP的作用就是定义一个切入点,拦截切入点的方法,拦截方法之前或者之后进行处理,其作用是简化代码,减少重复代码
(3)使用环绕通知
- 判断方法上是否有@ExtRateLimiter注解(自定义的注解)
- 如果方法上有该注解的话
- 使用反射技术获取注解方法上的参数
- 调用原生的RateLimiter代码创建令牌桶
- 如果获取令牌超时,直接调用服务降级的方法(服务降级的方法也需要自己定义)
- 如果能够获取令牌的话,直接进入实际的请求方法。
-
注意:我们利用AOP实现令牌桶的时候,初始化令牌桶的时候即RateLimter.create(速率);调用该方法的时候,一定不能将其放在方法中,因为这样会造成一个请求多次访问会重复创建自己的令牌桶。需要保证其是单例的。一个请求只创建一个令牌桶。
-
java代码实现:
(1)程序架构
(2)pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.xiyou</groupId>
<artifactId>token-annotation</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
</parent>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
</project>
(3)启动类
package com.xiyou;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TokenApplication {
public static void main(String[] args) {
SpringApplication.run(TokenApplication.class, args);
}
}
(4)controller
package com.xiyou.controller;
import com.xiyou.annotation.ExtRateLimiter;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class IndexController {
/**
* 测试自定义注解
* @return
* @throws InterruptedException
*/
@RequestMapping("/findOrder")
@ExtRateLimiter(value = 1, timeOut = 1)
public String findOrder() throws InterruptedException {
System.out.println("findOrder");
return "SUCCESS";
}
}
(5)自定义注解
package com.xiyou.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义注解类
* 只能用在方法上
* @author
*/
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtRateLimiter {
/**
* 表示令牌桶一秒放入多少个令牌
* @return
*/
double value();
/**
* 表示令牌桶的超时时间
* @return
*/
long timeOut();
}
(6)重要,实现自定义注解(要加上@Component,否则无法加载)
package com.xiyou.aop;
import com.google.common.util.concurrent.RateLimiter;
import com.xiyou.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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* 自定义令牌桶注解的切面
* 我们借由切面实现令牌桶的注解
* @author
*/
@Aspect
@Component
public class RateLimiterAop {
/**
* 该Map线程安全
* 其key是请求的url value是各自请求的令牌桶对象
*/
private static ConcurrentHashMap<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<String, RateLimiter>();
/**
* 自定义切点,其切点是controller包下的所有方法,都会进入拦截
*/
@Pointcut("execution(public * com.xiyou.controller.*.*(..))")
public void rlAop() {
}
/**
* 环绕通知
* @param proceedingJoinPoint
* @return
* @throws Throwable
*/
@Around("rlAop()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
// 先得到当前调用的方法的对象
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
// 看该方法上是否有@ExtRateLimter注解
ExtRateLimiter extRateLimiter = signature.getMethod().getDeclaredAnnotation(ExtRateLimiter.class);
if (extRateLimiter == null) {
// 表示没有这个注解
Object proceed = proceedingJoinPoint.proceed();
return proceed;
}
// 有注解,要开始判断能否拿到令牌
// 取令牌配置的value 即执行的速率(每秒放多少个令牌进去)
double value = extRateLimiter.value();
// 取令牌的等待时间
long timeOut = extRateLimiter.timeOut();
// 创建令牌桶
RateLimiter rateLimiter = getRateLimiter(value, timeOut);
// 判断其是否超时
boolean tryAcquire = rateLimiter.tryAcquire(timeOut, TimeUnit.MILLISECONDS);
if (!tryAcquire) {
// 如果超时了 返回false
// 调用服务降级的方法
fallBack();
return null;
}
// 获取到了令牌 开始执行
Object proceed = proceedingJoinPoint.proceed();
return proceed;
}
/**
* 服务降级
*/
private void fallBack() throws Exception{
System.out.println("令牌桶已经满了。");
// 得到当前的请求参数,用来去response,写数据
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();
}
}
/**
* 往map里面放置,去重
* key是url value是RateLimter对象(令牌桶对象)
*/
private RateLimiter getRateLimiter(double value, long timeout){
// 获取当前方法的url
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String requestURI = request.getRequestURI();
// 令牌桶对象
RateLimiter rateLimiter = null;
if (!rateLimiterMap.containsKey(requestURI)) {
// map中没有值
// 创建独立的线程 创建令牌桶(速度是每秒放value个令牌)
rateLimiter = RateLimiter.create(value);
rateLimiterMap.put(requestURI, rateLimiter);
} else {
// 取值
rateLimiter = rateLimiterMap.get(requestURI);
}
return rateLimiter;
}
}
(4)漏桶算法(限制常量流出速率)
- 以固定的速率从桶中流出,以任意的速率往桶中流入(客户端发送的请求),注意这里的流入流出并不是token,而是指的是不重复的标识
- 漏桶算法作为计量工具时候,可以用于流量整形和流量控制,漏桶算法的描述如下:
- 一个固定容量的漏桶,按照常量固定速率流出水滴
- 如果桶是空的,则不需要流出水滴
- 可以以任意的速率流入水滴到漏桶;
- 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶的容量是不变的。
- 流入速率 > 流出速率,可能桶中的水滴会发生溢出,那么溢出的水滴请求都是拒绝访问,或者直接调用服务降级的方法。(水滴可以理解为一个不重复的唯一标识)
- 令牌桶和漏桶的对比:(重要)
- 令牌桶是按照固定的速率往桶中添加令牌,请求是否被处理需要看桶中的令牌是否足够,当令牌数减少为0的时候则拒绝新的请求。
- 漏桶则是按照常量固定速率流出请求,流入请求的速率任意,当流入的请求数累计到漏桶容量的时候,则新流入的请求就会被拒绝。
- 令牌桶限制的是平均的流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿到3个令牌,4个令牌),并且允许一定程度突发流量, 因为我们是用线程放入令牌到令牌桶的,相当于是平均速度;
- 漏桶则是限制的常量流出速率(就是流出的速率是一个固定的常量值,比如都是1的速率流出,而不是一会1 ,一会2的),从而平滑突发流入的速率;
- 令牌桶允许一定程度上的突发(因为我们事先是用独立线程将令牌放入令牌桶),而漏桶主要的目的是平滑流入速率
- 令牌桶和漏桶两个算法的实现其实是可以一样的,但是方向是反的,对于相同的参数得到的限流效果是一样的。
- 另外我们有时候也可以使用计数器去实现限流,主要用于限制总并发数,比如数据库连接池,线程池,秒杀的并发数;只要全局总请求数或者一定时间段的总请求数设定的阈值则进行限流,是简单粗暴的总量限流,而不是平均速率的限流(不推荐使用计数器限流)
应用级限流
- 对于一个应用系统来说一定会有极限并发/请求数,就是说总是有一个TPS/QPS阈值,如果超过了阈值则系统就会不响应或者响应的特别慢用户的请求,因此我们最好进行过载保护,防止大量请求涌入击垮系统。
- 如果使用过Tomcat,其Connector中有几个配置参数可以实现限制:
(1)acceptCount:如果Tomcat的线程都忙于响应,新来的连接就会进入队列排队,如果超出了排队的大小,则会拒绝连接。
(2)maxConnections:瞬时的最大连接数,超出的会排队等待
(3)maxThreads:Tomcat能够启用来处理请求的最大的线程数,如果请求处理量一直远远大于最大线程数则可能僵死。
- 如果有的资源是稀缺资源(数据库连接,线程),而且可能有多个系统都会去使用它,那么就需要限制应用;可以使用池化技术限制总资源数:连接池,线程池。比如分配给每个应用的数据库连接是100 ,那么本应用最多可以使用100个资源,超出了可以等待或者抛出异常。
- 如果接口可能会有突发的访问情况,但是又担心访问量太大造成崩溃,如抢购业务;这个时候就会需要限制这个接口的总并发/请求数总请求数;因为粒度比较细,可以为每个接口都设置相对应的阈值,可以使用Java中的原子类进行限流(AtomicLong)
- 适合对业务无损的服务或者需要过载保护的服务进行限流,如抢购业务,超出了大小要么让用户排队,要么告诉告诉用户已经没货了,对用户来说是可以接受的。而一些开放平台也会限制用户调用某个接口的试用请求量,也可以使用计数器限流的方式去实现。这种方式没有平滑的处理,是粗暴的限流,不推荐使用。
- 即第一个时间窗口内的请求数,如想限制某个接口/服务每秒/每分钟/每天的请求数/调用量。如一些基础服务会被很多其他的系统调用,比如商品详情页服务会调用基础商品服务调用,但是怕因为更新量较大将基础服务打挂,这时候我们将要对每秒/每分钟的调用量进行限速;
- 平滑限流某个接口的请求数:
之前的限流方式都不能很好的应对突发请求,即瞬间请求可能都被允许从而导致一些问题;因此在一些场景中需要对突发请求进行整形,整形为平均速率请求处理(如5r/s,则每隔200ms处理一个请求,平滑了速率)。这个时候有两种算法满足了我们的场景:
(1)令牌桶
(2)漏桶算法
接入层限流
- 接入层通常指的是请求流量的入口,该层的主要目的有:负载均衡,非法请求过滤,请求聚合,缓存,降级,限流,A/B测试,服务质量监控等等。
更多推荐
所有评论(0)