【高併發】億級流量場景下如何實現分佈式限流?看完我完全懂了!!(文末有福利)

寫在前面

在互聯網應用中,高併發系統會面臨一個重大的挑戰,那就是大量流高併發訪問,好比:天貓的雙11、京東61八、秒殺、搶購促銷等,這些都是典型的大流量高併發場景。關於秒殺,小夥伴們能夠參見個人另外一篇文章《【高併發】高併發秒殺系統架構解密,不是全部的秒殺都是秒殺!html

關於【冰河技術】微信公衆號,解鎖更多【高併發】專題文章。java

注意:因爲原文篇幅比較長,因此被拆分爲:理論、算法、實戰(HTTP接口實戰+分佈式限流實戰)三大部分。nginx

理論篇:《【高併發】如何實現億級流量下的分佈式限流?這些理論你必須掌握!!git

算法篇:《【高併發】如何實現億級流量下的分佈式限流?這些算法你必須掌握!!github

項目源碼已提交到github:https://github.com/sunshinelyz/mykit-ratelimiter面試

本文是在《【高併發】億級流量場景下如何爲HTTP接口限流?看完我懂了!!》一文的基礎上進行實現,有關項目的搭建可參見《【高併發】億級流量場景下如何爲HTTP接口限流?看完我懂了!!》一文的內容。小夥伴們能夠關注【冰河技術】微信公衆號來閱讀上述文章。redis

前面介紹的限流方案有一個缺陷就是:它不是全局的,不是分佈式的,沒法很好的應對分佈式場景下的大流量衝擊。那麼,接下來,咱們就介紹下如何實現億級流量下的分佈式限流。算法

分佈式限流的關鍵就是須要將限流服務作成全局的,統一的。能夠採用Redis+Lua技術實現,經過這種技術能夠實現高併發和高性能的限流。spring

Lua是一種輕量小巧的腳本編程語言,用標準的C語言編寫的開源腳本,其設計的目的是爲了嵌入到應用程序中,爲應用程序提供靈活的擴展和定製功能。編程

Redis+Lua腳本實現分佈式限流思路

咱們可使用Redia+Lua腳本的方式來對咱們的分佈式系統進行統一的全侷限流,Redis+Lua實現的Lua腳本:

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 "2")
    return 1
end

咱們能夠按照以下的思路來理解上述Lua腳本代碼。

(1)在Lua腳本中,有兩個全局變量,用來接收Redis應用端傳遞的鍵和其餘參數,分別爲:KEYS、ARGV;

(2)在應用端傳遞KEYS時是一個數組列表,在Lua腳本中經過索引下標方式獲取數組內的值。

(3)在應用端傳遞ARGV時參數比較靈活,能夠是一個或多個獨立的參數,但對應到Lua腳本中統一用ARGV這個數組接收,獲取方式也是經過數組下標獲取。

(4)以上操做是在一個Lua腳本中,又由於我當前使用的是Redis 5.0版本(Redis 6.0支持多線程),執行的請求是單線程的,所以,Redis+Lua的處理方式是線程安全的,而且具備原子性。

這裏,須要注意一個知識點,那就是原子性操做:若是一個操做時不可分割的,是多線程安全的,咱們就稱爲原子性操做。

接下來,咱們可使用以下Java代碼來判斷是否須要限流。

//List設置Lua的KEYS[1]
String key = "ip:" + System.currentTimeMillis() / 1000;
List<String> keyList = Lists.newArrayList(key);

//List設置Lua的ARGV[1]
List<String> argvList = Lists.newArrayList(String.valueOf(value));

//調用Lua腳本並執行
List result = stringRedisTemplate.execute(redisScript, keyList, argvList)

至此,咱們簡單的介紹了使用Redis+Lua腳本實現分佈式限流的整體思路,並給出了Lua腳本的核心代碼和Java程序調用Lua腳本的核心代碼。接下來,咱們就動手寫一個使用Redis+Lua腳本實現的分佈式限流案例。

Redis+Lua腳本實現分佈式限流案例

這裏,咱們和在《【高併發】億級流量場景下如何爲HTTP接口限流?看完我懂了!!》一文中的實現方式相似,也是經過自定義註解的形式來實現分佈式、大流量場景下的限流,只不過這裏咱們使用了Redis+Lua腳本的方式實現了全局統一的限流模式。接下來,咱們就一塊兒手動實現這個案例。

建立註解

首先,咱們在項目中,定義個名稱爲MyRedisLimiter的註解,具體代碼以下所示。

package io.mykit.limiter.annotation;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
/**
 * @author binghe
 * @version 1.0.0
 * @description 自定義註解實現分佈式限流
 */
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyRedisLimiter {
    @AliasFor("limit")
    double value() default Double.MAX_VALUE;
    double limit() default Double.MAX_VALUE;
}

在MyRedisLimiter註解內部,咱們爲value屬性添加了別名limit,在咱們真正使用@MyRedisLimiter註解時,便可以使用@MyRedisLimiter(10),也可使用@MyRedisLimiter(value=10),還可使用@MyRedisLimiter(limit=10)。

建立切面類

建立註解後,咱們就來建立一個切面類MyRedisLimiterAspect,MyRedisLimiterAspect類的做用主要是解析@MyRedisLimiter註解,而且執行限流的規則。這樣,就不須要咱們在每一個須要限流的方法中執行具體的限流邏輯了,只須要咱們在須要限流的方法上添加@MyRedisLimiter註解便可,具體代碼以下所示。

package io.mykit.limiter.aspect;
import com.google.common.collect.Lists;
import io.mykit.limiter.annotation.MyRedisLimiter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.List;

/**
 * @author binghe
 * @version 1.0.0
 * @description MyRedisLimiter註解的切面類
 */
@Aspect
@Component
public class MyRedisLimiterAspect {
    private final Logger logger = LoggerFactory.getLogger(MyRedisLimiter.class);
    @Autowired
    private HttpServletResponse response;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private DefaultRedisScript<List> redisScript;

    @PostConstruct
    public void init(){
        redisScript = new DefaultRedisScript<List>();
        redisScript.setResultType(List.class);
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(("limit.lua"))));
    }

    @Pointcut("execution(public * io.mykit.limiter.controller.*.*(..))")
    public void pointcut(){

    }

    @Around("pointcut()")
    public Object process(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
        MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
        //使用反射獲取MyRedisLimiter註解
        MyRedisLimiter myRedisLimiter = signature.getMethod().getDeclaredAnnotation(MyRedisLimiter.class);
        if(myRedisLimiter == null){
            //正常執行方法
            return proceedingJoinPoint.proceed();
        }
        //獲取註解上的參數,獲取配置的速率
        double value = myRedisLimiter.value();
        //List設置Lua的KEYS[1]
        String key = "ip:" + System.currentTimeMillis() / 1000;
        List<String> keyList = Lists.newArrayList(key);

        //List設置Lua的ARGV[1]
        List<String> argvList = Lists.newArrayList(String.valueOf(value));

        //調用Lua腳本並執行
        List result = stringRedisTemplate.execute(redisScript, keyList, String.valueOf(value));
        logger.info("Lua腳本的執行結果:" + result);

        //Lua腳本返回0,表示超出流量大小,返回1表示沒有超出流量大小。
        if("0".equals(result.get(0).toString())){
            fullBack();
            return null;
        }

        //獲取到令牌,繼續向下執行
        return proceedingJoinPoint.proceed();
    }

    private void fullBack() {
        response.setHeader("Content-Type" ,"text/html;charset=UTF8");
        PrintWriter writer = null;
        try{
            writer = response.getWriter();
            writer.println("回退失敗,請稍後閱讀。。。");
            writer.flush();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(writer != null){
                writer.close();
            }
        }
    }
}

上述代碼會讀取項目classpath目錄下的limit.lua腳本文件來肯定是否執行限流的操做,調用limit.lua文件執行的結果返回0則表示執行限流邏輯,不然不執行限流邏輯。既然,項目中須要使用Lua腳本,那麼,接下來,咱們就須要在項目中建立Lua腳本。

建立limit.lua腳本文件

在項目的classpath目錄下建立limit.lua腳本文件,文件的內容以下所示。

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 "2")
    return 1
end

limit.lua腳本文件的內容比較簡單,這裏就再也不贅述了。

接口添加註解

註解類、解析註解的切面類、Lua腳本文件都已經準備好。那麼,接下來,咱們在PayController類中在sendMessage2()方法上添加@MyRedisLimiter註解,而且將limit屬性設置爲10,以下所示。

@MyRedisLimiter(limit = 10)
@RequestMapping("/boot/send/message2")
public String sendMessage2(){
    //記錄返回接口
    String result = "";
    boolean flag = messageService.sendMessage("恭喜您成長值+1");
    if (flag){
        result = "短信發送成功!";
        return result;
    }
    result = "哎呀,服務器開小差了,請再試一下吧";
    return result;
}

此處,咱們限制了sendMessage2()方法,每秒鐘最多隻能處理10個請求。那麼。接下來,咱們就使用JMeter對sendMessage2()進行測試。

測試分佈式限流

此時,咱們使用JMeter進行壓測,這裏,咱們配置的線程數爲50,也就是說:會有50個線程同時訪問咱們寫的接口。JMeter的配置以下所示。

保存並運行Jemeter,以下所示。

運行完成後,咱們來查看下JMeter的測試結果,以下所示。

從測試結果能夠看出,測試中途有部分接口的訪問返回了「哎呀,服務器開小差了,請再試一下吧」,說明接口被限流了。而再日後,又有部分接口成功返回了「短信發送成功!」的字樣。這是由於咱們設置的是接口每秒最多接受10次請求,在第一秒內訪問接口時,前面的10次請求成功返回「短信發送成功!」的字樣,後面再訪問接口就會返回「哎呀,服務器開小差了,請再試一下吧」。然後面的請求又返回了「短信發送成功!」的字樣,說明後面的請求已是在第二秒的時候調用的接口。

咱們使用Redis+Lua腳本的方式實現的限流方式,能夠將Java程序進行集羣部署,這種方式實現的是全局的統一的限流,不管客戶端訪問的是集羣中的哪一個節點,都會對訪問進行計數並實現最終的限流效果。

這種思想就有點像分佈式鎖了,小夥伴們能夠關注【冰河技術】微信公衆號閱讀我寫的一篇《【高併發】高併發分佈式鎖架構解密,不是全部的鎖都是分佈式鎖!!》來深刻理解如何實現真正線程安全的分佈式鎖,此文章,以按部就班的方式深刻剖析了實現分佈式鎖過程當中的各類坑和解決方案,讓你真正理解什麼纔是分佈式鎖。

Nginx+Lua實現分佈式限流

Nginx+Lua實現分佈式限流,一般會用在應用的入口處,也就是對系統的流量入口進行限流。這裏,咱們也以一個實際案例的形式來講明如何使用Nginx+Lua來實現分佈式限流。

首先,咱們須要建立一個Lua腳本,腳本文件的內容以下所示。

local locks = require "resty.lock"
 
local function acquire()
    local lock =locks:new("locks")
    local elapsed, err =lock:lock("limit_key") --互斥鎖
    local limit_counter =ngx.shared.limit_counter --計數器
 
    local key = "ip:" ..os.time()
    local limit = 5 --限流大小
    local current =limit_counter:get(key)
 
    if current ~= nil and current + 1> limit then --若是超出限流大小
       lock:unlock()
       return 0
    end
    if current == nil then
       limit_counter:set(key, 1, 1) --第一次須要設置過時時間,設置key的值爲1,過時時間爲1秒
    else
        limit_counter:incr(key, 1) --第二次開始加1便可
    end
    lock:unlock()
    return 1
end
ngx.print(acquire())

實現中咱們須要使用lua-resty-lock互斥鎖模塊來解決原子性問題(在實際工程中使用時請考慮獲取鎖的超時問題),並使用ngx.shared.DICT共享字典來實現計數器。若是須要限流則返回0,不然返回1。使用時須要先定義兩個共享字典(分別用來存放鎖和計數器數據)。

接下來,須要在Nginx的nginx.conf配置文件中定義數據字典,以下所示。

http {
    ……
    lua_shared_dict locks 10m;
    lua_shared_dict limit_counter 10m;
}

靈魂拷問

說到這裏,相信有不少小夥伴可能會問:若是應用併發量很是大,那麼,Redis或者Nginx能不能扛的住呢?

能夠這麼說:Redis和Nginx基本都是高性能的互聯網組件,對於通常互聯網公司的高併發流量是徹底沒有問題的。爲何這麼說呢?我們繼續往下看。

若是你的應用流量真的很是大,能夠經過一致性哈希將分佈式限流進行分片,還能夠將限流降級爲應用級限流;解決方案也很是多,能夠根據實際狀況進行調整,使用Redis+Lua的方式進行限流,是能夠穩定達到對上億級別的高併發流量進行限流的(筆者親身經歷)。

須要注意的是:面對高併發系統,尤爲是這種流量上千萬、上億級別的高併發系統,咱們不可能只用限流這一招,還要加上其餘的一些措施,

對於分佈式限流,目前遇到的場景是業務上的限流,而不是流量入口的限流。對於流量入口的限流,應該在接入層來完成。

對於秒殺場景來講,能夠在流量入口處進行限流,小夥伴們能夠關注【冰河技術】微信公衆號,來閱讀我寫的《【高併發】高併發秒殺系統架構解密,不是全部的秒殺都是秒殺!》一文,來深刻理解如何架構一個高併發秒殺系統

重磅福利

關注「 冰河技術 」微信公衆號,後臺回覆 「設計模式」 關鍵字領取《深刻淺出Java 23種設計模式》PDF文檔。回覆「Java8」關鍵字領取《Java8新特性教程》PDF文檔。回覆「限流」關鍵字獲取《億級流量下的分佈式限流解決方案》PDF文檔,三本PDF均是由冰河原創並整理的超硬核教程,面試必備!!

好了,今天就聊到這兒吧!別忘了點個贊,給個在看和轉發,讓更多的人看到,一塊兒學習,一塊兒進步!!

寫在最後

若是你以爲冰河寫的還不錯,請微信搜索並關注「 冰河技術 」微信公衆號,跟冰河學習高併發、分佈式、微服務、大數據、互聯網和雲原生技術,「 冰河技術 」微信公衆號更新了大量技術專題,每一篇技術文章乾貨滿滿!很多讀者已經經過閱讀「 冰河技術 」微信公衆號文章,吊打面試官,成功跳槽到大廠;也有很多讀者實現了技術上的飛躍,成爲公司的技術骨幹!若是你也想像他們同樣提高本身的能力,實現技術能力的飛躍,進大廠,升職加薪,那就關注「 冰河技術 」微信公衆號吧,天天更新超硬核技術乾貨,讓你對如何提高技術能力再也不迷茫!

相關文章
相關標籤/搜索