懵了!簡(jiǎn)單的HTTP調(diào)用,時(shí)延竟如此大?
最近項(xiàng)目測(cè)試遇到個(gè)奇怪的現(xiàn)象,在測(cè)試環(huán)境通過 Apache HTTP Client 調(diào)用后端的 HTTP 服務(wù),平均耗時(shí)居然接近 39.2ms。
圖片來自 Pexels
可能乍一看覺得這不是很正常嗎,有什么好奇怪的?其實(shí)不然,我再來說下一些基本信息。
該后端的 HTTP 服務(wù)并沒有什么業(yè)務(wù)邏輯,只是將一段字符串轉(zhuǎn)成大寫然后返回,字符串長(zhǎng)度也僅只有 100 字符,另外網(wǎng)絡(luò) Ping 延時(shí)只有 1.9ms左右。
因此,理論上該調(diào)用耗時(shí)應(yīng)該在 2-3ms 左右,但為什么平均耗時(shí) 39.2ms 呢?
調(diào)用時(shí)延
Ping 時(shí)延
由于工作原因,調(diào)用耗時(shí)的問題,對(duì)我來說,已經(jīng)見怪不怪了,經(jīng)常會(huì)幫業(yè)務(wù)解決內(nèi)部 RPC 框架調(diào)用超時(shí)的相關(guān)問題,但是 HTTP 調(diào)用耗時(shí)是第一次遇到。
不過,排查問題的套路是一樣的。主要方法論無外乎由外而內(nèi)、自上而下等排查方法。我們先來看看外圍的一些指標(biāo),看能否發(fā)現(xiàn)蛛絲馬跡。
外圍指標(biāo)
系統(tǒng)指標(biāo)
主要看外圍的一些系統(tǒng)指標(biāo)(注意:調(diào)用與被調(diào)用的機(jī)器都要看)。例如負(fù)載、CPU。只需一個(gè) Top 命令就能一覽無余。
因此,確認(rèn)了下 CPU 和負(fù)載都很空閑。由于當(dāng)時(shí)沒有截圖,這里就不放圖了。
進(jìn)程指標(biāo)
Java 程序進(jìn)程指標(biāo)主要看 GC、線程堆棧情況(注意:調(diào)用與被調(diào)用的機(jī)器都要看)。
Young GC 都非常少,而且耗時(shí)也在 10ms 以內(nèi),因此沒有長(zhǎng)時(shí)間的 STW。
因?yàn)槠骄{(diào)用時(shí)間 39.2ms,比較大,如果耗時(shí)是代碼導(dǎo)致,線程堆棧應(yīng)該能發(fā)現(xiàn)點(diǎn)啥。
看了之后一無所獲,服務(wù)的相關(guān)線程堆棧主要表現(xiàn)是線程池的線程在等任務(wù),這就意味著線程并不忙。
是不是感覺黔驢技窮了,接下來該怎么辦呢?
本地復(fù)現(xiàn)
如果本地(本地是 MAC 系統(tǒng))能復(fù)現(xiàn),對(duì)排查問題也是極好的。
因此在本地使用 Apache HTTP Client 寫了個(gè)簡(jiǎn)單 Test 程序,直接調(diào)用后端的 HTTP 服務(wù),發(fā)現(xiàn)平均耗時(shí)在 55ms 左右。
咦,怎么跟測(cè)試環(huán)境 39.2ms 的結(jié)果有點(diǎn)區(qū)別。主要是本地與測(cè)試環(huán)境的后端的 HTTP 服務(wù)機(jī)器跨地區(qū)了,Ping 時(shí)延在 26ms 左右,所以延時(shí)增大了。
不過本地確實(shí)也是存在問題的,因?yàn)?Ping 時(shí)延是 26ms,后端 HTTP 服務(wù)邏輯簡(jiǎn)單,幾乎不耗時(shí),因此本地調(diào)用平均耗時(shí)應(yīng)該在 26ms 左右,為什么是 55ms?
是不是越來越迷惑,一頭霧水,不知如何下手?期間懷疑過 Apache HTTP Client 是不是有什么地方使用的不對(duì)。
因此使用 JDK 自帶的 HttpURLConnection 寫了簡(jiǎn)單的程序,做了測(cè)試,結(jié)果一樣。
診斷
定位
其實(shí)從外圍的系統(tǒng)指標(biāo)、進(jìn)程指標(biāo),以及本地復(fù)現(xiàn)來看,大致能夠斷定不是程序上的原因。那 TCP 協(xié)議層面呢?
有網(wǎng)絡(luò)編程經(jīng)驗(yàn)的同學(xué)一定知道 TCP 什么參數(shù)會(huì)引起這個(gè)現(xiàn)象。對(duì),你猜的沒錯(cuò),就是 TCP_NODELAY。
那調(diào)用方和被調(diào)用方哪邊的程序沒有設(shè)置呢?調(diào)用方使用的是 Apache Http Client ,tcpNoDelay 默認(rèn)設(shè)置的就是 True。
我們?cè)賮砜纯幢徽{(diào)用方,也就是我們的后端 HTTP 服務(wù),這個(gè) HTTP 服務(wù)用的是 JDK 自帶的 HttpServer:
- HttpServer server = HttpServer.create(new InetSocketAddress(config.getPort()), BACKLOGS);
居然沒看到直接設(shè)置 tcpNoDelay 接口,翻了下源碼。哦,原來在這里。
在 ServerConfig 的類中有這段靜態(tài)塊,用來獲取啟動(dòng)參數(shù),默認(rèn) ServerConfig.noDelay 為 false:
- static {
- AccessController.doPrivileged(new PrivilegedAction<Void>() {
- public Void run() {
- ServerConfig.idleInterval = Long.getLong("sun.net.httpserver.idleInterval", 30L) * 1000L;
- ServerConfig.clockTick = Integer.getInteger("sun.net.httpserver.clockTick", 10000);
- ServerConfig.maxIdleConnections = Integer.getInteger("sun.net.httpserver.maxIdleConnections", 200);
- ServerConfig.drainAmount = Long.getLong("sun.net.httpserver.drainAmount", 65536L);
- ServerConfig.maxReqHeaders = Integer.getInteger("sun.net.httpserver.maxReqHeaders", 200);
- ServerConfig.maxReqTime = Long.getLong("sun.net.httpserver.maxReqTime", -1L);
- ServerConfig.maxRspTime = Long.getLong("sun.net.httpserver.maxRspTime", -1L);
- ServerConfig.timerMillis = Long.getLong("sun.net.httpserver.timerMillis", 1000L);
- ServerConfig.debug = Boolean.getBoolean("sun.net.httpserver.debug");
- ServerConfig.noDelay = Boolean.getBoolean("sun.net.httpserver.nodelay");
- return null;
- }
- });
- }
驗(yàn)證
在后端 HTTP 服務(wù),加上啟動(dòng)"-Dsun.net.httpserver.nodelay=true"參數(shù)再試試。
效果很明顯,平均耗時(shí)從 39.2ms 降到 2.8ms:
優(yōu)化后調(diào)用時(shí)延
問題是解決了,但是到這里如果你就此止步,那就太便宜了這個(gè)案例了,簡(jiǎn)直暴殄天物。
因?yàn)檫€有一堆疑惑等著你呢:
- 為什么加了 TCP_NODELAY ,時(shí)延就從 39.2ms 降低到 2.8ms?
- 為什么本地測(cè)試的平均時(shí)延是 55ms,而不是 Ping 的時(shí)延 26ms?
- TCP 協(xié)議究竟是怎么發(fā)送數(shù)據(jù)包的?
來,我們接著趁熱打鐵。
解惑
①TCP_NODELAY 何許人也?
在 Socket 編程中,TCP_NODELAY 選項(xiàng)是用來控制是否開啟 Nagle 算法。
在 Java 中,為 Ture 表示關(guān)閉 Nagle 算法,為 False 表示打開 Nagle 算法。你一定會(huì)問 Nagle 算法是什么?
②Nagle 算法是什么鬼?
Nagle 算法是一種通過減少通過網(wǎng)絡(luò)發(fā)送的數(shù)據(jù)包數(shù)量來提高 TCP/IP 網(wǎng)絡(luò)效率的方法。
它使用發(fā)明人 John Nagle 的名字來命名的,John Nagle 在 1984 年首次用這個(gè)算法來嘗試解決福特汽車公司的網(wǎng)絡(luò)擁塞問題。
試想如果應(yīng)用程序每次產(chǎn)生 1 個(gè)字節(jié)的數(shù)據(jù),然后這 1 個(gè)字節(jié)數(shù)據(jù)又以網(wǎng)絡(luò)數(shù)據(jù)包的形式發(fā)送到遠(yuǎn)端服務(wù)器,那么就很容易導(dǎo)致網(wǎng)絡(luò)由于太多的數(shù)據(jù)包而過載。
在這種典型情況下,傳送一個(gè)只擁有 1 個(gè)字節(jié)有效數(shù)據(jù)的數(shù)據(jù)包,卻要花費(fèi) 40 個(gè)字節(jié)長(zhǎng)包頭(即 IP 頭部 20 字節(jié)+TCP 頭部 20 字節(jié))的額外開銷,這種有效載荷(payload)的利用率是極其低下。
Nagle 算法的內(nèi)容比較簡(jiǎn)單,以下是偽代碼:
- if there is new data to send
- if the window size >= MSS and available data is >= MSS
- send complete MSS segment now
- else
- if there is unconfirmed data still in the pipe
- enqueue data in the buffer until an acknowledge is received
- else
- send data immediately
- end if
- end if
- end if
具體的做法就是:
- 如果發(fā)送內(nèi)容大于等于 1 個(gè) MSS,立即發(fā)送。
- 如果之前沒有包未被 ACK,立即發(fā)送。
- 如果之前有包未被 ACK,緩存發(fā)送內(nèi)容。
- 如果收到 ACK,立即發(fā)送緩存的內(nèi)容。(MSS 為 TCP 數(shù)據(jù)包每次能夠傳輸?shù)淖畲髷?shù)據(jù)分段)
③Delayed ACK 又是什么玩意?
大家都知道 TCP 協(xié)議為了保證傳輸?shù)目煽啃?,?guī)定在接受到數(shù)據(jù)包時(shí)需要向?qū)Ψ桨l(fā)送一個(gè)確認(rèn)。
只是單純的發(fā)送一個(gè)確認(rèn),代價(jià)會(huì)比較高(IP 頭部 20 字節(jié)+TCP 頭部 20 字節(jié))。
TCP Delayed ACK(延遲確認(rèn))就是為了努力改善網(wǎng)絡(luò)性能,來解決這個(gè)問題的。
它將幾個(gè) ACK 響應(yīng)組合合在一起成為單個(gè)響應(yīng),或者將 ACK 響應(yīng)與響應(yīng)數(shù)據(jù)一起發(fā)送給對(duì)方,從而減少協(xié)議開銷。
具體的做法是:
- 當(dāng)有響應(yīng)數(shù)據(jù)要發(fā)送時(shí),ACK 會(huì)隨響應(yīng)數(shù)據(jù)立即發(fā)送給對(duì)方。
- 如果沒有響應(yīng)數(shù)據(jù),ACK 將會(huì)延遲發(fā)送,以等待看是否有響應(yīng)數(shù)據(jù)可以一起發(fā)送。在 Linux 系統(tǒng)中,默認(rèn)這個(gè)延遲時(shí)間是 40ms。
- 如果在等待發(fā)送 ACK 期間,對(duì)方的第二個(gè)數(shù)據(jù)包又到達(dá)了,這時(shí)要立即發(fā)送 ACK。
但是如果對(duì)方的三個(gè)數(shù)據(jù)包相繼到達(dá),第三個(gè)數(shù)據(jù)段到達(dá)時(shí)是否立即發(fā)送 ACK,則取決于以上兩條。
④Nagle 與 Delayed ACK 一起會(huì)發(fā)生什么化學(xué)反應(yīng)?
Nagle 與 Delayed ACK 都能提高網(wǎng)絡(luò)傳輸?shù)男剩谝黄饡?huì)好心辦壞事。
例如,以下這個(gè)場(chǎng)景,A 和 B 進(jìn)行數(shù)據(jù)傳輸 : A 運(yùn)行 Nagle 算法,B 運(yùn)行 Delayed ACK 算法。
如果 A 向 B 發(fā)一個(gè)數(shù)據(jù)包,B 由于 Delayed ACK 不會(huì)立即響應(yīng)。而 A 使用 Nagle 算法,A 就會(huì)一直等 B 的 ACK,ACK 不來一直不發(fā)送第二個(gè)數(shù)據(jù)包,如果這兩個(gè)數(shù)據(jù)包是應(yīng)對(duì)同一個(gè)請(qǐng)求,那這個(gè)請(qǐng)求就會(huì)被耽誤了 40ms。
⑤抓個(gè)包玩玩吧
我們來抓個(gè)包驗(yàn)證下吧,在后端 HTTP 服務(wù)上執(zhí)行以下腳本,就可以輕松完成抓包過程。
- sudo tcpdump -i eth0 tcp and host 10.48.159.165 -s 0 -w traffic.pcap
如下圖,這是使用 Wireshark 分析包內(nèi)容的展示,紅框內(nèi)是一個(gè)完整的 POST 請(qǐng)求處理過程。
測(cè)試環(huán)境數(shù)據(jù)包分析
看 130 序號(hào)和 149 序號(hào)之間相差 40ms(0.1859 - 0.1448 = 0.0411s = 41ms),這個(gè)就是 Nagle 與 Delayed ACK 一起發(fā)送的化學(xué)反應(yīng)。
其中 10.48.159.165 運(yùn)行的是 Delayed ACK,10.22.29.180 運(yùn)行的是 Nagle 算法。
10.22.29.180 在等 ACK,而 10.48.159.165 觸發(fā)了 Delayed ACK,這樣傻傻等了 40ms。
這也就解釋了為什么測(cè)試環(huán)境耗時(shí)是 39.2ms,因?yàn)榇蟛糠侄急?Delayed ACK 的 40ms 給耽誤了。
但是本地復(fù)現(xiàn)時(shí),為什么本地測(cè)試的平均時(shí)延是 55ms,而不是 Ping 的時(shí)延 26ms?我們也來抓個(gè)包吧。
如下圖,紅框內(nèi)是一個(gè)完整的 POST 請(qǐng)求處理過程,看 8 序號(hào)和 9 序號(hào)之間相差 25ms 左右,再減去網(wǎng)絡(luò)延時(shí)約是 Ping 延時(shí)的一半 13ms。
本地環(huán)境數(shù)據(jù)包分析
因此 Delayed Ack 約 12ms 左右(由于本地是 MAC 系統(tǒng)與 Linux 有些差異)。
- Linux 使用的是 /proc/sys/net/ipv4/tcp_delack_min 這個(gè)系統(tǒng)配置來控制 Delayed ACK 的時(shí)間,Linux 默認(rèn)是 40ms;
- 2. MAC 是通過 net.inet.tcp.delayed_ack 系統(tǒng)配置來控制 Delayed ACK 的。
- delayed_ack=0 responds after every packet (OFF)
- delayed_ack=1 always employs delayed ack, 6 packets can get 1 ack
- delayed_ack=2 immediate ack after 2nd packet, 2 packets per ack (Compatibility Mode)
- delayed_ack=3 should auto detect when to employ delayed ack, 4packets per ack. (DEFAULT)
- 設(shè)置為 0 表示禁止延遲 ACK,設(shè)置為 1 表示總是延遲 ACK,設(shè)置為 2 表示每?jī)蓚€(gè)數(shù)據(jù)包回復(fù)一個(gè) ACK,設(shè)置為 3 表示系統(tǒng)自動(dòng)探測(cè)回復(fù) ACK 的時(shí)機(jī)。
⑥為什么 TCP_NODELAY 能夠解決問題?
tcpNoDelay 關(guān)閉了 Nagle 算法,即使上個(gè)數(shù)據(jù)包的 ACK 沒有到達(dá),也會(huì)發(fā)送下個(gè)數(shù)據(jù)包,進(jìn)而打破 Delayed ACK 造成的影響。
一般在網(wǎng)絡(luò)編程中,強(qiáng)烈建議開啟 tcpNoDelay,來提升響應(yīng)速度。
當(dāng)然也可以通過 Delayed ACK 相關(guān)系統(tǒng)的配置來解決問題,但由于需要修改機(jī)器配置,很不方便,因此,這種方式不太推薦。
總結(jié)
本文是從一個(gè)簡(jiǎn)單的 HTTP 調(diào)用,時(shí)延比較大而引發(fā)的一次問題排查過程。首先由外而內(nèi)的分析了相關(guān)問題,然后定位問題并驗(yàn)證解決方案。
最后刨根問底對(duì) TCP 傳輸中的 Nagle 與 Delayed ACK 做了全面的講解,更加透徹的剖析了該問題案例。