文章参考翻译自搜云库的一篇文章:原文详细地址
高并发系统时有三把利器可以保护系统稳定:限流、降级、缓存。今天聊聊限流方案以及实现
▎了解什么是限流、以及限流的意义
为什么需要限流呢?相信大家都经历过春运高铁的安检,场景如下
为什么要摆这样的长龙阵进站呢?答案就是为了限流,如果一下涌进去太多人会对安检造成过大的负担,存在安全隐患
联系到互联网场景中,某些高并发系统的流量巨大,尤其像网站的促销秒杀活动,为了保证系统不被巨大的流量压垮,上线前会做流量峰值的评估,其中TPS/QPS是衡量系统处理能力两个重要指标
TPS(Transactions Per Second
) 系统每秒事务数
QPS(Queries Per Second
) 系统每秒查询率
限流就是当系统流量到达一定阀值的时候,拒绝掉一部分流量,假设系统每秒处理请求的阀值是100,理论上这一秒内100以后的请求都将被拒绝。
▎限流解决方案
1:漏铜算法
漏桶算法思路:
我们把水比作是请求
,漏桶比作是系统处理能力极限
,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流。
2:令牌桶算法
令牌桶算法思路:
我们可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病。系统会维护一个令牌(token
)桶,以一个恒定的速度往桶里放入令牌(token
),这时如果有请求进来想要被处理,则需要先从桶里获取一个令牌(token
),当桶里没有令牌(token
)可取时,则该请求将被拒绝服务。令牌桶算法通过控制桶的容量、发放令牌的速率,来达到对请求的限制。
3:Redis + Lua
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能,Redis支持Lua脚本,所以通过Lua实现限流的算法。
Lua脚本实现算法对比操作Redis实现算法的优点:
-
减少网络开销:使用
Lua
脚本,无需向Redis
发送多次请求,执行一次即可,减少网络传输 -
原子操作:
Redis
将整个Lua
脚本作为一个命令执行,原子,无需担心并发 -
复用:
Lua
脚本一旦执行,会永久保存Redis
中,,其他客户端可复用
▎Redis + Lua 实现
Lua环境安装
Linux安装Lua步骤:
curl -R -O http://www.lua.org/ftp/lua-5.3.0.tar.gz
tar zxf lua-5.3.0.tar.gz
cd lua-5.3.0
make linux test # 检查依赖,缺什么就安装什么,通过后再执行下一步
make install
Windows安装Lua步骤
安装包下载地址:https://github.com/rjpcomputing/luaforwindows/releases
下载完成后、双击安装即可在该环境下编写 Lua 程序并运行
使用 lua -i 或 lua 命令检查Lua环境是否安装成功
$ lua -i
$ Lua 5.3.0 Copyright (C) 1994-2015 Lua.org, PUC-Rio
Redis环境安装
Linux安装Redis
$ wget http://download.redis.io/releases/redis-5.0.8.tar.gz
$ tar xzf redis-5.0.8.tar.gz
$ cd redis-5.0.8
$ make
Windows安装Redis
安装包下载地址:https://redis.io/download
下载完成后,双击安装
搭建SpringBoot项目,引入依赖
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 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>
项目整合Redis
application.properties配置
spring.redis.host=127.0.0.1
spring.redis.port=6379
# 如果没配置redis认证,password不需要配
spring.redis.password=Mote12345
配置RedisTemplate
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Serializable> limitRedisTemplate(
LettuceConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Serializable> template = new RedisTemplate<>();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
限流类型枚举类
public enum LimitType {
// 自定义key
CUSTOMER,
// 请求IP
IP;
}
自定义@Limit注解
period
表示请求限制时间段,count
表示在period
这个时间段内允许放行请求的次数。limitType
代表限流的类型,可以根据请求的IP
、自定义key
,如果不传limitType
属性则默认用方法名作为默认key。
//表明注解可用于的地方 METHOD:方法上 TYPE:用于描述类、接口(包括注解类型) 或enum声明
@Target({ElementType.METHOD, ElementType.TYPE})
//存活阶段 runtime:运行期
@Retention(RetentionPolicy.RUNTIME)
//可继承
@Inherited
//作用域 javaDoc
@Documented
public @interface Limit {
// key
String key() default "";
// 给定的时间范围
int period();
// 一定时间内最多访问次数
int count();
// 限流的类型 (自定义key或者请求ip)
LimitType limitType() default LimitType.CUSTOMER;
}
定义切面类
@Aspect
@Configuration
public class LimitInterceptor {
@Autowired
private RedisTemplate<String, Serializable> redisTemplate;
/**
* 拦截有@Limit注解的public方法
*
* @param pjp
* @return
*/
@Around("execution(public * *(..)) && @annotation(com.mote.lua.Limit)")
public Object interceptor(ProceedingJoinPoint ppt) {
// 获取方法对象
MethodSignature signature = (MethodSignature) ppt.getSignature();
Method method = signature.getMethod();
// 获取@Limit注解对象
Limit limitAnnotation = method.getAnnotation(Limit.class);
// 获取key类型
LimitType limitType = limitAnnotation.limitType();
// 获取请求限制时间段、请求限制次数
int limitPeriod = limitAnnotation.period();
int limitCount = limitAnnotation.count();
// 根据限流类型获取不同的key ,如果不传以方法名作为key
String key;
switch (limitType) {
case IP:
key = getIpAddress();
break;
case CUSTOMER:
key = limitAnnotation.key();
break;
default:
key = method.getName();
}
// 定义key参数
List<String> keys = new ArrayList<String>();
keys.add(key);
try {
// 获取Lua脚本内容
String luaScript = buildLuaScript();
// Reids整合Lua
RedisScript<Number> redisScript = new DefaultRedisScript<>(
luaScript, Number.class);
// 执行Lua,并返回key值
Number count = redisTemplate.execute(redisScript, keys, limitCount,
limitPeriod);
// 判断是否阻止请求
if (count != null && count.intValue() <= limitCount) {
return ppt.proceed();
} else {
throw new RuntimeException("please try again later");
}
} catch (Throwable e) {
if (e instanceof RuntimeException) {
throw new RuntimeException(e.getLocalizedMessage());
}
throw new RuntimeException("server error");
}
}
/**
* 编写 redis Lua 限流脚本
*/
public String buildLuaScript() {
StringBuilder lua = new StringBuilder();
lua.append("local c");
lua.append("\nc = redis.call('get',KEYS[1])");
// 调用不超过最大值,则直接返回
lua.append("\nif c and tonumber(c) > tonumber(ARGV[1]) then");
lua.append("\nreturn c;");
lua.append("\nend");
// 执行计算器自加
lua.append("\nc = redis.call('incr',KEYS[1])");
lua.append("\nif tonumber(c) == 1 then");
// 从第一次调用开始限流,设置对应键值的过期
lua.append("\nredis.call('expire',KEYS[1],ARGV[2])");
lua.append("\nend");
lua.append("\nreturn c;");
return lua.toString();
}
/**
* 获取请求ip
*/
public String getIpAddress() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0) {
ip = request.getRemoteAddr();
}
return ip;
}
}
下面写个Controller测试一下限流
@RestController
public class LimiterController {
private static int count1 = 0;
private static int count2 = 0;
private static int count3 = 0;
/**
* 20秒内允许请求3次,key为方法名称
*
* @return
*/
@Limit(key = "limitTest", period = 20, count = 3)
@GetMapping("/limit1")
public String testLimiter1() {
return "success--" + ++count1;
}
/**
* 20秒内允许请求3次,自定义key
*
* @return
*/
@Limit(key = "customer_limit_test", period = 20, count = 3, limitType = LimitType.CUSTOMER)
@GetMapping("/limit2")
public String testLimiter2() {
return "success--" + ++count2;
}
/**
* 20秒内允许请求3次,key为请求ip
*
* @return
*/
@Limit(period = 20, count = 3, limitType = LimitType.IP)
@GetMapping("/limit3")
public String testLimiter3() {
return "success--" + ++count3;
}
}
测试:连续请求3次均可以成功,第4次请求被拒绝
----------------------------分割线-----------------------------------
----------------------------分割线-----------------------------------
----------------------------分割线-----------------------------------