如何在不會導致伺服器當機的情況下,用 PHP 讀取大檔案

發表於2017-12-17

作為PHP開發人員,我們並不經常需要擔心記憶體管理。PHP 引擎在我們背後做了很好的清理工作,短期執行上下文的 Web 伺服器模型意味著即使是最潦草的程式碼也不會造成持久的影響。

很少情況下我們可能需要走出這個舒適的地方 ——比如當我們試圖在一個大型專案上執行 Composer 來建立我們可以建立的最小的 VPS 時,或者當我們需要在一個同樣小的伺服器上讀取大檔案時。

如何在不會導致伺服器當機的情況下,用 PHP 讀取大檔案

後面的問題就是我們將在本教程中深入探討的。

在 GitHub上可以找到本教程的原始碼。

衡量成功的標準

確保我們對程式碼有改進的唯一方法是測試一個不好的情況,然後將我們修復之後的測量與另一個進行比較。換句話說,除非我們知道“解決方案”對我們有多大的幫助(如果有的話),否則我們不知道它是否真的是一個解決方案。

這裡有兩個我們可以關係的衡量標準。首先是CPU使用率。我們要處理的程式有多快或多慢?第二是記憶體使用情況。指令碼執行時需要多少記憶體?這兩個通常是成反比的 – 這意味著我們可以以CPU使用率為代價來降低記憶體使用,反之亦然。

在一個非同步執行模型(如多程式或多執行緒的PHP應用程式)中,CPU和記憶體的使用率是很重要的考量因素。在傳統的PHP架構中,當任何一個值達到伺服器的極限時,這些通常都會成為問題。

測量PHP內的CPU使用率是不切實際的。如果這是你要關注的領域,請考慮在Ubuntu或MacOS上使用類似top的工具。對於Windows,請考慮使用Linux子系統,以便在Ubuntu中使用top。

為了本教程的目的,我們將測量記憶體使用情況。我們將看看在“傳統”的指令碼中使用了多少記憶體。我們將執行一些優化策略並對其進行度量。最後,我希望你能夠做出一個有經驗的選擇。
我們檢視記憶體使用多少的方法是:

我們將在指令碼的最後使用這些函式,以便我們能夠看到哪個指令碼一次使用最大的記憶體。

我們的選擇是什麼?

這裡有很多方法可以有效地讀取檔案。但是也有兩種我們可能使用它們的情況。我們想要同時讀取和處理所有資料,輸出處理過的資料或根據我們所讀取的內容執行其他操作。我們也可能想要轉換一個資料流,而不需要真正訪問的資料。
讓我們設想一下,對於第一種情況,我們希望讀取一個檔案,並且每10,000行建立一個獨立排隊的處理作業。我們需要在記憶體中保留至少10000行,並將它們傳遞給排隊的工作管理器(無論採取何種形式)。
對於第二種情況,我們假設我們想要壓縮一個特別大的API響應的內容。我們不在乎它的內容是什麼,但我們需要確保它是以壓縮形式備份的。
在這兩種情況下,如果我們需要讀取大檔案,首先,我們需要知道資料是什麼。第二,我們並不在乎資料是什麼。讓我們來探索這些選擇吧…

逐行讀取檔案

有許多操作檔案的函式,我們把部分結合到一個簡單的檔案閱讀器中(封裝為一個方法):

我們讀取一個文字檔案為莎士比亞全集。檔案大小為5.5MB,記憶體佔用峰值為12.8MB。現在讓我們用一個生成器來讀取每一行:

文字檔案大小不變,但記憶體使用峰值只是393KB。即使我們能把讀取到的資料做一些事情也並不意味著什麼。也許我們可以在看到兩條空白時把文件分割成塊,像這樣:

猜到我們使用了多少記憶體嗎?我們把文件分割為1216塊,仍然只使用了459KB的記憶體,這是否讓你驚訝?考慮到生成器的性質,我們使用的最多記憶體是使用在迭代中我們需要儲存的最大文字塊。在本例中,最大的塊為101985字元。

我已經撰寫了使用生成器提示效能Nikita Popov的迭代器庫,如果你感興趣就去看看吧!

生成器還有其它用途,但是最明顯的好處就是高效能讀取大檔案。如果我們需要處理這些資料,生成器可能是最好的方法。

管道間的檔案

在我們不需要處理資料的情況下,我們可以把檔案資料傳遞到另一個檔案。通常被稱為管道(大概是因為我們看不到除了兩端的管子裡面,當然,它也是不透明的),我們可以通過使用流方法實現。讓我們先寫一個指令碼從一個檔案傳到另一個檔案。這樣我們可以測量記憶體的佔用情況:

不出所料,這個指令碼使用更多的記憶體來進行文字檔案複製。這是因為它讀取(和保留)檔案內容在記憶體中,直到它被寫到新檔案中。對於小檔案這種方法也許沒問題。當為更大的檔案時,就捉襟見肘了…

讓我們嘗試用流(管道)來傳送一個檔案到另一個:

這段程式碼稍微有點陌生。我們開啟了兩檔案的控制程式碼,第一個是隻讀模式,第二個是隻寫模式,然後我們從第一個複製到第二個中。最後我們關閉了它,也許使你驚訝,記憶體只佔用了393KB

這似乎很熟悉。像程式碼生成器在儲存它讀到的每一行程式碼?那是因為第二個引數fgets規定了每行讀多少個位元組(預設值是-1或者直到下一行為止)。

第三個引數stream_copy_to_stream和第二個引數是同一類引數(預設值相同),stream_copy_to_stream一次從一個資料流裡讀一行,同時寫到另一個資料流裡。它跳過生成器只有一個值的部分(因為我們不需要這個值)。

這篇文章對於我們來說可能是沒用的,所以讓我們想一些我們可能會用到的例子。假設我們想從我們的CDN中輸出一張圖片,作為一種重定向的路由應用程式。我們可以參照下邊的程式碼來實現它:

設想一下,一個路由應用程式讓我們看到這段程式碼。但是,我們想從CDN獲取一個檔案,而不是從本地的檔案系統獲取。我們可以用一些其他的東西來更好的替換file_get_contents(就像Guzzle),即使在引擎內部它們幾乎是一樣的。

圖片的記憶體大概有581K。現在,讓我們來試試這個

記憶體使用明顯變少(大概400K),但是結果是一樣的。如果我們不關注記憶體資訊,我們依舊可以用標準模式輸出。實際上,PHP提供了一個簡單的方式來完成:

其它流

還有其它一些流,我們可以通過管道來寫入和讀取(或只讀取/只寫入):

  • php://stdin (只讀)
  • php://stderr (只寫, 如php://stdout)
  • php://input (只讀) 這使我們能夠訪問原始請求體
  • php://output (只寫) 讓我們寫入輸出緩衝區
  • php://memory 和 php://temp (讀-寫) 是我們可以臨時儲存資料的地方。 不同之處在於一旦它變得足夠大 php://temp 會將資料儲存在檔案系統中,而 php://memory 將一直持儲存在記憶體中直到資源耗盡。

過濾器

還有一個我們可以在stream上使用的技巧,稱為過濾器。它們是一種中間的步驟,提供對stream資料的一些控制,但不把他們暴露給我們。想象一下,我們會使用Zip副檔名來壓縮我們的shakespeare.txt檔案。

這是一小段整潔的程式碼,但它測量記憶體佔用在10.75MB左右。使用過濾器的話,我們可以減少記憶體:

此處,我們可以看到名為php://filter/zlib.deflate的過濾器,它讀取並壓縮資源的內容。我們可以在之後將壓縮資料匯出到另一個檔案中。這僅使用了896KB.

我知道這是不一樣的格式,或者製作zip存檔是有好處的。你不得不懷疑:如果你可以選擇不同的格式並節省約12倍的記憶體,為什麼不選呢?

為了解壓此資料,我們可以通過執行另一個zlib filter將壓縮後的資料還原:

Streams have been extensively covered in Stream在“理解PHP中的流”和“U高效使用PHP中的流”中已經被全面介紹了。如果你喜歡一個完全不同的視角,可以閱讀一下。

定製流

fopen和file_get_contents有它們自己的一套預設選項,但是這些都是完全可定製的。為了定義它們,我們需要建立一個新的流上下文:

在這個例子中,我們正在嘗試向API發出POST請求。 API終端是安全的,但我們仍然需要使用http上下文屬性(用於http和https)。我們設定一些訊息頭引數,並開啟一個檔案控制程式碼到API。由於上下文處理寫操作,我們可以將控制程式碼開啟為只讀。

檢視文件瞭解更多

制定自定義協議和過濾器

在我們結束之前,讓我們談談制定自定義協議。 如果你檢視文件,你可以找到一個示例類來實現:

我們不打算實現其中的一個,因為我認為它應該有自己的教程。這裡有很多工作需要完成。但是一旦這個工作完成,我們可以很容易地註冊我們的流包裝:

同樣,也可以建立自定義流過濾器。該文件有一個示例過濾器類:

這可以很容易地註冊:

突出顯示名稱需要匹配新的篩選器類的filtername屬性。也可以在php://filter/highligh-names/resource=story.txt字串中使用自定義過濾器。定義過濾器比定義協議要容易得多。因為協議需要處理目錄操作,而過濾器只需處理每個資料塊。

如果你有這個想法,我強烈建議你嘗試建立自定義協議和過濾器。如果你可以將過濾器應用於stream_copy_to_streamoperations,那麼即使在使用大容量檔案時,你的應用程式也可以在沒有記憶體的情況下使用。想象一下,編寫一個調整大小的影象過濾器或加密的應用程式過濾器。

總結

雖然這不是我們經常遇到的問題,但在處理大檔案時很容易搞砸。在非同步應用程式中,當我們不注意小心使用記憶體的話,很容易導致整個伺服器當機。
本教程希望向你介紹一些新的想法(或者讓你重新認識他們),以便你可以更多地考慮如何高效地讀取和寫入大型檔案。當我們開始熟悉流程和生成器,並停止使用像file_get_contents這樣的函式時,我們的應用程式中就會減少錯誤的類別,這看起來是很好。

相關文章