安琪拉教妲己分佈式限流

安琪拉教妲己分佈式限流

在系統設計中,限流是保障系統高可用的一種常規手段,同樣的手段還有熔斷、服務降級等等,此篇文章作爲一個開端,是《安琪拉教妲己分佈式系統設計》的第一篇

妲己:聽說最近你們系統又對接了幾條業務線,而且早上9.10點鐘流量非常大,你怎麼保證系統不被搞掛的啊?

安琪拉:你算是問對了,最近對接了幾家大機構,同時由於疫情的影響,線上渠道的流量也比平常多了很多,我這邊系統做了很多系統優化,大致可以歸爲以下幾類:

  • 限流:對應用入口流量做控制,瞬時流量向後遷移,對下游請求流量做自適應限流,根據接口響應時間動態調整流量。
  • 延遲排隊:如果請求量大,按業務線優先級排隊,例如優先保障線上渠道實時的請求,優先級提高
  • 路由:其實這個是因爲業務的特殊性,所有的請求都依賴下游第三方的服務,因爲可以將多家下游服務供應商做個動態路由表,將請求優先路由給接口成功率高、耗時低的服務供應商;
  • 備份:這基本是所有分佈式組件都會做的,能做多機的不做單機,例如:Redis 做三主三備(集羣)、MySQL分庫分表、MQ 與 Redis 互爲備份(安琪拉遇到過MQ事故)等等;
  • 降級:這個是最後的逼不得已的措施,如果遇到全線崩潰,使用降級手段保障系統核心功能可用,或讓模塊達到最小可用。
  • 日誌:完整的監控和鏈路日誌,日誌功能很多,也分很多種,一方面是方便排查問題,另一方面可用來做任務重做、數據恢復、狀態持久化等。

妲己:能給我講講限流的基礎概念嗎?

安琪拉:限流,顧名思義,就是限制流量,一般分爲限制入口流量和限制出口流量,入口流量是人家來請求我的系統,我在入口處加了一道閥門,出口流量是我調外部系統,我在出口加一道閥門。

妲己:那一般怎麼來實現限流呢?

安琪拉:如果是單機,可以通過Semphore 限制統一時間請求接口的量,也可以用 Google Guava 包提供的限流包,如果是分佈式環境,可以使用 Redis 實現,也有阿里 SentinalSpring 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 就是鎖的機制,進了廁所,在門上加鎖,看下控制檯輸出:

在這裏插入圖片描述

每次只有三個人能同時上廁所。

妲己:我似乎有點明白了,廁所就是資源,我們上測試就好比請求,大家一起上就產生了流量高峯,那分佈式環境怎麼解決呢?

安琪拉:分佈式環境的思想和單機的思想是一樣的,也是控制資源的訪問頻率,一般主流的設計思想有二種:

  1. 漏洞算法


    在這裏插入圖片描述

    把請求比作水,在請求入口和響應請求的服務之間加一個漏桶,桶中的水以恆定的速度流出,這樣保證了服務接收到的流量速度是穩定的,如果桶裏的水滿了,再進來的水就直接溢出(請求直接拒絕)。

    漏桶是網絡環境中流量整形(Traffic Shaping)或速率限制(Rate Limiting)時經常使用的一種算法,它的主要目的是控制數據進入到網絡的速率,平滑網絡上的突發流量。

  2. 令牌桶算法


    (img-tDtuCkfP-1586806007623)(/Users/zw/Library/Application Support/typora-user-images/image-20200414010223412.png)]

    令牌桶算法有點類似於生產者消費者模式,專門有一個生產者往令牌桶中以恆定速率放入令牌,而請求處理器(消費者)在處理請求時必須先從桶中獲得令牌,如果沒有拿到令牌,有二種策略:一種是直接返回拒絕請求,一種是等待一段時間,再次嘗試獲取令牌。

    令牌桶算法用來控制發送到網絡上的數據的數目,並允許突發數據的發送

    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 爲這一次需要放入令牌的數量,計算邏輯爲:

filledTokens=math.min(lastTokens+(deltarate),capacity)filledTokens = math.min(lastTokens+(delta*rate), capacity)

此刻應填充令牌數 = 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,分別爲下一行和打印當前局部變量

[(img-KHHPnrUg-1586806007625)(/Users/zw/Library/Application Support/typora-user-images/image-20200414025001967.png)]

另外也可以直接通過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工程中呢?

安琪拉:下面我們就開始工程化之路,

首先

  1. 手寫一個lua 腳本(上面的腳本直接拷貝),在Spring 工程目錄中放好,如下圖;


    在這裏插入圖片描述

  2. 程序啓動時加載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

  3. 配置限流器

    @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 公衆號:安琪拉的博客

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