某次在 SpringBoot 2.2.0 專案的一個設定類中引入了這麼一行程式碼:
InetAddress.getLocalHost().getHostAddress()
導致專案啟動明顯變慢。同時報出了相關的警告資訊:
2022-10-03 23:32:01.806 [TID: N/A] WARN [main] o.s.b.StartupInfoLogger - InetAddress.getLocalHost().getHostName() took 5007 milliseconds to respond. Please verify your network configuration (macOS machines may need to add entries to /etc/hosts).
根據報警資訊可知,只要獲取主機資訊的耗時超過了閾值HOST_NAME_RESOLVE_THRESHOLD=200ms,就會提示這個資訊。很明顯,我們的耗時已經超過5s。同時,如果為 Mac 系統,還會貼心地提示在/etc/hosts檔案中設定本地dns。
我們看看目前hosts檔案中的設定:
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
根據網上各種文章的提示,我們將主機名追加進去,變成這樣:
127.0.0.1 localhost xiaoxi666s-MacBook-Pro.local
255.255.255.255 broadcasthost
::1 localhost
其中,xiaoxi666s-MacBook-Pro.local 就是我的主機名。
注:更改hosts檔案內容後,可使用命令
sudo killall -HUP mDNSResponder
重新整理dns,無需重啟電腦。
再次啟動 SpringBoot 程式,我們發現警告資訊消失了,也就意味著主機資訊獲取的耗時不會超過200ms。
那麼問題來了,這背後究竟是什麼機制,讓我們一探究竟。
由於我們要獲取自己的主機資訊,這裡走的是本地迴環網路,因此選中Loopback網路介面:
先把hosts改回去,抓一下hosts檔案改動前的網路包:
按照時間順序,可以將抓到的網路包分為三段,每段中又可以分為Ipv4和Ipv6兩種地址的請求。
其中用到的協定是 mdns,也即多播dns(Multicast DNS),它主要實現了在沒有傳統 dns 伺服器的情況下使區域網內的主機實現相互發現和通訊,使用的埠為 5353,遵從 dns 協定。隨便點開一個請求檢視詳情便可以得到驗證:
另外,網路包中的目標ip 224.0.0.251是 Mac 的官方 mdns 查詢地址,詳情可參見https://github.com/apple-oss-distributions/mDNSResponder/tree/mDNSResponder-1096.100.3
實際多次測試發現,主機資訊都在第三次傳送網路包後返回(阻塞在 InetAddress.getLocalHost() 方法上。參見下圖,阻塞在第18行,5秒後才跳到第19行)。從上圖的時間線看,約在8秒時返回,整體耗時與上面報出的 5007ms 吻合。再仔細觀察網路包,看起來是連續發了三次請求。第一次在 3.1s 時發出,第二次在 4.1s 時發出,第三次在 7.1s 時發出,重試間隔分別為 1s 和 3s,看起來像是一種指數退避的重試。當然,8秒左右時返回結果,就對應第一次請求,剩下兩次請求的結果被忽略了。
我們再看看hosts中新增主機資訊後,對應的網路包:
啊噢,這次沒有抓到任何相關的網路包,猜測直接讀取了hosts檔案拿到了主機名,根本沒走網路。
那麼,這段獲取主機資訊的程式究竟是怎麼運作的呢,hosts檔案中沒有新增主機名時,時間都耗在了哪裡?
原始碼比較好找,參見下圖:
我們再次把hosts中的主機名去掉,並使用 Arthas 工具的 trace
命令看看鏈路耗時:
提示:如果抓包時出現 No class or method is affected 的報錯,可檢視對應的紀錄檔檔案進行排查,見下圖:
可知需要提升下許可權,執行命令 options unsafe true
後,再嘗試使用 trace
命令即可。
但好巧不巧,居然抓不到呼叫鏈?那我們試試用 Arthas 的 profiler
命令生成一下火焰圖吧:
可以看到很多編譯相關的,我們忽略之,只把主機資訊獲取的那部分放大:
哦吼,時間基本都耗在了 InetAddress.getAddressesFromNameService 這行程式碼:
往下追溯,可知時間基本耗在了 nameService.lookupAllHostAddr:
再往下就到了native方法:
於是我們到 jdk 原始碼中看看(我用的 jdk8):
接下來需要找 getaddrinfo 的實現,由於不知道具體的實現原始碼在哪裡,於是我們在網上找一下 Linux 系統的原始碼作為參考,參見:https://codebrowser.dev/glibc/glibc/sysdeps/posix/getaddrinfo.c.html
內部的具體實現基本都是和作業系統互動,我們簡單瞄幾眼就行。另外,在 getaddrinfo 原始碼中沒有找到火焰圖給出的呼叫鏈,我們暫時不再深入。
目前,我們知道了方法 getaddrinfo 會被呼叫,因此簡單寫段 c 程式復現一下:
#include<sys/time.h> #include <iostream> #include <string.h> #include <sys/types.h> #include <sys/socket.h> #include <netdb.h> #include <netinet/in.h> #include <arpa/inet.h> using namespace std; int main(){ char* hostname = "xiaoxi666s-MacBook-Pro.local"; addrinfo hints, *res; in_addr addr; int err; struct timeval start, end; gettimeofday(&start, NULL); memset(&hints, 0, sizeof(addrinfo)); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET; if((err = getaddrinfo(hostname, NULL, &hints, &res)) != 0){ // 列印耗時(異常情況) gettimeofday(&end, NULL); printf("times=%d\n", end.tv_usec - start.tv_usec); printf("error %d : %s\n", err, gai_strerror(err)); return 1; } // 列印耗時(正常情況) gettimeofday(&end, NULL); printf("times=%d\n", end.tv_usec - start.tv_usec); addr.s_addr = ((sockaddr_in*)(res->ai_addr))->sin_addr.s_addr; printf("ip addresss: %s\n", inet_ntoa(addr)); freeaddrinfo(res); return 0; }
其中的 hostname 即為主機名 xiaoxi666s-MacBook-Pro.local,我們在 Java 專案中偵錯時也可以看到,上面的程式中直接將其寫死。
執行程式,對比下 hosts 檔案中 沒有新增主機名 和 新增主機名後的輸出結果:
# hosts 檔案中沒有新增主機名
times=6431
error 8 : nodename nor servname provided, or not known
# hosts 檔案中新增主機名
times=1789
ip addresss: 127.0.0.1
可以看到,當 hosts 檔案中沒有新增主機名時,根本找不到對應的網路地址(因為 dns 中也沒有解析到),新增之後就能返回對應的 ip 127.0.0.1 了。
這裡有幾個地方需要注意:
即使 hosts 檔案中新增主機名,標準 Linux 的 getaddrinfo 方法執行時,也會有接近兩秒的耗時,但我們在 Java 程式碼中執行時卻只有幾十毫秒;
前文我們使用 Wireshark 抓包時提到,mdns 查詢時存在重試機制,但標準 Linux 的 getaddrinfo 方法中沒有看到對應的程式碼;
前面提到的5秒返回結果,其實不是返回結果,而是超時了。但標準 Linux 的 getaddrinfo 方法中沒有看到對應的超時控制程式碼;
因此,我們可以大膽猜測 MaxOS 系統對標準 Linux 程式碼進行了修改,加了本地快取、重試、超時等機制。
接著上面的第3點,回到 Java 專案偵錯一下,看看為什麼超時了還能返回結果。
當 hosts 檔案中沒有新增主機名時,會返回本機所有的 ip 地址:
當 hosts 檔案中新增主機名後,只會返回設定的 127.0.01 的 ip 地址:
其中,當 hosts 檔案中沒有新增主機名時,getaddrinfo 呼叫返回錯誤碼,此時 jdk 會轉而呼叫 lookupIfLocalhost 方法,它內部呼叫了作業系統的 getifaddrs 方法,以獲取本機所有 ip 地址:
對應的原始碼可以參考https://codebrowser.dev/glibc/glibc/sysdeps/unix/sysv/linux/ifaddrs.c.html。
本文以 Java 中獲取主機名慢的場景為契機,使用多種技術手段研究背後的原理,包括使用 Wireshark 抓包,使用 Arthas 工具定位到效能瓶頸,再轉到 jdk 中檢視對應的 native 方法實現,由於沒找到最底層呼叫鏈路原始碼,轉而參照標準Linux的相關原始碼,簡單復現了上述場景。
進一步地,由於沒找到最底層呼叫鏈路原始碼,我們根據現象猜測的本地快取、重試、超時等機制沒有得到驗證,有興趣的同學可以進一步研究探索。