提醒注意
本文不是个人测试,而是已经应用在线上,自定义限流组件代码。可以在线上推广使用。无需惧怕量级。
采用了计数器+滑动窗口配合实现, 借鉴了著名的 Sentinel 滑动窗口思想:
需求描述
按照 每小时,每分钟,每秒 维度 进行分布式限流。
效果展示
首先来看下压测jmeter配置:
每秒200个线程访问限流接口,无限循环下去。
后台设置的接口限流条件为: 5次/每秒 ,代码:
观察后台输出情况:
这里我抽查了几个, 发现每秒确实是 5次成功拿锁。 证明我们的限流代码没有问题。
Redis内存占用情况:
空间占用只有一个zset集合,而且个数为5个,也就是每秒通过的个数,也就是说如果 设置每秒100w个请求通过, 那么zset将有100w大。我相信你不会这么设置!!!!!
当没有请求访问时,1秒后,zset集合将清空(不要担心会永占内存):
代码讲解
实现思想:
《 计数器限流 + 滑动窗口 》
当限流条件是每小时,每分钟 时,由于精度不是很大,可以采用计数器限流,优点:代码简单,redis内存只占用一个 string 类型的key。
当限流条件是每秒时。 由于可能会产生瞬时,double流量进入问题,采用滑动窗口实现。
撸代码
首先定义一个限流接口:
/***
*
* title: 分布式限流器
*
* @author HadLuo
* @date 2021-5-28 9:45:41
*/
public interface ClusterRateLimiter {
/***
*
* title: 返回0 表示 限流住了,否则返回剩余次数
*
* @param key:限流标识
* @param limitCount:1个单位时间内允许通过的最大次数
* @return
* @author HadLuo 2021-5-28 9:48:45
*/
public Long acquire(String key, Integer limitCount);
/***
*
* title: 返回0 表示 限流住了,否则返回剩余次数
*
* @param key:限流标识
* @param limitCount:单位时间内允许通过的最大次数
* @param interval:单位时间
* @return
* @author HadLuo 2021-5-28 9:48:45
*/
public Long acquire(String key, Integer limitCount, Integer interval);
}
计数器实现类:
package com.uc.framework.redis.limit;
import java.util.Collections;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.util.StringUtils;
/***
*
* title: Redis + Lua 限流实现 计数器限流
*
* @author HadLuo
* @date 2021-5-28 9:41:19
*/
public class RedisLuaOfCountedLimiter implements InitializingBean, ClusterRateLimiter {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
DefaultRedisScript<Long> script;
@Override
public void afterPropertiesSet() throws Exception {
// 加载 lua
script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("counter_limit.lua")));
script.setResultType(Long.class);
}
@Override
public Long acquire(String key, Integer limitCount, Integer interval) {
if (StringUtils.isEmpty(key) || limitCount <= 0 || interval <= 0) {
return 0L;
}
return (Long)redisTemplate.execute(script, Collections.singletonList(getKey(key)), limitCount + "",
interval + "");
}
private String getKey(String key) {
return "cluster:limit:" + key;
}
@Override
public Long acquire(String key, Integer limitCount) {
throw new UnsupportedOperationException();
}
}
滑动窗口实现类:
package com.uc.framework.redis.limit;
import java.util.Collections;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.util.StringUtils;
/***
*
* title: Redis + Lua 限流实现 滑动窗口限流
*
* @author HadLuo
* @date 2021-5-28 9:41:19
*/
public class RedisLuaOfSlidingLimiter implements InitializingBean, ClusterRateLimiter {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
DefaultRedisScript<Long> script;
@Override
public void afterPropertiesSet() throws Exception {
// 加载 lua
script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("sliding_limit.lua")));
script.setResultType(Long.class);
}
@Override
public Long acquire(String key, Integer limitCount) {
if (StringUtils.isEmpty(key) || limitCount <= 0) {
return 0L;
}
// 1秒 = 1 000 000 000纳秒
final long windowSize = 1000000000L;
// 1000000000
Long ret = (Long)redisTemplate.execute(script, Collections.singletonList(getKey(key)), System.nanoTime() + "",
windowSize + "", limitCount + "");
return ret;
}
private String getKey(String key) {
return "cluster:limit:" + key;
}
@Override
public Long acquire(String key, Integer limitCount, Integer interval) {
throw new UnsupportedOperationException();
}
}
Context环境,用来构造上面两个类:
package com.uc.framework.redis.limit;
import com.uc.framework.App;
public final class LimiterContext {
public enum Typer {
Second, Minute, Hour, Day
}
private static volatile ClusterRateLimiter counter, sliding;
private static final Object LockOfSliding = new Object();
private static final Object LockOfCounter = new Object();
public static boolean acquire(Typer typer, String key, int limitCount) {
if (typer == Typer.Second) {
// 精度高,走滑动窗口
if (sliding == null) {
synchronized (LockOfSliding) {
if (sliding == null) {
// 由于RedisLuaOfSlidingLimiter里面注入了RedisTemplate ,所以要注入到spring
sliding = App.registerBeanDefinition(RedisLuaOfSlidingLimiter.class);
}
}
}
long ret = sliding.acquire(key, limitCount);
return ret > 0;
}
// 按 每分钟, 每小时 ,每天 限流 走计数器, 不耗资源
if (counter == null) {
synchronized (LockOfCounter) {
if (counter == null) {
counter = App.registerBeanDefinition(RedisLuaOfCountedLimiter.class);
}
}
}
int segment;
if (typer == Typer.Minute) {
segment = 60; // 分
} else if (typer == Typer.Hour) {
segment = 60 * 60; // 时
} else {
segment = 24 * 60 * 60; // 天
}
long ret = counter.acquire(key, limitCount, segment);
return ret > 0;
}
}
我们是判断秒级限流,就用RedisLuaOfSlidingLimiter滑动窗口实现,否则 就是 RedisLuaOfCountedLimiter计数器实现,两个类都使用动态注册到spring。
App代码:
package com.uc.framework;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Component;
import com.google.common.base.Preconditions;
/**
* App spring 句柄方便获取
*
* @author HadLuo
* @date 2020-9-2 11:15:54
*/
@Component
public class App implements BeanFactoryAware {
private static BeanFactory beanFactory;
public static BeanFactory getBeanFactory() {
return beanFactory;
}
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
Preconditions.checkNotNull(beanFactory);
App.beanFactory = beanFactory;
}
public static Object getBean(String name) throws BeansException {
// TODO Auto-generated method stub
return beanFactory.getBean(name);
}
// -----------自己实现 其它方法
/***
*
* title: 动态注入spring
*
* @param clazz
* @author HadLuo 2021-5-31 10:42:24
* @param <T>
*/
public static <T> T registerBeanDefinition(Class<T> clazz) {
BeanDefinition beanDefinition = new GenericBeanDefinition();
beanDefinition.setBeanClassName(clazz.getName());
((DefaultListableBeanFactory)beanFactory).registerBeanDefinition(clazz.getName(), beanDefinition);
return beanFactory.getBean(clazz);
}
}
RedisTemplate 和 App配置:
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
template.setConnectionFactory(factory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(stringRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(stringRedisSerializer);
// template.afterPropertiesSet();
return template;
}
@Bean
public App app() {
return new App();
}
接下来 就只有两个lua脚本了。放在环境resource目录下即可:
计数器counter_limit.lua :
local identify = tostring(KEYS[1])
local expireSeconds = tonumber(ARGV[2])
local limitTimes = tonumber(ARGV[1])
- 当前次数值
local times = redis.call("GET", identify)
- 不为nil, lua里面nil判断要用false
if times ~= false then
times = tonumber(times)
if times >= limitTimes then - 大于限流次数,返回限流了
return 0
else
redis.call("INCR", identify) - 增加次数
return 1
end
end
-- 不存在的话,设置为1并设置过期时间
local flag = redis.call("SETEX", identify, expireSeconds, 1)
return 1
滑动窗口lua脚本 , 比较重要。