为什么要限流

互联网系统通常都要面对高并发请求(如秒杀、抢购等),难免会对后端服务造成高压力,严重甚至会导致系统宕机。为避免这种问题通常会添加限流降级熔断等能力,从而使系统更为健壮。

Java领域常见的开源组件有Netflixhystrix,阿里系开源的sentinel(以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性)等,都是蛮不错的限流熔断框架。

限流维度

QPS和连接数控制

设定IP维度的限流,也可以设置基于单个服务器的限流。在真实环境中通常会设置多个维度的限流规则,比如设定同一个IP每秒访问频率小于10,连接数小于5,再设定每台机器QPS最高1000,连接数最大保持200。

小知识:

  • 吞吐量(TPS):指系统在单位时间内处理请求的数量。
  • QPS 每秒查询率(Query Per Second):对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准(类似于TPS,只是应用于特定场景的吞吐量)。

传输速率

有的网站在这方面的限流逻辑做的更细致,比如普通注册用户下载速度为100k/s,购买会员后是10M/s,这背后就是基于用户组或者用户标签的限流逻辑。

黑白名单

如果某个IP在一段时间的访问次数过于频繁,被系统识别为机器人用户或流量攻击,那么这个IP就会被加入到黑名单,从而限制其对系统资源的访问,这就是俗称的“封IP”。

比如爬虫程序爬知乎上的美女图片,或者爬券商系统的股票分时信息,这类爬虫程序都必须实现更换IP的功能,以防被加入黑名单。

有时发现公司的网络无法访问12306这类大型公共网站,这也是因为某些公司的出网IP是同一个地址,因此在访问量过高的情况下,这个IP地址就被对方系统识别,进而被添加到了黑名单。

白名单可以自由穿梭在各种限流规则里,畅行无阻。比如某些电商公司会将超大卖家的账号加入白名单,因为这类卖家往往有自己的一套运维系统,需要对接公司的IT系统做大量的商品发布、补货等等操作。

分布式环境

分布式区别于单机限流的场景,它把整个分布式环境中所有服务器当做一个整体来考量。比如说针对IP的限流,限制1个IP每秒最多10个访问,不管来自这个IP的请求落在了哪台机器上,只要是访问了集群中的服务节点,那么都会受到限流规则的制约。

必须将限流信息保存在一个“中心化”的组件上,这样它就可获取到集群中所有机器的访问状态,目前有两个比较主流的限流方案

  1. 网关层限流
    将限流规则应用在所有流量的入口处
  2. 中间件限流
    将限流信息存储在分布式环境中某个中间件里(比如Redis缓存),每个组件都可以从这里获取到当前时刻的流量统计,从而决定是拒绝服务还是放行流量

基础算法

固定窗口算法

固定窗口算法又叫计数器算法,是一种简单方便的限流算法。主要通过一个支持原子操作的计数器来累计 1 秒内的请求次数,当 1 秒内计数达到限流阈值时触发拒绝策略。每过 1 秒,计数器重置为 0 开始重新计数。

如:使用 AomicInteger来进行统计当前正在并发执行的次数,如果超过域值就直接拒绝请求,提示系统繁忙。

但固定窗口算法存在问题,比如当遇到时间窗口的临界突变时,如 1s 中的后 500 ms 和第 2s 的前 500ms 时,虽然是加起来是 1s时间,却可以被请求 4 次。

滑动窗口算法

滑动窗口算法是对固定窗口算法的改进。既然固定窗口算法在遇到时间窗口的临界突变时会有问题,那么在遇到下一个时间窗口前调整时间窗口是否可以?

每 500ms 滑动一次窗口,可以发现窗口滑动的间隔越短,时间窗口的临界突变问题发生的概率也就越小,不过只要有时间窗口的存在,还是有可能发生时间窗口的临界突变问题

漏桶算法

1.png

漏桶算法思路可把比作是请求漏桶比作是系统处理能力极限,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流。

令牌桶算法

2.png

令牌桶算法原理可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病。

系统会维护一个令牌(token)桶,以一个恒定的速度往桶里放入令牌(token),这时如果有请求进来想要被处理,则需要先从桶里获取一个令牌(token),当桶里没有令牌(token)可取时,则该请求将被拒绝服务。令牌桶算法通过控制桶的容量、发放令牌的速率,来达到对请求的限制。

小结

  • 窗口算法实现简单,逻辑清晰,可以很直观的得到当前的QPS情况,但是会有时间窗口的临界突变问题,而且不像桶一样有队列可以缓冲。
  • 漏桶模式消费速率恒定,可以很好的保护自身系统,可以对流量进行整形,但是面对突发流量不能快速响应。
  • 令牌桶模式可以面对突发流量,但是启动时会有缓慢加速的过程,不过常见的开源工具中已经对此优化。

RateLimiter 限流

Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制,使用十分方便,而且十分高效。

常用方法如下,其中create()tryAcquire()是RateLimiter的2个核心方法

方法名 描述
create(double permitsPerSecond) 根据每秒固定速率进行放置permitsPerSecond个令牌来创建RateLimiter
create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 根据每秒固定速率(permitsPerSecond)和预热期(warmupPeriod)来创建RateLimiter;在预热时间内,RateLimiter每秒分配的令牌数会平稳增长,直到预热期结束时达到其最大速率。
acquire() 获取一个令牌,改方法会阻塞直到获取到这一个令牌,返回值为获取到这个令牌花费的时间
acquire(int permits) 获取指定数量的令牌,该方法也会阻塞,返回值为获取到这 N 个令牌花费的时间
tryAcquire() 判断是否能获取到令牌,如果不能获取立即返回 false
tryAcquire(int permits) 获取指定数量的令牌,如果不能获取立即返回false
tryAcquire(long timeout, TimeUnit unit) 判断能否在指定时间内获取到令牌,如果不能获取立即返回false
tryAcquire(int permits, long timeout, TimeUnit unit) 判断能否在指定时间内获取到指定令牌,如果不能获取立即返回false

平滑突发限流(SmoothBursty)

使用 RateLimiter的静态方法创建一个限流器,比如设置每秒放置的令牌数为5个。返回的RateLimiter对象可以保证1秒内不会给超过5个令牌,并且以固定速率进行放置,达到平滑输出的效果。

RateLimiter r = RateLimiter.create(5);
r.acquire();

RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌频率比较低,则不会导致等待,直接获取令牌。

平滑预热限流(SmoothwarmingUp)

RateLimiter带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。

如下由于设置了预热时间是3秒,令牌桶一开始并不会0.5秒发一个令牌,而是随着频率越来越高,在3秒钟之内达到原本设置的频率,以后就以固定的频率输出。这种功能适合系统刚启动需要一点时间来“热身”的场景。

RateLimiter r = RateLimiter.create(2, 3, TimeUnit.SECONDS);
r.acquire();

预消费

RateLimiter由于会累积令牌,所以可以应对突发流量。每次请求,acquire获取令牌,但是acquire还有个acquire(int permits)的重载方法,即允许每次获取多个令牌数

public void testRateLimiter2() {
RateLimiter rateLimiter = RateLimiter.create(1);

double cost = rateLimiter.acquire(1);
System.out.println("获取1个令牌" + ", 耗时" + cost + "ms");

cost = rateLimiter.acquire(5);
System.out.println("获取5个令牌" + ", 耗时" + cost + "ms");

cost = rateLimiter.acquire(3);
System.out.println("获取3个令牌" + ", 耗时" + cost + "ms");
}
获取1个令牌, 耗时0.0ms

获取5个令牌, 耗时0.997237ms

获取3个令牌, 耗时4.996529ms

这就是预消费能力,RateLimiter中允许一定程度突发流量的实现方式。第二次需要获取5个令牌,指定的是每秒放1个令牌到桶中,实际上并没有等5秒钟,等桶中积累了5个令牌,才让acquire成功,而是直接等了1秒钟就成功了。逻辑如下:

  • 第一次请求过来需要获取1个令牌,直接拿到
  • RateLimiter在1秒钟后放一个令牌,还上了第一次请求预支的1个令牌
  • 1秒钟之后第二次请求过来需要获得5个令牌,直接拿到
  • RateLimiter在花了5秒钟放了5个令牌,还上了第二次请求预支的5个令牌
  • 第三次请求在5秒钟之后拿到3个令牌

前面的请求如果流量大于``每秒放置令牌的数量允许处理,但是带来的结果就是后面请求延后处理,从而在整体上达到一个平衡整体处理速率的效果。

突发流量的处理,在令牌桶算法中有两种方式:

  1. 有足够令牌,才能消费
  2. 先消费后,还令牌

先让请求得到处理,再慢慢还上预支的令牌,用户体验得到提升,否则假设预支60个令牌,1分钟之后才能处理请求,不合理也不人性化。

示范案例

依赖

<!-- 切面编程 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- Google Guava 使用其中限流工具类 RateLimiter -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>

自定义注解

package cn.goitman.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
* @author Nicky
* @version 1.0
* @className Restrict
* @blog goitman.cn | blog.csdn.net/minkeyto
* @description 限流控制注解
* @date 2022/3/16 15:35
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Restrict {

// 业务标识;不同接口,不同流量控制
String key() default "";

// 访问限制数
long limitedNumber() default 1;

// 取令牌最大等待时间
long timeout() default 500;

// 最大等待时间单位(毫秒)
TimeUnit timeunit() default TimeUnit.MILLISECONDS;

// 提示信息
String msg() default "活动火爆,请稍候再试!";
}

预加载

package cn.goitman.commandrunner;

import com.google.common.collect.Maps;
import com.google.common.util.concurrent.RateLimiter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

/**
* @author Nicky
* @version 1.0
* @className StartRunner
* @blog goitman.cn | blog.csdn.net/minkeyto
* @description 预加载数据
* @date 2022/3/16 16:48
*/
@Component
@Order(0)
public class StartRunner implements CommandLineRunner {

private static Logger log = LoggerFactory.getLogger(StartRunner.class);

public static final Map<String, RateLimiter> rateLimiterMap = Maps.newConcurrentMap();

@Override
public void run(String... args) throws Exception {
RateLimiter rateLimiter = null;

// 可改造为读取数据库
Map<String, Integer> limitMap = new HashMap<>();
limitMap.put("restrict1",1);
limitMap.put("restrict2",2);

Set<Map.Entry<String, Integer>> entries = limitMap.entrySet();
for (Map.Entry<String, Integer> entry : entries) {
rateLimiter = RateLimiter.create(entry.getValue());
log.info("创建令牌桶 : {},大小为{}", entry.getKey(), entry.getValue());
rateLimiterMap.put(entry.getKey(),rateLimiter);
}
}
}

切面拦截

package cn.goitman.aspect;

import cn.goitman.annotation.Restrict;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.RateLimiter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Map;

/**
* @author Nicky
* @version 1.0
* @className RestrictAspect
* @blog goitman.cn | blog.csdn.net/minkeyto
* @description 切面拦截
* @date 2022/3/16 11:18
*/
@Aspect
@Component
public class RestrictAspect {

private static Logger log = LoggerFactory.getLogger(RestrictAspect.class);

private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();

@Around("@annotation(cn.goitman.annotation.Restrict)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Restrict restrict = method.getAnnotation(Restrict.class);

if (restrict != null) {
String key = restrict.key();
RateLimiter rateLimiter = null;
synchronized (this) {
if (!limitMap.containsKey(key)) {
rateLimiter = RateLimiter.create(restrict.limitedNumber());
log.info("创建令牌桶 : {},大小为{}", key, restrict.limitedNumber());
limitMap.put(key, rateLimiter);
}
}
rateLimiter = limitMap.get(key);
boolean acquire = rateLimiter.tryAcquire(restrict.timeout(), restrict.timeunit());
if (!acquire) {
log.error("{}:获取令牌失败", key);
return null;
} else {
log.info("令牌桶 : {},获取令牌成功", key);
}
}
return joinPoint.proceed();
}
}

限流接口

package cn.goitman.controller;

import cn.goitman.annotation.Restrict;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @author Nicky
* @version 1.0
* @className RestrictController
* @blog goitman.cn | blog.csdn.net/minkeyto
* @description 测试接口
* @date 2022/3/16 10:07
*/
@RestController
public class RestrictController {

@GetMapping("/restrict1")
@Restrict(key = "restrict1", limitedNumber = 1, msg = "当前排队人数较多,请稍后再试!")
public String restrict1() {
return "OK";
}

@GetMapping("/restrict2")
@Restrict(key = "restrict2", limitedNumber = 2)
public String restrict2() {
return "OK";
}
}

并发测试

模拟1秒内10个并发线程,依次请求restrict1、restrict2接口各一次,日志如下

预加载日志:

2022-03-16 17:09:15.709 [TRACEID:] INFO  [main] cn.goitman.commandrunner.StartRunner : 创建令牌桶 : restrict2,大小为2
2022-03-16 17:09:15.710 [TRACEID:] INFO [main] cn.goitman.commandrunner.StartRunner : 创建令牌桶 : restrict1,大小为1

restrict1接口日志:

2022-03-14 16:25:58.886 [TRACEID:] INFO  [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 创建令牌桶 : restrict1,大小为1
2022-03-14 16:25:58.900 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict1,获取令牌成功
2022-03-14 16:25:58.900 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:58.900 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:58.901 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:58.901 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:58.902 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:58.934 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:59.038 [TRACEID:] ERROR [http-nio-8080-exec-9] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:59.136 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
2022-03-14 16:25:59.277 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败

restrict2接口日志:

2022-03-14 16:46:02.434 [TRACEID:] INFO  [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 创建令牌桶 : restrict2,大小为2
2022-03-14 16:46:02.436 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict2,获取令牌成功
2022-03-14 16:46:02.451 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:02.559 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:02.671 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:02.767 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:02.868 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:02.935 [TRACEID:] INFO [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict2,获取令牌成功
2022-03-14 16:46:03.060 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:03.153 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败
2022-03-14 16:46:03.433 [TRACEID:] INFO [http-nio-8080-exec-9] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict2,获取令牌成功

从restrict1和restrict2接口日志可以看出,1秒钟内只有1次(restrict1)或2次(restrict2)获取令牌成功,其他都失败,说明已经成功给接口加上了限流功能。

2022-03-14 21:01:28.034 [TRACEID:] INFO  [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 获取令牌成功
2022-03-14 21:01:28.241 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : 获取令牌失败
2022-03-14 21:01:28.330 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 获取令牌失败
2022-03-14 21:01:28.439 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 获取令牌失败
2022-03-14 21:01:28.490 [TRACEID:] INFO [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 获取令牌成功
2022-03-14 21:01:28.676 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 获取令牌失败
2022-03-14 21:01:28.735 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 获取令牌失败
2022-03-14 21:01:29.026 [TRACEID:] INFO [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 获取令牌成功

局限

预加载只能在项目启动时加载一次,不够灵活;有人说为什么不用Redis保存RateLimiter对象呢?众所周知任何数据存储都需要序列化,而Redis不会主动去做这个事情,看看下图

由图可知:

  1. SmoothRateLimiter继承RateLimiter,两者都是抽象类(不能实例化)
  2. SmoothRateLimiter有SmoothWarmingUp和SmoothBursty两个默认修饰的匿名内部静态类(外部无法调用)
  3. 只能依赖RateLimiter提供的静态方法来创建具体的子类实例

又有人说Spring的redisTemplate默认会使用java serialization做序列化,说的没错,但RateLimiter是抽象类,即使使用也会报如下错误

# 非法参数异常,默认的序列化器需要一个Serializable有效负载,但是接收到一个类型为[com.google.common.util.concurrent.SmoothRateLimiter$SmoothBursty]的对象

java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.google.common.util.concurrent.SmoothRateLimiter$SmoothBursty]
at org.springframework.core.serializer.DefaultSerializer.serialize(DefaultSerializer.java:43) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE]
at org.springframework.core.serializer.Serializer.serializeToByteArray(Serializer.java:56) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE]
at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:60) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE]
at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:33) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE]
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:94) ~[spring-data-redis-2.3.2.RELEASE.jar:2.3.2.RELEASE]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:127) ~[spring-data-redis-2.3.2.RELEASE.jar:2.3.2.RELEASE]
at org.springframework.data.redis.core.DefaultValueOperations.set(DefaultValueOperations.java:235) ~[spring-data-redis-2.3.2.RELEASE.jar:2.3.2.RELEASE]

结论:抽象类不能实例化,就不能序列化,自然Redis保存不了

RateLimiter是单机限流,假设集群中部署了10台服务器,想要保证集群1000QPS的接口调用量,那么RateLimiter就不适用了;集群流控最常见的方法,还是Redis + Lua 限流。

Redis + Lua 限流

Redis内置了Lua解释器,实现分布式的令牌桶算法,能够很好的满足原子性、事务性的支持,免去了很多的异常逻辑处理:

  1. 减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;
  2. 原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
  3. 复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用。

Lua脚本

简介

Lua是由标准的C语言编写的,源码部分不过2万多行C代码,甚至一个完整的Lua解释器也就200k的大小。

安装

Windows 环境

进入下载网址 下载lua绿色压缩版

配置环境变量

win + R 快捷键进入cmd,验证是否安装配置成功

Mac 环境

建议用brew工具直接执行brew install lua就可以顺利安装,有关brew工具的安装可以参考 https://brew.sh/ 网站,使用brew安装后的目录在/usr/local/Cellar/lua/X.X.X_X

linux 环境

//从官网下载安装包
curl -R -O http://www.lua.org/ftp/lua-5.3.5.tar.gz
//解压安装包
tar zxf lua-5.3.5.tar.gz
//进入文件夹中
cd lua-5.3.5
//如果安装了readline,直接进行↓(若无安装会报错,解决方法看下方)
make linux test
//安装
sudo make install

如果在make Linux test处系统报错如下:

gcc -std=gnu99 -O2 -Wall -Wextra -DLUA_COMPAT_5_2 -DLUA_USE_LINUX    -c -o lua.o lua.c
lua.c:82:31: error: readline/readline.h: No such file or directory
lua.c:83:30: error: readline/history.h: No such file or directory
lua.c: In function ‘pushline’:
lua.c:312: warning: implicit declaration of function ‘readline’
lua.c:312: warning: assignment makes pointer from integer without a cast
lua.c: In function ‘addreturn’:
lua.c:339: warning: implicit declaration of function ‘add_history’
make[2]: *** [lua.o] Error 1
make[2]: Leaving directory `/home/Workspace/lua-5.3.5/src'
make[1]: *** [linux] Error 2
make[1]: Leaving directory `/home/Workspace/lua-5.3.5/src'
make: *** [linux] Error 2

由于没有下载安装readline, 缺少libreadline-dev。打开终端输入

sudo apt-get install libreadline-dev

创建一个 HelloWorld.lua 文件,验证是否安装成功,代码如下:

print("Hello World!")

执行以下命令:

$ lua HelloWorld.lua

输出结果为:

Hello World!

IDEA 插件安装

File -> Settings -> Plugins,搜索lua,选中EmmyLua插件安装

示范案例

依赖

<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

Redis配置

Redis单机简易配置如下,分布式配置可参考 Redis 教程

spring:
redis:
database: 0
host: 127.0.0.1
port: 6379
jedis:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0

自定义注解

@Restrict注解类,原基础上加多个expire(过期时间)属性

package cn.goitman.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
* @author Nicky
* @version 1.0
* @className Restrict
* @blog goitman.cn | blog.csdn.net/minkeyto
* @description 限流控制注解
* @date 2022/3/16 15:35
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Restrict {

// 业务标识;不同接口,不同流量控制
String key() default "";

// 访问限制数
long limitedNumber() default 1;

// 过期时间,单位:秒
long expire() default 10;

// 取令牌最大等待时间
long timeout() default 500;

// 最大等待时间单位(毫秒)
TimeUnit timeunit() default TimeUnit.MILLISECONDS;

// 提示信息
String msg() default "活动火爆,请稍候再试!";
}

切面拦截

package cn.goitman.aspect;

import cn.goitman.annotation.Restrict;
import com.google.common.base.Preconditions;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

/**
* @author Nicky
* @version 1.0
* @className RestrictAspect
* @blog goitman.cn | blog.csdn.net/minkeyto
* @description 切面拦截
* @date 2022/3/16 11:18
*/
@Aspect
@Component
public class RestrictAspect {

private static Logger log = LoggerFactory.getLogger(RestrictAspect.class);

/**
* 因Lua脚本只接受String类型数据,使用RedisTemplate无法正确传参到Lua脚本,Lua脚本取值为空
*
* 先改变redisTemplate的序列化方式:redisTemplate.setKeySerializer(new StringRedisSerializer());
* execute(RedisScript<T> script,List<K> keys,Object... args)方法中的args参数,Lua脚本取值同样为空
*/
@Autowired
StringRedisTemplate stringRedisTemplate;

private DefaultRedisScript<Long> script;

/**
* 初始化DefaultRedisScript,并加载Lua脚本
*
* @PostConstruct修饰的方法会在服务器加载时运行,并且只会被执行一次。
* PostConstruct在构造函数之后执行,init()方法之前执行。
* 该注解的方法在整个Bean初始化中的执行顺序:
* Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct(注释的方法)
*/
@PostConstruct
public void init() {
script = new DefaultRedisScript<>();
script.setResultType(Long.class);
// 固定窗口限流
// script.setScriptSource(new ResourceScriptSource(new ClassPathResource("fixedLimter.lua")));
// 滑动窗口限流
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("slidingLimter.lua")));
log.info("Lua 脚本加载完成!");
}

// 注解切点
@Pointcut("@annotation(cn.goitman.annotation.Restrict)")
public void restrict() {
}

@Around("@annotation(restrict)")
public Object around(ProceedingJoinPoint joinPoint, Restrict restrict) throws Throwable {
// 获取代理类,并判断是否属于MethodSignature
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)) {
throw new IllegalArgumentException("@Restrict 注解只能在方法上使用!");
}

// 获取注解参数
String key = restrict.key();
// 对key(业务标识)判空
Preconditions.checkNotNull(key);
// 获取限流数和过期时间,并转换为String类型
String limitTimes = String.valueOf(restrict.limitedNumber());
String expireTime = String.valueOf(restrict.expire());

List<String> keyList = new ArrayList();
keyList.add(key);
// redis调用Lua脚本(固定窗口限流)
Long result = (Long) stringRedisTemplate.execute(script, keyList, expireTime, limitTimes);

/* // redis调用Lua脚本(滑动窗口限流)
long now = System.currentTimeMillis();
String oldest = String.valueOf(now - 1_000);
String score = String.valueOf(now);
// 参数三:时间戳(时间窗口);参数四:当前时间戳作为score;参数六:当前时间戳作为score的值
Long result = (Long) stringRedisTemplate.execute(script, keyList, oldest, score, limitTimes, score);*/

if (result == 0) {
log.error(restrict.msg());
return null;
}else {
log.info("请求正常!");
}
return joinPoint.proceed();
}
}

Lua脚本

在此例举固定窗口限流滑动窗口限流两种Lua脚本,Redis的数据保存方式不同,任选其一。

固定窗口限流

---
--- Created by Nicky.
--- blog:goitman.cn | blog.csdn.net/minkeyto
--- DateTime: 2022/3/17 16:20
--- 固定窗口限流
---

--- 获取 execute(RedisScript<T> script,List<K> keys,Object... args)方法中的keys值
local key1 = KEYS[1]
--- 使用 key 做自增操作,初始值为1
local val = redis.call('incr', key1)
--- 查询key的过期时间(未设置过期时间,默认值为-1)
local ttl = redis.call('ttl', key1)
--- 获取execute(RedisScript<T> script,List<K> keys,Object... args)方法中args参数的第一个和第二个参数
local expire = ARGV[1]
local number = ARGV[2]
--- 在redis控制台打印日志
redis.log(redis.LOG_NOTICE,tostring(number))
redis.log(redis.LOG_NOTICE,tostring(expire))

redis.log(redis.LOG_NOTICE, "incr "..key1.." "..val);
--- 如果 key 值为1,设置过期时间
if val == 1 then
redis.call('expire', key1, tonumber(expire))
else
--- 如果key已存在,并未设置过期时间的情况下,重新设置过期时间
if ttl == -1 then
redis.call('expire', key1, tonumber(expire))
end
end
--- 如果自增数大于限流数,触发限流
if val > tonumber(number) then
return 0
end
--- 未触发限流,正常请求,返回1
return 1

滑动窗口限流

---
--- Created by Nicky.
--- blog:goitman.cn | blog.csdn.net/minkeyto
--- DateTime: 2022/3/17 17:20
--- 滑动窗口限流
---

--- 移除时间窗口之前的数据
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
--- 统计当前元素数量
local res = redis.call('zcard', KEYS[1])
--- 是否超过阈值,判断key是否存在,不存在则创建一个空的有序集并执行
if (res == nil) or (res < tonumber(ARGV[3])) then
redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
return 1
else
return 0
end

并发测试

用回上述限流接口测试并发(限流注解中的过期时间默认为10秒),日志如下:
restrict1接口日志:

2022-03-17 20:27:26.812 [TRACEID:] INFO  [main] cn.goitman.aspect.RestrictAspect : Lua 脚本加载完成!
2022-03-17 20:27:27.457 [TRACEID:] INFO [main] org.apache.coyote.http11.Http11NioProtocol : Starting ProtocolHandler ["http-nio-8080"]
2022-03-17 20:27:27.510 [TRACEID:] INFO [main] cn.goitman.ThrottlingApplication : Started ThrottlingApplication in 3.755 seconds (JVM running for 7.728)
2022-03-17 20:27:33.748 [TRACEID:] INFO [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 请求正常!
2022-03-17 20:27:33.748 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:33.748 [TRACEID:] INFO [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 请求正常!
2022-03-17 20:27:33.844 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:33.931 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:34.023 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:34.205 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:34.231 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:34.358 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:27:34.452 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!

restrict2接口日志:

2022-03-17 20:28:43.691 [TRACEID:] INFO  [main] cn.goitman.aspect.RestrictAspect : Lua 脚本加载完成!
2022-03-17 20:28:44.343 [TRACEID:] INFO [main] org.apache.coyote.http11.Http11NioProtocol : Starting ProtocolHandler ["http-nio-8080"]
2022-03-17 20:28:44.399 [TRACEID:] INFO [main] cn.goitman.ThrottlingApplication : Started ThrottlingApplication in 4.176 seconds (JVM running for 9.37)
2022-03-17 20:28:49.519 [TRACEID:] INFO [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 请求正常!
2022-03-17 20:28:49.519 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 请求正常!
2022-03-17 20:28:49.520 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:49.572 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:49.698 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:49.776 [TRACEID:] ERROR [http-nio-8080-exec-8] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:49.900 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:49.974 [TRACEID:] ERROR [http-nio-8080-exec-9] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:50.088 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:50.203 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:54.599 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:54.706 [TRACEID:] ERROR [http-nio-8080-exec-8] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:54.814 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:54.936 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:55.067 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:55.113 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:55.215 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:55.311 [TRACEID:] ERROR [http-nio-8080-exec-8] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:55.413 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:55.521 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:28:59.718 [TRACEID:] INFO [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 请求正常!
2022-03-17 20:28:59.831 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 请求正常!
2022-03-17 20:28:59.922 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
2022-03-17 20:29:00.028 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!

根据日志可看出,正常请求和正常触发限流,说明Lua脚本限流逻辑生效。

Nginx 限流

Windows环境 nginx 资源下载

iP限流

  • 修改host文件添加IP + 对应的域名:
127.0.0.1   www.goitman.cn

Windows 10 中 hosts 文件位置:C:\Windows\System32\drivers\etc\hosts

Linux 中 hosts 文件位置:/etc/hosts

  • 修改nginx配置文件

将上述域名,添加到路由规则当中

vim /usr/local/nginx/conf/nginx.conf
# $binary_remote_addr:binary_目的是缩写内存占用,remote_addr表示通过IP地址来限流
# zone=iplimit:20m:iplimit是一块内存区域(记录访问频率信息),20m是指这块内存区域的大小
# rate=1r/s:每秒放行1个请求
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=1r/s;

server{
server_name www.goitman.cn;
location /limit/ {
proxy_pass http://127.0.0.1:8080/;

# zone=iplimit:引用limit_rep_zone中的zone变量
# burst=2:设置一个大小为2的缓冲区域,当大量请求到来,请求数量超过限流频率时,将其放入缓冲区域
# nodelay:缓冲区满了以后,直接返回503异常
limit_req zone=iplimit burst=2 nodelay;
}
}
  • 测试限流

访问地址,测试是否限流

www.goitman.cn/limit/restrict1

多维度限流

修改nginx.conf配置

#根据IP地址限制速度
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=10r/s;
#根据服务器级别做限流
limit_req_zone $server_name zone=serverlimit:10m rate=1r/s;
#根据ip地址的链接数量做限流
limit_conn_zone $binary_remote_addr zone=perip:20m;
#根据服务器的连接数做限流
limit_conn_zone $server_name zone=perserver:20m;


server{
server_name www.goitman.cn;
location /limit/ {
proxy_pass http://127.0.0.1:8080/;

#基于ip地址的限制
limit_req zone=iplimit burst=2 nodelay;
#基于服务器级别做限流
limit_req zone=serverlimit burst=2 nodelay;
#基于ip地址的链接数量做限流 最多保持100个链接
limit_conn zone=perip 100;
#基于服务器的连接数做限流 最多保持100个链接
limit_conn zone=perserver 1;
#配置request的异常返回504(默认为503)
limit_req_status 504;
limit_conn_status 504;
}

location /download/ {
#前100m不限制速度
limit_rate_affer 100m;
#限制速度为256k
limit_rate 256k;
}
}

后语

一般在系统上线时需要通过对系统压测,评估出系统的性能阀值,然后给接口加上合理的限流参数,防止出现大流量请求时直接压垮系统。

源码地址:https://github.com/wangdaicong/spring-boot-project/tree/master/throttling-demo