分布式限流
分布式限流的几种维度
QPS和连接数控制
针对上图中的连接数和QPS(query per second)限流来说,我们可以设定IP维度的限流,也可以设置基于单个服务器的限流。在真实环境中通常会设置多个维度的限流规则,比如设定同一个IP每秒访问频率小于10,连接数小于5,再设定每台机器QPS最高1000,连接数最大保持200。更进一步,我们可以把某个服务器组或整个机房的服务器当做一个整体,设置更high-level的限流规则,这些所有限流规则都会共同作用于流量控制。
在稍后的小节里,我们的实践Demo部分将主要围绕在QPS和连接数控制的限流规则。
传输速率
对于“传输速率”大家都不会陌生,比如资源的下载速度。有的网站在这方面的限流逻辑做的更细致,比如普通注册用户下载速度为100k/s,购买会员后是10M/s,这背后就是基于用户组或者用户标签的限流逻辑。
在稍后的小节我们会给大家展示如何在Nginx中限制传输速度。
黑白名单
黑白名单是各个大型企业应用里很常见的限流和放行手段,而且黑白名单往往是动态变化的。举个例子,如果某个IP在一段时间的访问次数过于频繁,被系统识别为机器人用户或流量攻击,那么这个IP就会被加入到黑名单,从而限制其对系统资源的访问,这就是我们俗称的“封IP”。
我们平时见到的爬虫程序,比如说爬知乎上的美女图片,或者爬券商系统的股票分时信息,这类爬虫程序都必须实现更换IP的功能,以防被加入黑名单。有时我们还会发现公司的网络无法访问12306这类大型公共网站,这也是因为某些公司的出网IP是同一个地址,因此在访问量过高的情况下,这个IP地址就被对方系统识别,进而被添加到了黑名单。使用家庭宽带的同学们应该知道,大部分网络运营商都会将用户分配到不同出网IP段,或者时不时动态更换用户的IP地址。
白名单就更好理解了,相当于御赐金牌在身,可以自由穿梭在各种限流规则里,畅行无阻。比如某些电商公司会将超大卖家的账号加入白名单,因为这类卖家往往有自己的一套运维系统,需要对接公司的IT系统做大量的商品发布、补货等等操作。
分布式限流的主流方案
Guava乱入
说起Guava大家一定不陌生,它是Google出品的一款工具包(com.google.guava),我们经常用它做一些集合操作比如Lists.newArrayList(),它最早源于2007年的"Google Collections Library"项目。Guava不甘于将自己平凡的一生都耗费在Collections上面,于是乎它开始了转型,慢慢扩展了自己在Java领域的影响力,从反射工具、函数式编程、安全验证、数学运算等等方面,都提供了响应的工具包。
在限流这个领域中,Guava也贡献了一份绵薄之力,在其多线程模块下提供了以RateLimiter为首的几个限流支持类。我们前面提到了,Guava是一个客户端组件,也就是说它的作用范围仅限于“当前”这台服务器,不能对集群以内的其他服务器施加流量控制。
打个比方,目前我有2台服务器[Server 1,Server 2],这两台服务器都部署了一个登陆服务,假如我希望对这两台机器的流量进行控制,比如将两台机器的访问量总和控制在每秒20以内,如果用Guava来做,只能独立控制每台机器的访问量<=10。
这里不过多介绍:[https://juejin.cn/post/6844903783432978439][1]
Nginx限流
官方文档地址:[http://nginx.org/en/docs/][2]
官网中提供了限流的两个模块:一种是漏桶算法,一种是令牌桶算法.
ngx_http_limit_conn_module 用来限制同一时间连接数,即并发限制。
ngx_http_limit_req_module 用来限制单位时间内的请求数目,以及速度限制。
limit_req 和limit_conn 配置
# 放在http{} 内
limit_req_zone $binary_remote_addr/$server_name zone=name:numer m rate=number r/s;
limit_conn_zone $binary_remote_addr/$server_name zone=name:name:numer m;
- 第一个参数:$binary_remote_addr 表示通过remote_addr这个标识来做限制,“binary_”的目的是缩写内存占用量,是限制同一客户端ip地址;server_name通过服务来做限制。
- 第二个参数:zone=iplimit:10m表示生成一个大小为10M,名字为iplimit的内存区域,用来存储访问的频次信息。
- 第三个参数:rate=1r/s表示允许相同标识的客户端的访问频次,这里限制的是每秒1次,还可以有比如30r/m的。
# 放在server{}内
limit_req zone=name burst=1 nodelay;
limit_conn perip 1;
- 第一个参数:zone=iplimit设置使用哪个配置区域来做限制,与上面limit_req_zone 里的name对应。
- 第二个参数:burst=1,重点说明一下这个配置,burst爆发的意思,这个配置的意思是设置一个大小为5的缓冲区当有大量请求(爆发)过来时,超过了访问频次限制的请求可以先放到这个缓冲区内。
- 第三个参数:nodelay,如果设置,超过访问频次而且缓冲区也满了的时候就会直接返回503,如果没有设置,则所有请求会等待排队。
完整的配置如下:
http {
# 基于ip地址的限制
limit_req_zone $binary_remote_addr zone=iplimit:10m rate=1r/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.bearjun.com;
location / {
proxy_pass http://127.0.0.1/;
# 基于ip地址的限制
limit_req zone=iplimit burst=1 nodelay;
# 基于服务器级别的限制,一般的,server级别的限流速率最大
limit_req zone=serverlimit burst=100 nodelay;
# 每个server保持100个链接
limit_conn perserver 100;
# 每个ip地址保持1个链接
limit_conn perip 1;
}
}
}
中间件限流(redis + lua)
Redis,作为缓存组件,如果不采用持久化方案的话,Redis的大部分操作都是纯内存操作,性能十分优异。而且线程安全,只用单线程承接网络请求(其他模块仍然多线程),天然具有线程安全的特性,而且对原子性操作的支持非常到位。限流服务不仅需要承接超高QPS,还需要保证限流逻辑的执行层面具备线程安全的特性。利用Redis的这些天然特性做限流,既能保证线程安全,也能保持良好的性能。
LUA教程:[https://www.runoob.com/lua/lua-tutorial.html][3]
话不多少,直接上代码,
- maven相关依赖
<!--guava-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
<!--spring-data-redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--aop-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
lua脚本,放在resources下的rateLimiter.lua
-- 获取方法签名的特征
local methodKey = KEYS[1]
-- 添加redis日志,打印到redis的控制台
redis.log(redis.LOG_DEBUG, 'key is ', methodKey)
-- 调用脚本传入的限流大小
local limit = tonumber(ARGV[1])
-- 获取当前流量的大小
local count = tonumber(redis.call('get', methodKey) or "0")
--判断是否超过阀值
if count + 1 > limit then
-- 拒绝访问
return false
else
-- 没有超过阀值
-- 设置当前的访问量加一
redis.call("INCRBY", methodKey, 1)
-- 设置过期时间
redis.call("EXPIRE", methodKey, 1)
-- 放行
return true
end
- 编写redis的配置文件
@Configuration
public class RedisConfigration {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory){
return new StringRedisTemplate(factory);
}
@Bean
public DefaultRedisScript loadRedisScript(){
DefaultRedisScript redisScript = new DefaultRedisScript();
redisScript.setLocation(new ClassPathResource("rateLimiter.lua"));
redisScript.setResultType(java.lang.Boolean.class);
return redisScript;
}
}
- 自定义注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Limiter {
int limit();
String methodKey() default "";
}
- 定义一个切面
@Aspect
@Component
@Slf4j
public class AccessLimiterAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedisScript<Boolean> redisScript;
@Pointcut("@annotation(com.bearjun.springcloud.common.Limiter)")
public void cut(){
}
@Before("cut()")
public void before(JoinPoint joinpoint){
// 获取类签名
MethodSignature signature = (MethodSignature) joinpoint.getSignature();
// 获取签名方法
Method method = signature.getMethod();
// 获取方法上的Limiter注解
Limiter annotation = method.getAnnotation(Limiter.class);
// 如果不存在直接返回
if (annotation == null){
return;
}
// 获取limit
Integer limit = annotation.limit();
// 获取methodKey
String key = annotation.methodKey();
// 如果没有定义key
if (StringUtils.isBlank(key)){
// 获取方法参数的类型数组
Class[] type = method.getParameterTypes();
// 获取方法名称
key = method.getName();
// 如果方法不为空,证明存在值,拼接key
if (type != null){
// 获取所有的参数的类型,并用,隔开
String paramTypes = Arrays.stream(type).map(Class::getName).collect(Collectors.joining(","));
// 拼接方法名称#参数名称1,参数名称2...
key += "#" + paramTypes;
}
}
/**
* 执行lua脚本
* redisScript: lua脚本
* Lists.newArrayList(key):lua脚本中key列表
* limit.toString():lua脚本value的列表
*/
Boolean accessFlag = stringRedisTemplate.execute(redisScript, Lists.newArrayList(key), limit.toString());
// 判断是否限流了
if (!accessFlag){
log.error("your access is blocked,key={}", key);
throw new RuntimeException("your access is blocked");
}
}
}
- 测试
@RequestMapping("/test-a")
@Limiter(limit = 1)
public String tryAcquire2(){
return "success";
}
redis + lua 主要运用redis和lua脚本的天然聚合,无缝衔接,高效的,可预热的执行lua脚本的能力。
[1]: https://juejin.cn/post/6844903783432978439
[2]: http://nginx.org/en/docs/
[3]: https://www.runoob.com/lua/lua-tutorial.html
评论区