Inotify 用于監(jiān)聽文件系統(tǒng)的變更,我們有個場景需要監(jiān)聽一個根目錄下的所有文件,包括不同深度子目錄下的文件,如果文件發(fā)生變化,增量讀取新增的內(nèi)容。
之前我們使用輪詢的模式,周期性遞歸遍歷根目錄,對比文件屬性信息選擇要讀取的文件,這種方式有一個問題就是周期很難選擇:
- 如果選擇較小的周期,讀文件的磁盤 I/O 會相對均勻,但是頻繁的遞歸遍歷會造成較高的 CPU 占用
- 如果選擇較大的周期,遞歸遍歷占用的資源將會下降,但是周期變大造成增量數(shù)據(jù)變大,結(jié)果每次讀取將會造成瞬間較高的磁盤 I/O
所以我們考慮用系統(tǒng)級別的 inotify 替換掉輪詢的邏輯,結(jié)果踩了不少坑。。。
1. JDK 封裝的問題 —— poller
jdk 將 inotify 的功能封裝為 WatchService,使用起來非常簡單,sample 中還提供了一個遞歸注冊目錄的例子 WatchDir.java。
由于 inotify 只支持 1.監(jiān)聽文件,2.監(jiān)聽目錄,以及目錄下的目錄和文件,不能監(jiān)聽子目錄下的變更,所以需要遞歸注冊目錄。
基于官方的 sample 我們很快就用上了 inotify,效果很不錯,不僅 CPU 占用下降了,而且文件讀取特別均勻。
直到有一天 CPU 使用率開始飚高了,通過 top 和 jstack 發(fā)現(xiàn)占用 CPU 最多的線程不是讀文件的線程,而是 LinuxWatchService 中的 poller 線程。

poller 線程的作用如上圖所示:
- poll requestList,處理業(yè)務(wù)代碼中的目錄注冊、取消和關(guān)閉等請求;
- poll inotify 文件描述符獲取 inoitfy_event, 然后按照 WatchKey (也就是 watch descriptor)放到不同的隊列,隊列的長度默認(rèn)為 512,如果隊列滿了,那么清空隊列并且放入 overflow 事件。
由于我們監(jiān)聽的目錄比較多,當(dāng)大量文件頻繁修改的時候是會產(chǎn)生大量的事件,而 poller 線程會盡最大能力讀取這些事件,所以造成了很高的 CPU 占用。
然后這是一種無用功,因?yàn)?1.事件數(shù)量超出 WatchEvent 隊列長度,上層業(yè)務(wù)邏輯也就取不到事件;2. inotify 的文件隊列也有簡單的事件去重邏輯,沒必要特別快地讀取事件然后再去重。
實(shí)際上 poller 的速度應(yīng)該受制于下游的處理能力,如果下游已經(jīng)不能處理,那么暫時放在中轉(zhuǎn)隊列也是意義不大的,我們只需要依賴 inotify 的文件隊列即可。
順便提一下 inotify 相關(guān)的幾個系統(tǒng)參數(shù):
- /proc/sys/fs/inotify/max_user_instances 初始化 ifd 的數(shù)量限制
- /proc/sys/fs/inotify/max_queued_events ifd 文件隊列長度限制
- /proc/sys/fs/inotify/max_user_watches 注冊監(jiān)聽目錄的數(shù)量限制
- 既然是文件描述符,當(dāng)然也受 /etc/security/limits.conf 和 /proc/sys/fs/file-max 限制
所以我們通過 JNI 調(diào)用重新實(shí)現(xiàn)了 WatchService 的邏輯,去掉了 poller 線程,由業(yè)務(wù)線程控制 poll 的頻率。
2. 莫名其妙的 MODIFY 事件 —— deleted
然后一切又變正常了,只是偶爾有幾條莫名其妙的 MODIFY 事件。
讀到 MODIFY 事件,首先需要讀取文件屬性,然后走不同的邏輯,但是此刻卻拋出 NoSuchFileException。
這個問題一開始也毫無頭緒,甚至懷疑是不是文件瞬間被刪除了。。。
直到有一天想起了 lsof 這個命令,驗(yàn)證了一下:
REG 8,6 16 6161077 /xxx/xxx/xxx/xxx (deleted)
果然是文件被刪除了,但是打開的文件描述符沒有關(guān)閉,而且還偶爾在寫數(shù)據(jù)。
3. 有目錄脫離監(jiān)聽 —— overflow
然后一切又變正常了,文件不存在的 MODIFY 事件歸咎于業(yè)務(wù)方程序的問題。
但是又遇到了新的問題,偶爾發(fā)現(xiàn)存在沒有讀取的文件,而且是整個子目錄下的所有文件都沒有讀。
由于我們監(jiān)聽的目錄比較多,有些機(jī)器上有約五千個監(jiān)聽目錄,所以偶爾會發(fā)生事件 overflow,我們猜測是目錄創(chuàng)建的事件被 overflow 了,所以沒有監(jiān)聽到這個目錄。
針對這個問題,我們添加了一個周期性重新遞歸注冊的機(jī)制。
由于 inotify 是基于 inode 的,所以重復(fù)注冊目錄獲得的 wd (watch descriptor) 是一樣的,但是如果目錄被移除然后重建了,那么就是一個新的 wd。
此處由于一個 bug 使得問題更嚴(yán)重,由于需要構(gòu)建文件的絕對路徑,我們采用 guava 的 BiMap 存儲 wd 與 path 之間的映射,完成 inotify_add_watch 之后需要更新 wdToPath

調(diào)用 BiMap 的 put 方法,如果存在遺留的 path,那么是會拋出 IllegalArgumentException 的,所以首先需要處理這種情況,然后調(diào)用 forcePut 覆蓋舊的映射。
4. 隱藏最深的 mv
移動監(jiān)聽目錄這個場景,由于寫代碼的時候沒有考慮,第一次發(fā)現(xiàn)這個問題著實(shí)被嚇了一下。
有些業(yè)務(wù)方的重啟腳本有保存歷史日志的邏輯,也就是 mv 舊的日志根目錄,然后創(chuàng)建一個新的日志根目錄。
由于 inotify 是基于 inode 的,所以 mv 后的目錄還在監(jiān)聽中,并且 wd 沒有變化,所以目錄下文件的改動還是會觸發(fā)事件。觸發(fā)事件不可怕,關(guān)鍵是通過 wdToPath 映射還原的絕對路徑已經(jīng)不對了。
在我們的使用場景中,將 mv 作為刪除處理即可,所以注冊目錄時默認(rèn)加上 IN_MOVE_SELF 事件監(jiān)聽,如果收到該事件,那么 forceUnregister 這個 wd,如果事件 overflow 了,那么還是依賴周期性重新注冊的機(jī)制。
參考
本文只是粗略記一下遇過的問題,inotify 的詳細(xì)介紹可以參考下述文章。

如果覺得我的文章對您有用,請隨意打賞。