gRPC網(wǎng)關(guān)如何針對HTTP 2.0長連接進行性能優(yōu)化,提升吞吐量
最近要搞個網(wǎng)關(guān)GateWay,由于系統(tǒng)間請求調(diào)用是基于gRPC框架,所以網(wǎng)關(guān)第一職責(zé)就是能接收并轉(zhuǎn)發(fā)gRPC請求,大致的系統(tǒng)架構(gòu)如下所示:
簡單看下即可,由于含有定制化業(yè)務(wù)背景,架構(gòu)圖看不懂也沒關(guān)系,后面我會對里面的核心技術(shù)點單獨剖析講解。
為什么要引入網(wǎng)關(guān)?請求鏈路多了一跳,性能有損耗不說,一旦宕機就全部玩完了!
但現(xiàn)實就是這樣,不是你想怎么樣,就能怎么樣!
有時技術(shù)方案繞一個大圈子,就是為了解決一個無法避開的因素。這個因素可能是多方面:
- 可能是技術(shù)上的需求,比如要做監(jiān)控統(tǒng)計,需要在上層某個位置加個攔截層,收集數(shù)據(jù),統(tǒng)一處理
- 可能是技術(shù)實現(xiàn)遇到巨大挑戰(zhàn),至少是當(dāng)前技術(shù)團隊研發(fā)實力解決不了這個難題
- 可能上下文會話關(guān)聯(lián),一個任務(wù)要觸發(fā)多次請求,但始終要在一臺機器上完成全部處理
- 可能是政策因素,為了數(shù)據(jù)安全,你必須走這一繞。
本文引入的網(wǎng)關(guān)就是安全原因,由于一些公司的安全限制,外部服務(wù)無法直接訪問公司內(nèi)部的計算節(jié)點,需要引入一個前置網(wǎng)關(guān),負責(zé)反向代理、請求路由轉(zhuǎn)發(fā)、數(shù)據(jù)通信、調(diào)用監(jiān)控等。
一、問題抽象,技術(shù)選型
上面的業(yè)務(wù)架構(gòu)可能比較復(fù)雜,不了解業(yè)務(wù)背景同學(xué)很容易被繞暈。那么我們簡化一些,抽象出一個具體要解決的問題,簡化描述。
過程分為三步:
1、client端發(fā)起gPRC調(diào)用(基于HTTP2),請求打到gRPC網(wǎng)關(guān)
2、網(wǎng)關(guān)接到請求,根據(jù)請求約定的參數(shù)標(biāo)識,從Redis緩存里查詢目標(biāo)服務(wù)器的映射關(guān)系
3、最后,網(wǎng)關(guān)將請求轉(zhuǎn)發(fā)給目標(biāo)服務(wù)器,獲取響應(yīng)結(jié)果,將數(shù)據(jù)原路返回。
gRPC必須使用 HTTP/2 傳輸數(shù)據(jù),支持明文和TLS加密數(shù)據(jù),支持流數(shù)據(jù)的交互。充分利用 HTTP/2 連接的多路復(fù)用和流式特性。
技術(shù)選型
1、最早計劃采用Netty來做,但由于gRPC的proto模板不是我們定義的,所以解析成本很高,另外還要讀取請求Header中的數(shù)據(jù),開發(fā)難度較大,所以這個便作為了備選方案。
2、另一種改變思路,往反向代理框架方向?qū)ふ?,重新回到主流的Nginx這條線,但是nginx采用C語言開發(fā),如果是基于常規(guī)的負載均衡策略轉(zhuǎn)發(fā)請求,倒是沒什么大的問題。但是,我們內(nèi)部有依賴任務(wù)資源關(guān)系,也間接決定著要依賴外部的存儲系統(tǒng)。
Nginx適合處理靜態(tài)內(nèi)容,做一個靜態(tài)web服務(wù)器,但我們又看重其高性能,最后我們選型 Openresty
OpenResty? 是一個基于 Nginx 與 Lua 的高性能 Web 平臺,其內(nèi)部集成了大量精良的 Lua 庫、第三方模塊以及大多數(shù)的依賴項。用于方便地搭建能夠處理超高并發(fā)、擴展性極高的動態(tài) Web 應(yīng)用、Web 服務(wù)和動態(tài)網(wǎng)關(guān)。
二、Openresty 代碼 SHOW
- http {
- include mime.types;
- default_type application/octet-stream;
- access_log logs/access.log main;
- sendfile on;
- keepalive_timeout 120;
- client_max_body_size 3000M;
- server {
- listen 8091 http2;
- location / {
- set $target_url '' ;
- access_by_lua_block{
- local headers = ngx.req.get_headers(0)
- local jobid= headers["jobid"]
- local redis = require "resty.redis"
- local red = redis:new()
- red:set_timeouts(1000) -- 1 sec
- local ok, err = red:connect("156.9.1.2", 6379)
- local res, err = red:get(jobid)
- ngx.var.target_url = res
- }
- grpc_pass grpc://$target_url;
- }
- }
- }
三、性能壓測
- Client 端機器,壓測期間,觀察網(wǎng)絡(luò)連接:
結(jié)論:
并發(fā)壓測場景下,請求會轉(zhuǎn)發(fā)到三臺網(wǎng)關(guān)服務(wù)器,每臺服務(wù)器處于TIME_WAIT狀態(tài)的TCP連接并不多??梢姶硕芜B接基本能達到連接復(fù)用效果。
- gRPC網(wǎng)關(guān)機器,壓測期間,觀察網(wǎng)絡(luò)連接情況:
有大量的請求連接處于TIME_WAIT狀態(tài)。按照端口號可以分為兩大類:6379和 40928
- [root@tf-gw-64bd9f775c-qvpcx nginx]# netstat -na | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
- LISTEN 2
- ESTABLISHED 6
- TIME_WAIT 27500
通過linux shell 統(tǒng)計命令,172.16.66.46服務(wù)器有27500個TCP連接處于 TIME_WAIT
- [root@tf-gw-64bd9f775c-qvpcx nginx]# netstat -na | grep 6379 |awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
- ESTABLISHED 1
- TIME_WAIT 13701
其中,連接redis(redis的訪問端口 6379) 并處于 TIME_WAIT 狀態(tài)有 13701 個連接
- [root@tf-gw-64bd9f775c-qvpcx nginx]# netstat -na | grep 40928 |awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
- ESTABLISHED 2
- TIME_WAIT 13671
其中,連接后端Server目標(biāo)服務(wù)器 并處于 TIME_WAIT 狀態(tài)有 13671 個連接。兩者的連接數(shù)基本相等,因為每一次轉(zhuǎn)發(fā)請求都要查詢一次Redis。
結(jié)論匯總:
- client端發(fā)送請求到網(wǎng)關(guān),目前已經(jīng)維持長連接,滿足要求。
- gRPC網(wǎng)關(guān)連接Redis緩存服務(wù)器,目前是短連接,每次請求都去創(chuàng)建一個連接,性能開銷太大。需要單獨優(yōu)化
- gRPC網(wǎng)關(guān)轉(zhuǎn)發(fā)請求到目標(biāo)服務(wù)器,目前也是短連接,用完即廢棄,完全沒有發(fā)揮Http2.0的長連接優(yōu)勢。需要單獨優(yōu)化
四、什么是 TIME_WAIT
統(tǒng)計服務(wù)器tcp連接狀態(tài)處于TIME_WAIT的命令腳本:
- netstat -anpt | grep TIME_WAIT | wc -l
我們都知道TCP是三次握手,四次揮手。那揮手具體過程是什么?
1、主動關(guān)閉連接的一方,調(diào)用close(),協(xié)議層發(fā)送FIN包,主動關(guān)閉方進入FIN_WAIT_1狀態(tài)
2、被動關(guān)閉的一方收到FIN包后,協(xié)議層回復(fù)ACK;然后被動關(guān)閉的一方,進入CLOSE_WAIT狀態(tài),主動關(guān)閉的一方等待對方關(guān)閉,則進入FIN_WAIT_2狀態(tài);此時,主動關(guān)閉的一方 等待 被動關(guān)閉一方的應(yīng)用程序,調(diào)用close()操作
3、被動關(guān)閉的一方在完成所有數(shù)據(jù)發(fā)送后,調(diào)用close()操作;此時,協(xié)議層發(fā)送FIN包給主動關(guān)閉的一方,等待對方的ACK,被動關(guān)閉的一方進入LAST_ACK狀態(tài);
4、主動關(guān)閉的一方收到FIN包,協(xié)議層回復(fù)ACK;此時,主動關(guān)閉連接的一方,進入TIME_WAIT狀態(tài);而被動關(guān)閉的一方,進入CLOSED狀態(tài)
5、等待 2MSL(Maximum Segment Lifetime, 報文最大生存時間),主動關(guān)閉的一方,結(jié)束TIME_WAIT,進入CLOSED狀態(tài)
2MSL到底有多長呢?這個不一定,1分鐘、2分鐘或者4分鐘,還有的30秒。不同的發(fā)行版可能會不同。在Centos 7.6.1810 的3.10內(nèi)核版本上是60秒。
來張TCP狀態(tài)機大圖,一目了然:
為什么一定要有 TIME_WAIT ?
雖然雙方都同意關(guān)閉連接了,而且握手的4個報文也都協(xié)調(diào)和發(fā)送完畢,按理可以直接到CLOSED狀態(tài)。但是網(wǎng)絡(luò)是不可靠的,發(fā)起方無法確保最后發(fā)送的ACK報文一定被對方收到,比如丟包或延遲到達,對方處于LAST_ACK狀態(tài)下的SOCKET可能會因為超時未收到ACK報文,而重發(fā)FIN報文。所以TIME_WAIT狀態(tài)的作用就是用來重發(fā)可能丟失的ACK報文。
簡單講,TIME_WAIT之所以等待2MSL的時長,是為了避免因為網(wǎng)絡(luò)丟包或者網(wǎng)絡(luò)延遲而造成的tcp傳輸不可靠,而這個TIME_WAIT狀態(tài)則可以最大限度的提升網(wǎng)絡(luò)傳輸?shù)目煽啃浴?/p>
注意:一個連接沒有進入 CLOSED 狀態(tài)之前,這個連接是不能被重用的!
如何優(yōu)化 TIME_WAIT 過多的問題
1、調(diào)整系統(tǒng)內(nèi)核參數(shù)
- net.ipv4.tcp_syncookies = 1 表示開啟SYN Cookies。當(dāng)出現(xiàn)SYN等待隊列溢出時,啟用cookies來處理,可防范少量SYN攻擊,默認為0,表示關(guān)閉;
- net.ipv4.tcp_tw_reuse = 1 表示開啟重用。允許將 TIME-WAIT sockets重新用于新的TCP連接,默認為0,表示關(guān)閉;
- net.ipv4.tcp_tw_recycle = 1 表示開啟TCP連接中 TIME-WAIT sockets的快速回收,默認為0,表示關(guān)閉。
- net.ipv4.tcp_fin_timeout = 修改系統(tǒng)默認的 TIMEOUT 時間
- net.ipv4.tcp_max_tw_buckets = 5000 表示系統(tǒng)同時保持TIME_WAIT套接字的最大數(shù)量,(默認是18000). 當(dāng)TIME_WAIT連接數(shù)量達到給定的值時,所有的TIME_WAIT連接會被立刻清除,并打印警告信息。但這種粗暴的清理掉所有的連接,意味著有些連接并沒有成功等待2MSL,就會造成通訊異常。一般不建議調(diào)整
- net.ipv4.tcp_timestamps = 1(默認即為1)60s內(nèi)同一源ip主機的socket connect請求中的timestamp必須是遞增的。也就是說服務(wù)器打開了 tcp_tw_reccycle了,就會檢查時間戳,如果對方發(fā)來的包的時間戳是亂跳的或者說時間戳是滯后的,那么服務(wù)器就會丟掉不回包,現(xiàn)在很多公司都用LVS做負載均衡,通常是前面一臺LVS,后面多臺后端服務(wù)器,這其實就是NAT,當(dāng)請求到達LVS后,它修改地址數(shù)據(jù)后便轉(zhuǎn)發(fā)給后端服務(wù)器,但不會修改時間戳數(shù)據(jù),對于后端服務(wù)器來說,請求的源地址就是LVS的地址,加上端口會復(fù)用,所以從后端服務(wù)器的角度看,原本不同客戶端的請求經(jīng)過LVS的轉(zhuǎn)發(fā),就可能會被認為是同一個連接,加之不同客戶端的時間可能不一致,所以就會出現(xiàn)時間戳錯亂的現(xiàn)象,于是后面的數(shù)據(jù)包就被丟棄了,具體的表現(xiàn)通常是是客戶端明明發(fā)送的SYN,但服務(wù)端就是不響應(yīng)ACK,還可以通過下面命令來確認數(shù)據(jù)包不斷被丟棄的現(xiàn)象,所以根據(jù)情況使用
- 其他優(yōu)化:
- net.ipv4.ip_local_port_range = 1024 65535 ,增加可用端口范圍,讓系統(tǒng)擁有的更多的端口來建立鏈接,這里有個問題需要注意,對于這個設(shè)置系統(tǒng)就會從1025~65535這個范圍內(nèi)隨機分配端口來用于連接,如果我們服務(wù)的使用端口比如8080剛好在這個范圍之內(nèi),在升級服務(wù)期間,可能會出現(xiàn)8080端口被其他隨機分配的鏈接給占用掉
- net.ipv4.ip_local_reserved_ports = 7005,8001-8100 針對上面的問題,我們可以設(shè)置這個參數(shù)來告訴系統(tǒng)給我們預(yù)留哪些端口,不可以用于自動分配。
2、將短連接優(yōu)化為長連接
短連接工作模式:連接->傳輸數(shù)據(jù)->關(guān)閉連接
長連接工作模式:連接->傳輸數(shù)據(jù)->保持連接 -> 傳輸數(shù)據(jù)-> 。。。->關(guān)閉連接
五、訪問 Redis 短連接優(yōu)化
高并發(fā)編程中,必須要使用連接池技術(shù),把短鏈接改成長連接。也就是改成創(chuàng)建連接、收發(fā)數(shù)據(jù)、收發(fā)數(shù)據(jù)... 拆除連接,這樣我們就可以減少大量創(chuàng)建連接、拆除連接的時間。從性能上來說肯定要比短連接好很多
在 OpenResty 中,可以設(shè)置set_keepalive 函數(shù),來支持長連接。
set_keepalive 函數(shù)有兩個參數(shù):
第一個參數(shù):連接的最大空閑時間
第二個參數(shù):連接池大小
- local res, err = red:get(jobid)
- // redis操作完后,將連接放回到連接池中
- // 連接池大小設(shè)置成40,連接最大空閑時間設(shè)置成10秒
- red:set_keepalive(10000, 40)
reload nginx配置后,重新壓測。
結(jié)論:redis的連接數(shù)基本控制在40個以內(nèi)。
其他的參數(shù)設(shè)置可以參考:
https://github.com/openresty/lua-resty-redis#set_keepalive
六、訪問目標(biāo) Server 機器短連接優(yōu)化
nginx 提供了一個upstream模塊,用來控制負載均衡、內(nèi)容分發(fā)。提供了以下幾種負載算法:
- 輪詢(默認)。每個請求按時間順序逐一分配到不同的后端服務(wù)器,如果后端服務(wù)器down掉,能自動剔除。
- weight(權(quán)重)。指定輪詢幾率,weight和訪問比率成正比,用于后端服務(wù)器性能不均的情況。
- ip_hash。每個請求按訪問ip的hash結(jié)果分配,這樣每個訪客固定訪問一個后端服務(wù)器,可以解決session的問題。
- fair(第三方)。按后端服務(wù)器的響應(yīng)時間來分配請求,響應(yīng)時間短的優(yōu)先分配。
- url_hash(第三方)。按訪問url的hash結(jié)果來分配請求,使每個url定向到同一個后端服務(wù)器,后端服務(wù)器為緩存時比較有效。
由于 upstream提供了keepalive函數(shù),每個工作進程的高速緩存中保留的到上游服務(wù)器的空閑保持連接的最大數(shù)量,可以保持連接復(fù)用,從而減少TCP連接頻繁的創(chuàng)建、銷毀性能開銷。
缺點:
Nginx官方的upstream不支持動態(tài)修改,而我們的目標(biāo)地址是動態(tài)變化,請求時根據(jù)業(yè)務(wù)規(guī)則動態(tài)實時查詢路由。為了解決這個動態(tài)性問題,我們引入OpenResty的balancer_by_lua_block。
通過編寫Lua腳本方式,來擴展upstream功能。
修改nginx.conf的upstream,動態(tài)獲取路由目標(biāo)的IP和Port,并完成請求的轉(zhuǎn)發(fā),核心代碼如下:
- upstream grpcservers {
- balancer_by_lua_block{
- local balancer = require "ngx.balancer"
- local host = ngx.var.target_ip
- local port = ngx.var.target_port
- local ok, err = balancer.set_current_peer(host, port)
- if not ok then
- ngx.log(ngx.ERR, "failed to set the current peer: ", err)
- return ngx.exit(500)
- end
- }
- keepalive 40;
- }
修改配置后,重啟Nginx,繼續(xù)壓測,觀察結(jié)果:
TCP連接基本都處于ESTABLISHED狀態(tài),優(yōu)化前的TIME_WAIT狀態(tài)幾乎沒有了。
- [root@tf-gw-64bd9f775c-qvpcx nginx]# netstat -na | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
- LISTEN 2
- ESTABLISHED 86
- TIME_WAIT 242
寫在最后
本文主要是解決gRPC的請求轉(zhuǎn)發(fā)問題,構(gòu)建一個網(wǎng)關(guān)系統(tǒng),技術(shù)選型OpenResty,既保留了Nginx的高性能又兼具了OpenResty動態(tài)易擴展。然后針對編寫的LUA代碼,性能壓測,不斷調(diào)整優(yōu)化,解決各個鏈路區(qū)間的TCP連接保證可重復(fù)使用。