摘要:在網際網路應用中,高併發系統會面臨一個重大的挑戰,那就是大量流高併發訪問,比如:天貓的雙十一、京東618、秒殺、搶購促銷等,這些都是典型的大流量高併發場景。
本文分享自華為雲社群《【高併發】如何實現億級流量下的分散式限流?這些理論你必須掌握!!》,作者:冰 河。
在網際網路應用中,高併發系統會面臨一個重大的挑戰,那就是大量流高併發訪問,比如:天貓的雙十一、京東618、秒殺、搶購促銷等,這些都是典型的大流量高併發場景。
高併發系統限流
短時間內巨大的訪問流量,我們如何讓系統在處理高併發的同時還能保證自身系統的穩定性?有人會說,增加機器就可以了,因為我的系統是分散式的,所以可以只需要增加機器就可以解決問題了。但是,如果你通過增加機器還是不能解決這個問題怎麼辦呢?而且這種情況下又不能無限制的增加機器,伺服器的硬體資源始終都是有限的,在有限的資源下,我們要應對這種大流量高併發的訪問,就不得不採取一些其他的措施來保護我們的後端服務系統了,比如:快取、非同步、降級、限流、靜態化等。
這裡,我們先說說如何實現限流。
什麼是限流?
在高併發系統中,限流通常指的是:對高併發訪問或者請求進行限速或者對一個時間內的請求進行限速來保護我們的系統,一旦達到系統的限速規則(比如系統限制的請求速度),則可以採用下面的方式來處理這些請求。
- 拒絕服務(友好提示或者跳轉到錯誤頁面)。
- 排隊或等待(比如秒殺系統)。
- 服務降級(返回預設的兜底資料)。
其實,就是對請求進行限速,比如10r/s,即每秒只允許10個請求,這樣就限制了請求的速度。從某種意義上說,限流,其實就是在一定頻率上進行量的限制。
限流一般用來控制系統服務請求的速率,比如:天貓雙十一的限流,京東618的限流,12306的搶票等。
限流有哪些使用場景?
這裡,我們來舉一個例子,假設你做了一個商城系統,某個節假日的時候,突然發現提交訂單的介面請求比平時請求量突然上漲了將近50倍,沒多久提交訂單的介面就超時並且丟擲了異常,幾乎不可用了。而且,因為訂單介面超時不可用,還導致了系統其它服務出現故障。
我們該如何應對這種大流量場景呢?一種典型的處理方案就是限流。當然了,除了限流之外,還有其他的處理方案,我們這篇文章就主要講限流。
- 對稀缺資源的秒殺、搶購;
- 對資料庫的高併發讀寫操作,比如提交訂單,瞬間往資料庫插入大量的資料;
限流可以說是處理高併發問題的利器,有了限流就可以不用擔心瞬間高峰流量壓垮系統服務或者服務雪崩,最終做到有損服務而不是不服務。
使用限流同樣需要注意的是:限流要評估好,測試好,否則會導致正常的訪問被限流。
計數器
計數器法
限流演算法中最簡單粗暴的一種演算法,例如,某一個介面1分鐘內的請求不超過60次,我們可以在開始時設定一個計數器,每次請求時,這個計數器的值加1,如果這個這個計數器的值大於60並且與第一次請求的時間間隔在1分鐘之內,那麼說明請求過多;如果該請求與第一次請求的時間間隔大於1分鐘,並且該計數器的值還在限流範圍內,那麼重置該計數器。
使用計數器還可以用來限制一定時間內的總併發數,比如資料庫連線池、執行緒池、秒殺的併發數;計數器限流只要一定時間內的總請求數超過設定的閥值則進行限流,是一種簡單粗暴的總數量限流,而不是平均速率限流。
這個方法有一個致命問題:臨界問題——當遇到惡意請求,在0:59時,瞬間請求100次,並且在1:00請求100次,那麼這個使用者在1秒內請求了200次,使用者可以在重置節點突發請求,而瞬間超過我們設定的速率限制,使用者可能通過演算法漏洞擊垮我們的應用。
這個問題我們可以使用滑動視窗解決。
滑動視窗
在上圖中,整個紅色矩形框是一個時間視窗,在我們的例子中,一個時間視窗就是1分鐘,然後我們將時間視窗進行劃分,如上圖我們把滑動視窗劃分為6格,所以每一格代表10秒,每超過10秒,我們的時間視窗就會向右滑動一格,每一格都有自己獨立的計數器,例如:一個請求在0:35到達, 那麼0:30到0:39的計數器會+1,那麼滑動視窗是怎麼解決臨界點的問題呢?如上圖,0:59到達的100個請求會在灰色區域格子中,而1:00到達的請求會在紅色格子中,視窗會向右滑動一格,那麼此時間視窗內的總請求數共200個,超過了限定的100,所以此時能夠檢測出來觸發了限流。回頭看看計數器演算法,會發現,其實計數器演算法就是視窗滑動演算法,只不過計數器演算法沒有對時間視窗進行劃分,所以是一格。
由此可見,當滑動視窗的格子劃分越多,限流的統計就會越精確。
漏桶演算法
演算法的思路就是水(請求)先進入到漏桶裡面,漏桶以恆定的速度流出,當水流的速度過大就會直接溢位,可以看出漏桶演算法能強行限制資料的傳輸速率。如下圖所示。
漏桶演算法不支援突發流量。
令牌桶演算法
從上圖中可以看出,令牌演算法有點複雜,桶裡存放著令牌token。桶一開始是空的,token以固定的速率r往桶裡面填充,直到達到桶的容量,多餘的token會被丟棄。每當一個請求過來時,就會嘗試著移除一個token,如果沒有token,請求無法通過。
令牌桶演算法支援突發流量。
令牌桶演算法實現
Guava框架提供了令牌桶演算法的實現,可直接使用這個框架的RateLimiter類建立一個令牌桶限流器,比如:每秒放置的令牌桶的數量為5,那麼RateLimiter物件可以保證1秒內不會放入超過5個令牌,並且以固定速率進行放置令牌,達到平滑輸出的效果。
平滑流量示例
這裡,我寫了一個使用Guava框架實現令牌桶演算法的示例,如下所示。
package io.binghe.limit.guava; import com.google.common.util.concurrent.RateLimiter; /** * @author binghe * @version 1.0.0 * @description 令牌桶演算法 */ public class TokenBucketLimiter { public static void main(String[] args){ //每秒鐘生成5個令牌 RateLimiter limiter = RateLimiter.create(5); //返回值表示從令牌桶中獲取一個令牌所花費的時間,單位是秒 System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); } }
程式碼的實現非常簡單,就是使用Guava框架的RateLimiter類生成了一個每秒向桶中放入5個令牌的物件,然後不斷從桶中獲取令牌。我們先來執行下這段程式碼,輸出的結果資訊如下所示。
0.0 0.197294 0.191278 0.19997 0.199305 0.200472 0.200184 0.199417 0.200111 0.199759
從輸出結果可以看出:第一次從桶中獲取令牌時,返回的時間為0.0,也就是沒耗費時間。之後每次從桶中獲取令牌時,都會耗費一定的時間,這是為什麼呢?按理說,向桶中放入了5個令牌後,再從桶中獲取令牌也應該和第一次一樣並不會花費時間啊!
因為在Guava的實現是這樣的:我們使用RateLimiter.create(5)建立令牌桶物件時,表示每秒新增5個令牌,1秒等於1000毫秒,也就是每隔200毫秒向桶中放入一個令牌。
當我們執行程式時,程式執行到RateLimiter limiter = RateLimiter.create(5);時,就會向桶中放入一個令牌,當程式執行到第一個System.out.println(limiter.acquire(1));時,由於桶中已經存在一個令牌,直接獲取這個令牌,並沒有花費時間。然而程式繼續向下執行時,由於程式會每隔200毫秒向桶中放入一個令牌,所以,獲取令牌時,花費的時間幾乎都是200毫秒左右。
突發流量示例
我們再來看一個突發流量的示例,程式碼示例如下所示。
package io.binghe.limit.guava; import com.google.common.util.concurrent.RateLimiter; /** * @author binghe * @version 1.0.0 * @description 令牌桶演算法 */ public class TokenBucketLimiter { public static void main(String[] args){ //每秒鐘生成5個令牌 RateLimiter limiter = RateLimiter.create(5); //返回值表示從令牌桶中獲取一個令牌所花費的時間,單位是秒 System.out.println(limiter.acquire(50)); System.out.println(limiter.acquire(5)); System.out.println(limiter.acquire(5)); System.out.println(limiter.acquire(5)); System.out.println(limiter.acquire(5)); } }
上述程式碼表示的含義為:每秒向桶中放入5個令牌,第一次從桶中獲取50個令牌,也就是我們說的突發流量,後續每次從桶中獲取5個令牌。接下來,我們執行上述程式碼看下效果。
0.0 9.998409 0.99109 1.000148 0.999752
執行程式碼時,會發現當命令列列印出0.0後,會等很久才會列印出後面的輸出結果。
程式每秒鐘向桶中放入5個令牌,當程式執行到 RateLimiter limiter = RateLimiter.create(5); 時,就會向桶中放入令牌。當執行到 System.out.println(limiter.acquire(50)); 時,發現很快就會獲取到令牌,花費了0.0秒。接下來,執行到第一個System.out.println(limiter.acquire(5));時,花費了9.998409秒。小夥們可以思考下,為什麼這裡會花費10秒中的時間呢?
這是因為我們使用RateLimiter limiter = RateLimiter.create(5);程式碼向桶中放入令牌時,一秒鐘放入5個,而System.out.println(limiter.acquire(50));需要獲取50個令牌,也就是獲取50個令牌需要花費10秒鐘時間,這是因為程式向桶中放入50個令牌需要10秒鐘。程式第一次從桶中獲取令牌時,很快就獲取到了。而第二次獲取令牌時,花費了將近10秒的時間。
Guava框架支援突發流量,但是在突發流量之後再次請求時,會被限速,也就是說:在突發流量之後,再次請求時,會彌補處理突發請求所花費的時間。所以,我們的突發示例程式中,在一次從桶中獲取50個令牌後,再次從桶中獲取令牌,則會花費10秒左右的時間。
Guava令牌桶演算法的特點
- RateLimiter使用令牌桶演算法,會進行令牌的累積,如果獲取令牌的頻率比較低,則不會導致等待,直接獲取令牌。
- RateLimiter由於會累積令牌,所以可以應對突發流量。也就是說如果同時請求5個令牌,由於此時令牌桶中有累積的令牌,能夠快速響應請求。
- RateLimiter在沒有足夠的令牌發放時,採用的是滯後的方式進行處理,也就是前一個請求獲取令牌所需要等待的時間由下一次請求來承受和彌補,也就是代替前一個請求進行等待。(這裡,小夥伴們要好好理解下)
HTTP介面限流實戰
這裡,我們實現Web介面限流,具體方式為:使用自定義註解封裝基於令牌桶限流演算法實現介面限流。
不使用註解實現介面限流
搭建專案
這裡,我們使用SpringBoot專案來搭建Http介面限流專案,SpringBoot專案本質上還是一個Maven專案。所以,小夥伴們可以直接建立一個Maven專案,我這裡的專案名稱為mykit-ratelimiter-test。接下來,在pom.xml檔案中新增如下依賴使專案構建為一個SpringBoot專案。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.6.RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>io.mykit.limiter</groupId> <artifactId>mykit-ratelimiter-test</artifactId> <version>1.0.0-SNAPSHOT</version> <packaging>jar</packaging> <name>mykit-ratelimiter-test</name> <properties> <guava.version>28.2-jre</guava.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-undertow</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>${guava.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.1</version><!--$NO-MVN-MAN-VER$--> <configuration> <source>${java.version}</source> <target>${java.version}</target> </configuration> </plugin> </plugins> </build>
可以看到,我在專案中除了引用了SpringBoot相關的Jar包外,還引用了guava框架,版本為28.2-jre。
建立核心類
這裡,我主要是模擬一個支付介面的限流場景。首先,我們定義一個PayService介面和MessageService介面。PayService介面主要用於模擬後續的支付業務,MessageService介面模擬傳送訊息。介面的定義分別如下所示。
- PayService
package io.mykit.limiter.service; import java.math.BigDecimal; /** * @author binghe * @version 1.0.0 * @description 模擬支付 */ public interface PayService { int pay(BigDecimal price); }
- MessageService
package io.mykit.limiter.service; /** * @author binghe * @version 1.0.0 * @description 模擬傳送訊息服務 */ public interface MessageService { boolean sendMessage(String message); }
接下來,建立二者的實現類,分別如下。
- MessageServiceImpl
package io.mykit.limiter.service.impl; import io.mykit.limiter.service.MessageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; /** * @author binghe * @version 1.0.0 * @description 模擬實現傳送訊息 */ @Service public class MessageServiceImpl implements MessageService { private final Logger logger = LoggerFactory.getLogger(MessageServiceImpl.class); @Override public boolean sendMessage(String message) { logger.info("傳送訊息成功===>>" + message); return true; } }
- PayServiceImpl
package io.mykit.limiter.service.impl; import io.mykit.limiter.service.PayService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.math.BigDecimal; /** * @author binghe * @version 1.0.0 * @description 模擬支付 */ @Service public class PayServiceImpl implements PayService { private final Logger logger = LoggerFactory.getLogger(PayServiceImpl.class); @Override public int pay(BigDecimal price) { logger.info("支付成功===>>" + price); return 1; } }
由於是模擬支付和傳送訊息,所以,我在具體實現的方法中列印出了相關的日誌,並沒有實現具體的業務邏輯。
接下來,就是建立我們的Controller類PayController,在PayController類的介面pay()方法中使用了限流,每秒鐘向桶中放入2個令牌,並且客戶端從桶中獲取令牌,如果在500毫秒內沒有獲取到令牌的話,我們可以則直接走服務降級處理。
PayController的程式碼如下所示。
package io.mykit.limiter.controller; import com.google.common.util.concurrent.RateLimiter; import io.mykit.limiter.service.MessageService; import io.mykit.limiter.service.PayService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.concurrent.TimeUnit; /** * @author binghe * @version 1.0.0 * @description 測試介面限流 */ @RestController public class PayController { private final Logger logger = LoggerFactory.getLogger(PayController.class); /** * RateLimiter的create()方法中傳入一個引數,表示以固定的速率2r/s,即以每秒2個令牌的速率向桶中放入令牌 */ private RateLimiter rateLimiter = RateLimiter.create(2); @Autowired private MessageService messageService; @Autowired private PayService payService; @RequestMapping("/boot/pay") public String pay(){ //記錄返回介面 String result = ""; //限流處理,客戶端請求從桶中獲取令牌,如果在500毫秒沒有獲取到令牌,則直接走服務降級處理 boolean tryAcquire = rateLimiter.tryAcquire(500, TimeUnit.MILLISECONDS); if (!tryAcquire){ result = "請求過多,降級處理"; logger.info(result); return result; } int ret = payService.pay(BigDecimal.valueOf(100.0)); if(ret > 0){ result = "支付成功"; return result; } result = "支付失敗,再試一次吧..."; return result; } }
最後,我們來建立mykit-ratelimiter-test專案的核心啟動類,如下所示。
package io.mykit.limiter; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * @author binghe * @version 1.0.0 * @description 專案啟動類 */ @SpringBootApplication public class MykitLimiterApplication { public static void main(String[] args){ SpringApplication.run(MykitLimiterApplication.class, args); } }
至此,我們不使用註解方式實現限流的Web應用就基本完成了。
執行專案
專案建立完成後,我們來執行專案,執行SpringBoot專案比較簡單,直接執行MykitLimiterApplication類的main()方法即可。
專案執行成功後,我們在瀏覽器位址列輸入連結:http://localhost:8080/boot/pay。頁面會輸出“支付成功”的字樣,說明專案搭建成功了。如下所示。
此時,我只訪問了一次,並沒有觸發限流。接下來,我們不停的刷瀏覽器,此時,瀏覽器會輸出“支付失敗,再試一次吧…”的字樣,如下所示。
在PayController類中還有一個sendMessage()方法,模擬的是傳送訊息的介面,同樣使用了限流操作,具體程式碼如下所示。
@RequestMapping("/boot/send/message") public String sendMessage(){ //記錄返回介面 String result = ""; //限流處理,客戶端請求從桶中獲取令牌,如果在500毫秒沒有獲取到令牌,則直接走服務降級處理 boolean tryAcquire = rateLimiter.tryAcquire(500, TimeUnit.MILLISECONDS); if (!tryAcquire){ result = "請求過多,降級處理"; logger.info(result); return result; } boolean flag = messageService.sendMessage("恭喜您成長值+1"); if (flag){ result = "訊息傳送成功"; return result; } result = "訊息傳送失敗,再試一次吧..."; return result; }
sendMessage()方法的程式碼邏輯和執行效果與pay()方法相同,我就不再瀏覽器訪問 http://localhost:8080/boot/send/message 地址的訪問效果了,小夥伴們可以自行驗證。
不使用註解實現限流缺點
通過對專案的編寫,我們可以發現,當在專案中對介面進行限流時,不使用註解進行開發,會導致程式碼出現大量冗餘,每個方法中幾乎都要寫一段相同的限流邏輯,程式碼十分冗餘。
如何解決程式碼冗餘的問題呢?我們可以使用自定義註解進行實現。
使用註解實現介面限流
使用自定義註解,我們可以將一些通用的業務邏輯封裝到註解的切面中,在需要新增註解業務邏輯的方法上加上相應的註解即可。針對我們這個限流的例項來說,可以基於自定義註解實現。
實現自定義註解
實現,我們來建立一個自定義註解,如下所示。
package io.mykit.limiter.annotation; import java.lang.annotation.*; /** * @author binghe * @version 1.0.0 * @description 實現限流的自定義註解 */ @Target(value = ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface MyRateLimiter { //向令牌桶放入令牌的速率 double rate(); //從令牌桶獲取令牌的超時時間 long timeout() default 0; }
自定義註解切面實現
接下來,我們還要實現一個切面類MyRateLimiterAspect,如下所示。
package io.mykit.limiter.aspect; import com.google.common.util.concurrent.RateLimiter; import io.mykit.limiter.annotation.MyRateLimiter; 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.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.util.concurrent.TimeUnit; /** * @author binghe * @version 1.0.0 * @description 一般限流切面類 */ @Aspect @Component public class MyRateLimiterAspect { private RateLimiter rateLimiter = RateLimiter.create(2); @Pointcut("execution(public * io.mykit.limiter.controller.*.*(..))") public void pointcut(){ } /** * 核心切面方法 */ @Around("pointcut()") public Object process(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{ MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); //使用反射獲取方法上是否存在@MyRateLimiter註解 MyRateLimiter myRateLimiter = signature.getMethod().getDeclaredAnnotation(MyRateLimiter.class); if(myRateLimiter == null){ //程式正常執行,執行目標方法 return proceedingJoinPoint.proceed(); } //獲取註解上的引數 //獲取配置的速率 double rate = myRateLimiter.rate(); //獲取客戶端等待令牌的時間 long timeout = myRateLimiter.timeout(); //設定限流速率 rateLimiter.setRate(rate); //判斷客戶端獲取令牌是否超時 boolean tryAcquire = rateLimiter.tryAcquire(timeout, TimeUnit.MILLISECONDS); if(!tryAcquire){ //服務降級 fullback(); return null; } //獲取到令牌,直接執行 return proceedingJoinPoint.proceed(); } /** * 降級處理 */ private void fullback() { response.setHeader("Content-type", "text/html;charset=UTF-8"); PrintWriter writer = null; try { writer = response.getWriter(); writer.println("出錯了,重試一次試試?"); writer.flush();; } catch (IOException e) { e.printStackTrace(); }finally { if(writer != null){ writer.close(); } } } }
自定義切面的功能比較簡單,我就不細說了,大家有啥問題可以關注【冰河技術】微信公眾號來進行提問。
接下來,我們改造下PayController類中的sendMessage()方法,修改後的方法片段程式碼如下所示。
@MyRateLimiter(rate = 1.0, timeout = 500) @RequestMapping("/boot/send/message") public String sendMessage(){ //記錄返回介面 String result = ""; boolean flag = messageService.sendMessage("恭喜您成長值+1"); if (flag){ result = "訊息傳送成功"; return result; } result = "訊息傳送失敗,再試一次吧..."; return result; }
執行部署專案
部署專案比較簡單,只需要執行MykitLimiterApplication類下的main()方法即可。這裡,為了簡單,我們還是從瀏覽器中直接輸入連結地址來進行訪問
效果如下所示。
接下來,我們不斷的重新整理瀏覽器。會出現“訊息傳送失敗,再試一次吧…”的字樣,說明已經觸發限流操作。
- 專案原始碼已提交到github:https://github.com/sunshinelyz/mykit-ratelimiter