分散式---基於Redis進行介面IP限流
場景
為了防止我們的介面被人惡意訪問,比如有人通過JMeter工具頻繁訪問我們的介面,導致介面響應變慢甚至崩潰,所以我們需要對一些特定的介面進行IP限流,即一定時間內同一IP訪問的次數是有限的。
實現原理
用Redis作為限流元件的核心的原理,將使用者的IP地址當Key,一段時間內訪問次數為value,同時設定該Key過期時間。
比如某介面設定相同IP10秒
內請求5次
,超過5次不讓訪問該介面。
1. 第一次該IP地址存入redis的時候,key值為IP地址,value值為1,設定key值過期時間為10秒。
2. 第二次該IP地址存入redis時,如果key沒有過期,那麼更新value為2。
3. 以此類推當value已經為5時,如果下次該IP地址在存入redis同時key還沒有過期,那麼該Ip就不能訪問了。
4. 當10秒後,該key值過期,那麼該IP地址再進來,value又從1開始,過期時間還是10秒,這樣反反覆覆。
說明
從上面的邏輯可以看出,是一時間段內訪問次數受限,不是完全不讓該IP訪問介面。
技術框架
SpringBoot + RedisTemplate (採用自定義註解完成)
這個可以用於真實專案開發場景。
一、程式碼
1、自定義註解
這邊採用自定義註解的目的就是,在介面上使用自定義註解,讓程式碼看去非常整潔。
IpLimiter
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IpLimiter {
/**
* 限流ip
*/
String ipAdress() ;
/**
* 單位時間限制通過請求數
*/
long limit() default 10;
/**
* 單位時間,單位秒
*/
long time() default 1;
/**
* 達到限流提示語
*/
String message();
}
2、測試介面
在介面上使用了自定義註解@IpLimiter
@Controller
public class IpController {
private static final Logger LOGGER = LoggerFactory.getLogger(IpController.class);
private static final String MESSAGE = "請求失敗,你的IP訪問太頻繁";
//這裡就不獲取請求的ip,而是寫死一個IP
@ResponseBody
@RequestMapping("iplimiter")
@IpLimiter(ipAdress = "127.198.66.01", limit = 5, time = 10, message = MESSAGE)
public String sendPayment(HttpServletRequest request) throws Exception {
return "請求成功";
}
@ResponseBody
@RequestMapping("iplimiter1")
@IpLimiter(ipAdress = "127.188.145.54", limit = 4, time = 10, message = MESSAGE)
public String sendPayment1(HttpServletRequest request) throws Exception {
return "請求成功";
}
}
3、處理IpLimter註解的AOP
這邊採用切面的方式處理自定義註解。同時為了保證原子性,這邊寫了redis指令碼ipLimiter.lua
來執行redis命令,來保證操作原子性。
@Aspect
@Component
public class IpLimterHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(IpLimterHandler.class);
@Autowired
RedisTemplate redisTemplate;
/**
* getRedisScript 讀取指令碼工具類
* 這裡設定為Long,是因為ipLimiter.lua 指令碼返回的是數字型別
*/
private DefaultRedisScript<Long> getRedisScript;
@PostConstruct
public void init() {
getRedisScript = new DefaultRedisScript<>();
getRedisScript.setResultType(Long.class);
getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("ipLimiter.lua")));
LOGGER.info("IpLimterHandler[分散式限流處理器]指令碼載入完成");
}
/**
* 這個切點可以不要,因為下面的本身就是個註解
*/
// @Pointcut("@annotation(com.jincou.iplimiter.annotation.IpLimiter)")
// public void rateLimiter() {}
/**
* 如果保留上面這個切點,那麼這裡可以寫成
* @Around("rateLimiter()&&@annotation(ipLimiter)")
*/
@Around("@annotation(ipLimiter)")
public Object around(ProceedingJoinPoint proceedingJoinPoint, IpLimiter ipLimiter) throws Throwable {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("IpLimterHandler[分散式限流處理器]開始執行限流操作");
}
Signature signature = proceedingJoinPoint.getSignature();
if (!(signature instanceof MethodSignature)) {
throw new IllegalArgumentException("the Annotation @IpLimter must used on method!");
}
/**
* 獲取註解引數
*/
// 限流模組IP
String limitIp = ipLimiter.ipAdress();
Preconditions.checkNotNull(limitIp);
// 限流閾值
long limitTimes = ipLimiter.limit();
// 限流超時時間
long expireTime = ipLimiter.time();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("IpLimterHandler[分散式限流處理器]引數值為-limitTimes={},limitTimeout={}", limitTimes, expireTime);
}
// 限流提示語
String message = ipLimiter.message();
/**
* 執行Lua指令碼
*/
List<String> ipList = new ArrayList();
// 設定key值為註解中的值
ipList.add(limitIp);
/**
* 呼叫指令碼並執行
*/
Long result = (Long) redisTemplate.execute(getRedisScript, ipList, expireTime, limitTimes);
if (result == 0) {
String msg = "由於超過單位時間=" + expireTime + "-允許的請求次數=" + limitTimes + "[觸發限流]";
LOGGER.debug(msg);
// 達到限流返回給前端資訊
return message;
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("IpLimterHandler[分散式限流處理器]限流執行結果-result={},請求[正常]響應", result);
}
return proceedingJoinPoint.proceed();
}
}
4、RedisCacheConfig(配置類)
@Configuration
public class RedisCacheConfig {
private static final Logger LOGGER = LoggerFactory.getLogger(RedisCacheConfig.class);
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
//使用Jackson2JsonRedisSerializer來序列化和反序列化redis的value值(預設使用JDK的序列化方式)
Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
template.setValueSerializer(serializer);
//使用StringRedisSerializer來序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
LOGGER.info("Springboot RedisTemplate 載入完成");
return template;
}
}
5、ipLimiter.lua 指令碼
優點
減少網路的開銷
: 指令碼只執行一次,不需要傳送多次請求, 減少網路傳輸;
保證原子操作
: 整個指令碼作為一個原子執行, 就不用擔心併發問題;
--獲取KEY
local key1 = KEYS[1]
local val = redis.call('incr', key1)
local ttl = redis.call('ttl', key1)
--獲取ARGV內的引數並列印
local expire = ARGV[1]
local times = ARGV[2]
redis.log(redis.LOG_DEBUG,tostring(times))
redis.log(redis.LOG_DEBUG,tostring(expire))
redis.log(redis.LOG_NOTICE, "incr "..key1.." "..val);
if val == 1 then
redis.call('expire', key1, tonumber(expire))
else
if ttl == -1 then
redis.call('expire', key1, tonumber(expire))
end
end
if val > tonumber(times) then
return 0
end
return 1
6、application.properties
#redis
spring.redis.hostName=
spring.redis.host=
spring.redis.port=6379
spring.redis.jedis.pool.max-active=8
spring.redis.jedis.pool.max-wait=
spring.redis.jedis.pool.max-idle=8
spring.redis.jedis.pool.min-idle=10
spring.redis.timeout=100ms
spring.redis.password=
logging.path= /Users/xub/log
logging.level.com.jincou.iplimiter=DEBUG
server.port=8888
7、SpringBoot啟動類
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
8、測試
完美
上面這個測試非常符合我們的預期,前五次訪問介面是成功的,後面就失敗了,直到10秒後才可以重新訪問,這樣反反覆覆。
其它的這邊就不一一展示了,附上該專案原始碼。
Github地址
https://github.com/yudiandemingzi/ipLimiter
參考
這個設計是我在刷github的時候看到確實很好,我這邊只是在它的基礎上做了點小改動,非常感謝該作者的分享。
github地址:https://github.com/TaXueWWL/shleld-ratelimter
有關AOP有篇文章講的不錯:spring aop 中@annotation()的使用
只要自己變優秀了,其他的事情才會跟著好起來(中將1)