1. SpringBoot 2.x 優(yōu)雅解決分布式限流

        共 6677字,需瀏覽 14分鐘

         ·

        2022-01-10 02:12

        程序員的成長之路
        互聯(lián)網(wǎng)/程序員/技術(shù)/資料共享?
        關(guān)注


        閱讀本文大概需要 9?分鐘。

        來自:blog.csdn.net/johnf_nash/article/details/89791808

        某天A君突然發(fā)現(xiàn)自己的接口請求量突然漲到之前的10倍,沒多久該接口幾乎不可使用,并引發(fā)連鎖反應(yīng)導(dǎo)致整個(gè)系統(tǒng)崩潰。如何應(yīng)對(duì)這種情況呢?
        生活給了我們答案:比如老式電閘都安裝了保險(xiǎn)絲,一旦有人使用超大功率的設(shè)備,保險(xiǎn)絲就會(huì)燒斷以保護(hù)各個(gè)電器不被強(qiáng)電流給燒壞。同理我們的接口也需要安裝上“保險(xiǎn)絲”,以防止非預(yù)期的請求對(duì)系統(tǒng)壓力過大而引起的系統(tǒng)癱瘓,當(dāng)流量過大時(shí),可以采取拒絕或者引流等機(jī)制。

        一、常用的限流算法

        1.計(jì)數(shù)器方式(傳統(tǒng)計(jì)數(shù)器缺點(diǎn):臨界問題 可能違背定義固定速率原則)
        2.令牌桶方式
        令牌桶算法的原理是系統(tǒng)會(huì)以一個(gè)恒定的速度往桶里放入令牌,而如果請求需要被處理,則需要先從桶里獲取一個(gè)令牌,當(dāng)桶里沒有令牌可取時(shí),則拒絕服務(wù)。從原理上看,令牌桶算法和漏桶算法是相反的,一個(gè)“進(jìn)水”,一個(gè)是“漏水”。
        RateLimiter是guava提供的基于令牌桶算法的實(shí)現(xiàn)類,可以非常簡單的完成限流特技,并且根據(jù)系統(tǒng)的實(shí)際情況來調(diào)整生成token的速率。
        RateLimiter 是單機(jī)(單進(jìn)程)的限流,是JVM級(jí)別的的限流,所有的令牌生成都是在內(nèi)存中,在分布式環(huán)境下不能直接這么用。
        3、漏桶算法
        如上圖所示,我們假設(shè)系統(tǒng)是一個(gè)漏桶,當(dāng)請求到達(dá)時(shí),就是往漏桶里“加水”,而當(dāng)請求被處理掉,就是水從漏桶的底部漏出。水漏出的速度是固定的,當(dāng)“加水”太快,桶就會(huì)溢出,也就是“拒絕請求”。從而使得桶里的水的體積不可能超出桶的容量。
        令牌桶算法與漏桶算法的區(qū)別:
        令牌桶里面裝載的是令牌,然后讓令牌去關(guān)聯(lián)到數(shù)據(jù)發(fā)送,常規(guī)漏桶里面裝載的是數(shù)據(jù),令牌桶允許用戶的正常的持續(xù)突發(fā)量(Bc),就是一次就將桶里的令牌全部用盡的方式來支持續(xù)突發(fā),而常規(guī)的漏桶則不允許用戶任何突發(fā)行。

        二、限流實(shí)現(xiàn)

        基于 redis 的分布式限流
        單機(jī)版中我們了解到 AtomicInteger、RateLimiter、Semaphore 這幾種解決方案,但它們也僅僅是單機(jī)的解決手段,在集群環(huán)境下就透心涼了,后面又講述了 Nginx 的限流手段,可它又屬于網(wǎng)關(guān)層面的策略之一,并不能解決所有問題。例如供短信接口,你無法保證消費(fèi)方是否會(huì)做好限流控制,所以自己在應(yīng)用層實(shí)現(xiàn)限流還是很有必要的。
        導(dǎo)入依賴
        在 pom.xml 中添加上 starter-web、starter-aop、starter-data-redis 的依賴即可,習(xí)慣了使用 commons-lang3 和 guava 中的一些工具包…
        <dependencies>
        ????
        ????<dependency>
        ????????<groupId>org.springframework.bootgroupId>
        ????????<artifactId>spring-boot-starter-aopartifactId>
        ????dependency>
        ????<dependency>
        ????????<groupId>org.springframework.bootgroupId>
        ????????<artifactId>spring-boot-starter-webartifactId>
        ????dependency>
        ????<dependency>
        ????????<groupId>org.springframework.bootgroupId>
        ????????<artifactId>spring-boot-starter-data-redisartifactId>
        ????dependency>
        ????<dependency>
        ????????<groupId>com.google.guavagroupId>
        ????????<artifactId>guavaartifactId>
        ????????<version>21.0version>
        ????dependency>
        ????<dependency>
        ????????<groupId>org.apache.commonsgroupId>
        ????????<artifactId>commons-lang3artifactId>
        ????dependency>
        ????<dependency>
        ????????<groupId>org.springframework.bootgroupId>
        ????????<artifactId>spring-boot-starter-testartifactId>
        ????dependency>
        dependencies>
        屬性配置
        在?application.properites?資源文件中添加 redis 相關(guān)的配置項(xiàng)
        spring.redis.host=localhost
        spring.redis.port=6379
        spring.redis.password=battcn
        Limit 注解
        創(chuàng)建一個(gè) Limit 注解,不多說注釋都給各位寫齊全了….
        package?com.johnfnash.learn.springboot.ratelimiter.annotation;

        import?java.lang.annotation.Documented;
        import?java.lang.annotation.ElementType;
        import?java.lang.annotation.Inherited;
        import?java.lang.annotation.Retention;
        import?java.lang.annotation.RetentionPolicy;
        import?java.lang.annotation.Target;

        //?限流
        @Target({ElementType.METHOD,?ElementType.TYPE})
        @Retention(RetentionPolicy.RUNTIME)
        @Inherited
        @Documented
        public?@interface?Limit?{

        ????/**
        ?????*?資源的名稱
        ?????*?@return
        ?????*/

        ????String?name()?default?"";
        ????
        ????/**
        ?????*?資源的key
        ?????*
        ?????*?@return
        ?????*/

        ????String?key()?default?"";

        ????/**
        ?????*?Key的prefix
        ?????*
        ?????*?@return
        ?????*/

        ????String?prefix()?default?"";
        ????
        ????/**
        ?????*?給定的時(shí)間段
        ?????*?單位秒
        ?????*
        ?????*?@return
        ?????*/

        ????int?period();

        ????/**
        ?????*?最多的訪問限制次數(shù)
        ?????*
        ?????*?@return
        ?????*/

        ????int?count();
        ????
        ????/**
        ?????*?類型
        ?????*
        ?????*?@return
        ?????*/

        ????LimitType?limitType()?default?LimitType.CUSTOMER;
        }
        package?com.johnfnash.learn.springboot.ratelimiter.annotation;

        //?限制的類型
        public?enum?LimitType?{

        ????/**
        ?????*?自定義key
        ?????*/

        ????CUSTOMER,
        ????/**
        ?????*?根據(jù)請求者IP
        ?????*/

        ????IP;
        ????
        }
        RedisTemplate
        默認(rèn)情況下 spring-boot-data-redis 為我們提供了StringRedisTemplate 但是滿足不了其它類型的轉(zhuǎn)換,所以還是得自己去定義其它類型的模板….
        package?com.johnfnash.learn.springboot.ratelimiter;

        import?java.io.Serializable;

        import?org.springframework.context.annotation.Bean;
        import?org.springframework.context.annotation.Configuration;
        import?org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
        import?org.springframework.data.redis.core.RedisTemplate;
        import?org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
        import?org.springframework.data.redis.serializer.StringRedisSerializer;

        @Configuration
        public?class?RedisLimiterHelper?{

        ????@Bean
        ????public?RedisTemplate?limitRedisTemplate(LettuceConnectionFactory?factory)?{
        ????????RedisTemplate?template?=?new?RedisTemplate();
        ????????template.setKeySerializer(new?StringRedisSerializer());
        ????????template.setValueSerializer(new?GenericJackson2JsonRedisSerializer());
        ????????template.setConnectionFactory(factory);
        ????????return?template;
        ????}
        ????
        }
        Limit 攔截器(AOP)
        熟悉 Redis 的朋友都知道它是線程安全的,我們利用它的特性可以實(shí)現(xiàn)分布式鎖、分布式限流等組件。官方雖然沒有提供相應(yīng)的API,但卻提供了支持 Lua 腳本的功能,我們可以通過編寫 Lua 腳本實(shí)現(xiàn)自己的API,同時(shí)他是滿足原子性的….
        下面核心就是調(diào)用 execute 方法傳入我們的 Lua 腳本內(nèi)容,然后通過返回值判斷是否超出我們預(yù)期的范圍,超出則給出錯(cuò)誤提示。
        package?com.johnfnash.learn.springboot.ratelimiter.aop;

        import?java.io.Serializable;
        import?java.lang.reflect.Method;

        import?javax.servlet.http.HttpServletRequest;

        import?org.apache.commons.lang3.StringUtils;
        import?org.aspectj.lang.ProceedingJoinPoint;
        import?org.aspectj.lang.annotation.Around;
        import?org.aspectj.lang.annotation.Aspect;
        import?org.aspectj.lang.reflect.MethodSignature;
        import?org.slf4j.Logger;
        import?org.slf4j.LoggerFactory;
        import?org.springframework.beans.factory.annotation.Autowired;
        import?org.springframework.context.annotation.Configuration;
        import?org.springframework.data.redis.core.RedisTemplate;
        import?org.springframework.data.redis.core.script.DefaultRedisScript;
        import?org.springframework.data.redis.core.script.RedisScript;
        import?org.springframework.web.context.request.RequestContextHolder;
        import?org.springframework.web.context.request.ServletRequestAttributes;

        import?com.google.common.collect.ImmutableList;
        import?com.johnfnash.learn.springboot.ratelimiter.annotation.Limit;
        import?com.johnfnash.learn.springboot.ratelimiter.annotation.LimitType;

        @Aspect
        @Configuration
        public?class?LimitInterceptor?{

        ????private?static?final?Logger?logger?=?LoggerFactory.getLogger(LimitInterceptor.class);;
        ????
        ????private?final?String?REDIS_SCRIPT?=?buildLuaScript();
        ????
        ????@Autowired
        ????private?RedisTemplate?redisTemplate;
        ????
        ????@Around("execution(public?*?*(..))?&&?@annotation(com.johnfnash.learn.springboot.ratelimiter.annotation.Limit)")
        ????public?Object?interceptor(ProceedingJoinPoint?pjp)?{
        ????????MethodSignature?signature?=?(MethodSignature)?pjp.getSignature();
        ????????Method?method?=?signature.getMethod();
        ????????Limit?limitAnno?=?method.getAnnotation(Limit.class);
        ????????LimitType?limitType?=?limitAnno.limitType();
        ????????String?name?=?limitAnno.name();
        ????????
        ????????String?key?=?null;
        ????????int?limitPeriod?=?limitAnno.period();
        ????????int?limitCount?=?limitAnno.count();
        ????????switch?(limitType)?{
        ????????case?IP:
        ????????????key?=?getIpAddress();
        ????????????break;
        ????????case?CUSTOMER:
        ????????????// TODO 如果此處想根據(jù)表達(dá)式或者一些規(guī)則生成?請看?一起來學(xué)Spring Boot |?第二十三篇:輕松搞定重復(fù)提交(分布式鎖)
        ????????????key?=?limitAnno.key();
        ????????????break;
        ????????default:
        ????????????break;
        ????????}
        ????????
        ????????ImmutableList?keys?=?ImmutableList.of(StringUtils.join(limitAnno.prefix(),?key));
        ????????try?{
        ????????????RedisScript?redisScript?=?new?DefaultRedisScript(REDIS_SCRIPT,?Number.class);
        ????????????Number?count?=?redisTemplate.execute(redisScript,?keys,?limitCount,?limitPeriod);
        ????????????logger.info("Access?try?count?is?{}?for?name={}?and?key?=?{}",?count,?name,?key);
        ????????????if(count?!=?null?&&?count.intValue()?<=?limitCount)?{
        ????????????????return?pjp.proceed();
        ????????????}?else?{
        ????????????????throw?new?RuntimeException("You?have?been?dragged?into?the?blacklist");
        ????????????}
        ????????}?catch?(Throwable?e)?{
        ????????????if?(e?instanceof?RuntimeException)?{
        ????????????????throw?new?RuntimeException(e.getLocalizedMessage());
        ????????????}
        ????????????throw?new?RuntimeException("server?exception");
        ????????}
        ????}
        ????
        ????/**
        ?????*?限流?腳本
        ?????*
        ?????*?@return?lua腳本
        ?????*/

        ????private?String?buildLuaScript()?{
        ????????StringBuilder?lua?=?new?StringBuilder();
        ????????lua.append("local?c")
        ???????????.append("\nc?=?redis.call('get',?KEYS[1])")
        ???????????//?調(diào)用不超過最大值,則直接返回
        ???????????.append("\nif?c?and?tonumber(c)?>?tonumber(ARGV[1])?then")
        ???????????.append("\nreturn?c;")
        ???????????.append("\nend")
        ???????????//?執(zhí)行計(jì)算器自加
        ???????????.append("\nc?=?redis.call('incr',?KEYS[1])")
        ???????????.append("\nif?tonumber(c)?==?1?then")
        ???????????//?從第一次調(diào)用開始限流,設(shè)置對(duì)應(yīng)鍵值的過期
        ???????????.append("\nredis.call('expire',?KEYS[1],?ARGV[2])")
        ???????????.append("\nend")
        ???????????.append("\nreturn?c;");
        ????????return?lua.toString();
        ????}
        ????
        ????private?static?final?String?UNKNOWN?=?"unknown";

        ????public?String?getIpAddress()?{
        ????????HttpServletRequest?request?=?((ServletRequestAttributes)?RequestContextHolder.getRequestAttributes()).getRequest();
        ????????String?ip?=?request.getHeader("x-forwarded-for");
        ????????if?(ip?==?null?||?ip.length()?==?0?||?UNKNOWN.equalsIgnoreCase(ip))?{
        ????????????ip?=?request.getHeader("Proxy-Client-IP");
        ????????}
        ????????if?(ip?==?null?||?ip.length()?==?0?||?UNKNOWN.equalsIgnoreCase(ip))?{
        ????????????ip?=?request.getHeader("WL-Proxy-Client-IP");
        ????????}
        ????????if?(ip?==?null?||?ip.length()?==?0?||?UNKNOWN.equalsIgnoreCase(ip))?{
        ????????????ip?=?request.getRemoteAddr();
        ????????}
        ????????return?ip;
        ????}
        ????
        }
        控制層
        在接口上添加@Limit()注解,如下代碼會(huì)在 Redis 中生成過期時(shí)間為 100s 的?key = test?的記錄,特意定義了一個(gè) AtomicInteger 用作測試…
        package?com.johnfnash.learn.springboot.ratelimiter.controller;

        import?java.util.concurrent.atomic.AtomicInteger;

        import?org.springframework.web.bind.annotation.GetMapping;
        import?org.springframework.web.bind.annotation.RestController;

        import?com.johnfnash.learn.springboot.ratelimiter.annotation.Limit;

        @RestController
        public?class?LimiterController?{

        ????private?static?final?AtomicInteger?ATOMIC_INTEGER?=?new?AtomicInteger();

        ????@Limit(key?=?"test",?period?=?100,?count?=?10)
        ????//?意味著?100S?內(nèi)最多允許訪問10次
        ????@GetMapping("/test")
        ????public?int?testLimiter()?{
        ????????return?ATOMIC_INTEGER.incrementAndGet();
        ????}
        ????
        }
        測試
        完成準(zhǔn)備事項(xiàng)后,啟動(dòng) 啟動(dòng)類 自行測試即可,測試手段相信大伙都不陌生了,如 瀏覽器、postman、junit、swagger,此處基于 postman。
        未達(dá)設(shè)定的閥值時(shí),正常響應(yīng)
        達(dá)到設(shè)置的閥值時(shí),錯(cuò)誤響應(yīng)

        總結(jié)

        目前很多大佬都寫過關(guān)于 Spring Boot 的教程了,如有雷同,請多多包涵,本教程基于最新的 spring-boot-starter-parent:2.0.3.RELEASE編寫…
        本篇文章核心的 Lua 腳本截取自軍哥的 Aquarius 開源項(xiàng)目,有興趣的朋友可以 fork star ,該項(xiàng)目干貨滿滿…
        全文代碼:
        https://github.com/battcn/spring-boot2-learning/tree/master/chapter27
        注:上面的方式是使用計(jì)數(shù)器的限流方式,無法處理臨界的時(shí)候,大量請求的的情況。要解決這個(gè)問題,可以使用redis中列表類型的鍵來記錄最近N次訪問的時(shí)間,一旦鍵中的元素超過N個(gè),就判斷時(shí)間最早的元素距現(xiàn)在的時(shí)間是否小于M秒。如果是則表示用戶最近M秒的訪問次數(shù)超過了N次;如果不是就將現(xiàn)在的時(shí)間加入列表中同時(shí)把最早的元素刪除(可以通過腳本功能避免競態(tài)條件)。
        由于需要記錄每次訪問的時(shí)間,所以當(dāng)要限制“M時(shí)間最多訪問N次”時(shí),如果“N”的數(shù)值較大,此方法會(huì)占用較多的存儲(chǔ)空間,實(shí)際使用時(shí)還需要開發(fā)者自己去權(quán)衡。
        下面的解決思路的實(shí)現(xiàn)如下:
        /**
        ?*?限流?腳本(處理臨界時(shí)間大量請求的情況)
        ?*
        ?*?@return?lua腳本
        ?*/

        private?String?buildLuaScript2()?{
        ????StringBuilder?lua?=?new?StringBuilder();
        ????lua.append("local?listLen,?time")
        ???????.append("\nlistLen?=?redis.call('LLEN',?KEYS[1])")
        ???????//?不超過最大值,則直接寫入時(shí)間
        ???????.append("\nif?listLen?and?tonumber(listLen)?)
        ????????????.append("\nlocal?a?=?redis.call('TIME');")
        ????????????.append("\nredis.call('LPUSH',?KEYS[1],?a[1]*1000000+a[2])")
        ???????.append("\nelse")
        ???????????//?取出現(xiàn)存的最早的那個(gè)時(shí)間,和當(dāng)前時(shí)間比較,看是小于時(shí)間間隔
        ???????????.append("\ntime?=?redis.call('LINDEX',?KEYS[1],?-1)")
        ???????????.append("\nlocal?a?=?redis.call('TIME');")
        ???????????.append("\nif?a[1]*1000000+a[2]?-?time?)
        ???????????????//?訪問頻率超過了限制,返回0表示失敗
        ???????????????.append("\nreturn?0;")
        ???????????.append("\nelse")???????????????????
        ???????????????.append("\nredis.call('LPUSH',?KEYS[1],?a[1]*1000000+a[2])")
        ???????????????.append("\nredis.call('LTRIM',?KEYS[1],?0,?tonumber(ARGV[1])-1)")
        ???????????.append("\nend")???
        ???????.append("\nend")
        ???????.append("\nreturn?1;");
        ????return?lua.toString();
        }
        調(diào)用的地方的也相應(yīng)修改如下:
        if(count?!=?null?&&?count.intValue()?==?1)?{
        ????return?pjp.proceed();
        }?else?{
        ????throw?new?RuntimeException("You?have?been?dragged?into?the?blacklist");
        }
        補(bǔ)充,最近執(zhí)行?buildLuaScript2()?中的lua腳本,報(bào)錯(cuò)Write commands not allowed after non deterministic commands.
        這個(gè)錯(cuò)誤的原因大家可以參見這篇文章:
        https://yq.aliyun.com/articles/195914
        大致原因跟redis集群的重放和備份策略有關(guān),相當(dāng)于我調(diào)用TIME操作,會(huì)在主從各執(zhí)行一次,得到的結(jié)果肯定會(huì)存在差異,這個(gè)差異就給最終邏輯正確性帶來了不確定性。在redis 4.0之后引入了redis.replicate_commands()來放開限制。
        于是,在 buildLuaScript2 的 lua 腳本最前面加上 “redis.replicate_commands();”,錯(cuò)誤得以解決。

        推薦閱讀:

        字節(jié)工程師薪資排世界第五,中位數(shù) 43 萬美元,2021 全球程序員收入報(bào)告出爐!

        巧用Stream優(yōu)化老代碼,太清爽了!

        互聯(lián)網(wǎng)初中高級(jí)大廠面試題(9個(gè)G)

        內(nèi)容包含Java基礎(chǔ)、JavaWeb、MySQL性能優(yōu)化、JVM、鎖、百萬并發(fā)、消息隊(duì)列、高性能緩存、反射、Spring全家桶原理、微服務(wù)、Zookeeper、數(shù)據(jù)結(jié)構(gòu)、限流熔斷降級(jí)......等技術(shù)棧!

        ?戳閱讀原文領(lǐng)??!? ? ? ? ? ? ? ??? ??? ? ? ? ? ? ? ? ? ?朕已閱?

        瀏覽 36
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 国产黄色视频在线观看免费 | 夜色福利在线 | jizzzz成熟丰满韩国女视频 | 91在线播放视频 | 免费做爱小视频 |