重大事故!IO問題引發(fā)線上20臺(tái)機(jī)器同時(shí)崩潰
幾年前的一個(gè)下午,公司里碼農(nóng)們正在安靜地敲著代碼,突然很多人的手機(jī)同時(shí)“嗶嗶”地響了起來。本來以為發(fā)工資了,都挺高興!打開一看,原來是告警短信。
圖片來自 Pexels
故障回顧
告警提示“線程數(shù)過多,超出閾值”,“CPU 空閑率太低”。打開監(jiān)控系統(tǒng)一看,訂單服務(wù)所有 20 個(gè)服務(wù)節(jié)點(diǎn)都不行了,服務(wù)沒響應(yīng)。
查看監(jiān)控(一個(gè)全鏈路性能監(jiān)控工具),每個(gè) Spring Boot 節(jié)點(diǎn)線程數(shù)全都達(dá)到了最大值。但是 JVM 堆內(nèi)存和 GC 沒有明顯異常。
CPU 空閑率基本都是 0%,但是 CPU 使用率并不高,反而 IO 等待卻非常高。
下面是執(zhí)行 top 命令查看 CPU 狀況的截圖:
從上圖,我們可以看到:
- CPU 空閑率是 0%(上圖中紅框 id)。
- CPU 使用率是 22%(上圖中紅框 us 13% 加上 sy 9%,us 可以理解成用戶進(jìn)程占用的 CPU,sy 可以理解成系統(tǒng)進(jìn)程占用的 CPU)。
- CPU 在等待磁盤 IO 操作上花費(fèi)的時(shí)間占比是 76.6% (上圖中紅框 wa)。
到現(xiàn)在可以確定,問題肯定發(fā)生在 IO 等待上。利用監(jiān)控系統(tǒng)和 jstack 命令,最終定位問題發(fā)生在文件寫入上。
大量的磁盤讀寫導(dǎo)致了系統(tǒng)線程資源耗盡,最終導(dǎo)致訂單服務(wù)無法響應(yīng)上游服務(wù)的請(qǐng)求。
IO,你不知道的那些事兒
既然 IO 對(duì)系統(tǒng)性能和穩(wěn)定性影響這么大,我們就來深入探究一下。
所謂的 I/O(Input/Output)操作實(shí)際上就是輸入輸出的數(shù)據(jù)傳輸行為。程序員最關(guān)注的主要是磁盤 IO 和網(wǎng)絡(luò) IO,因?yàn)檫@兩個(gè) IO 操作和應(yīng)用程序的關(guān)系最直接最緊密。
磁盤 IO:磁盤的輸入輸出,比如磁盤和內(nèi)存之間的數(shù)據(jù)傳輸;網(wǎng)絡(luò) IO:不同系統(tǒng)間跨網(wǎng)絡(luò)的數(shù)據(jù)傳輸,比如兩個(gè)系統(tǒng)間的遠(yuǎn)程接口調(diào)用。
下面這張圖展示了應(yīng)用程序中發(fā)生 IO 的具體場(chǎng)景:
通過上圖,我們可以了解到 IO 操作發(fā)生的具體場(chǎng)景。一個(gè)請(qǐng)求過程可能會(huì)發(fā)生很多次的 IO 操作:
- 頁面請(qǐng)求到服務(wù)器會(huì)發(fā)生網(wǎng)絡(luò) IO。
- 服務(wù)之間遠(yuǎn)程調(diào)用會(huì)發(fā)生網(wǎng)絡(luò) IO。
- 應(yīng)用程序訪問數(shù)據(jù)庫會(huì)發(fā)生網(wǎng)絡(luò) IO。
- 數(shù)據(jù)庫查詢或者寫入數(shù)據(jù)會(huì)發(fā)生磁盤 IO。
IO 和 CPU 的關(guān)系
不少攻城獅會(huì)這樣理解,如果 CPU 空閑率是 0%,就代表 CPU 已經(jīng)在滿負(fù)荷工作,沒精力再處理其他任務(wù)了。真是這樣的嗎?
我們先看一下計(jì)算機(jī)是怎么管理磁盤 IO 操作的。計(jì)算機(jī)發(fā)展早期,磁盤和內(nèi)存的數(shù)據(jù)傳輸是由 CPU 控制的,也就是說從磁盤讀取數(shù)據(jù)到內(nèi)存中,是需要 CPU 存儲(chǔ)和轉(zhuǎn)發(fā)的,期間 CPU 一直會(huì)被占用。
我們知道磁盤的讀寫速度遠(yuǎn)遠(yuǎn)比不上 CPU 的運(yùn)轉(zhuǎn)速度。這樣在傳輸數(shù)據(jù)時(shí)就會(huì)占用大量 CPU 資源,造成 CPU 資源嚴(yán)重浪費(fèi)。
后來有人設(shè)計(jì)了一個(gè) IO 控制器,專門控制磁盤 IO。當(dāng)發(fā)生磁盤和內(nèi)存間的數(shù)據(jù)傳輸前,CPU 會(huì)給 IO 控制器發(fā)送指令,讓 IO 控制器負(fù)責(zé)數(shù)據(jù)傳輸操作,數(shù)據(jù)傳輸完 IO 控制器再通知 CPU。
因此,從磁盤讀取數(shù)據(jù)到內(nèi)存的過程就不再需要 CPU 參與了,CPU 可以空出來處理其他事情,大大提高了 CPU 利用率。
這個(gè) IO 控制器就是“DMA”,即直接內(nèi)存訪問,Direct Memory Access。現(xiàn)在的計(jì)算機(jī)基本都采用這種 DMA 模式進(jìn)行數(shù)據(jù)傳輸。
通過上面內(nèi)容我們了解到,IO 數(shù)據(jù)傳輸時(shí),是不占用 CPU 的。
當(dāng)應(yīng)用進(jìn)程或線程發(fā)生 IO 等待時(shí),CPU 會(huì)及時(shí)釋放相應(yīng)的時(shí)間片資源并把時(shí)間片分配給其他進(jìn)程或線程使用,從而使 CPU 資源得到充分利用。
所以,假如 CPU 大部分消耗在 IO 等待(wa)上時(shí),即便 CPU 空閑率(id)是 0%,也并不意味著 CPU 資源完全耗盡了,如果有新的任務(wù)來了,CPU 仍然有精力執(zhí)行任務(wù)。
如下圖:
在 DMA 模式下執(zhí)行 IO 操作是不占用 CPU 的,所以 CPU IO 等待(上圖的wa)實(shí)際上屬于 CPU 空閑率的一部分。
所以我們執(zhí)行 top 命令時(shí),除了要關(guān)注 CPU 空閑率,CPU 使用率(us,sy),還要關(guān)注 IO Wait(wa)。注意,wa 只代表磁盤 IO Wait,不包括網(wǎng)絡(luò) IO Wait。
Java 中線程狀態(tài)和 IO 的關(guān)系
當(dāng)我們用 jstack 查看 Java 線程狀態(tài)時(shí),會(huì)看到各種線程狀態(tài)。當(dāng)發(fā)生 IO 等待時(shí)(比如遠(yuǎn)程調(diào)用時(shí)),線程是什么狀態(tài)呢,Blocked 還是 Waiting?
答案是 Runnable 狀態(tài),是不是有些出乎意料!實(shí)際上,在操作系統(tǒng)層面 Java 的 Runnable 狀態(tài)除了包括 Running 狀態(tài),還包括 Ready(就緒狀態(tài),等待 CPU 調(diào)度)和 IO Wait 等狀態(tài)。
如上圖,Runnable 狀態(tài)的注解明確說明了,在 JVM 層面執(zhí)行的線程,在操作系統(tǒng)層面可能在等待其他資源。
如果等待的資源是 CPU,在操作系統(tǒng)層面線程就是等待被 CPU 調(diào)度的 Ready 狀態(tài);如果等待的資源是磁盤網(wǎng)卡等 IO 資源,在操作系統(tǒng)層面線程就是等待 IO 操作完成的 IO Wait 狀態(tài)。
有人可能會(huì)問,為什么 Java 線程沒有專門的 Running 狀態(tài)呢?
目前絕大部分主流操作系統(tǒng)都是以時(shí)間分片的方式對(duì)任務(wù)進(jìn)行輪詢調(diào)度,時(shí)間片通常很短,大概幾十毫秒。
也就是說一個(gè)線程每次在 CPU 上只能執(zhí)行幾十毫秒,然后就會(huì)被 CPU 調(diào)度出來變成 Ready 狀態(tài),等待再一次被 CPU 執(zhí)行,線程在 Ready 和 Running 兩個(gè)狀態(tài)間快速切換。
通常情況,JVM 線程狀態(tài)主要為了監(jiān)控使用,是給人看的。當(dāng)你看到線程狀態(tài)是 Running 的一瞬間,線程狀態(tài)早已經(jīng)切換 N 次了。所以,再給線程專門加一個(gè) Running 狀態(tài)也就沒什么意義了。
深入理解網(wǎng)絡(luò) IO 模型
5 種 Linux 網(wǎng)絡(luò) IO 模型包括:
- 同步阻塞 IO
- 同步非阻塞 IO
- 多路復(fù)用 IO
- 信號(hào)驅(qū)動(dòng) IO
- 異步 IO
為了更好地理解網(wǎng)絡(luò) IO 模型,我們先了解幾個(gè)基本概念:
①Socket(套接字):Socket 可以理解成,在兩個(gè)應(yīng)用程序進(jìn)行網(wǎng)絡(luò)通信時(shí),分別在兩個(gè)應(yīng)用程序中的通信端點(diǎn)。
通信時(shí),一個(gè)應(yīng)用程序?qū)?shù)據(jù)寫入 Socket,然后通過網(wǎng)卡把數(shù)據(jù)發(fā)送到另外一個(gè)應(yīng)用程序的 Socket 中。
我們平常所說的 HTTP 和 TCP 協(xié)議的遠(yuǎn)程通信,底層都是基于 Socket 實(shí)現(xiàn)的。5 種網(wǎng)絡(luò) IO 模型也都要基于 Socket 實(shí)現(xiàn)網(wǎng)絡(luò)通信。
②阻塞與非阻塞:所謂阻塞,就是發(fā)出一個(gè)請(qǐng)求不能立刻返回響應(yīng),要等所有的邏輯全處理完才能返回響應(yīng)。
非阻塞反之,發(fā)出一個(gè)請(qǐng)求立刻返回應(yīng)答,不用等處理完所有邏輯。
③內(nèi)核空間與用戶空間:在 Linux 中,應(yīng)用程序穩(wěn)定性遠(yuǎn)遠(yuǎn)比不上操作系統(tǒng)程序,為了保證操作系統(tǒng)的穩(wěn)定性,Linux 區(qū)分了內(nèi)核空間和用戶空間。
可以這樣理解,內(nèi)核空間運(yùn)行操作系統(tǒng)程序和驅(qū)動(dòng)程序,用戶空間運(yùn)行應(yīng)用程序。
Linux 以這種方式隔離了操作系統(tǒng)程序和應(yīng)用程序,避免了應(yīng)用程序影響到操作系統(tǒng)自身的穩(wěn)定性。
這也是 Linux 系統(tǒng)超級(jí)穩(wěn)定的主要原因。所有的系統(tǒng)資源操作都在內(nèi)核空間進(jìn)行,比如讀寫磁盤文件,內(nèi)存分配和回收,網(wǎng)絡(luò)接口調(diào)用等。
所以在一次網(wǎng)絡(luò) IO 讀取過程中,數(shù)據(jù)并不是直接從網(wǎng)卡讀取到用戶空間中的應(yīng)用程序緩沖區(qū),而是先從網(wǎng)卡拷貝到內(nèi)核空間緩沖區(qū),然后再從內(nèi)核拷貝到用戶空間中的應(yīng)用程序緩沖區(qū)。
對(duì)于網(wǎng)絡(luò) IO 寫入過程,過程則相反,先將數(shù)據(jù)從用戶空間中的應(yīng)用程序緩沖區(qū)拷貝到內(nèi)核緩沖區(qū),再從內(nèi)核緩沖區(qū)把數(shù)據(jù)通過網(wǎng)卡發(fā)送出去。
同步阻塞 IO
我們先看一下傳統(tǒng)阻塞 IO。在 Linux 中,默認(rèn)情況下所有 Socket 都是阻塞模式的。
當(dāng)用戶線程調(diào)用系統(tǒng)函數(shù) read(),內(nèi)核開始準(zhǔn)備數(shù)據(jù)(從網(wǎng)絡(luò)接收數(shù)據(jù)),內(nèi)核準(zhǔn)備數(shù)據(jù)完成后,數(shù)據(jù)從內(nèi)核拷貝到用戶空間的應(yīng)用程序緩沖區(qū),數(shù)據(jù)拷貝完成后,請(qǐng)求才返回。
從發(fā)起 Read 請(qǐng)求到最終完成內(nèi)核到應(yīng)用程序的拷貝,整個(gè)過程都是阻塞的。為了提高性能,可以為每個(gè)連接都分配一個(gè)線程。
因此,在大量連接的場(chǎng)景下就需要大量的線程,會(huì)造成巨大的性能損耗,這也是傳統(tǒng)阻塞 IO 的最大缺陷。
同步非阻塞 IO
用戶線程在發(fā)起 Read 請(qǐng)求后立即返回,不用等待內(nèi)核準(zhǔn)備數(shù)據(jù)的過程。如果 Read 請(qǐng)求沒讀取到數(shù)據(jù),用戶線程會(huì)不斷輪詢發(fā)起 Read 請(qǐng)求,直到數(shù)據(jù)到達(dá)(內(nèi)核準(zhǔn)備好數(shù)據(jù))后才停止輪詢。
非阻塞 IO 模型雖然避免了由于線程阻塞問題帶來的大量線程消耗,但是頻繁的重復(fù)輪詢大大增加了請(qǐng)求次數(shù),對(duì) CPU 消耗也比較明顯。這種模型在實(shí)際應(yīng)用中很少使用。
多路復(fù)用 IO 模型
多路復(fù)用 IO 模型,建立在多路事件分離函數(shù) Select,Poll,Epoll 之上。
在發(fā)起 Read 請(qǐng)求前,先更新 Select 的 Socket 監(jiān)控列表,然后等待 Select 函數(shù)返回(此過程是阻塞的,所以說多路復(fù)用 IO 也是阻塞 IO 模型)。
當(dāng)某個(gè) Socket 有數(shù)據(jù)到達(dá)時(shí),Select 函數(shù)返回。此時(shí)用戶線程才正式發(fā)起 Read 請(qǐng)求,讀取并處理數(shù)據(jù)。
這種模式用一個(gè)專門的監(jiān)視線程去檢查多個(gè) Socket,如果某個(gè) Socket 有數(shù)據(jù)到達(dá)就交給工作線程處理。
由于等待 Socket 數(shù)據(jù)到達(dá)過程非常耗時(shí),所以這種方式解決了阻塞 IO 模型一個(gè) Socket 連接就需要一個(gè)線程的問題,也不存在非阻塞 IO 模型忙輪詢帶來的 CPU 性能損耗的問題。
多路復(fù)用 IO 模型的實(shí)際應(yīng)用場(chǎng)景很多,比如大家耳熟能詳?shù)?Java NIO,Redis 以及 Dubbo 采用的通信框架 Netty 都采用了這種模型。
下圖是基于 Select 函數(shù) Socket 編程的詳細(xì)流程:

信號(hào)驅(qū)動(dòng) IO 模型
信號(hào)驅(qū)動(dòng) IO 模型,應(yīng)用進(jìn)程使用 Sigaction 函數(shù),內(nèi)核會(huì)立即返回,也就是說內(nèi)核準(zhǔn)備數(shù)據(jù)的階段應(yīng)用進(jìn)程是非阻塞的。
內(nèi)核準(zhǔn)備好數(shù)據(jù)后向應(yīng)用進(jìn)程發(fā)送 SIGIO 信號(hào),接到信號(hào)后數(shù)據(jù)被復(fù)制到應(yīng)用程序進(jìn)程。
采用這種方式,CPU 的利用率很高。不過這種模式下,在大量 IO 操作的情況下可能造成信號(hào)隊(duì)列溢出導(dǎo)致信號(hào)丟失,造成災(zāi)難性后果。
異步 IO 模型
異步 IO 模型的基本機(jī)制是,應(yīng)用進(jìn)程告訴內(nèi)核啟動(dòng)某個(gè)操作,內(nèi)核操作完成后再通知應(yīng)用進(jìn)程。
在多路復(fù)用 IO 模型中,Socket 狀態(tài)事件到達(dá),得到通知后,應(yīng)用進(jìn)程才開始自行讀取并處理數(shù)據(jù)。
在異步 IO 模型中,應(yīng)用進(jìn)程得到通知時(shí),內(nèi)核已經(jīng)讀取完數(shù)據(jù)并把數(shù)據(jù)放到了應(yīng)用進(jìn)程的緩沖區(qū)中,此時(shí)應(yīng)用進(jìn)程直接使用數(shù)據(jù)即可。
很明顯,異步 IO 模型性能很高。不過到目前為止,異步 IO 和信號(hào)驅(qū)動(dòng) IO 模型應(yīng)用并不多見,傳統(tǒng)阻塞 IO 和多路復(fù)用 IO 模型還是目前應(yīng)用的主流。
Linux 2.6 版本后才引入異步 IO 模型,目前很多系統(tǒng)對(duì)異步 IO 模型支持尚不成熟。很多應(yīng)用場(chǎng)景采用多路復(fù)用 IO 替代異步 IO 模型。
如何避免 IO 問題帶來的系統(tǒng)故障
對(duì)于磁盤文件訪問的操作,可以采用線程池方式,并設(shè)置線程上線,從而避免整個(gè) JVM 線程池污染,進(jìn)而導(dǎo)致線程和 CPU 資源耗盡。
對(duì)于網(wǎng)絡(luò)間遠(yuǎn)程調(diào)用。為了避免服務(wù)間調(diào)用的全鏈路故障,要設(shè)置合理的 TImeout 值,高并發(fā)場(chǎng)景下可以采用熔斷機(jī)制。
在同一 JVM 內(nèi)部采用線程隔離機(jī)制,把線程分為若干組,不同的線程組分別服務(wù)于不同的類和方法,避免因?yàn)橐粋€(gè)小功能點(diǎn)的故障,導(dǎo)致 JVM 內(nèi)部所有線程受到影響。
此外,完善的運(yùn)維監(jiān)控(磁盤 IO,網(wǎng)絡(luò) IO)和 APM(全鏈路性能監(jiān)控)也非常重要,能及時(shí)預(yù)警,防患于未然,在故障發(fā)生時(shí)也能幫助我們快速定位問題。
作者:二馬讀書
簡(jiǎn)介:曾任職于阿里巴巴,每日優(yōu)鮮等互聯(lián)網(wǎng)公司,任技術(shù)總監(jiān),15 年電商互聯(lián)網(wǎng)經(jīng)歷。
編輯:陶家龍
出處:架構(gòu)師進(jìn)階之路(ID:ermadushu)