Redis + Lua 實現系統限流

文章參考翻譯自搜雲庫的一篇文章:原文詳細地址

 

高併發系統時有三把利器可以保護系統穩定:限流、降級、緩存。今天聊聊限流方案以及實現

 

▎瞭解什麼是限流、以及限流的意義

爲什麼需要限流呢?相信大家都經歷過春運高鐵的安檢,場景如下

爲什麼要擺這樣的長龍陣進站呢?答案就是爲了限流,如果一下涌進去太多人會對安檢造成過大的負擔,存在安全隱患

聯繫到互聯網場景中,某些高併發系統的流量巨大,尤其像網站的促銷秒殺活動,爲了保證系統不被巨大的流量壓垮,上線前會做流量峯值的評估,其中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次請求被拒絕

----------------------------分割線----------------------------------- 

----------------------------分割線-----------------------------------  

----------------------------分割線-----------------------------------  

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章