前言
在開(kāi)發(fā)十萬(wàn)博客系統(tǒng)的的過(guò)程中,前面主要分享了爬蟲(chóng)、緩存穿透以及文章閱讀量計(jì)數(shù)等等。爬蟲(chóng)的目的就是解決十萬(wàn)+問(wèn)題;緩存穿透是為了保護(hù)后端數(shù)據(jù)庫(kù)查詢服務(wù);計(jì)數(shù)服務(wù)解決了接近真實(shí)閱讀數(shù)以及數(shù)據(jù)庫(kù)服務(wù)的壓力。
架構(gòu)圖
限流
就拿十萬(wàn)博客來(lái)說(shuō),如果存在熱點(diǎn)文章,可能會(huì)有數(shù)十萬(wàn)級(jí)別的并發(fā)用戶參與閱讀。如果想讓這些用戶正常訪問(wèn),無(wú)非就是加機(jī)器橫向擴(kuò)展各種服務(wù),但凡事都有一個(gè)利益平衡點(diǎn),有時(shí)候只需要少量的機(jī)器保證大部分用戶在大部分時(shí)間可以正常訪問(wèn)即可。
亦或是,如果存在大量爬蟲(chóng)或者惡意攻擊,我們必須采取一定的措施來(lái)保證服務(wù)的正常運(yùn)行。這時(shí)候我們就要考慮限流來(lái)保證服務(wù)的可用性,以防止非預(yù)期的請(qǐng)求對(duì)系統(tǒng)壓力過(guò)大而引起的系統(tǒng)癱瘓。通常的策略就是拒絕多余的訪問(wèn),或者讓多余的訪問(wèn)排隊(duì)等待服務(wù)。
限流算法
任何限流都不是漫無(wú)目的的,也不是一個(gè)開(kāi)關(guān)就可以解決的問(wèn)題,常用的限流算法有:令牌桶,漏桶。
令牌桶
令牌桶算法是網(wǎng)絡(luò)流量整形(Traffic Shaping)和速率限制(Rate
Limiting)中最常使用的一種算法。典型情況下,令牌桶算法用來(lái)控制發(fā)送到網(wǎng)絡(luò)上的數(shù)據(jù)的數(shù)目,并允許突發(fā)數(shù)據(jù)的發(fā)送(百科)。
用戶的請(qǐng)求速率是不固定的,這里我們假定為10r/s,令牌按照5個(gè)每秒的速率放入令牌桶,桶中最多存放20個(gè)令牌。仔細(xì)想想,是不是總有那么一部分請(qǐng)求被丟棄。
漏桶
漏桶算法的主要目的是控制數(shù)據(jù)注入到網(wǎng)絡(luò)的速率,平滑網(wǎng)絡(luò)上的突發(fā)流量。漏桶算法提供了一種機(jī)制,通過(guò)它,突發(fā)流量可以被整形以便為網(wǎng)絡(luò)提供一個(gè)穩(wěn)定的流量(百科)。
令牌桶是無(wú)論你流入速率多大,我都按照既定的速率去處理,如果桶滿則拒絕服務(wù)。
應(yīng)用限流
Tomcat
在Tomcat容器中,我們可以通過(guò)自定義線程池,配置最大連接數(shù),請(qǐng)求處理隊(duì)列等參數(shù)來(lái)達(dá)到限流的目的。
Tomcat默認(rèn)使用自帶的連接池,這里我們也可以自定義實(shí)現(xiàn),打開(kāi)/conf/server.xml文件,在Connector之前配置一個(gè)線程池:
<Executor name="tomcatThreadPool" namePrefix="tomcatThreadPool-"
maxThreads="1000" maxIdleTime="300000" minSpareThreads="200"/>
* name:共享線程池的名字。這是Connector為了共享線程池要引用的名字,該名字必須唯一。默認(rèn)值:None;
* namePrefix:在JVM上,每個(gè)運(yùn)行線程都可以有一個(gè)name
字符串。這一屬性為線程池中每個(gè)線程的name字符串設(shè)置了一個(gè)前綴,Tomcat將把線程號(hào)追加到這一前綴的后面。默認(rèn)值:tomcat-exec-;
* maxThreads:該線程池可以容納的最大線程數(shù)。默認(rèn)值:200;
*
maxIdleTime:在Tomcat關(guān)閉一個(gè)空閑線程之前,允許空閑線程持續(xù)的時(shí)間(以毫秒為單位)。只有當(dāng)前活躍的線程數(shù)大于minSpareThread的值,才會(huì)關(guān)閉空閑線程。默認(rèn)值:60000(一分鐘)。
* minSpareThreads:Tomcat應(yīng)該始終打開(kāi)的最小不活躍線程數(shù)。默認(rèn)值:25。
配置Connector
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1"
connectionTimeout="20000" redirectPort="8443" minProcessors="5"
maxProcessors="75" acceptCount="1000"/>
* executor:表示使用該參數(shù)值對(duì)應(yīng)的線程池;
* minProcessors:服務(wù)器啟動(dòng)時(shí)創(chuàng)建的處理請(qǐng)求的線程數(shù);
* maxProcessors:最大可以創(chuàng)建的處理請(qǐng)求的線程數(shù);
* acceptCount:指定當(dāng)所有可以使用的處理請(qǐng)求的線程數(shù)都被使用時(shí),可以放到處理隊(duì)列中的請(qǐng)求數(shù),超過(guò)這個(gè)數(shù)的請(qǐng)求將不予處理。
API限流
這里我們采用開(kāi)源工具包guava提供的限流工具類RateLimiter進(jìn)行API限流,該類基于"令牌桶算法",開(kāi)箱即用。
自定義定義注解
/** * 自定義注解 限流 * 創(chuàng)建者 爪洼筆記 * 博客 https://blog.52itstyle.vip * 創(chuàng)建時(shí)間 2019年8月15日 */
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME) @Documented public @interface ServiceLimit
{ /** * 描述 */ String description() default ""; /** * key */ String key()
default ""; /** * 類型 */ LimitType limitType() default LimitType.CUSTOMER; enum
LimitType { /** * 自定義key */ CUSTOMER, /** * 根據(jù)請(qǐng)求者IP */ IP } }
自定義切面
/** * 限流 AOP * 創(chuàng)建者 爪洼筆記 * 博客 https://blog.52itstyle.vip * 創(chuàng)建時(shí)間 2019年8月15日 */
@Aspect @Configuration @Order(1) public class LimitAspect{ //根據(jù)IP分不同的令牌桶,
每天自動(dòng)清理緩存 private static LoadingCache<String, RateLimiter> caches =
CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(1,
TimeUnit.DAYS) .build(new CacheLoader<String, RateLimiter>() { @Override public
RateLimiter load(String key){ // 新的IP初始化 每秒只發(fā)出5個(gè)令牌 return
RateLimiter.create(5); } }); //Service層切點(diǎn) 限流
@Pointcut("@annotation(com.itstyle.blog.common.limit.ServiceLimit)") public
void ServiceAspect() { } @Around("ServiceAspect()") public Object
around(ProceedingJoinPoint joinPoint) { MethodSignature signature =
(MethodSignature) joinPoint.getSignature(); Method method =
signature.getMethod(); ServiceLimit limitAnnotation =
method.getAnnotation(ServiceLimit.class); ServiceLimit.LimitType limitType =
limitAnnotation.limitType(); String key = limitAnnotation.key(); Object obj;
try { if(limitType.equals(ServiceLimit.LimitType.IP)){ key =
IPUtils.getIpAddr(); } RateLimiter rateLimiter = caches.get(key); Boolean flag
= rateLimiter.tryAcquire(); if(flag){ obj = joinPoint.proceed(); }else{ throw
new RrException("小同志,你訪問(wèn)的太頻繁了"); } } catch (Throwable e) { throw new
RrException("小同志,你訪問(wèn)的太頻繁了"); } return obj; } }
業(yè)務(wù)實(shí)現(xiàn):
/** * 執(zhí)行順序 * 1)限流 * 2)布隆 * 3)計(jì)數(shù) * 4) 緩存 * @param id * @return */ @Override
@ServiceLimit(limitType= ServiceLimit.LimitType.IP) @BloomLimit @HyperLogLimit
@Cacheable(cacheNames ="blog") public Blog getById(Long id) { String nativeSql
= "SELECT * FROM blog WHERE id=?"; return
dynamicQuery.nativeQuerySingleResult(Blog.class,nativeSql,new Object[]{id}); }
分布式限流
Nginx
如何使用Nginx實(shí)現(xiàn)基本的限流,比如單個(gè)IP限制每秒訪問(wèn)50次。通過(guò)Nginx限流模塊,我們可以設(shè)置一旦并發(fā)連接數(shù)超過(guò)我們的設(shè)置,將返回503錯(cuò)誤給客戶端。
配置nginx.conf
#統(tǒng)一在http域中進(jìn)行配置 #限制請(qǐng)求 limit_req_zone $binary_remote_addr $uri zone=api_read:20m
rate=50r/s; #按ip配置一個(gè)連接 zone limit_conn_zone $binary_remote_addr
zone=perip_conn:10m; #按server配置一個(gè)連接 zone limit_conn_zone $server_name
zone=perserver_conn:100m; server { listen 80; server_name blog.52itstyle.top;
index index.jsp; location / { #請(qǐng)求限流排隊(duì)通過(guò) burst默認(rèn)是0 limit_req zone=api_read
burst=5; #連接數(shù)限制,每個(gè)IP并發(fā)請(qǐng)求為2 limit_conn perip_conn 2;
#服務(wù)所限制的連接數(shù)(即限制了該server并發(fā)連接數(shù)量) limit_conn perserver_conn 1000; #連接限速 limit_rate
100k; proxy_pass http://seckill; } } upstream seckill { fair; server
172.16.1.120:8080 weight=1 max_fails=2 fail_timeout=30s; server
172.16.1.130:8080 weight=1 max_fails=2 fail_timeout=30s; }
配置說(shuō)明
imit_conn_zone
是針對(duì)每個(gè)IP定義一個(gè)存儲(chǔ)session狀態(tài)的容器。這個(gè)示例中定義了一個(gè)100m的容器,按照32bytes/session,可以處理3200000個(gè)session。
limit_rate 300k;
對(duì)每個(gè)連接限速300k. 注意,這里是對(duì)連接限速,而不是對(duì)IP限速。如果一個(gè)IP允許兩個(gè)并發(fā)連接,那么這個(gè)IP就是限速limit_rate×2。
burst=5;
這相當(dāng)于桶的大小,如果某個(gè)請(qǐng)求超過(guò)了系統(tǒng)處理速度,會(huì)被放入桶中,等待被處理。如果桶滿了,那么抱歉,請(qǐng)求直接返回503,客戶端得到一個(gè)服務(wù)器忙的響應(yīng)。如果系統(tǒng)處理請(qǐng)求的速度比較慢,桶里的請(qǐng)求也不能一直待在里面,如果超過(guò)一定時(shí)間,也是會(huì)被直接退回,返回服務(wù)器忙的響應(yīng)。
OpenResty
這里我們使用 OpenResty
開(kāi)源的限流方案,測(cè)試案例使用OpenResty1.15.8.1最新版本,自帶lua-resty-limit-traffic模塊以及案例 ,實(shí)現(xiàn)起來(lái)更為方便。
限制接口總并發(fā)數(shù)/請(qǐng)求數(shù)
熱點(diǎn)博文,由于突發(fā)流量暴增,有可能會(huì)影響整個(gè)系統(tǒng)的穩(wěn)定性從而造成崩潰,這時(shí)候我們就要限制熱點(diǎn)博文的總并發(fā)數(shù)/請(qǐng)求數(shù)。
這里我們采用 lua-resty-limit-traffic中的resty.limit.count模塊實(shí)現(xiàn):
-- 限制接口總并發(fā)數(shù)/請(qǐng)求數(shù) local limit_count = require "resty.limit.count" --
這里我們使用AB測(cè)試,-n訪問(wèn)10000次, -c并發(fā)1200個(gè) -- ab -n 10000 -c 1200 http://121.42.155.213/
,第一次測(cè)試數(shù)據(jù):1000個(gè)請(qǐng)求會(huì)有差不多8801請(qǐng)求失敗,符合以下配置說(shuō)明 -- 限制 一分鐘內(nèi)只能調(diào)用 1200 次
接口(允許在時(shí)間段開(kāi)始的時(shí)候一次性放過(guò)1200個(gè)請(qǐng)求) local lim, err =
limit_count.new("my_limit_count_store", 1200, 60) if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.count object: ", err)
return ngx.exit(500) end -- use the Authorization header as the limiting key
local key = ngx.req.get_headers()["Authorization"] or "public" local delay, err
= lim:incoming(key, true) if not delay then if err == "rejected" then
ngx.header["X-RateLimit-Limit"] = "5000" ngx.header["X-RateLimit-Remaining"] =
0 return ngx.exit(503) end ngx.log(ngx.ERR, "failed to limit count: ", err)
return ngx.exit(500) end -- the 2nd return value holds the current remaining
number -- of requests for the specified key. local remaining = err
ngx.header["X-RateLimit-Limit"] = "5000" ngx.header["X-RateLimit-Remaining"] =
remaining
限制接口時(shí)間窗請(qǐng)求數(shù)
現(xiàn)在網(wǎng)絡(luò)爬蟲(chóng)泛濫,有時(shí)候并不是人為的去點(diǎn)擊,亦或是存在惡意攻擊的情況。此時(shí)我們就要對(duì)客戶端單位時(shí)間內(nèi)的請(qǐng)求數(shù)進(jìn)行限制,以至于黑客不是那么猖獗。當(dāng)然了道高一尺魔高一丈,攻擊者總是會(huì)有辦法繞開(kāi)你的防線,從另一方面講也促進(jìn)了技術(shù)的進(jìn)步。
這里我們采用 lua-resty-limit-traffic中的resty.limit.conn模塊實(shí)現(xiàn):
-- well, we could put the require() and new() calls in our own Lua -- modules
to save overhead. here we put them below just for -- convenience. local
limit_conn = require "resty.limit.conn" -- 這里我們使用AB測(cè)試,-n訪問(wèn)1000次, -c并發(fā)100個(gè) -- ab
-n 1000 -c 100 http://121.42.155.213/ ,這里1000個(gè)請(qǐng)求將會(huì)有700個(gè)失敗 --
相同IP段的人將不能被訪問(wèn),不影響其它IP -- 限制 IP 總請(qǐng)求數(shù) -- 限制單個(gè) ip 客戶端最大 200 req/sec 并且允許100
req/sec的突發(fā)請(qǐng)求 -- 就是說(shuō)我們會(huì)把200以上300一下的請(qǐng)求請(qǐng)求給延遲, 超過(guò)300的請(qǐng)求將會(huì)被拒絕 --
最后一個(gè)參數(shù)其實(shí)是你要預(yù)估這些并發(fā)(或者說(shuō)單個(gè)請(qǐng)求)要處理多久,可以通過(guò)的log_by_lua中的leaving()調(diào)用進(jìn)行動(dòng)態(tài)調(diào)整 local lim,
err = limit_conn.new("my_limit_conn_store", 200, 100, 0.5) if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.conn object: ", err)
return ngx.exit(500) end -- the following call must be per-request. -- here we
use the remote (IP) address as the limiting key -- commit 為true 代表要更新shared
dict中key的值, -- false 代表只是查看當(dāng)前請(qǐng)求要處理的延時(shí)情況和前面還未被處理的請(qǐng)求數(shù) local key =
ngx.var.binary_remote_addr local delay, err = lim:incoming(key, true) if not
delay then if err == "rejected" then return ngx.exit(503) end ngx.log(ngx.ERR,
"failed to limit req: ", err) return ngx.exit(500) end if lim:is_committed()
then local ctx = ngx.ctx ctx.limit_conn = lim ctx.limit_conn_key = key
ctx.limit_conn_delay = delay end -- the 2nd return value holds the current
concurrency level -- for the specified key. local conn = err if delay >= 0.001
then -- 其實(shí)這里的 delay 肯定是上面說(shuō)的并發(fā)處理時(shí)間的整數(shù)倍, --
舉個(gè)例子,每秒處理100并發(fā),桶容量200個(gè),當(dāng)時(shí)同時(shí)來(lái)500個(gè)并發(fā),則200個(gè)拒掉 --
100個(gè)在被處理,然后200個(gè)進(jìn)入桶中暫存,被暫存的這200個(gè)連接中,0-100個(gè)連接其實(shí)應(yīng)該延后0.5秒處理, --
101-200個(gè)則應(yīng)該延后0.5*2=1秒處理(0.5是上面預(yù)估的并發(fā)處理時(shí)間) -- the request exceeding the 200
connections ratio but below -- 300 connections, so -- we intentionally delay it
here a bit to conform to the -- 200 connection limit. -- ngx.log(ngx.WARN,
"delaying") ngx.sleep(delay) end
平滑限制接口請(qǐng)求數(shù)
之前的限流方式允許突發(fā)流量,也就是說(shuō)瞬時(shí)流量都會(huì)被允許。突然流量如果不加以限制會(huì)影響整個(gè)系統(tǒng)的穩(wěn)定性,因此在秒殺場(chǎng)景中需要對(duì)請(qǐng)求整形為平均速率處理,即20r/s。
這里我們采用 lua-resty-limit-traffic 中的resty.limit.req 模塊實(shí)現(xiàn)漏桶限流和令牌桶限流。
其實(shí)漏桶和令牌桶根本的區(qū)別就是,如何處理超過(guò)請(qǐng)求速率的請(qǐng)求。漏桶會(huì)把請(qǐng)求放入隊(duì)列中去等待均速處理,隊(duì)列滿則拒絕服務(wù);令牌桶在桶容量允許的情況下直接處理這些突發(fā)請(qǐng)求。
漏桶
桶容量大于零,并且是延遲模式。如果桶沒(méi)滿,則進(jìn)入請(qǐng)求隊(duì)列以固定速率等待處理,否則請(qǐng)求被拒絕。
令牌桶
桶容量大于零,并且是非延遲模式。如果桶中存在令牌,則允許突發(fā)流量,否則請(qǐng)求被拒絕。
壓測(cè)
為了測(cè)試以上配置效果,我們采用AB壓測(cè),Linux下執(zhí)行以下命令即可:
# 安裝 yum -y install httpd-tools # 查看ab版本 ab -v # 查看幫助 ab --help
測(cè)試命令:
ab -n 1000 -c 100 http://127.0.0.1/
測(cè)試結(jié)果:
Server Software: openresty/1.15.8.1 #服務(wù)器軟件 Server Hostname: 127.0.0.1 #IP
Server Port: 80 #請(qǐng)求端口號(hào) Document Path: / #文件路徑 Document Length: 12 bytes #頁(yè)面字節(jié)數(shù)
Concurrency Level: 100 #請(qǐng)求的并發(fā)數(shù) Time taken for tests: 4.999 seconds #總訪問(wèn)時(shí)間
Complete requests: 1000 #總請(qǐng)求樹(shù) Failed requests: 0 #請(qǐng)求失敗數(shù)量 Write errors: 0 Total
transferred: 140000 bytes #請(qǐng)求總數(shù)據(jù)大小 HTML transferred: 12000 bytes #html頁(yè)面實(shí)際總字節(jié)數(shù)
Requests per second: 200.06 [#/sec] (mean) #每秒多少請(qǐng)求,這個(gè)是非常重要的參數(shù)數(shù)值,服務(wù)器的吞吐量 Time
per request: 499.857 [ms] (mean) #用戶平均請(qǐng)求等待時(shí)間 Time per request: 4.999 [ms]
(mean, across all concurrent requests) # 服務(wù)器平均處理時(shí)間,也就是服務(wù)器吞吐量的倒數(shù) Transfer rate:
27.35 [Kbytes/sec] received #每秒獲取的數(shù)據(jù)長(zhǎng)度 Connection Times (ms) min mean[+/-sd]
median max Connect: 0 0 0.8 0 4 Processing: 5 474 89.1 500 501 Waiting: 2 474
89.2 500 501 Total: 9 475 88.4 500 501 Percentage of the requests served within
a certain time (ms) 50% 500 66% 500 75% 500 80% 500 90% 501 95% 501 98% 501 99%
501 100% 501 (longest request)
源碼
SpringBoot開(kāi)發(fā)案例之打造十萬(wàn)博文Web篇 <https://gitee.com/52itstyle/spring-boot-blog>
總結(jié)
以上限流方案,只是針對(duì)此次十萬(wàn)博文做一個(gè)簡(jiǎn)單的小結(jié),大家也不要刻意區(qū)分那種方案的好壞,只要適合業(yè)務(wù)場(chǎng)景就是最好的。
熱門(mén)工具 換一換
