一、Maven依賴
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.0.2.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
二、application.properties中加入redis相關配置
# Redis數據庫索引(默認為0) spring.redis.database=0 # Redis服務器地址 spring.redis.host=192.168.0.24 # Redis服務器連接端口 spring.redis.port=6379 # Redis服務器連接密碼(默認為空) spring.redis.password= # 連接池最大連接數(使用負值表示沒有限制) spring.redis.pool.max-active=200 # 連接池最大阻塞等待時間(使用負值表示沒有限制) spring.redis.pool.max-wait=-1 # 連接池中的最大空閑連接 spring.redis.pool.max-idle=10 # 連接池中的最小空閑連接 spring.redis.pool.min-idle=0 # 連接超時時間(毫秒) spring.redis.timeout=1000
三、寫一個redis配置類
其實現在就可以在代碼中注入RedisTemplate,為啥可以直接注入呢?先看下源碼吧。下圖為 RedisAutoConfiguration類中的截圖和代碼:
@Configuration @ConditionalOnClass(RedisOperations.class) @EnableConfigurationProperties(RedisProperties.class) @Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class }) public class RedisAutoConfiguration { @Bean @ConditionalOnMissingBean(name = "redisTemplate") public RedisTemplate<Object, Object> redisTemplate( RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); return template; } @Bean @ConditionalOnMissingBean public StringRedisTemplate stringRedisTemplate( RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { StringRedisTemplate template = new StringRedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; } }
重新配置一個RedisTemplate
package com.zxy.demo.redis; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; /** * redis配置類 * @author ZENG.XIAO.YAN * @date 2018年6月6日 * */ @Configuration public class RedisConfig { @Bean @SuppressWarnings("all") public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<String, Object>(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(om); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); // key采用String的序列化方式 template.setKeySerializer(stringRedisSerializer); // hash的key也采用String的序列化方式 template.setHashKeySerializer(stringRedisSerializer); // value序列化方式采用jackson template.setValueSerializer(jackson2JsonRedisSerializer); // hash的value序列化方式采用jackson template.setHashValueSerializer(jackson2JsonRedisSerializer); template.afterPropertiesSet(); return template; } }
四、寫一個Redis工具類

package com.zxy.demo.redis; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; /** * Redis工具類 * @author ZENG.XIAO.YAN * @date 2018年6月7日 */ @Component public final class RedisUtil { @Autowired private RedisTemplate<String, Object> redisTemplate; // =============================common============================ /** * 指定緩存失效時間 * @param key 鍵 * @param time 時間(秒) * @return */ public boolean expire(String key, long time) { try { if (time > 0) { redisTemplate.expire(key, time, TimeUnit.SECONDS); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 根據key 獲取過期時間 * @param key 鍵 不能為null * @return 時間(秒) 返回0代表為永久有效 */ public long getExpire(String key) { return redisTemplate.getExpire(key, TimeUnit.SECONDS); } /** * 判斷key是否存在 * @param key 鍵 * @return true 存在 false不存在 */ public boolean hasKey(String key) { try { return redisTemplate.hasKey(key); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 刪除緩存 * @param key 可以傳一個值 或多個 */ @SuppressWarnings("unchecked") public void del(String... key) { if (key != null && key.length > 0) { if (key.length == 1) { redisTemplate.delete(key[0]); } else { redisTemplate.delete(CollectionUtils.arrayToList(key)); } } } // ============================String============================= /** * 普通緩存獲取 * @param key 鍵 * @return 值 */ public Object get(String key) { return key == null ? null : redisTemplate.opsForValue().get(key); } /** * 普通緩存放入 * @param key 鍵 * @param value 值 * @return true成功 false失敗 */ public boolean set(String key, Object value) { try { redisTemplate.opsForValue().set(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 普通緩存放入並設置時間 * @param key 鍵 * @param value 值 * @param time 時間(秒) time要大於0 如果time小於等於0 將設置無限期 * @return true成功 false 失敗 */ public boolean set(String key, Object value, long time) { try { if (time > 0) { redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); } else { set(key, value); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 遞增 * @param key 鍵 * @param delta 要增加幾(大於0) * @return */ public long incr(String key, long delta) { if (delta < 0) { throw new RuntimeException("遞增因子必須大於0"); } return redisTemplate.opsForValue().increment(key, delta); } /** * 遞減 * @param key 鍵 * @param delta 要減少幾(小於0) * @return */ public long decr(String key, long delta) { if (delta < 0) { throw new RuntimeException("遞減因子必須大於0"); } return redisTemplate.opsForValue().increment(key, -delta); } // ================================Map================================= /** * HashGet * @param key 鍵 不能為null * @param item 項 不能為null * @return 值 */ public Object hget(String key, String item) { return redisTemplate.opsForHash().get(key, item); } /** * 獲取hashKey對應的所有鍵值 * @param key 鍵 * @return 對應的多個鍵值 */ public Map<Object, Object> hmget(String key) { return redisTemplate.opsForHash().entries(key); } /** * HashSet * @param key 鍵 * @param map 對應多個鍵值 * @return true 成功 false 失敗 */ public boolean hmset(String key, Map<String, Object> map) { try { redisTemplate.opsForHash().putAll(key, map); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * HashSet 並設置時間 * @param key 鍵 * @param map 對應多個鍵值 * @param time 時間(秒) * @return true成功 false失敗 */ public boolean hmset(String key, Map<String, Object> map, long time) { try { redisTemplate.opsForHash().putAll(key, map); if (time > 0) { expire(key, time); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 向一張hash表中放入數據,如果不存在將創建 * @param key 鍵 * @param item 項 * @param value 值 * @return true 成功 false失敗 */ public boolean hset(String key, String item, Object value) { try { redisTemplate.opsForHash().put(key, item, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 向一張hash表中放入數據,如果不存在將創建 * @param key 鍵 * @param item 項 * @param value 值 * @param time 時間(秒) 注意:如果已存在的hash表有時間,這里將會替換原有的時間 * @return true 成功 false失敗 */ public boolean hset(String key, String item, Object value, long time) { try { redisTemplate.opsForHash().put(key, item, value); if (time > 0) { expire(key, time); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 刪除hash表中的值 * @param key 鍵 不能為null * @param item 項 可以使多個 不能為null */ public void hdel(String key, Object... item) { redisTemplate.opsForHash().delete(key, item); } /** * 判斷hash表中是否有該項的值 * @param key 鍵 不能為null * @param item 項 不能為null * @return true 存在 false不存在 */ public boolean hHasKey(String key, String item) { return redisTemplate.opsForHash().hasKey(key, item); } /** * hash遞增 如果不存在,就會創建一個 並把新增后的值返回 * @param key 鍵 * @param item 項 * @param by 要增加幾(大於0) * @return */ public double hincr(String key, String item, double by) { return redisTemplate.opsForHash().increment(key, item, by); } /** * hash遞減 * @param key 鍵 * @param item 項 * @param by 要減少記(小於0) * @return */ public double hdecr(String key, String item, double by) { return redisTemplate.opsForHash().increment(key, item, -by); } // ============================set============================= /** * 根據key獲取Set中的所有值 * @param key 鍵 * @return */ public Set<Object> sGet(String key) { try { return redisTemplate.opsForSet().members(key); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 根據value從一個set中查詢,是否存在 * @param key 鍵 * @param value 值 * @return true 存在 false不存在 */ public boolean sHasKey(String key, Object value) { try { return redisTemplate.opsForSet().isMember(key, value); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 將數據放入set緩存 * @param key 鍵 * @param values 值 可以是多個 * @return 成功個數 */ public long sSet(String key, Object... values) { try { return redisTemplate.opsForSet().add(key, values); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 將set數據放入緩存 * @param key 鍵 * @param time 時間(秒) * @param values 值 可以是多個 * @return 成功個數 */ public long sSetAndTime(String key, long time, Object... values) { try { Long count = redisTemplate.opsForSet().add(key, values); if (time > 0) expire(key, time); return count; } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 獲取set緩存的長度 * @param key 鍵 * @return */ public long sGetSetSize(String key) { try { return redisTemplate.opsForSet().size(key); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 移除值為value的 * @param key 鍵 * @param values 值 可以是多個 * @return 移除的個數 */ public long setRemove(String key, Object... values) { try { Long count = redisTemplate.opsForSet().remove(key, values); return count; } catch (Exception e) { e.printStackTrace(); return 0; } } // ===============================list================================= /** * 獲取list緩存的內容 * @param key 鍵 * @param start 開始 * @param end 結束 0 到 -1代表所有值 * @return */ public List<Object> lGet(String key, long start, long end) { try { return redisTemplate.opsForList().range(key, start, end); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 獲取list緩存的長度 * @param key 鍵 * @return */ public long lGetListSize(String key) { try { return redisTemplate.opsForList().size(key); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 通過索引 獲取list中的值 * @param key 鍵 * @param index 索引 index>=0時, 0 表頭,1 第二個元素,依次類推;index<0時,-1,表尾,-2倒數第二個元素,依次類推 * @return */ public Object lGetIndex(String key, long index) { try { return redisTemplate.opsForList().index(key, index); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 將list放入緩存 * @param key 鍵 * @param value 值 * @param time 時間(秒) * @return */ public boolean lSet(String key, Object value) { try { redisTemplate.opsForList().rightPush(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 將list放入緩存 * @param key 鍵 * @param value 值 * @param time 時間(秒) * @return */ public boolean lSet(String key, Object value, long time) { try { redisTemplate.opsForList().rightPush(key, value); if (time > 0) expire(key, time); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 將list放入緩存 * @param key 鍵 * @param value 值 * @param time 時間(秒) * @return */ public boolean lSet(String key, List<Object> value) { try { redisTemplate.opsForList().rightPushAll(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 將list放入緩存 * * @param key 鍵 * @param value 值 * @param time 時間(秒) * @return */ public boolean lSet(String key, List<Object> value, long time) { try { redisTemplate.opsForList().rightPushAll(key, value); if (time > 0) expire(key, time); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 根據索引修改list中的某條數據 * @param key 鍵 * @param index 索引 * @param value 值 * @return */ public boolean lUpdateIndex(String key, long index, Object value) { try { redisTemplate.opsForList().set(key, index, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 移除N個值為value * @param key 鍵 * @param count 移除多少個 * @param value 值 * @return 移除的個數 */ public long lRemove(String key, long count, Object value) { try { Long remove = redisTemplate.opsForList().remove(key, count, value); return remove; } catch (Exception e) { e.printStackTrace(); return 0; } } }
注意 : 設置下key和value的序列化方式,不然存到Redis的中數據看起來像亂碼一下。
五、通過redisTemplate調用lua腳本 並打印調試信息到redis log
第一次寫完lua時,想到的就是如何在應用調用腳本的時候,去調試腳本。在網上海搜了一把,能找到的有點相關的寥寥無幾。
有一種方法是通過執行redis命令,調用redis客戶端,加載lua腳本,然后出現基於命令行調試的交互界面,輸入調試命令去調試腳本。如下:
在終端輸入命令:redis-cli.exe --ldb --eval LimitLoadTimes.lua 1 mykey , myargv
--ldb:redis-cli.exe進行命令調試的必要參數
--eval:告訴redis客戶端去加載Lua腳本,后面跟着的就是 lua腳本的路徑(我是直接放在redis目錄下),
1:傳給Lua腳本的key的數量,我測試的時候是1
--mykey:自己傳的一個key值,和前面的數量1對應
--myargv:自己傳的除key外的參數,可以是多個
注,命令中的逗號不能忽略,並且前后要有一個空格
回車,如上圖,本來以為可以進入調試,結果等了半天,一直沒有出現交互的命令行界面,找了好久,還是沒找到辦法,結果只好先暫停(如果有大神遇到這種情況,跪求解~~)。換一種調試方式,把調試信息打在redis日志上。
下面是我自己調用腳本時,打印調試信息的方式,如果有更好的方式,請不吝賜教。
1、選擇redisTemplate序列化方式
首先,創建一個redisTemplate,具體代碼就不說了,這個比較簡單。要注意的是,需要設置redisTemplate的序列化方式,springBoot默認是基於java jdk的序列化。通過這種序列化后的參數傳到Lua腳本是,是無法正常打印到redis日志的,會出現亂碼,而且參數如果傳的是一個Map或List的話,不方便解析。並且這種序列化占用的字節比較大。所以改成JSON序列化,用FastJson實現。
下面貼上redis序列化代碼:
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> { public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); private Class<T> clazz; public FastJsonRedisSerializer(Class<T> clazz){ super(); this.clazz = clazz; } @Override public byte[] serialize(T t) throws SerializationException { return ofNullable(t) .map(r -> JSON.toJSONString(r, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET)) .orElseGet(() -> new byte[0]); } @Override public T deserialize(byte[] bytes) throws SerializationException { return Optional.ofNullable(bytes) .map(t -> JSON.parseObject(new String(t, DEFAULT_CHARSET), clazz)) .orElseGet(() -> null); } }
2、應用端加載腳本,並設置傳遞參數
在springboot中,是用 DefaultRedisScript 類來加載腳本的,並設置相應的數據類型來接收lua腳本返回的數據,這個泛型類在使用時設置泛型是什么類型,腳本返回的結果就是用什么類型接收。注意,該類只接收4種類型的返回類型,之前沒注意,還納悶為什么出錯,看源碼才曉得,截圖,如下:
在lua腳本中,有兩個全局的變量,是用來接收redis應用端傳遞的鍵值和其它參數的,分別為KEYS、ARGV。
在應用端傳遞給KEYS時是一個數組列表,在lua腳本中通過索引方式獲取數組內的值。
在應用端,傳遞給ARGV的參數比較靈活,可以是多個獨立的參數,但對應到Lua腳本中是,統一用ARGV這個數組接收,獲取方式也是通過數組下標獲取。
下面貼上應用端的測試代碼:
@Service("luaScriptService") public class LuaScriptServiceImpl implements LuaScriptService{ @Autowired private RedisTemplate<String,Object> redisTemplate1; private DefaultRedisScript<List> getRedisScript; @PostConstruct public void init(){ getRedisScript = new DefaultRedisScript<List>(); getRedisScript.setResultType(List.class); getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/LimitLoadTimes.lua"))); } @Override public void redisAddScriptExec(){ /** * List設置lua的KEYS */ List<String> keyList = new ArrayList(); keyList.add("count"); keyList.add("rate.limiting:127.0.0.1"); /** * 用Mpa設置Lua的ARGV[1] */ Map<String,Object> argvMap = new HashMap<String,Object>(); argvMap.put("expire",10000); argvMap.put("times",10); /** * 調用腳本並執行 */ List result = redisTemplate1.execute(getRedisScript,keyList, argvMap); System.out.println(result); } }
代碼中發送了兩個key,還有一個map包裝的argv,傳遞到Lua腳本中時,KEYS和ARGV接收到的是對象字符串,所以得用lua的庫做相關的解碼,我們發送的時候是用json序列化的,用Lua的庫cjson可以轉成json對象。下面貼上Lua腳本代碼:
--獲取KEY local key1 = KEYS[1] local key2 = KEYS[2] -- 獲取ARGV[1],這里對應到應用端是一個List<Map>. -- 注意,這里接收到是的字符串,所以需要用csjon庫解碼成table類型 local receive_arg_json = cjson.decode(ARGV[1]) --返回的變量 local result = {} --打印日志到reids --注意,這里的打印日志級別,需要和redis.conf配置文件中的日志設置級別一致才行 redis.log(redis.LOG_DEBUG,key1) redis.log(redis.LOG_DEBUG,key2) redis.log(redis.LOG_DEBUG, ARGV[1],#ARGV[1]) --獲取ARGV內的參數並打印 local expire = receive_arg_json.expire local times = receive_arg_json.times redis.log(redis.LOG_DEBUG,tostring(times)) redis.log(redis.LOG_DEBUG,tostring(expire)) --往redis設置值 redis.call("set",key1,times) redis.call("incr",key2) redis.call("expire",key2,expire) --用一個臨時變量來存放json,json是要放入要返回的數組中的 local jsonRedisTemp={} jsonRedisTemp[key1] = redis.call("get",key1) jsonRedisTemp[key2] = redis.call("get", key2) jsonRedisTemp["ttl"] = redis.call("ttl",key2) redis.log(redis.LOG_DEBUG, cjson.encode(jsonRedisTemp)) result[1] = cjson.encode(jsonRedisTemp) --springboot redistemplate接收的是List,如果返回的數組內容是json對象,需要將json對象轉成字符串,客戶端才能接收 result[2] = ARGV[1] --將源參數內容一起返回 redis.log(redis.LOG_DEBUG,cjson.encode(result)) --打印返回的數組結果,這里返回需要以字符返回 return result
3、設置日志級別
代碼中,redis.log()函數向運日志中輸出信息,這里要注意一下,函數里面設置的日志級別要和redis.conf配置文件中設置的日志級別一樣才能正常打印到文件,這里我是設置成了deubg級別。這里可設置的級別有4種,分別如下:
- redis.LOG_DEBUG
- redis.LOG_VERBOSE
- redis.LOG_NOTICE
- redis.LOG_WARNING
在應用端,我們設置接收返回的數據類型是List,所以在Lua腳本中,返回的類型用table與之對應,並且放到table變量中的內容,得是字符串,應用端才能通過反序列化,正常解析。下圖是輸出lua返回值的打印信息:

public class LuaDemo { private final static String lua = "local num=redis.call('incr',KEYS[1])\n" + "if tonumber(num)==1 then\n" + "\tredis.call('expire',KEYS[1],ARGV[1])\n" + "\treturn 1\n" + "elseif tonumber(num)>tonumber(ARGV[2]) then\n" + "\treturn 0\n" + "else \n" + "\treturn 1\n" + "end"; /** * 這是將腳本提取到外面為常量,用 jedis.evalsha()加載 */ public static void main(String[] args) { Jedis jedis = RedisManager.getJedis(); List<String> keys = new ArrayList<>(); keys.add("ip:limit:127.0.0.1"); List<String> arggs = new ArrayList<>(); arggs.add("6000"); arggs.add("10"); String luaLoad = jedis.scriptLoad(lua); System.out.println(luaLoad); Object obj = jedis.evalsha(luaLoad,keys,arggs); System.out.println(obj); } /** * 這是直接將腳本寫死在代碼中,用 jedis.eval() */ public static void method(){ Jedis jedis = RedisManager.getJedis(); String lua = "local num=redis.call('incr',KEYS[1])\n" + "if tonumber(num)==1 then\n" + "\tredis.call('expire',KEYS[1],ARGV[1])\n" + "\treturn 1\n" + "elseif tonumber(num)>tonumber(ARGV[2]) then\n" + "\treturn 0\n" + "else \n" + "\treturn 1\n" + "end"; List<String> keys = new ArrayList<>(); keys.add("ip:limit:127.0.0.1"); List<String> arggs = new ArrayList<>(); arggs.add("6000"); arggs.add("10"); Object obj = jedis.eval(lua,keys,arggs); System.out.println(obj); } }
六、Redis + Lua 限流
基於Redis的限流系統的設計,主要會談及限流系統中限流策略這個功能的設計;在實現方面,算法使用的是令牌桶算法來,訪問Redis使用lua腳本。
Lua 嵌入 Redis 優勢:
-
減少網絡開銷: 不使用 Lua 的代碼需要向 Redis 發送多次請求, 而腳本只需一次即可, 減少網絡傳輸;
-
原子操作: Redis 將整個腳本作為一個原子執行, 無需擔心並發, 也就無需事務;
-
復用: 腳本會永久保存 Redis 中, 其他客戶端可繼續使用.
1、概念
In computer networks, rate limiting is used to control the rate of traffic sent or received by a network interface controller and is used to prevent DoS attacks
用我的理解翻譯一下:限流是對系統的出入流量進行控制,防止大流量出入,導致資源不足,系統不穩定。
限流系統是對資源訪問的控制組件,控制主要的兩個功能:限流策略和熔斷策略,對於熔斷策略,不同的系統有不同的熔斷策略訴求,有的系統希望直接拒絕、有的系統希望排隊等待、有的系統希望服務降級、有的系統會定制自己的熔斷策略,很難一一列舉,所以本文只針對限流策略這個功能做詳細的設計。
針對限流策略這個功能,限流系統中有兩個基礎概念:資源和策略。
-
資源 :或者叫稀缺資源,被流量控制的對象;比如寫接口、外部商戶接口、大流量下的讀接口
-
策略 :限流策略由限流算法和可調節的參數兩部分組成
熔斷策略:超出速率閾值的請求的處理策略,是我自己理解的一個叫法,不是業界主流的說法。
2、限流算法
-
限制瞬時並發數
-
限制時間窗最大請求數
-
令牌桶
2.1、限制瞬時並發數
定義:瞬時並發數,系統同時處理的請求/事務數量
優點:這個算法能夠實現控制並發數的效果
缺點:使用場景比較單一,一般用來對入流量進行控制
java偽代碼實現:
AtomicInteger atomic = new AtomicInteger(1) try { if(atomic.incrementAndGet() > 限流數) { //熔斷邏輯 } else { //處理邏輯 } } finally { atomic.decrementAndGet(); }
2.2、限制時間窗最大請求數
定義:時間窗最大請求數,指定的時間范圍內允許的最大請求數
優點:這個算法能夠滿足絕大多數的流控需求,通過時間窗最大請求數可以直接換算出最大的QPS(QPS = 請求數/時間窗)
缺點:這種方式可能會出現流量不平滑的情況,時間窗內一小段流量占比特別大
lua代碼實現:
--- 資源唯一標識 local key = KEYS[1] --- 時間窗最大並發數 local max_window_concurrency = tonumber(ARGV[1]) --- 時間窗 local window = tonumber(ARGV[2]) --- 時間窗內當前並發數 local curr_window_concurrency = tonumber(redis.call('get', key) or 0) if current + 1 > limit then return false else redis.call("INCRBY", key,1) if window > -1 then redis.call("expire", key,window) end return true end ---也可以這樣編寫: local key = KEYS[1] --限流KEY(一秒一個) local limit = tonumber(ARGV[1]) --限流大小 local current = tonumber(redis.call(‘get‘, key) or "0") if current + 1 > limit then --如果超出限流大小 return 0 else --請求數+1,並設置2秒過期 redis.call("INCRBY", key,"1") redis.call("expire", key,"1") return 1 end
2.3、令牌桶
算法描述
-
假如用戶配置的平均發送速率為r,則每隔1/r秒一個令牌被加入到桶中
-
假設桶中最多可以存放b個令牌。如果令牌到達時令牌桶已經滿了,那么這個令牌會被丟棄
-
當流量以速率v進入,從桶中以速率v取令牌,拿到令牌的流量通過,拿不到令牌流量不通過,執行熔斷邏輯
屬性
-
長期來看,符合流量的速率是受到令牌添加速率的影響,被穩定為:r
-
因為令牌桶有一定的存儲量,可以抵擋一定的流量突發情況
-
M是以字節/秒為單位的最大可能傳輸速率:M>r
-
T max = b/(M-r) 承受最大傳輸速率的時間
-
B max = T max * M 承受最大傳輸速率的時間內傳輸的流量
優點:流量比較平滑,並且可以抵擋一定的流量突發情況
因為我們限流系統的實現就是基於令牌桶這個算法,具體的代碼實現參考下文。
3、工程實現
3.1、技術選型
-
mysql:存儲限流策略的參數等元數據
-
redis+lua:令牌桶算法實現
說明:因為我們把redis 定位為:緩存、計算媒介,所以元數據都是存在db中
3.2、架構圖
3.3、 數據結構
字段 | 描述 |
---|---|
name | 令牌桶的唯一標示 |
apps | 能夠使用令牌桶的應用列表 |
max_permits | 令牌桶的最大令牌數 |
rate | 向令牌桶中添加令牌的速率 |
created_by | 創建人 |
updated_by | 更新人 |
限流系統的實現是基於redis的,本可以和應用無關,但是為了做限流元數據配置的統一管理,按應用維度管理和使用,在數據結構中加入了apps這個字段,出現問題,排查起來也比較方便。
3.4、代碼實現
3.4.1、代碼實現遇到的問題
參考令牌桶的算法描述,一般思路是在RateLimiter-client放一個重復執行的線程,線程根據配置往令牌桶里添加令牌,這樣的實現由如下缺點:
-
需要為每個令牌桶配置添加一個重復執行的線程
-
重復的間隔精度不夠精確:線程需要每1/r秒向桶里添加一個令牌,當r>1000 時間線程執行的時間間隔根本沒辦法設置(從后面性能測試的變現來看RateLimiter-client 是可以承擔 QPS > 5000 的請求速率)
3.4.2、解決方案
基於上面的缺點,參考了google的guava中RateLimiter中的實現,我們使用了觸發式添加令牌的方式。
算法描述
-
基於上述的令牌桶算法
-
將添加令牌改成觸發式的方式,取令牌的是做添加令牌的動作
-
在去令牌的時候,通過計算上一次添加令牌和當前的時間差,計算出這段間應該添加的令牌數,然后往桶里添加
-
curr_mill_second = 當前毫秒數
-
last_mill_second = 上一次添加令牌的毫秒數
-
r = 添加令牌的速率
-
reserve_permits = (curr_mill_second-last_mill_second)/1000 * r
-
添加完令牌之后再執行取令牌邏輯
3.4.3、 lua代碼實現
--- 獲取令牌 --- 返回碼 --- 0 沒有令牌桶配置 --- -1 表示取令牌失敗,也就是桶里沒有令牌 --- 1 表示取令牌成功 --- @param key 令牌(資源)的唯一標識 --- @param permits 請求令牌數量 --- @param curr_mill_second 當前毫秒數 --- @param context 使用令牌的應用標識 local function acquire(key, permits, curr_mill_second, context) local rate_limit_info = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate", "apps") local last_mill_second = rate_limit_info[1] local curr_permits = tonumber(rate_limit_info[2]) local max_permits = tonumber(rate_limit_info[3]) local rate = rate_limit_info[4] local apps = rate_limit_info[5] --- 標識沒有配置令牌桶 if type(apps) == 'boolean' or apps == nil or not contains(apps, context) then return 0 end local local_curr_permits = max_permits; --- 令牌桶剛剛創建,上一次獲取令牌的毫秒數為空 --- 根據和上一次向桶里添加令牌的時間和當前時間差,觸發式往桶里添加令牌 --- 並且更新上一次向桶里添加令牌的時間 --- 如果向桶里添加的令牌數不足一個,則不更新上一次向桶里添加令牌的時間 if (type(last_mill_second) ~= 'boolean' and last_mill_second ~= false and last_mill_second ~= nil) then local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate) local expect_curr_permits = reverse_permits + curr_permits; local_curr_permits = math.min(expect_curr_permits, max_permits); --- 大於0表示不是第一次獲取令牌,也沒有向桶里添加令牌 if (reverse_permits > 0) then redis.pcall("HSET", key, "last_mill_second", curr_mill_second) end else redis.pcall("HSET", key, "last_mill_second", curr_mill_second) end local result = -1 if (local_curr_permits - permits >= 0) then result = 1 redis.pcall("HSET", key, "curr_permits", local_curr_permits - permits) else redis.pcall("HSET", key, "curr_permits", local_curr_permits) end return result end
關於限流系統的所有實現細節,我都已經放到github上,gitbub地址:https://github.com/wukq/rate-limiter,有興趣的同學可以前往查看,由於筆者經驗與知識有限,代碼中如有錯誤或偏頗,歡迎探討和指正。
3.5、性能測試
配置:aws-elasticcache-redis 2核4g
因為Ratelimiter-client的功能比較簡單,基本上是redis的性能打個折扣。
-
單線程取令牌:Ratelimiter-client的 QPS = 250/s
-
10個線程取令牌:Ratelimiter-client的 QPS = 2000/s
-
100個線程取令牌:Ratelimiter-client的 QPS = 5000/s
4、總結
限流系統從設計到實現都比較簡單,但是確實很實用,用四個字來形容就是:短小強悍,其中比較重要的是結合公司的權限體系和系統結構,設計出符合自己公司規范的限流系統。
不足:
-
redis 我們用的是單點redis,只做了主從,沒有使用redis高可用集群(可能使用redis高可用集群,會帶來新的問題)
-
限流系統目前只做了應用層面的實現,沒有做接口網關上的實現
-
熔斷策略需要自己定制,如果實現的好一點,可以給一些常用的熔斷策略模板
參考文章:
https://blog.csdn.net/fsw4848438/article/details/81540495
https://www.cnblogs.com/zeng1994/p/03303c805731afc9aa9c60dbbd32a323.html