安琪拉教妲己分佈式限流
在系統設計中,限流是保障系統高可用的一種常規手段,同樣的手段還有熔斷、服務降級等等,此篇文章作爲一個開端,是《安琪拉教妲己分佈式系統設計》的第一篇
妲己:聽說最近你們系統又對接了幾條業務線,而且早上9.10點鐘流量非常大,你怎麼保證系統不被搞掛的啊?
安琪拉:你算是問對了,最近對接了幾家大機構,同時由於疫情的影響,線上渠道的流量也比平常多了很多,我這邊系統做了很多系統優化,大致可以歸爲以下幾類:
- 限流:對應用入口流量做控制,瞬時流量向後遷移,對下游請求流量做自適應限流,根據接口響應時間動態調整流量。
- 延遲排隊:如果請求量大,按業務線優先級排隊,例如優先保障線上渠道實時的請求,優先級提高
- 路由:其實這個是因爲業務的特殊性,所有的請求都依賴下游第三方的服務,因爲可以將多家下游服務供應商做個動態路由表,將請求優先路由給接口成功率高、耗時低的服務供應商;
- 備份:這基本是所有分佈式組件都會做的,能做多機的不做單機,例如:Redis 做三主三備(集羣)、MySQL分庫分表、MQ 與 Redis 互爲備份(安琪拉遇到過MQ事故)等等;
- 降級:這個是最後的逼不得已的措施,如果遇到全線崩潰,使用降級手段保障系統核心功能可用,或讓模塊達到最小可用。
- 日誌:完整的監控和鏈路日誌,日誌功能很多,也分很多種,一方面是方便排查問題,另一方面可用來做任務重做、數據恢復、狀態持久化等。
妲己:能給我講講限流的基礎概念嗎?
安琪拉:限流,顧名思義,就是限制流量,一般分爲限制入口流量和限制出口流量,入口流量是人家來請求我的系統,我在入口處加了一道閥門,出口流量是我調外部系統,我在出口加一道閥門。
妲己:那一般怎麼來實現限流呢?
安琪拉:如果是單機,可以通過Semphore
限制統一時間請求接口的量,也可以用 Google Guava
包提供的限流包,如果是分佈式環境,可以使用 Redis
實現,也有阿里 Sentinal
或 Spring Cloud Gateway
可以實現限流。我們先來看單機版本的限流,代碼如下:
//一共只有 3 個坑位
private static Semaphore semphore = new Semaphore(3);
private static String[] userName = {"妲己", "亞瑟", "魯班", "甄姬"};
private static Random random = new Random();
//廁所類
public static class Toilet{
public void enter(User user){
try{
semphore.acquire();
if(Thread.interrupted()){
return;
}
System.out.println("時間:" + DateAndTimeUtil.getFormattedDate() + " "+user.getName() + "上廁所");
Thread.sleep(2000);
}catch (InterruptedException ex){
}finally {
semphore.release();
}
}
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
Toilet toilet = new Toilet();
for(int i = 0; i < 20; i++){
executorService.submit(()->{
toilet.enter(new User(userName[random.nextInt(3)]));
});
}
}
這個代碼很好理解,一共就只有三個坑位,使用 Semaphore
定義,“妲己”, “亞瑟”, “魯班”, “甄姬” 輪番上總共二十次廁所,Semaphore
就是鎖的機制,進了廁所,在門上加鎖,看下控制檯輸出:
每次只有三個人能同時上廁所。
妲己:我似乎有點明白了,廁所就是資源,我們上測試就好比請求,大家一起上就產生了流量高峯,那分佈式環境怎麼解決呢?
安琪拉:分佈式環境的思想和單機的思想是一樣的,也是控制資源的訪問頻率,一般主流的設計思想有二種:
-
漏洞算法
把請求比作水,在請求入口和響應請求的服務之間加一個漏桶,桶中的水以恆定的速度流出,這樣保證了服務接收到的流量速度是穩定的,如果桶裏的水滿了,再進來的水就直接溢出(請求直接拒絕)。
漏桶是網絡環境中流量整形(Traffic Shaping)或速率限制(Rate Limiting)時經常使用的一種算法,它的主要目的是控制數據進入到網絡的速率,平滑網絡上的突發流量。
-
令牌桶算法
令牌桶算法有點類似於生產者消費者模式,專門有一個生產者往令牌桶中以恆定速率放入令牌,而請求處理器(消費者)在處理請求時必須先從桶中獲得令牌,如果沒有拿到令牌,有二種策略:一種是直接返回拒絕請求,一種是等待一段時間,再次嘗試獲取令牌。
令牌桶算法用來控制發送到網絡上的數據的數目,並允許突發數據的發送
Google的Guava包中的RateLimiter類就是令牌桶算法的解決方案。
對比一下這二種算法,其實無非是一個在出口處是以恆定的速率出水,一個是以恆定速率放令牌,安琪拉看來區分度不大,只是令牌桶算法更加靈活,往往實際工作中,可以實現動態調整令牌的放入速度、以及令牌桶的總大小。
妲己: 爲什麼我看完覺得二種算法差不多?
安琪拉:令牌桶相比漏桶有個優勢,能夠滿足突發流量的請求。打個比方:如果線上環境資源很空閒,因爲漏洞水流出的速度恆定,請求因爲速度受限不會及時得到響應。比如現在漏洞出水速度是 3個/秒,現在線上來了5個請求,全部進漏桶,漏桶裏面現在一共只有5個請求,但是也只能一秒處理 3 個(出水速度限制)。但是如果是令牌桶算法,放入令牌的速度是 3個/秒,假設令牌桶中已經有二個令牌了,這時來了5個請求,都能拿到令牌完成請求,因此令牌桶算法是面向請求的(請求主動拿令牌,按需分配),而漏洞則是面向令牌,我以恆定的速度出水,纔不管你有多少請求。
妲己:我明白了,那你給我講講 Google Guava怎麼實現令牌桶算法的?
安琪拉:明白了思想之後,很容易理解實現,我們來看一下源代碼:
API 很簡單,只需要指定限流的速度,例如第一個, 速度是每秒鐘2個,如果是分鐘級限流,你也可以設置爲 0.2,代表1秒鐘生成0.2 個令牌,1分鐘限流爲 12個。第二個例子是每秒鐘5000,這個例子演示瞭如何通過限流器限制網絡處理流量爲每秒鐘 5kb。5000個byte。
Guava 還有很多方法,如下:
返回值和方法修飾符 | 方法和描述 |
---|---|
double | acquire() 從RateLimiter獲取一個許可,方法會被阻塞直到獲取到請求 |
double | acquire(int permits) 從RateLimiter獲取指定許可數,方法會被阻塞直到獲取到請求 |
static RateLimiter | create(double permitsPerSecond) 根據每秒放到令牌桶數量創建RateLimiter,這裏的令牌桶數量是指每秒生成令牌數(通常是指QPS,每秒多少查詢) |
static RateLimiter | create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 根據每秒放到令牌桶數量和預熱期來創建RateLimiter,意思是不會一下生成全部的令牌,把令牌桶塞滿,而是會漸進式的增加令牌,這裏的每秒放到令牌桶數量是指每秒生成令牌數(通常是指QPS,每秒多少個請求量),在這段預熱時間內,RateLimiter每秒分配的許可數會平穩地增長直到預熱期結束時達到最大。 |
double | getRate() 返回RateLimiter 配置中的穩定速率,該速率單位是每秒生成多少令牌數 |
void | setRate(double permitsPerSecond) 更新RateLimite的穩定速率,參數permitsPerSecond 由構造RateLimiter的工廠方法提供。 |
boolean | tryAcquire() 從RateLimiter 獲取許可,如果該許可可以在無延遲下的情況下立即獲取得到的話 |
boolean | tryAcquire(int permits) 從RateLimiter 獲取許可數,如果該許可數可以在無延遲下的情況下立即獲取得到的話 |
boolean | tryAcquire(int permits, long timeout, TimeUnit unit) 從RateLimiter 獲取指定許可數如果該許可數可以在不超過timeout的時間內獲取得到的話,或者如果無法在timeout 過期之前獲取得到許可數的話,那麼立即返回false (無需等待) |
妲己:那我們來看一下分佈式限流吧。
安琪拉:分佈式其實就是把本地令牌桶放到一個所有主機都可以訪問的地方。
妲己:一般放哪裏比較合適。
安琪拉:分佈式中間件,例如 Redis,分佈式緩存,生成令牌和獲取令牌都可以用redis指令實現,而且速度還快。
妲己:那你快給我講講怎麼實現。
安琪拉:那我說下我的實現步驟和背景吧。在令牌桶算法中,有一個單獨的生產者以恆定的速率向令牌桶中放入令牌,如果通過redis實現,一個生產者線程不斷往redis添加令牌(寫),其他請求線程每次請求讀redis獲取令牌,這樣會有很大的性能損耗,好的解決辦法是延遲放令牌的操作,獲取令牌的時候才放入令牌,將二個操作合併。
妲己:那獲取令牌的時候怎麼計算應該放桶中放入多少令牌呢?
安琪拉:嗯,這是個好問題,filledTokens
爲這一次需要放入令牌的數量,計算邏輯爲:
此刻應填充令牌數 = min((令牌桶剩餘令牌數 + 當前時間與上一次令牌生成時間間隔 * 令牌生成速度), 令牌總容量)
根據上面的思路,寫一個分佈式限流的Redis腳本,redis提供lua支持,腳本如下:
--打印日誌到reids
--注意,這裏的打印日誌級別,需要和redis server啓動配置文件 redis.conf中的日誌設置級別一致纔行
redis.log(redis.LOG_DEBUG, "start_ratelimit")
redis.log(redis.LOG_DEBUG, KEYS[1])
redis.log(redis.LOG_DEBUG, KEYS[2])
redis.log(redis.LOG_DEBUG, ARGV[1])
redis.log(redis.LOG_DEBUG, ARGV[2])
redis.log(redis.LOG_DEBUG, ARGV[3])
redis.log(redis.LOG_DEBUG, ARGV[4])
local tokens_key = KEYS[1] -- request_rate_limiter.${id}.tokens 令牌桶剩餘令牌數的KEY值
local timestamp_key = KEYS[2] -- 令牌桶最後填充令牌時間的KEY值
local rate = tonumber(ARGV[1]) -- replenishRate 令令牌桶填充平均速率 多長時間生成1個 6秒一個,秒爲單位
local capacity = tonumber(ARGV[2]) -- burstCapacity 令牌桶上限
local now = tonumber(ARGV[3]) -- 得到從 1970-01-01 00:00:00 開始的秒數
local requested = tonumber(ARGV[4]) -- 消耗令牌數量,默認 1
local fill_time = capacity/rate -- 計算令牌桶填充滿令牌需要多久時間 10個
redis.log(redis.LOG_DEBUG, "--fill_time--")
redis.log(redis.LOG_DEBUG, fill_time)
local ttl = math.floor(fill_time*2) -- *2 保證時間充足
redis.log(redis.LOG_DEBUG, "--fill_time--")
redis.log(redis.LOG_DEBUG, ttl)
local last_tokens = tonumber(redis.call("get", tokens_key))
-- 獲得令牌桶剩餘令牌數
if last_tokens == nil then -- 第一次時,沒有數值,所以桶時滿的
last_tokens = capacity
end
local last_refreshed = tonumber(redis.call("get", timestamp_key))
-- 令牌桶最後填充令牌時間
if last_refreshed == nil then
last_refreshed = 0
end
local delta = math.max(0, now-last_refreshed)
redis.log(redis.LOG_DEBUG, "--獲取距離上一次刷新的時間間隔 delta--")
redis.log(redis.LOG_DEBUG, delta)
-- 獲取距離上一次刷新的時間間隔
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
-- 填充令牌,計算新的令牌桶剩餘令牌數 填充不超過令牌桶令牌上限。
redis.log(redis.LOG_DEBUG, "**填充令牌 filled_tokens**")
redis.log(redis.LOG_DEBUG, filled_tokens)
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
-- 若成功,令牌桶剩餘令牌數(new_tokens) 減消耗令牌數( requested ),並設置獲取成功( allowed_num = 1 ) 。
new_tokens = filled_tokens - requested
allowed_num = 1
end
-- 設置令牌桶剩餘令牌數( new_tokens ) ,令牌桶最後填充令牌時間(now) ttl是超時時間
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
redis.log(redis.LOG_DEBUG, "**當前擁有令牌數量 new_tokens**")
redis.log(redis.LOG_DEBUG, new_tokens)
redis.log(redis.LOG_DEBUG, "**最後更新令牌時間 now**")
redis.log(redis.LOG_DEBUG, now)
-- 返回數組結果
redis.log(redis.LOG_DEBUG, "end_ratelimit")
return { allowed_num, new_tokens }
妲己:這個寫好之後怎麼測試 ?
安琪拉:關注Wx 公衆號:安琪拉的博客, 我教你! Redis 提供了客戶端加載工具可以方便lua 腳本的調試,如下所示:
//開啓調試模式 參數分別爲redis剩餘令牌數 key、 上次生成時間key、 生成速率、令牌桶數量、當前時間、這次獲取令牌數
redis-cli --ldb --eval ratelimit.lua remain.${1}.tokens last_fill_time , 0.2 12 `gdate +%s%3N` 1
如下圖所示:可以輸入help 查看完整命令,常用n和print,分別爲下一行和打印當前局部變量
另外也可以直接通過script load 命令加載redis lua腳本,得到sha1 之後直接運行(這個是模型真實程序運行模式,可以暫時跳過)。
// 1. 在redis服務端load 腳本 拿到sha
redis-cli script load "$(cat ratelimit.lua)"
//sha1: ebbcd2ed99990afaca6d2ba61a0f2d5bdd907e59
// 2. 通過腳本 sha1 值運行腳本
redis-cli evalsha ebbcd2ed99990afaca6d2ba61a0f2d5bdd907e59 2 remain.${0}.tokens last_fill_time 0.2 12 `gdate +%s%3N` 1
妲己:lua
腳本的執行會不會有性能上的損耗,比較redis是單線程的?
安琪拉:redis 使用 epoll 實現I/O多路複用的事件驅動模型,對於每一個讀取和寫入操作都儘量要快速,所以我們需要對編寫的lua
腳本做個壓測,redis 提供了壓測指令 redis-benchmark
, 測試10萬 腳本的執行,命令如下:
redis-benchmark -n 100000 evalsha ebbcd2ed99990afaca6d2ba61a0f2d5bdd907e59 2 remain.${1}.tokens last_fill_time 0.2 12 `gdate +%s%3N` 1
實際效果如下:
99.9%都在 2ms以內完成,每秒鐘執行4萬5千多次,因此損耗可以接受。
妲己: 怎麼把分佈式限流lua 放到Spring boot工程中呢?
安琪拉:下面我們就開始工程化之路,
首先
-
手寫一個
lua
腳本(上面的腳本直接拷貝),在Spring 工程目錄中放好,如下圖;
-
程序啓動時加載
lua
腳本, 根據lua的 SHA1值判斷腳本是否已經加載到redis( redis 不能存太多的script),程序如下:@Configuration public class LuaConfiguration { private Logger logger = LoggerFactory.getLogger(LuaConfiguration.class); public static final String RATE_LIMIT_SCRIPT_LOCATION = "scripts/ratelimit.lua"; @Bean(name = "rateLimitRedisScript") public DefaultRedisScript<List> redisScript(LettuceConnectionFactory lettuceConnectionFactory) { DefaultRedisScript<List> redisScript = new DefaultRedisScript<>(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(RATE_LIMIT_SCRIPT_LOCATION))); redisScript.setResultType(List.class); String rateLimitSha1 = redisScript.getSha1(); logger.info("分佈式限流 lua 腳本 sha1 : {}", rateLimitSha1); logger.info("lua 腳本 scriptStr : {}", redisScript.getScriptAsString()); List<Boolean> luaScriptsExists = null; RedisClusterConnection clusterConnection = lettuceConnectionFactory.getClusterConnection(); //預加載腳本 if((luaScriptsExists = clusterConnection.scriptExists(redisScript.getSha1())) != null && luaScriptsExists.size() > 0){ logger.info("redis 已經存在 redis lua腳本 sha1 : {}", rateLimitSha1); }else { String scriptLuaSha1 = clusterConnection.scriptLoad(redisScript.getScriptAsString().getBytes(UTF_8)); logger.info("加載redis lua 成功 sha1 : {}", scriptLuaSha1); } return redisScript; } }
這裏程序啓動,加載腳本,檢查腳本在redis中是否存在,腳本如果沒有重新編輯更新,sha1是一致的,不會重複加載,另外注意一點,如果是集羣模式,Jedis 3.*版本以前不支持lua腳本,建議使用Lettuce。
關於Lettuce 和 Jedis 客戶端的對比,大家可以網上看一下,Spring Boot最新默認客戶端已經改成Lettuce了。
EvalSha is not supported in cluster environment
-
配置限流器
@Component public class RateLimiter implements IRateLimit{ private Logger logger = LoggerFactory.getLogger(RateLimiter.class); @Autowired RedisTemplate redisTemplate; @Autowired @Qualifier("rateLimitRedisScript") DefaultRedisScript<List> rateLimitRedisScript; private static final String REDIS_KEY_REMAIN_TOKENS = "{1}remain_tokens"; private static final String REDIS_KEY_LAST_FILL_TIME = "{1}last_fill_time"; @Override public boolean achieveDistributeToken(String keySuffix, int tokenCapacity, float tokenGenerateRate, int achiveTokenPer) { String remainTokenKey = REDIS_KEY_REMAIN_TOKENS + "_" + keySuffix; String lastFillTimeKey = REDIS_KEY_LAST_FILL_TIME + "_" + keySuffix; List<String> keys = Arrays.asList(remainTokenKey, lastFillTimeKey); String now = String.valueOf(System.currentTimeMillis()/1000); List<String> result = (List<String>) redisTemplate.execute(rateLimitRedisScript, keys, String.valueOf(tokenGenerateRate), String.valueOf(tokenCapacity), now, String.valueOf(achiveTokenPer)); if(result != null && result.size() > 0){ logger.info(" 獲取分佈式令牌是否成功 {} 接口 :{}, 剩餘令牌數量: {}", result.get(0), "yuntrustQuery", result.get(1)); return true; } return false; } }
這裏有一點需要注意一下,key 都帶了 {1} 的前綴,這個用於所有key 在集羣模式都hash 命中同一個slot (槽),因爲lua 腳本不能跨集羣節點執行。
看一下效果,舒服…:
其實還有一部分內容,關於動態調整令牌桶大小和生成令牌速率的部分,鑑於文章篇幅,下次再補上。
關注Wx 公衆號:安琪拉的博客