一、什么是限流?为什么要限流?
不知道大家有没有做过帝都的地铁,就是进地铁站都要排队的那种,为什么要这样摆长龙转圈圈?答案就是为了限流!因为一趟地铁的运力是有限的,一下挤进去太多人会造成站台的拥挤、列车的超载,存在一定的安全隐患。同理,我们的程序也是一样,它处理请求的能力也是有限的,一旦请求多到超出它的处理极限就会崩溃。为了不出现最坏的崩溃情况,只能耽误一下大家进站的时间。
限流是保证系统高可用的重要手段!!!
由于互联网公司的流量巨大,系统上线会做一个流量峰值的评估,尤其是像各种秒杀促销活动,为了保证系统不被巨大的流量压垮,会在系统流量到达一定阈值时,拒绝掉一部分流量。
限流会导致用户在短时间内(这个时间段是毫秒级的)系统不可用,一般我们衡量系统处理能力的指标是每秒的QPS或者TPS,假设系统每秒的流量阈值是1000,理论上一秒内有第1001个请求进来时,那么这个请求就会被限流。
二、限流方案
1、计数器
Java内部也可以通过原子类计数器AtomicInteger、Semaphore信号量来做简单的限流。
// 限流的个数
private int maxCount = 10;
// 指定的时间内
private long interval = 60;
// 原子类计数器
private AtomicInteger atomicInteger = new AtomicInteger(0);
// 起始时间
private long startTime = System.currentTimeMillis();
public boolean limit(int maxCount, int interval) {
atomicInteger.addAndGet(1);
if (atomicInteger.get() == 1) {
startTime = System.currentTimeMillis();
atomicInteger.addAndGet(1);
return true;
}
// 超过了间隔时间,直接重新开始计数
if (System.currentTimeMillis() - startTime > interval * 1000) {
startTime = System.currentTimeMillis();
atomicInteger.set(1);
return true;
}
// 还在间隔时间内,check有没有超过限流的个数
if (atomicInteger.get() > maxCount) {
return false;
}
return true;
}
2、漏桶算法
漏桶算法思路很简单,我们把水比作是请求,漏桶比作是系统处理能力极限,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流。
3、令牌桶算法
令牌桶算法的原理也比较简单,我们可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病。
系统会维护一个令牌(token)桶,以一个恒定的速度往桶里放入令牌(token),这时如果有请求进来想要被处理,则需要先从桶里获取一个令牌(token),当桶里没有令牌(token)可取时,则该请求将被拒绝服务。令牌桶算法通过控制桶的容量、发放令牌的速率,来达到对请求的限制。
4、Redis + Lua
很多同学不知道Lua是啥?个人理解,Lua脚本和 MySQL数据库的存储过程比较相似,他们执行一组命令,所有命令的执行要么全部成功或者失败,以此达到原子性。也可以把Lua脚本理解为,一段具有业务逻辑的代码块。
而Lua本身就是一种编程语言,虽然redis 官方没有直接提供限流相应的API,但却支持了 Lua 脚本的功能,可以使用它实现复杂的令牌桶或漏桶算法,也是分布式系统中实现限流的主要方式之一。
相比Redis事务,Lua脚本的优点:
- 减少网络开销: 使用Lua脚本,无需向Redis 发送多次请求,执行一次即可,减少网络传输
- 原子操作:Redis 将整个Lua脚本作为一个命令执行,原子,无需担心并发
- 复用:Lua脚本一旦执行,会永久保存 Redis 中,,其他客户端可复用
Lua脚本大致逻辑如下:
-- lua 下标从 1 开始
-- 限流 key
local key = KEYS[1]
-- 限流大小
local max = tonumber(ARGV[1])
-- 超时时间
local ttl = tonumber(ARGV[2])
-- 考虑主从策略和脚本回放机制,这个time由客户端获取传入
local now = tonumber(ARGV[3])
-- 已经过期的时间点
local expired = now - (ttl * 1000)
-- 清除过期的数据,移除指定分数(score)区间内的所有成员
redis.call('zremrangebyscore', key, 0, expired)
-- 获取当前流量大小
local currentLimit = tonumber(redis.call('zcard', key))
local nextLimit = currentLimit + 1
if nextLimit > max then
-- 达到限流大小 返回 0
return 0;
else
-- 没有达到阈值 value + 1
redis.call("zadd", key, now, now)
-- 秒为单位设置 key 的生存时间
redis.call("pexpire", key, ttl)
return nextLimit
end
- 通过KEYS[1] 获取传入的key参数
- 通过ARGV[1]获取传入的max参数(限流大小)
- 通过ARGV[2]获取传入的超时时间
- 通过ARGV[3]获取传入now
- 清除过期的数据,移除指定分数(score)区间内的所有成员
- 接着判断缓存中记录的数值是否会大于限制大小,如果超出表示该被限流,返回0
- 如果未超过,那么该key的缓存值+1,并设置过期时间为1秒钟以后,并返回缓存值+1
这种方式是本文推荐的方案,具体实现会在后边做细说。
5、网关层限流
限流常在网关这一层做,比如Nginx、Openresty、kong、zuul、Spring Cloud Gateway等,而像spring cloud - gateway网关限流底层实现原理,就是基于Redis + Lua,通过内置Lua限流脚本的方式。
Redis + Lua 限流实现
配置RedisTemplate实例
@Configuration
public class RedisLimiterHelper {
@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;
}
private RedisScript<List<Long>> redisRateLimiterScript() {
DefaultRedisScript redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("META-INF/scripts/blade_rate_limiter.lua")));
redisScript.setResultType(List.class);
return redisScript;
}
@Bean
@ConditionalOnMissingBean
public RedisRateLimiterClient redisRateLimiter(StringRedisTemplate redisTemplate, Environment environment) {
RedisScript<List<Long>> redisRateLimiterScript = redisRateLimiterScript();
return new RedisRateLimiterClient(redisTemplate, redisRateLimiterScript, environment);
}
}
RateLimiter 限流 Client
public interface RateLimiterClient {
/**
* 服务是否被限流
*
* @param key 自定义的key,请保证唯一
* @param max 支持的最大请求
* @param ttl 时间,单位默认为秒(seconds)
* @return 是否允许
*/
default boolean isAllowed(String key, long max, long ttl) {
return this.isAllowed(key, max, ttl, TimeUnit.SECONDS);
}
/**
* 服务是否被限流
*
* @param key 自定义的key,请保证唯一
* @param max 支持的最大请求
* @param ttl 时间
* @param timeUnit 时间单位
* @return 是否允许
*/
boolean isAllowed(String key, long max, long ttl, TimeUnit timeUnit);
/**
* 服务限流,被限制时抛出 RateLimiterException 异常,需要自行处理异常
*
* @param key 自定义的key,请保证唯一
* @param max 支持的最大请求
* @param ttl 时间
* @param supplier Supplier 函数式
* @return 函数执行结果
*/
default <T> T allow(String key, long max, long ttl, CheckedSupplier<T> supplier) {
return allow(key, max, ttl, TimeUnit.SECONDS, supplier);
}
/**
* 服务限流,被限制时抛出 RateLimiterException 异常,需要自行处理异常
*
* @param key 自定义的key,请保证唯一
* @param max 支持的最大请求
* @param ttl 时间
* @param timeUnit 时间单位
* @param supplier Supplier 函数式
* @param <T>
* @return 函数执行结果
*/
default <T> T allow(String key, long max, long ttl, TimeUnit timeUnit, CheckedSupplier<T> supplier) {
boolean isAllowed = this.isAllowed(key, max, ttl, timeUnit);
if (isAllowed) {
try {
return supplier.get();
} catch (Throwable e) {
throw Exceptions.unchecked(e);
}
}
throw new RateLimiterException(key, max, ttl, timeUnit);
}
}
redis 限流服务
@RequiredArgsConstructor
public class RedisRateLimiterClient implements RateLimiterClient {
/**
* redis 限流 key 前缀
*/
private static final String REDIS_KEY_PREFIX = "limiter:";
/**
* 失败的默认返回值
*/
private static final long FAIL_CODE = 0;
/**
* redisTemplate
*/
private final StringRedisTemplate redisTemplate;
/**
* redisScript
*/
private final RedisScript<List<Long>> script;
/**
* env
*/
private final Environment environment;
@Override
public boolean isAllowed(String key, long max, long ttl, TimeUnit timeUnit) {
// redis key
String redisKeyBuilder = REDIS_KEY_PREFIX +
getApplicationName(environment) + CharPool.COLON + key;
List<String> keys = Collections.singletonList(redisKeyBuilder);
// 毫秒,考虑主从策略和脚本回放机制,这个time由客户端获取传入
long now = System.currentTimeMillis();
// 转为毫秒,pexpire
long ttlMillis = timeUnit.toMillis(ttl);
// 执行命令
List<Long> results = this.redisTemplate.execute(this.script, keys, max + "", ttlMillis + "", now + "");
// 结果为空返回失败
if (results == null || results.isEmpty()) {
return false;
}
// 判断返回成功
Long result = results.get(0);
return result != FAIL_CODE;
}
private static String getApplicationName(Environment environment) {
return environment.getProperty("spring.application.name", "");
}
}
自定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface RateLimiter {
/**
* 限流的 key 支持,必须:请保持唯一性
*
* @return key
*/
String value();
/**
* 支持的最大请求,默认: 100
*
* @return 请求数
*/
long max() default 100L;
/**
* 持续时间,默认: 3600
*
* @return 持续时间
*/
long ttl() default 1L;
/**
* 时间单位,默认为分
*
* @return TimeUnit
*/
TimeUnit timeUnit() default TimeUnit.MINUTES;
}
切面代码实现
@Aspect
@RequiredArgsConstructor
public class RedisRateLimiterAspect implements ApplicationContextAware {
/**
* redis 限流服务
*/
private final RedisRateLimiterClient rateLimiterClient;
private ApplicationContext applicationContext;
/**
* AOP 环切 注解 @RateLimiter
*/
@Around("@annotation(limiter)")
public Object aroundRateLimiter(ProceedingJoinPoint point, RateLimiter limiter) throws Throwable {
String limitKey = limiter.value();
Assert.hasText(limitKey, "@RateLimiter value must have length; it must not be null or empty");
long max = limiter.max();
long ttl = limiter.ttl();
TimeUnit timeUnit = limiter.timeUnit();
return rateLimiterClient.allow(limitKey , max, ttl, timeUnit, point::proceed);
}
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
总结
以上 springboot + aop + Lua 限流实现是比较简单的,旨在让大家认识下什么是限流?如何做一个简单的限流功能,面试要知道这是个什么东西。上面虽然说了几种实现限流的方案,但选哪种还要结合具体的业务场景,不能为了用而用。