作為一個 phper,用的最多的架構就是 LNMP
。每次一到流量來了,我們的服務就從原來的 幾百毫秒到幾秒的時間。這個時候我們各種猜測,mysql
有慢sql,redis
有大key,php-fpm
程式數不夠等等情況。其中可以通過業務的一些日誌來排查如上情況。我們這次主要證明的卻是 php-fpm
程式數不夠情況的實踐。
重現現場
將我本地的的
PHP-FPM
程式數調整為 2#vim /etc/php-fpm.d/www.conf pm = static pm.max_children = 2
使用 ab 來壓測介面
$ ab -c 40 -n 3000 http://127.0.0.1/group/check_groups Server Software: nginx/1.16.0 Server Hostname: miner_platform.cn Server Port: 80 Document Path: /group/check_groups Document Length: 44 bytes Concurrency Level: 40 Time taken for tests: 29.384 seconds Complete requests: 3000 Failed requests: 0 Write errors: 0 Total transferred: 699000 bytes HTML transferred: 132000 bytes Requests per second: 102.10 [#/sec] (mean) Time per request: 391.788 [ms] (mean) Time per request: 9.795 [ms] (mean, across all concurrent requests) Transfer rate: 23.23 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.2 0 3 Processing: 306 344 80.6 318 3558 Waiting: 306 343 80.5 318 3555 Total: 307 344 80.6 318 3558 Percentage of the requests served within a certain time (ms) 50% 318 66% 322 75% 333 80% 369 90% 428 95% 461 98% 508 99% 553 100% 3558 (longest request)
嘗試解決問題
1. PHP-FPM STATUS
我們發現介面 318ms 到 3.558s 的都有,那我們如何知道 php-fpm
程式少不夠導致這個問題呢?換一種說話有什麼辦法能讓我們知道 php-fpm
內部是處理不過來嗎? 這個時候我們就需要開啟 php-fpm
內建 status
了。詳細步驟參考:www.cnblogs.com/tinywan/p/6848269....
$ curl http://127.0.0.1/status.php
pool: www
process manager: static
start time: 29/Nov/2021:18:27:38 +0800
start since: 6493
accepted conn: 3136
listen queue: 38
max listen queue: 39
listen queue len: 128
idle processes: 0
active processes: 2
total processes: 2
max active processes: 2
max children reached: 0
slow requests: 0
具體詳細的欄位可以參見上面的連結,有詳細說明,我們主要說下幾個引數
listen queue
:這個就是此時此刻我們的php-fpm
作為服務端,處於accept 佇列
的數量。max listen queue
: 從php-fpm
程式啟動到現在處於等待連線的最大數量(說白了,就是我們上面說的listen queue
的最大值持久化)listen queue len
: 有過 socket 網路程式設計經驗的同學都知道。int listen(int sockfd, int backlog);
是可以設定該引數,但是他和系統設定有關係。
2. netstat 檢視連結狀態
我們得到的結論是:當 php-fpm 程式處理不過來的時候,請求就會放在 accept 佇列
,知道了這個情況以後,我們甚至不需要通過 status
。
- 第一行表示的監聽 socket,
Recv-Q
表示accept queue
長度。
$netstat -antp | grep php-fpm
tcp 38 0 127.0.0.1:9000 0.0.0.0:* LISTEN 97/php-fpm: master
tcp 8 0 127.0.0.1:9000 127.0.0.1:55540 ESTABLISHED 964/php-fpm: pool w
tcp 8 0 127.0.0.1:9000 127.0.0.1:55536 ESTABLISHED 965/php-fpm: pool w
綜上我們知道了,當 PHP-FPM
程式數不夠的時候,nginx
客戶端請求的連線的 accept 佇列
長度就會變大。這樣就完了嗎?不,我們還需要去分析為什麼能得到這個現象。
原理分析
簡述PHP-FPM工作過程
首先我們需要簡單裡說一說 php-fpm
的工作過程。我們就簡單模型一下它的虛擬碼(這裡只為了表述整個socket的過程)
// 1. 建立 socket
$socket = socket_create(AF_INET, SOCK_STREAM, 0);
// 2. 繫結socket
socket_bind($socket, "0.0.0.0", 9000);
// 3. 監聽 socket
socket_listen($socket, 5);
for($i=0;$i<2;$i++) {
$pid = pcntl_fork()
// 4. 建立2個程式
if ($pid == 0) {
// 5. 子程式接受socket
while($fd = socket_accept($socket)) {
echo "客戶端${fd}連線" . PHP_EOL;
$tmp = socket_read($fd, 1024);
echo "client data:" . $tmp . PHP_EOL;
$data = "HTTP/1.1 200 ok\r\nContent-Length:2\r\n\r\nhi";
socket_write($fd, $data, strlen($data));
}
exit;
}
}
// 5. 監聽子程式退出
// 其他 TODO
master
程式建立了監聽socket
,但是不處理業務正在work
程式接受同步堵塞接受請求(堵塞在accept
),然後處理業務。
抓取 nginx->php-fpm socket
我們知道了 php-fpm 大概工作的過程,這個時候我們就需要通過一次請求大概知道 nginx
與 php-fpm
互動的過程。
$curl http://miner_platform.cn/group/check_groups
{"code":10006,"message":"sign\u65e0\u6548."}
nginx 系統呼叫
需要關注的點都在這個裡面註釋了。抓取的是
nginx
work
程式$ strace -f -s 64400 -p 958 strace: Process 958 attached epoll_wait(8, [{EPOLLIN, {u32=1226150064, u64=94773974503600}}], 512, -1) = 1 accept4(6, {sa_family=AF_INET, sin_port=htons(46616), sin_addr=inet_addr("127.0.0.1")}, [112->16], SOCK_NONBLOCK) = 3 epoll_ctl(8, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLRDHUP|EPOLLET, {u32=1226159737, u64=94773974513273}}) = 0 epoll_wait(8, [{EPOLLIN, {u32=1226159737, u64=94773974513273}}], 512, 60000) = 1 recvfrom(3, "GET /group/check_groups HTTP/1.1\r\nUser-Agent: curl/7.29.0\r\nHost: miner_platform.cn\r\nAccept: */*\r\n\r\n", 1024, 0, NULL, NULL) = 99 stat("/data/miner_platform/src/public/group/check_groups", 0x7ffcb593d1b0) = -1 ENOENT (No such file or directory) stat("/data/miner_platform/src/public/group/check_groups", 0x7ffcb593d1b0) = -1 ENOENT (No such file or directory) epoll_ctl(8, EPOLL_CTL_MOD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1226159737, u64=94773974513273}}) = 0 lstat("/data", {st_mode=S_IFDIR|0777, st_size=4096, ...}) = 0 lstat("/data/miner_platform", {st_mode=S_IFDIR|0777, st_size=4096, ...}) = 0 lstat("/data/miner_platform/src", {st_mode=S_IFDIR|0777, st_size=4096, ...}) = 0 lstat("/data/miner_platform/src/public", {st_mode=S_IFDIR|0777, st_size=4096, ...}) = 0 getsockname(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 0 // 1. 建立 socket socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 11 ioctl(11, FIONBIO, [1]) = 0 epoll_ctl(8, EPOLL_CTL_ADD, 11, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1226163953, u64=94773974517489}}) = 0 // 2. 連線 127.0.0.1:9000 connect(11, {sa_family=AF_INET, sin_port=htons(9000), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress) epoll_wait(8, [{EPOLLOUT, {u32=1226159737, u64=94773974513273}}, {EPOLLOUT, {u32=1226163953, u64=94773974517489}}], 512, 60000) = 2 getsockopt(11, SOL_SOCKET, SO_ERROR, [0], [4]) = 0 // 3. 按照FASTCGI協議寫入這次請求 writev(11, [{iov_base="\1\1\0\1\0\10\0\0\0\1\0\0\0\0\0\0\1\4\0\1\2!\7\0\17)SCRIPT_FILENAME/data/miner_platform/src/public/index.php\f\0QUERY_STRING\16\3REQUEST_METHODGET\f\0CONTENT_TYPE\16\0CONTENT_LENGTH\v\nSCRIPT_NAME/index.php\v\23REQUEST_URI/group/check_groups\f\nDOCUMENT_URI/index.php\r\37DOCUMENT_ROOT/data/miner_platform/src/public\17\10SERVER_PROTOCOLHTTP/1.1\16\4REQUEST_SCHEMEhttp\21\7GATEWAY_INTERFACECGI/1.1\17\fSERVER_SOFTWAREnginx/1.16.0\v\tREMOTE_ADDR127.0.0.1\v\5REMOTE_PORT46616\v\tSERVER_ADDR127.0.0.1\v\2SERVER_PORT80\v\21SERVER_NAMEminer_platform.cn\17\3REDIRECT_STATUS200\17\vHTTP_USER_AGENTcurl/7.29.0\t\21HTTP_HOSTminer_platform.cn\v\3HTTP_ACCEPT*/*\0\0\0\0\0\0\0\1\4\0\1\0\0\0\0\1\5\0\1\0\0\0\0", iov_len=592}], 1) = 592 epoll_wait(8, [{EPOLLIN|EPOLLOUT, {u32=1226163953, u64=94773974517489}}], 512, 60000) = 1 // 4. 接受 PHP-FPM響應結果 recvfrom(11, "\1\6\0\1\0\257\1\0X-Powered-By: PHP/7.2.16\r\nCache-Control: no-cache, private\r\nDate: Wed, 01 Dec 2021 12:24:52 GMT\r\nContent-Type: application/json\r\n\r\n{\"code\":10006,\"message\":\"sign\\u65e0\\u6548.\"}\0\1\3\0\1\0\10\0\0\0\0\0\0\0\"}\0", 4096, 0, NULL, NULL) = 200 epoll_wait(8, [{EPOLLIN|EPOLLOUT|EPOLLRDHUP, {u32=1226163953, u64=94773974517489}}], 512, 60000) = 1 readv(11, [{iov_base="", iov_len=3896}], 1) = 0 // 5. 關閉這次socket連線 close(11) = 0 // 6. 響應給瀏覽器 writev(3, [{iov_base="HTTP/1.1 200 OK\r\nServer: nginx/1.16.0\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nX-Powered-By: PHP/7.2.16\r\nCache-Control: no-cache, private\r\nDate: Wed, 01 Dec 2021 12:24:52 GMT\r\n\r\n", iov_len=222}, {iov_base="2c\r\n", iov_len=4}, {iov_base="{\"code\":10006,\"message\":\"sign\\u65e0\\u6548.\"}", iov_len=44}, {iov_base="\r\n", iov_len=2}, {iov_base="0\r\n\r\n", iov_len=5}], 5) = 277 write(5, "127.0.0.1 - - [01/Dec/2021:20:24:52 +0800] \"GET /group/check_groups HTTP/1.1\" 200 55 \"-\" \"curl/7.29.0\" \"-\" 1.029 127.0.0.1:9000 200 1.030\n", 138) = 138 setsockopt(3, SOL_TCP, TCP_NODELAY, [1], 4) = 0 epoll_wait(8, [{EPOLLIN|EPOLLOUT|EPOLLRDHUP, {u32=1226159737, u64=94773974513273}}], 512, 65000) = 1 recvfrom(3, "", 1024, 0, NULL, NULL) = 0 close(3) = 0 epoll_wait(8,
php-fpm 系統呼叫
抓取了
php-fpm
work
程式// 1. accept 接收到了 nginx(127.0.0.1:45512 ) 客戶端傳送的資料 965 accept(9, {sa_family=AF_INET, sin_port=htons(45512), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 4 中間省略了許多 // 2. 響應給客戶端 965 write(4, "\1\6\0\1\0\257\1\0X-Powered-By: PHP/7.2.16\r\nCache-Control: no-cache, private\r\nDate: Wed, 01 Dec 2021 12:37:18 GMT\r\nContent-Type: application/json\r\n\r\n{\"code\":10006,\"message\":\"sign\\u65e0\\u6548.\"}\0\1\3\0\1\0\10\0\0\0\0\0\0\0p\0\0", 200) = 200 // 3. 不給給這個socket 寫資料了 965 shutdown(4, SHUT_WR) = 0 // 4. 接受nginx(127.0.0.1:45512 )客戶端資料 965 recvfrom(4, "\1\5\0\1\0\0\0\0", 8, 0, NULL, NULL) = 8 // 5. 接受nginx(127.0.0.1:45512 )客戶端資料 965 recvfrom(4, "", 8, 0, NULL, NULL) = 0 // 6. 關閉這個連線 965 close(4) = 0 965 lstat("/data/miner_platform/src/vendor/composer/../../app/Http/Middleware/BusinessHeaderCheck.php", {st_mode=S_IFREG|0777, st_size=989, ...}) = 0 965 stat("/data/miner_platform/src/app/Http/Middleware/BusinessHeaderCheck.php", {st_mode=S_IFREG|0777, st_size=989, ...}) = 0 965 chdir("/") = 0 965 times({tms_utime=3583, tms_stime=1977, tms_cutime=0, tms_cstime=0}) = 4315309933 965 setitimer(ITIMER_PROF, {it_interval={tv_sec=0, tv_usec=0}, it_value={tv_sec=0, tv_usec=0}}, NULL) = 0 965 fcntl(3, F_SETLK, {l_type=F_UNLCK, l_whence=SEEK_SET, l_start=0, l_len=0}) = 0 965 setitimer(ITIMER_PROF, {it_interval={tv_sec=0, tv_usec=0}, it_value={tv_sec=0, tv_usec=0}}, NULL) = 0 965 accept(9,
TCP三次握手
上面我們已經清楚了一次請求,請求併發高的時候流程也是如此,這個時候我們就引出了下面這個圖與我們上面描述的過程是一樣的,只是細化了三次握手的過程。這個時候我們引出了 sync queue
和 accept queue
。
- 我們呼叫
listen
(上面是php-fpm
master
程式執行的),於此同時核心建立了兩個佇列sync queue
和accept queue
- 三次握手第二步當
Server
(指的是php-fpm
master
程式)傳送了SYN+ACK
報文後,此時會將這個資訊放入到sync queue
- 當三次握手完成時,未被應用(指的是
php-fpm
work
程式)呼叫accept
取走的連線佇列。此時的socket
處於ESTABLISHED
狀態。每次應用呼叫accept()
函式會移除佇列頭的連線。如果佇列為空,accept() 通常會阻塞。全連線佇列也被稱為accept queue
。
結論
經過上面的分析,我們知道了什麼是sync queue
和 accept queue
。應用程式 與 accept queue
與 核心 就是一個生產消費模型。核心為生產者,accept queue
儲存佇列資訊,應用程式為消費者。使用過佇列的同學都知道,當併發高的時候,佇列裡的資料就多,或者生產者消費的慢就會導致後面的連線處理的越來越慢,因此通常的做法就是增加消費者,提高消費速度這兩個方案。這也與我們上面的現象不謀而合。
本作品採用《CC 協議》,轉載必須註明作者和本文連結