SpringCloud系列——限流与熔断理论部分(五)

166 阅读10分钟

限流的目的(作用)

限流是保证最大限度为用户提供服务的手段之一

限流通过限制并发访问量或者一定窗口期内允许的请求量来保护系统,一旦达到限流量将会走相应的拒绝策略,比如:跳转的相对温和的拒绝页面拒绝请求、进入排队系统、降级等

总而言之,限流就是损失部分用户的可用性,为绝大多数用户提供稳定的服务。

限流的核心:保护全部核心业务,损失部分一般业务。

限流的实现方法

现在几乎无处不在:

  • 在Nginx层添加限流模块,限制平均访问速度
  • 通过设置数据库连接池的大小总并发量
  • 通过Guava提供的Ratelimiter限制接口的访问速度
  • TCP通讯协议的限流整形

以此诞生和很多非常优秀的限流方法

计数器固定窗口算法

在执行时间周期内,每发生一次访问就累计一次,直到累计值达到限流上限,触发限流拒绝策略,当进入下一次时间周期时,重置限流计数累计值。

缺点

但这种限流算法存在问题。。。

假设我们的时钟周期是 2s ,限流是 100

image.png

如上图所示,在短短不到 1s 的时间内连续发生了 200 次突然的访问

超出系统能够提供的并发请求量

代码

为了更好的观察到限流的情况,我们对限流需求进行了修改

2s 只能通过 2次 请求,共有10个请求,每个请求间隔 250ms

package com.zhazha.simplewindow;

import java.time.LocalTime;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 计算器固定窗口算法
 */
public class RateLimiterSimpleWindow {
	
	/**
	 * 每秒阈值
	 */
	private static final Integer QPS = 2;
	/**
	 * 时间窗口
	 */
	private static final long TIME_WINDOWS = 2000;
	/**
	 * 计数器
	 */
	private static final AtomicInteger REQ_COUNT = new AtomicInteger();
	/**
	 * 窗口计算开始时间
	 */
	private static long START_TIME = System.currentTimeMillis();
	
	public synchronized static boolean tryAcquire() {
		if ((System.currentTimeMillis() - START_TIME) > TIME_WINDOWS) {
			REQ_COUNT.set(0);
			START_TIME = System.currentTimeMillis();
		}
		return REQ_COUNT.incrementAndGet() <= QPS;
	}
	
	public static void main(String[] args) throws Exception {
		for (int i = 0; i < 10; i++) {
			TimeUnit.MILLISECONDS.sleep(250);
			LocalTime now = LocalTime.now();
			if (tryAcquire()) {
				System.out.println(now + " do something");
			} else {
				System.out.println(now + " 被限流了");
			}
		}
	}
	
}

18:48:32.459501800 do something
18:48:32.743208100 do something
18:48:32.999056300 被限流了
18:48:33.251895600 被限流了
18:48:33.504932600 被限流了
18:48:33.756792300 被限流了
18:48:34.009640500 被限流了
18:48:34.262830700 do something
18:48:34.523701700 do something
18:48:34.791088100 被限流了

滑动窗口算法

滑动窗口算法降低了计算器固定窗口算法的两倍_阈值_问题。

大体思路很简单,将 1s 的时间分段,分成更细颗粒度的时间单位,比如分成 4 分,如此原本的 1s 限流 100 次,变成 0.25s 限流 25次

即便出现了_阈值_问题,也仅仅是多了 25 次请求。125次请求也比200次请求要好得多

编码可以参考这张图:

根据这张图以及对滑动窗口算法的理解,就可以写出下面这段代码:

package com.zhazha.simplewindow;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * 滑动窗口算法
 */
public class RateLimiterSlidingWindow {
	
	/**
	 * 窗口大小
	 */
	private Long windowSize = 1000L;
	
	/**
	 * 分区
	 */
	private Integer partition = 4;
	
	/**
	 * 阈值
	 */
	private Integer qps = 2;
	
	/**
	 * 存放子窗口的信息
	 */
	private WindowInfo[] windowsArray = new WindowInfo[partition];
	
	public RateLimiterSlidingWindow(Integer qps) {
		this.qps = qps;
		long currentTimeMillis = System.currentTimeMillis();
		for (int i = 0; i < windowsArray.length; i++) {
			windowsArray[i] = new WindowInfo(currentTimeMillis, new AtomicInteger());
		}
	}
	
	public synchronized boolean tryAcquire() {
		long currentTimeMillis = System.currentTimeMillis();
		long curIndex = currentTimeMillis % windowSize / (windowSize / partition);
		int sum = 0;
		for (int i = 0; i < windowsArray.length; i++) {
			WindowInfo windowInfo = windowsArray[i];
			/**
			 * 对时间的判断,判断是否超时
			 * 这里每个子窗口的时间都是一样的 因为 currentTimeMillis 每次 tryAcquire 设置的都是相同一个值
			 * 所以这里可以直接跟 windowSize 判断,而不是 子窗口的 windowSize / partition 单位时间
			 */
			if ((currentTimeMillis - windowInfo.time) > windowSize) {
				windowInfo.time = currentTimeMillis;
				windowInfo.getCount().set(0);
			}
			/**
			 * 判断子窗口的计数是否超过 qps , 如果没有超过,则自增
			 */
			if (curIndex == i && windowInfo.getCount().get() < qps) {
				windowInfo.getCount().incrementAndGet();
			}
			sum += windowInfo.getCount().get();
		}
		/**
		 * 最终在这里判断所有子窗口累计的次数和是否超过 qps 的次数
		 */
		return sum <= qps;
	}
	
	private class WindowInfo {
		private Long time;
		private AtomicInteger count;
		
		public WindowInfo(Long time, AtomicInteger count) {
			this.time = time;
			this.count = count;
		}
		
		public Long getTime() {
			return time;
		}
		
		public void setTime(Long time) {
			this.time = time;
		}
		
		public AtomicInteger getCount() {
			return count;
		}
	}
	
	public static void main(String[] args) throws Exception {
		int success = 0;
		RateLimiterSlidingWindow limiterSlidingWindow = new RateLimiterSlidingWindow(2);
		for (int i = 0; i < 20; i++) {
			Thread.sleep(300);
			if (limiterSlidingWindow.tryAcquire()) {
				success++;
				System.err.println("访问成功");
			} else {
				System.out.println("访问失败");
			}
		}
		System.out.println("实测成功次数:" + success);
	}
}

这里看代码可以计算出大致的成功次数:
20 次请求, 每次请求需要等待 300ms 所以需要 6000 ms 也就是 6s
而每次的 qps 的次数是 2, 所以大致可以访问 12 次

访问成功
访问成功
访问失败
访问成功
访问成功
访问失败
访问失败
访问成功
访问成功
访问失败
访问失败
访问成功
访问成功
访问失败
访问失败
访问成功
访问成功
访问失败
访问失败
访问成功
实测成功次数:11

进程已结束,退出代码0

sentinel 就是使用的这种算法实现的限流

缺点:
这种方式无法处理突发情况,比如段时间内爆发大量的流量,此时将会有大量请求被拒绝

滑动日志算法

滑动日志的方式是:使用日志记录下用户所有请求的时间,新请求到来时先判断最近指定时间范围内的请求数量是否超过指定阈值,由此来确定是否达到限流,这种方式没有了时间窗口突变的问题,限流比较准确,但是因为要记录下每次请求的时间点,所以占用的内存较多

package com.zhazha.simplewindow;

import java.time.LocalTime;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeMap;

/**
 * 滑动日志方式限流
 * 设置 QPS 为 2.
 */
public class RateLimiterSildingLog {

    /**
     * 阈值
     */
    private Integer qps = 2;
    /**
     * 记录请求的时间戳,和数量
     */
    private TreeMap<Long, Long> treeMap = new TreeMap<>();

    /**
     * 清理请求记录间隔, 60 秒
     */
    private long claerTime = 60 * 1000;

    public RateLimiterSildingLog(Integer qps) {
        this.qps = qps;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 清理过期的数据老数据,最长 60 秒清理一次
        if (!treeMap.isEmpty() && (now - treeMap.firstKey()) > claerTime) {
            Set<Long> keySet = new HashSet<>(treeMap.subMap(0L, now - 1000).keySet());
            for (Long key : keySet) {
                treeMap.remove(key);
            }
        }
        // 计算当前请求次数
        int sum = 0;
        for (Long value : treeMap.subMap(now - 1000, now).values()) {
            sum += value;
        }
        // 超过QPS限制,直接返回 false
        if (sum + 1 > qps) {
            return false;
        }
        // 记录本次请求
        if (treeMap.containsKey(now)) {
            treeMap.compute(now, (k, v) -> v + 1);
        } else {
            treeMap.put(now, 1L);
        }
        return sum <= qps;
    }

    public static void main(String[] args) throws InterruptedException {
        RateLimiterSildingLog rateLimiterSildingLog = new RateLimiterSildingLog(3);
        for (int i = 0; i < 10; i++) {
            Thread.sleep(250);
            LocalTime now = LocalTime.now();
            if (rateLimiterSildingLog.tryAcquire()) {
                System.out.println(now + " 做点什么");
            } else {
                System.out.println(now + " 被限流");
            }
        }
    }
}

令牌桶限流算法

是什么?

令牌桶限流算法:系统会以一个恒定的速度往桶里放入令牌,而请求需要一个令牌,如果桶里没有令牌,则拒绝服务。

使用场景


令牌桶是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法

  • 那什么是网络流量整形和速率限制?

网络流量整形借助缓冲区和令牌桶实现,保证报文能够以一定的速率发出,大体的工作方式可以借助下图实现
image.png
如图所示,将报文的包放在下面缓存区,而上面的令牌桶以一定的效率生成令牌,每次报文想要发送前,需要获得一个令牌,这样,令牌桶就决定了报文的发送效率

假设令牌生成的效率是每秒 10 个,也就是 QPS = 10,此时请求获取令牌时存在这么三种情况:

  • 请求速度大于令牌生成速度:那么令牌就被很快用完,后续再有请求发生,就会被限流
  • 请求速度等于令牌生成速度:此时流量处于稳定状态
  • 请求小于令牌生成速度:说明此时系统请求较少,请求将被稳定处理,而令牌的数量将会被限制在 max 最大值

令牌桶可以处理突发请求:令牌桶是可以累积的,所以短时间内突然新增大量的请求这种情况可以被令牌桶积累的令牌处理

需要注意:令牌桶限流算法容易和漏桶算法混淆。主要的区别在于令牌桶可以积累令牌,对付突发流量,也能保证处理的平均效率,而漏桶算法只能够强制限流

代码可以借助这张图编写:

package com.zhazha.simplewindow;

import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

public class TokenBucketSmoothBursty {
	
	/**
	 * 令牌桶的容量
	 */
	private Integer capacity;
	/**
	 * 生成令牌的效率
	 */
	private long rate;
	/**
	 * 当前令牌的数量
	 */
	private int tokenAmount;
	
	
	public static void handleRequest(Request request) {
		request.setHandleTime(new Date());
		System.out.println(request.getCode() + "号请求被处理,请求发起时间:" + request.getLaunchTime() + ",请求处理时间: " + request.getHandleTime() + ", 处理耗时:" + (request.getHandleTime().getTime() - request.getLaunchTime().getTime()) + "ms");
	}
	
	public TokenBucketSmoothBursty(Integer capacity, long rate) {
		this.capacity = capacity;
		this.rate = rate;
		ScheduledExecutorService pool = Executors.newScheduledThreadPool(1);
		pool.scheduleAtFixedRate(() -> {
			if (tokenAmount < this.capacity) {
				this.tokenAmount++;
				System.out.println("tokenAmount: " + tokenAmount);
			}
		}, 0, rate, TimeUnit.MILLISECONDS);
	}
	
	public static void main(String[] args) throws Exception {
		TokenBucketSmoothBursty tokenBucketSmoothBursty = new TokenBucketSmoothBursty(5, 200);
		for (int i = 0; i < 10; i++) {
			TimeUnit.MILLISECONDS.sleep(100);
			Request request = new Request(i, new Date());
			if (tokenBucketSmoothBursty.tryAcquire(request, TokenBucketSmoothBursty::handleRequest)) {
				System.out.println(i + "号请求被接受");
			} else {
				System.err.println(i + "号请求被拒绝");
			}
		}
	}
	
	private boolean tryAcquire(Request request, Consumer<Request> consumer) {
		if (tokenAmount > 0) {
			tokenAmount--;
			consumer.accept(request);
			return true;
		} else {
			return false;
		}
	}
	
	static class Request {
		private int code;
		private Date launchTime;
		private Date handleTime;
		
		public Request(int code, Date launchTime) {
			this.code = code;
			this.launchTime = launchTime;
		}
		
		public Request() {
		}
		
		public int getCode() {
			return code;
		}
		
		public void setCode(int code) {
			this.code = code;
		}
		
		public Date getLaunchTime() {
			return launchTime;
		}
		
		public void setLaunchTime(Date launchTime) {
			this.launchTime = launchTime;
		}
		
		public Date getHandleTime() {
			return handleTime;
		}
		
		public void setHandleTime(Date handleTime) {
			this.handleTime = handleTime;
		}
	}
	
}

还可以使用 guava 的 RateLimiter

public static void main(String[] args) throws Exception {
    // 设置令牌桶的容量为 2, 意味着 1 秒钟只能访问 2 次
    RateLimiter limiter = RateLimiter.create(2);
    for (int i = 0; i < 10; i++) {
        String time = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
        // 产生了一个令牌
        System.out.println(time + " : " + limiter.tryAcquire());
        TimeUnit.MILLISECONDS.sleep(250);
    }
}

漏桶限流法

是什么?

漏桶限流算法的主要功能是:
控制数据注入网络的速度,以一种均速将桶内的请求一点点的放出来,平滑网络上的突发请求。
当请求超出桶的大小时,请求将被拒绝,走决绝策略。

image.png

漏桶算法和令牌桶算法的区别在于漏桶限流算法能够拦截请求,也能够拦截突发请求,但无法处理突发的多余请求,它只能够按照程序员规定的速度一点点处理请求,而多余桶容量的请求将被抛弃或者走其他拒绝策略

还可以直接使用 guava 提供的漏桶算法

各个限流算法的比较

算法确定参数空间复杂度时间复杂度限制突发流量平滑限流分布式环境下实现难度
固定窗口计数周期T、
周期内最大访问数N
低O(1)
(记录周期内访问次数及周期开始时间)
低O(1)
滑动窗口计数周期T、
周期内最大访问数N
高O(N)
(记录每个小周期中的访问数量)
中O(N)相对实现。滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑
漏桶漏桶流出速度r、漏桶容量N低O(1)
(记录当前漏桶中容量)
高O(N)
令牌桶令牌产生速度r、令牌桶容量N低O(1)
(记录当前令牌桶中令牌数)
高O(N)

服务的熔断和降低

什么是服务的熔断?

我们在各种场景下都会接触到熔断这两个字。高压电路中,如果某处电压过高,熔断器就会熔断,对电路进行保护。股票交易中,如果股票涨跌幅过大,也会采用熔断机制,暂停交易,来控制交易风险。

同样,在微服务架构中,熔断机制也是起着类似的作用。当调用链路中的某个微服务长时间不可用或者有延迟,响应过慢,系统就会熔断对该节点微服务的调用,快速返回错误信息。当监控到该微服务正常工作后,再次恢复该调用链路。

为什么需要服务的熔断?

举个例子:

image.png

服务A 需要 服务 B C D

但是 服务D 出现了过载或者不可用的情况,导致 服务A 的请求阻塞等待 服务D 的响应,连带着 服务A 也出现问题。

如果还有其他服务需要用到 服务A 那么也跟着出问题,也就是所谓的服务雪崩效应

此时如果我们给每一个服务外围包裹一个熔断器,在服务可用时,闭合熔断器,在不可以使用时断开熔断器,立即告知调用方服务处于不可用状态

熔断器都有什么落地实现?

Hystrix熔断器

Hystrix是一个用于处理分布式系统的延迟和容错的开源库,在分布式系统里,许多依赖不可避免的会调用失败,比如超时、异常等,Hystrix能够保证在一个依赖出问题的情况下, 不会导致整体服务失败,避免级联故障,以提高分布式系统的弹性”断路器”本身是一种开关装置,当某个服务单元发生故障之后, 通过断路器的故障监控 (类似熔断保险丝),向调用方返回一个符合预期的、可处理的备选响应 (FallBack) ,而不是长时间的等待或者抛出调用方无法处理的异常,这样就保证了服务调用方的线程不会被长时间、不必要地占用,从而避免了故障在分布式系统中的蔓延,乃至雪崩。

这一段既说明了 hystrix 的作用,也说出了熔断器的作用

hystrix存在的问题

2018 年前后Netflix公司宣布其核心组件Hystrix、Ribbon、Zuul、Eureka等进入维护状态,不再进行新特性开发,只修 BUG
这直接影响了Spring Cloud项目的发展路线,Spring 官方不得不采取了应对措施,在 2019 年的在 SpringOne 2019 大会中,Spring Cloud宣布 Spring Cloud Netflix 项目进入维护模式,并在 2020 年移除相关的Netflix OSS组件

如今Netflix OSS在Spring Cloud体系的时代已经落幕了。在本次的更新中以下组件被从Spring Cloud Netflix中移除了
Spring Cloud本次移除的Netflix组件
Spring Cloud官方尤其着重指出ribbon、hystrix 和 zuul从Spring Cloud 2020.0正式版发布后将不再被Spring Cloud支持。在目前最新的Spring Cloud 2020.0中仅仅剩下了Eureka。但是留给Eureka的时间也不多了
Feign 虽然是Netflix公司开源的,但从 9.x 版本开始就移交给OpenFeign组织管理,不从属于Netflix OSS范畴

所以 hystrix 入个门就行了,可能会有些公司还在用,但已淘汰,毕竟 spring-cloud-alibaba 系列实在太香了