文章參考翻譯自搜雲庫的一篇文章:原文詳細地址
高併發系統時有三把利器可以保護系統穩定:限流、降級、緩存。今天聊聊限流方案以及實現
▎瞭解什麼是限流、以及限流的意義
爲什麼需要限流呢?相信大家都經歷過春運高鐵的安檢,場景如下
爲什麼要擺這樣的長龍陣進站呢?答案就是爲了限流,如果一下涌進去太多人會對安檢造成過大的負擔,存在安全隱患
聯繫到互聯網場景中,某些高併發系統的流量巨大,尤其像網站的促銷秒殺活動,爲了保證系統不被巨大的流量壓垮,上線前會做流量峯值的評估,其中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次請求被拒絕
----------------------------分割線-----------------------------------
----------------------------分割線-----------------------------------
----------------------------分割線-----------------------------------