在開發高併發系統時有三把利器用來保護系統:緩存、降級和限流。緩存的目的是提升系統訪問速度和增大系統能處理的容量,可謂是抗高併發流量的銀彈;而降級是當服務出問題或者影響到核心流程的性能則需要暫時屏蔽掉,待高峯或者問題解決後再打開;而有些場景並不能用緩存和降級來解決,比如稀缺資源(秒殺、搶購)、寫服務(如評論、下單)、頻繁的複雜查詢(評論的最後幾頁),因此需有一種手段來限制這些場景的併發/請求量,即限流。
爲什麼要互聯網項目要限流
互聯網雪崩效應解決方案
服務降級: 在高併發的情況, 防止用戶一直等待,直接返回一個友好的錯誤提示給客戶端。
服務熔斷:在高併發的情況,一旦達到服務最大的承受極限,直接拒絕訪問,使用服務降級。
服務隔離: 使用服務隔離方式解決服務雪崩效應
服務限流: 在高併發的情況,一旦服務承受不了使用服務限流機制(計時器(滑動窗口計數)、漏桶算法、令牌桶(Restlimite))
高併發限流解決方案
高併發限流解決方案限流算法(令牌桶、漏桶、計數器)、應用層解決限流(Nginx)
限流算法
常見的限流算法有:令牌桶、漏桶。計數器也可以進行粗暴限流實現。
計數器
它是限流算法中最簡單最容易的一種算法,比如我們要求某一個接口,1分鐘內的請求不能超過10次,我們可以在開始時設置一個計數器,每次請求,該計數器+1;如果該計數器的值大於10並且與第一次請求的時間間隔在1分鐘內,那麼說明請求過多,如果該請求與第一次請求的時間間隔大於1分鐘,並且該計數器的值還在限流範圍內,那麼重置該計數器
/**
* 功能說明: 純手寫計數器方式<br>
*/
public class LimitService {
private int limtCount = 60;// 限制最大訪問的容量
AtomicInteger atomicInteger = new AtomicInteger(0); // 每秒鐘 實際請求的數量
private long start = System.currentTimeMillis();// 獲取當前系統時間
private int interval = 60;// 間隔時間60秒
public boolean acquire() {
long newTime = System.currentTimeMillis();
if (newTime > (start + interval)) {
// 判斷是否是一個週期
start = newTime;
atomicInteger.set(0); // 清理爲0
return true;
}
atomicInteger.incrementAndGet();// i++;
return atomicInteger.get() <= limtCount;
}
static LimitService limitService = new LimitService();
public static void main(String[] args) {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
for (int i = 1; i < 100; i++) {
final int tempI = i;
newCachedThreadPool.execute(new Runnable() {
public void run() {
if (limitService.acquire()) {
System.out.println("你沒有被限流,可以正常訪問邏輯 i:" + tempI);
} else {
System.out.println("你已經被限流呢 i:" + tempI);
}
}
});
}
}
}
滑動窗口計數
滑動窗口計數有很多使用場景,比如說限流防止系統雪崩。相比計數實現,滑動窗口實現會更加平滑,能自動消除毛刺。
滑動窗口原理是在每次有訪問進來時,先判斷前 N 個單位時間內的總訪問量是否超過了設置的閾值,並對當前時間片上的請求數 +1。
令牌桶算法
令牌桶算法是一個存放固定容量令牌的桶,按照固定速率往桶裏添加令牌。令牌桶算法的描述如下:
假設限制2r/s,則按照500毫秒的固定速率往桶中添加令牌;
桶中最多存放b個令牌,當桶滿時,新添加的令牌被丟棄或拒絕;
當一個n個字節大小的數據包到達,將從桶中刪除n個令牌,接着數據包被髮送到網絡上;
如果桶中的令牌不足n個,則不會刪除令牌,且該數據包將被限流(要麼丟棄,要麼緩衝區等待)。
使用RateLimiter實現令牌桶限流
RateLimiter是guava提供的基於令牌桶算法的實現類,可以非常簡單的完成限流特技,並且根據系統的實際情況來調整生成token的速率。
通常可應用於搶購限流防止沖垮系統;限制某接口、服務單位時間內的訪問量,譬如一些第三方服務會對用戶訪問量進行限制;限制網速,單位時間內只允許上傳下載多少字節等。
下面來看一些簡單的實踐,需要先引入guava的maven依賴。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>25.1-jre</version>
</dependency>
</dependencies>
/**
* 功能說明:使用RateLimiter 實現令牌桶算法
*
*/
@RestController
public class IndexController {
@Autowired
private OrderService orderService;
// 解釋:1.0 表示 每秒中生成1個令牌存放在桶中
RateLimiter rateLimiter = RateLimiter.create(1.0);
// 下單請求
@RequestMapping("/order")
public String order() {
// 1.限流判斷
// 如果在500秒內 沒有獲取不到令牌的話,則會一直等待
System.out.println("生成令牌等待時間:" + rateLimiter.acquire());
boolean acquire = rateLimiter.tryAcquire(500, TimeUnit.MILLISECONDS);
if (!acquire) {
System.out.println("你在怎麼搶,也搶不到,因爲會一直等待的,你先放棄吧!");
return "你在怎麼搶,也搶不到,因爲會一直等待的,你先放棄吧!";
}
// 2.如果沒有達到限流的要求,直接調用訂單接口
boolean isOrderAdd = orderService.addOrder();
if (isOrderAdd) {
return "恭喜您,搶購成功!";
}
return "搶購失敗!";
}
}
漏桶算法
漏桶作爲計量工具(The Leaky Bucket Algorithm as a Meter)時,可以用於流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:
一個固定容量的漏桶,按照常量固定速率流出水滴;
如果桶是空的,則不需流出水滴;
可以以任意速率流入水滴到漏桶;
如果流入水滴超出了桶的容量,則流入的水滴溢出了(被丟棄),而漏桶容量是不變的。
令牌桶和漏桶對比:
令牌桶是按照固定速率往桶中添加令牌,請求是否被處理需要看桶中令牌是否足夠,當令牌數減爲零時則拒絕新的請求;
漏桶則是按照常量固定速率流出請求,流入請求速率任意,當流入的請求數累積到漏桶容量時,則新流入的請求被拒絕;
令牌桶限制的是平均流入速率(允許突發請求,只要有令牌就可以處理,支持一次拿3個令牌,4個令牌),並允許一定程度突發流量;
漏桶限制的是常量流出速率(即流出速率是一個固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),從而平滑突發流入速率;
令牌桶允許一定程度的突發,而漏桶主要目的是平滑流入速率;
兩個算法實現可以一樣,但是方向是相反的,對於相同的參數得到的限流效果是一樣的。
另外有時候我們還使用計數器來進行限流,主要用來限制總併發數,比如數據庫連接池、線程池、秒殺的併發數;只要全局總請求數或者一定時間段的總請求數設定的閥值則進行限流,是簡單粗暴的總數量限流,而不是平均速率限流。
一個固定的漏桶,以常量固定的速率流出水滴。
如果桶中沒有水滴的話,則不會流出水滴
如果流入的水滴超過桶中的流量,則流入的水滴可能會發生溢出,溢出的水滴請求是無法訪問的,直接調用服務降級方法,桶中的容量是不會發生變化。
漏桶算法與令牌桶算法區別
主要區別在於“漏桶算法”能夠強行限制數據的傳輸速率,而“令牌桶算法”在能夠限制數據的平均傳輸速率外,還允許某種程度的突發傳輸。在“令牌桶算法”中,只要令牌桶中存在令牌,那麼就允許突發地傳輸數據直到達到用戶配置的門限,因此它適合於具有突發特性的流量。
封裝RateLimiter
自定義註解封裝RateLimiter.實例:
@RequestMapping("/myOrder")
@ExtRateLimiter(value = 10.0, timeOut = 500)
public String myOrder() throws InterruptedException {
System.out.println("myOrder");
return "SUCCESS";
}
自定義註解
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtRateLimiter {
double value();
long timeOut();
}
編寫AOP
@Aspect
@Component
public class RateLimiterAop {
// 存放接口是否已經存在
private static ConcurrentHashMap<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<String, RateLimiter>();
@Pointcut("execution(public * com.itmayeidu.api.*.*(..))")
public void rlAop() {
}
@Around("rlAop()")
public Object doBefore(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
// 使用Java反射技術獲取方法上是否有@ExtRateLimiter註解類
ExtRateLimiter extRateLimiter = signature.getMethod().getDeclaredAnnotation(ExtRateLimiter.class);
if (extRateLimiter == null) {
// 正常執行方法
Object proceed = proceedingJoinPoint.proceed();
return proceed;
}
// ############獲取註解上的參數 配置固定速率 ###############
// 獲取配置的速率
double value = extRateLimiter.value();
// 獲取等待令牌等待時間
long timeOut = extRateLimiter.timeOut();
RateLimiter rateLimiter = getRateLimiter(value, timeOut);
// 判斷令牌桶獲取token 是否超時
boolean tryAcquire = rateLimiter.tryAcquire(timeOut, TimeUnit.MILLISECONDS);
if (!tryAcquire) {
serviceDowng();
return null;
}
// 獲取到令牌,直接執行..
Object proceed = proceedingJoinPoint.proceed();
return proceed;
}
// 獲取RateLimiter對象
private RateLimiter getRateLimiter(double value, long timeOut) {
// 獲取當前URL
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String requestURI = request.getRequestURI();
RateLimiter rateLimiter = null;
if (!rateLimiterMap.containsKey(requestURI)) {
// 開啓令牌通限流
rateLimiter = RateLimiter.create(value); // 獨立線程
rateLimiterMap.put(requestURI, rateLimiter);
} else {
rateLimiter = rateLimiterMap.get(requestURI);
}
return rateLimiter;
}
// 服務降級
private void serviceDowng() throws IOException {
// 執行服務降級處理
System.out.println("執行降級方法,親,服務器忙!請稍後重試!");
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletResponse response = attributes.getResponse();
response.setHeader("Content-type", "text/html;charset=UTF-8");
PrintWriter writer = response.getWriter();
try {
writer.println("執行降級方法,親,服務器忙!請稍後重試!");
} catch (Exception e) {
} finally {
writer.close();
}
}
public static void main(String[] args) {
// 使用Java反射技術獲取方法上是否有@ExtRateLimiter註解類
ExtRateLimiter extRateLimiter = IndexController.class.getClass().getAnnotation(ExtRateLimiter.class);
System.out.println(extRateLimiter);
}
}
@RequestMapping("/myOrder")
@ExtRateLimiter(value = 10.0, timeOut = 500)
public String myOrder() throws InterruptedException {
System.out.println("myOrder");
return "SUCCESS";
}
應用級限流
限流總併發/連接/請求數
對於一個應用系統來說一定會有極限併發/請求數,即總有一個TPS/QPS閥值,如果超了閥值則系統就會不響應用戶請求或響應的非常慢,因此我們最好進行過載保護,防止大量請求湧入擊垮系統。
如果你使用過Tomcat,其Connector其中一種配置有如下幾個參數:
acceptCount:如果Tomcat的線程都忙於響應,新來的連接會進入隊列排隊,如果超出排隊大小,則拒絕連接;
maxConnections:瞬時最大連接數,超出的會排隊等待;
maxThreads:Tomcat能啓動用來處理請求的最大線程數,如果請求處理量一直遠遠大於最大線程數則可能會僵死。
詳細的配置請參考官方文檔。另外如MySQL(如max_connections)、Redis(如tcp-backlog)都會有類似的限制連接數的配置。
限流總資源數
如果有的資源是稀缺資源(如數據庫連接、線程),而且可能有多個系統都會去使用它,那麼需要限制應用;可以使用池化技術來限制總資源數:連接池、線程池。比如分配給每個應用的數據庫連接是100,那麼本應用最多可以使用100個資源,超出了可以等待或者拋異常。
限流某個接口的總併發/請求數
如果接口可能會有突發訪問情況,但又擔心訪問量太大造成崩潰,如搶購業務;這個時候就需要限制這個接口的總併發/請求數總請求數了;因爲粒度比較細,可以爲每個接口都設置相應的閥值。可以使用Java中的AtomicLong進行限流:
適合對業務無損的服務或者需要過載保護的服務進行限流,如搶購業務,超出了大小要麼讓用戶排隊,要麼告訴用戶沒貨了,對用戶來說是可以接受的。而一些開放平臺也會限制用戶調用某個接口的試用請求量,也可以用這種計數器方式實現。這種方式也是簡單粗暴的限流,沒有平滑處理,需要根據實際情況選擇使用;
限流某個接口的時間窗請求數
即一個時間窗口內的請求數,如想限制某個接口/服務每秒/每分鐘/每天的請求數/調用量。如一些基礎服務會被很多其他系統調用,比如商品詳情頁服務會調用基礎商品服務調用,但是怕因爲更新量比較大將基礎服務打掛,這時我們要對每秒/每分鐘的調用量進行限速;一種實現方式如下所示:
平滑限流某個接口的請求數
之前的限流方式都不能很好地應對突發請求,即瞬間請求可能都被允許從而導致一些問題;因此在一些場景中需要對突發請求進行整形,整形爲平均速率請求處理(比如5r/s,則每隔200毫秒處理一個請求,平滑了速率)。這個時候有兩種算法滿足我們的場景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法實現,可直接拿來使用。
Guava RateLimiter提供了令牌桶算法實現:平滑突發限流(SmoothBursty)和平滑預熱限流(SmoothWarmingUp)實現。