<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Opcache on Tarragon</title><link>https://tarrragon.github.io/blog/tags/opcache/</link><description>Recent content in Opcache on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 05 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/opcache/index.xml" rel="self" type="application/rss+xml"/><item><title>OPcache</title><link>https://tarrragon.github.io/blog/programming/php/knowledge-cards/opcache/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/knowledge-cards/opcache/</guid><description>&lt;p>OPcache 是 PHP 內建的擴充，把 PHP 檔案編譯好的 opcode（Zend 引擎執行的指令）存進共享記憶體，之後執行同一個檔案時跳過讀檔與編譯。它快取的是程式碼的編譯結果，不是資料。可先對照 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/php-fpm/" data-link-title="PHP-FPM（FastCGI Process Manager）" data-link-desc="在 Nginx 或容器環境看到 php-fpm、9000 埠、pool 設定或 502 Bad Gateway，要知道 PHP-FPM 負責什麼、哪些設定決定並發與記憶體時">PHP-FPM&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">Shared-nothing 請求模型&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>OPcache 是程序層的資源，效果取決於執行 PHP 的程序活多久：CGI 每個請求一個新程序，快取永遠命中不了；&lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/php-fpm/" data-link-title="PHP-FPM（FastCGI Process Manager）" data-link-desc="在 Nginx 或容器環境看到 php-fpm、9000 埠、pool 設定或 502 Bad Gateway，要知道 PHP-FPM 負責什麼、哪些設定決定並發與記憶體時">PHP-FPM&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/mod-php/" data-link-title="mod_php" data-link-desc="接手 Apache 加 mod_php 的舊站、評估要不要換成 PHP-FPM，或遇到 .htaccess 的 php_value 在新環境回 500 時">mod_php&lt;/a> 的父程序建立共享記憶體，所有 worker 共用同一份快取。PHP 8.5 起 OPcache 靜態編進 PHP 本體。它省掉的是編譯，框架每個請求的啟動流程它省不掉，那一段要靠常駐 worker 模型。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>opcache_get_status()&lt;/code> 的 &lt;code>hits&lt;/code>&lt;/strong>：CGI 下永遠是 0；PHP-FPM 下跨 worker 累加。&lt;/li>
&lt;li>&lt;strong>改了檔案頁面不變&lt;/strong>：&lt;code>opcache.validate_timestamps=0&lt;/code> 時 OPcache 不檢查檔案，要 reload PHP-FPM 才生效；預設的 &lt;code>validate_timestamps=1&lt;/code>、&lt;code>revalidate_freq=2&lt;/code> 下，改檔後最多約 2 秒生效。&lt;/li>
&lt;li>&lt;strong>開與關的差距&lt;/strong>：Laravel 13 骨架的健康檢查請求，開 OPcache 約 3.8 ms、關掉約 49.3 ms。&lt;/li>
&lt;li>&lt;strong>快取滿了不報錯&lt;/strong>：放不進去的檔案每次重新編譯，從外面看只是變慢；要讀 &lt;code>free_memory&lt;/code> 與 &lt;code>num_cached_scripts&lt;/code>。&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>部署要配合 &lt;code>validate_timestamps&lt;/code> 的設定：程式碼在映像檔裡的容器部署可以關掉檢查，覆寫檔案的部署要留著或在換完程式碼後 reload。&lt;code>memory_consumption&lt;/code> 與 &lt;code>max_accelerated_files&lt;/code> 依實際專案跑過主要請求後的統計調整。詳見 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache 與程序壽命&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>OPcache 是 PHP 內建的擴充，把 PHP 檔案編譯好的 opcode（Zend 引擎執行的指令）存進共享記憶體，之後執行同一個檔案時跳過讀檔與編譯。它快取的是程式碼的編譯結果，不是資料。可先對照 <a href="/blog/programming/php/knowledge-cards/php-fpm/" data-link-title="PHP-FPM（FastCGI Process Manager）" data-link-desc="在 Nginx 或容器環境看到 php-fpm、9000 埠、pool 設定或 502 Bad Gateway，要知道 PHP-FPM 負責什麼、哪些設定決定並發與記憶體時">PHP-FPM</a> 與 <a href="/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">Shared-nothing 請求模型</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>OPcache 是程序層的資源，效果取決於執行 PHP 的程序活多久：CGI 每個請求一個新程序，快取永遠命中不了；<a href="/blog/programming/php/knowledge-cards/php-fpm/" data-link-title="PHP-FPM（FastCGI Process Manager）" data-link-desc="在 Nginx 或容器環境看到 php-fpm、9000 埠、pool 設定或 502 Bad Gateway，要知道 PHP-FPM 負責什麼、哪些設定決定並發與記憶體時">PHP-FPM</a> 與 <a href="/blog/programming/php/knowledge-cards/mod-php/" data-link-title="mod_php" data-link-desc="接手 Apache 加 mod_php 的舊站、評估要不要換成 PHP-FPM，或遇到 .htaccess 的 php_value 在新環境回 500 時">mod_php</a> 的父程序建立共享記憶體，所有 worker 共用同一份快取。PHP 8.5 起 OPcache 靜態編進 PHP 本體。它省掉的是編譯，框架每個請求的啟動流程它省不掉，那一段要靠常駐 worker 模型。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong><code>opcache_get_status()</code> 的 <code>hits</code></strong>：CGI 下永遠是 0；PHP-FPM 下跨 worker 累加。</li>
<li><strong>改了檔案頁面不變</strong>：<code>opcache.validate_timestamps=0</code> 時 OPcache 不檢查檔案，要 reload PHP-FPM 才生效；預設的 <code>validate_timestamps=1</code>、<code>revalidate_freq=2</code> 下，改檔後最多約 2 秒生效。</li>
<li><strong>開與關的差距</strong>：Laravel 13 骨架的健康檢查請求，開 OPcache 約 3.8 ms、關掉約 49.3 ms。</li>
<li><strong>快取滿了不報錯</strong>：放不進去的檔案每次重新編譯，從外面看只是變慢；要讀 <code>free_memory</code> 與 <code>num_cached_scripts</code>。</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>部署要配合 <code>validate_timestamps</code> 的設定：程式碼在映像檔裡的容器部署可以關掉檢查，覆寫檔案的部署要留著或在換完程式碼後 reload。<code>memory_consumption</code> 與 <code>max_accelerated_files</code> 依實際專案跑過主要請求後的統計調整。詳見 <a href="/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache 與程序壽命</a>。</p>
]]></content:encoded></item><item><title>OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/opcache/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/opcache/</guid><description>&lt;p>這篇說明 OPcache 這個 PHP 內建的擴充：它快取什麼、快取存在哪裡、在不同執行模型下有沒有效果，以及部署新程式碼時要注意的設定。它接在 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI&lt;/a> 那組問題的第二個上：執行 PHP 的程序活多久。OPcache 是程序層的資源，程序活得越久，它重用得越多。文中的預設值與行為取自 &lt;code>php:8.5-fpm-alpine&lt;/code>。&lt;/p>
&lt;h2 id="opcache-快取的是編譯結果">OPcache 快取的是編譯結果&lt;/h2>
&lt;p>PHP 每次執行一個 &lt;code>.php&lt;/code> 檔，都要先把原始碼編譯成 opcode——Zend 引擎實際執行的指令——再執行它（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/opcache/" data-link-title="OPcache" data-link-desc="部署新版程式碼之後頁面還在跑舊版、評估 OPcache 的大小設定，或比較不同執行模型下 PHP 的效能差距時">OPcache&lt;/a>）。沒有快取時，每個請求的每個檔案都要重新讀檔、解析、編譯。&lt;/p>
&lt;p>OPcache 把編譯好的 opcode 存進一塊&lt;strong>共享記憶體&lt;/strong>，下一次要執行同一個檔案時，直接從共享記憶體拿編譯結果，跳過讀檔與編譯。它快取的是程式碼，不是資料：變數的值、查詢結果、請求之間的狀態都不在裡面，&lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing&lt;/a> 不受影響。PHP 5.5 起 OPcache 隨 PHP 一起發行；PHP 8.5 依〈Make OPcache required〉這份 RFC 把它靜態編進 PHP 本體，不必也不能再另外安裝，開關仍然由 &lt;code>opcache.enable&lt;/code> 控制。&lt;/p>
&lt;p>框架讓這件事的份量變得很大。Laravel 13 處理一個最簡單的健康檢查請求，&lt;code>get_included_files()&lt;/code> 回報這個請求載入了 484 個檔案——設定、服務提供者、路由、中介層、框架本身的類別。這 484 個檔案每個請求都要用到，所以有沒有快取它們的編譯結果，差距是每個請求都要重付一次的成本。&lt;/p>
&lt;p>在同一個 Laravel 容器（Nginx 加 PHP-FPM）上循序請求 &lt;code>/healthz&lt;/code>，只切換 OPcache 的開關：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>OPcache&lt;/th>
 &lt;th>每個請求的平均時間&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>開&lt;/td>
 &lt;td>3.8 ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>關&lt;/td>
 &lt;td>49.3 ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>相差十幾倍，而請求做的事情完全一樣。差出來的時間幾乎都花在編譯那 484 個檔案上。&lt;/p>
&lt;h2 id="快取效果與程序壽命的關係">快取效果與程序壽命的關係&lt;/h2>
&lt;p>OPcache 的共享記憶體在 PHP 啟動時建立，屬於那個程序（以及它 fork 出來的子程序）。程序結束，那塊記憶體就跟著釋放，快取也就消失。所以同一個 OPcache，在不同的執行模型下效果完全不同。&lt;/p>
&lt;p>&lt;strong>CGI：命中次數永遠是零。&lt;/strong> &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI&lt;/a> 每個請求啟動一個新程序。每個程序都建立自己的 OPcache、把這次用到的檔案編譯進去，然後隨請求結束一起消失。在 CGI 下印出 OPcache 的統計，&lt;code>cached_scripts&lt;/code> 每次都是 1（這次編譯進去了），&lt;code>hits&lt;/code> 每次都是 0（從來沒有被重用過）。OPcache 是開著的，只是沒有作用。&lt;/p>
&lt;p>&lt;strong>PHP-FPM：worker 共用同一份快取。&lt;/strong> &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM&lt;/a> 的 master 在啟動時建立 OPcache 的共享記憶體，再 fork 出 worker，worker 繼承了這塊記憶體。所以一個 pool 裡所有 worker 讀寫的是同一份快取。對兩個 worker 輪流處理的請求印出統計：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=3&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>PID 6 第一次執行時把腳本編譯進快取，PID 7 接下來直接命中——它自己沒有編譯過這個檔案，用的是 PID 6 編譯好的結果。命中次數跨 worker 累加。這帶出兩個推論：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>worker 被換掉不會清空快取。&lt;/strong> &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/#max_requests%e5%ae%9a%e6%9c%9f%e6%8f%9b%e6%8e%89-worker-%e4%bb%a5%e6%8e%a7%e5%88%b6%e8%a8%98%e6%86%b6%e9%ab%94" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">&lt;code>pm.max_requests&lt;/code>&lt;/a> 換掉的是 worker，快取住在 master 建立的共享記憶體裡，新 worker 一啟動就能用。要清空快取得重啟或 reload 整個 PHP-FPM。&lt;/li>
&lt;li>&lt;strong>同一台機器上的不同 pool 共用同一個 master 時，也共用這塊記憶體。&lt;/strong> 所以 &lt;code>memory_consumption&lt;/code> 要容得下所有 pool 的程式碼。&lt;/li>
&lt;/ul>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/mod-php/" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php&lt;/a> 的情況相同：Apache 的父程序載入 PHP 時建立共享記憶體，子程序繼承它。在 &lt;code>php:8.5-apache&lt;/code> 上連續請求五次，五個不同的子程序（PID 16 到 20）回報的命中次數依序是 0、1、2、3、4。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 OPcache 這個 PHP 內建的擴充：它快取什麼、快取存在哪裡、在不同執行模型下有沒有效果，以及部署新程式碼時要注意的設定。它接在 <a href="/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI</a> 那組問題的第二個上：執行 PHP 的程序活多久。OPcache 是程序層的資源，程序活得越久，它重用得越多。文中的預設值與行為取自 <code>php:8.5-fpm-alpine</code>。</p>
<h2 id="opcache-快取的是編譯結果">OPcache 快取的是編譯結果</h2>
<p>PHP 每次執行一個 <code>.php</code> 檔，都要先把原始碼編譯成 opcode——Zend 引擎實際執行的指令——再執行它（見 <a href="/blog/programming/php/knowledge-cards/opcache/" data-link-title="OPcache" data-link-desc="部署新版程式碼之後頁面還在跑舊版、評估 OPcache 的大小設定，或比較不同執行模型下 PHP 的效能差距時">OPcache</a>）。沒有快取時，每個請求的每個檔案都要重新讀檔、解析、編譯。</p>
<p>OPcache 把編譯好的 opcode 存進一塊<strong>共享記憶體</strong>，下一次要執行同一個檔案時，直接從共享記憶體拿編譯結果，跳過讀檔與編譯。它快取的是程式碼，不是資料：變數的值、查詢結果、請求之間的狀態都不在裡面，<a href="/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing</a> 不受影響。PHP 5.5 起 OPcache 隨 PHP 一起發行；PHP 8.5 依〈Make OPcache required〉這份 RFC 把它靜態編進 PHP 本體，不必也不能再另外安裝，開關仍然由 <code>opcache.enable</code> 控制。</p>
<p>框架讓這件事的份量變得很大。Laravel 13 處理一個最簡單的健康檢查請求，<code>get_included_files()</code> 回報這個請求載入了 484 個檔案——設定、服務提供者、路由、中介層、框架本身的類別。這 484 個檔案每個請求都要用到，所以有沒有快取它們的編譯結果，差距是每個請求都要重付一次的成本。</p>
<p>在同一個 Laravel 容器（Nginx 加 PHP-FPM）上循序請求 <code>/healthz</code>，只切換 OPcache 的開關：</p>
<table>
  <thead>
      <tr>
          <th>OPcache</th>
          <th>每個請求的平均時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開</td>
          <td>3.8 ms</td>
      </tr>
      <tr>
          <td>關</td>
          <td>49.3 ms</td>
      </tr>
  </tbody>
</table>
<p>相差十幾倍，而請求做的事情完全一樣。差出來的時間幾乎都花在編譯那 484 個檔案上。</p>
<h2 id="快取效果與程序壽命的關係">快取效果與程序壽命的關係</h2>
<p>OPcache 的共享記憶體在 PHP 啟動時建立，屬於那個程序（以及它 fork 出來的子程序）。程序結束，那塊記憶體就跟著釋放，快取也就消失。所以同一個 OPcache，在不同的執行模型下效果完全不同。</p>
<p><strong>CGI：命中次數永遠是零。</strong> <a href="/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI</a> 每個請求啟動一個新程序。每個程序都建立自己的 OPcache、把這次用到的檔案編譯進去，然後隨請求結束一起消失。在 CGI 下印出 OPcache 的統計，<code>cached_scripts</code> 每次都是 1（這次編譯進去了），<code>hits</code> 每次都是 0（從來沒有被重用過）。OPcache 是開著的，只是沒有作用。</p>
<p><strong>PHP-FPM：worker 共用同一份快取。</strong> <a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM</a> 的 master 在啟動時建立 OPcache 的共享記憶體，再 fork 出 worker，worker 繼承了這塊記憶體。所以一個 pool 裡所有 worker 讀寫的是同一份快取。對兩個 worker 輪流處理的請求印出統計：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=0
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=1
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=2
</span></span><span class="line"><span class="ln">4</span><span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=3</span></span></code></pre></div><p>PID 6 第一次執行時把腳本編譯進快取，PID 7 接下來直接命中——它自己沒有編譯過這個檔案，用的是 PID 6 編譯好的結果。命中次數跨 worker 累加。這帶出兩個推論：</p>
<ul>
<li><strong>worker 被換掉不會清空快取。</strong> <a href="/blog/programming/php/01-server-runtime/php-fpm/#max_requests%e5%ae%9a%e6%9c%9f%e6%8f%9b%e6%8e%89-worker-%e4%bb%a5%e6%8e%a7%e5%88%b6%e8%a8%98%e6%86%b6%e9%ab%94" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響"><code>pm.max_requests</code></a> 換掉的是 worker，快取住在 master 建立的共享記憶體裡，新 worker 一啟動就能用。要清空快取得重啟或 reload 整個 PHP-FPM。</li>
<li><strong>同一台機器上的不同 pool 共用同一個 master 時，也共用這塊記憶體。</strong> 所以 <code>memory_consumption</code> 要容得下所有 pool 的程式碼。</li>
</ul>
<p><a href="/blog/programming/php/01-server-runtime/mod-php/" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php</a> 的情況相同：Apache 的父程序載入 PHP 時建立共享記憶體，子程序繼承它。在 <code>php:8.5-apache</code> 上連續請求五次，五個不同的子程序（PID 16 到 20）回報的命中次數依序是 0、1、2、3、4。</p>
<h2 id="快取的大小">快取的大小</h2>
<p>OPcache 的共享記憶體大小固定，由兩個設定決定，<code>php:8.5-fpm-alpine</code> 的預設值是：</p>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>預設值</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>opcache.memory_consumption</code></td>
          <td>128</td>
          <td>共享記憶體的大小，單位 MB</td>
      </tr>
      <tr>
          <td><code>opcache.max_accelerated_files</code></td>
          <td>10000</td>
          <td>最多能快取幾個檔案</td>
      </tr>
  </tbody>
</table>
<p>Laravel 13 骨架處理過請求之後，OPcache 回報快取了 467 個腳本，用掉 22.0 MB，剩下 106.0 MB；另外 interned strings（PHP 把重複出現的字串只存一份的區塊）用掉 4.3 MB。一個空骨架用不到預設值的五分之一，但正式專案加上大量套件之後，<code>vendor/</code> 裡的檔案數可以到上萬個。</p>
<p>快取滿了時，OPcache 不會報錯讓請求失敗：放不進去的檔案就不被快取，每次重新編譯。檔案改過而留下的舊版本佔用的空間算是「浪費的記憶體」，剩餘空間不夠、而浪費的比例又超過 <code>opcache.max_wasted_percentage</code>（預設 5%）時，OPcache 會排定一次重啟，清空整份快取重新累積。兩種情形從外面看都只是請求變慢，所以要從 <code>opcache_get_status()</code> 讀：<code>free_memory</code> 接近零、或 <code>num_cached_scripts</code> 接近 <code>max_accelerated_files</code>，就是該調大的時候。手上的專案實際要多大，在部署環境跑過一輪主要的請求之後，讀這兩個值就知道。</p>
<h2 id="部署新程式碼時舊版還會執行多久">部署新程式碼時舊版還會執行多久</h2>
<p>OPcache 快取了編譯結果，所以檔案改了之後，PHP 什麼時候才去讀新版，由 <code>opcache.validate_timestamps</code> 與 <code>opcache.revalidate_freq</code> 決定。這是部署時最常撞到的問題。</p>
<p><code>opcache.validate_timestamps</code> 決定 OPcache 要不要檢查檔案有沒有改過。<code>opcache.revalidate_freq</code> 是檢查的間隔秒數。<code>php:8.5-fpm-alpine</code> 的預設值是 <code>validate_timestamps=On</code>、<code>revalidate_freq=2</code>。實測兩種設定下，改了檔案之後的行為：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl"># validate_timestamps=1, revalidate_freq=2（預設）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">改檔之前  version=2
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">改檔之後 t+0s  version=2
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">         t+1s  version=2
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">         t+3s  version=3     # 間隔過了，OPcache 檢查到檔案改了，重新編譯
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"># validate_timestamps=0
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">改檔之前  version=1
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">改檔之後  version=1          # 連續請求都還是舊版
</span></span><span class="line"><span class="ln">10</span><span class="cl">         version=1
</span></span><span class="line"><span class="ln">11</span><span class="cl">         version=1
</span></span><span class="line"><span class="ln">12</span><span class="cl">reload 之後 version=2        # kill -USR2 讓 PHP-FPM reload，快取清空才換成新版</span></span></code></pre></div><p>兩種設定各有用途：</p>
<ul>
<li><strong><code>validate_timestamps=1</code></strong>：改檔之後最多 <code>revalidate_freq</code> 秒就生效，代價是每隔這段時間 PHP 要對檔案做一次 <code>stat</code>。開發環境與「直接覆寫伺服器上的檔案」的部署方式適合這個。</li>
<li><strong><code>validate_timestamps=0</code></strong>：OPcache 完全不檢查檔案，省掉所有 <code>stat</code>；改了檔案永遠不會生效，部署流程必須在換完程式碼之後 reload 或重啟 PHP-FPM。程式碼包在容器映像檔裡的部署適合這個：映像檔裡的檔案不會在執行中被改，每次部署都是啟動新容器，新容器的快取在啟動時是空的。開發時用 bind mount 把主機上的原始碼掛進容器，雖然也是容器，檔案卻會被改，要算成上一種情形、留著 <code>validate_timestamps=1</code>；設成 0 的話，改了檔案頁面不會變，也沒有任何錯誤訊息。</li>
</ul>
<p><code>validate_timestamps=1</code> 下還有一個中間狀態要知道：部署是逐一覆寫檔案時，在 <code>revalidate_freq</code> 那幾秒內，有的檔案已經重新編譯成新版、有的還是舊版快取，同一個請求可能同時執行到新舊兩版的程式碼。把整個版本目錄準備好再一次切換（例如換一個符號連結），然後 reload，可以避開這個狀態。</p>
<h2 id="opcache-的-jit-與-preload">OPcache 的 JIT 與 preload</h2>
<p>OPcache 另外帶著 JIT 與 preload，兩者跟快取 opcode 相鄰，解決的是不同的問題：</p>
<ul>
<li><strong>JIT</strong>：把 opcode 進一步編譯成 CPU 的機器碼。它對計算密集的程式（數學運算、影像處理）有幫助，對多數時間在等資料庫與網路的 web 請求效果有限。<code>php:8.5-fpm-alpine</code> 預設 <code>opcache.jit=disable</code>。</li>
<li><strong>preload</strong>（<code>opcache.preload</code>）：PHP-FPM 啟動時先執行一支指定的腳本，把它載入的類別常駐在共享記憶體裡，之後每個請求不必再載入它們。被 preload 的檔案改了要重啟 PHP-FPM 才生效。</li>
</ul>
<h2 id="opcache-與執行模型的關係">OPcache 與執行模型的關係</h2>
<p>OPcache 的效果整個建立在程序壽命上：CGI 下沒有作用，常駐的 PHP-FPM worker 讓它重用到極致。但它只省掉「編譯」這一段。框架在每個請求開頭還要做另一件事——執行那 484 個檔案：建立服務容器、註冊服務、讀設定、建立路由表——這一段 OPcache 省不掉，因為 shared-nothing 要求每個請求從空白的狀態開始。</p>
<p>想連這一段也省掉，就要讓應用程式的狀態也跨請求保留，框架只啟動一次。那是 <a href="/blog/programming/php/01-server-runtime/long-running-workers/" data-link-title="常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態" data-link-desc="說明讓 PHP 應用程式只啟動一次、在記憶體裡連續處理請求的常駐 worker 模型：它省下的是每個請求重新啟動框架的成本，失去的是 shared-nothing 的隔離；以 FrankenPHP 的 worker 模式與 Laravel Octane 實測靜態屬性跨請求累積、記憶體隨請求數線性增長與 max-requests 重啟，以及寫給這個模型的程式要避開的寫法">常駐 worker 模型</a> 的做法，也是它要付出的代價的來源。</p>
]]></content:encoded></item></channel></rss>