頁面級檔案快取是 PHP 提升回應速度、降低資料庫負載的經典技術。本文實作一個生產就緒的快取類別,支援 GET 請求自動快取、過期時間控制、目錄分散儲存、過期檔案自動清理、POST 請求自動失效等功能。
<?php
declare(strict_types=1);
class FileCache {
private string $cacheRoot;
private int $ttl;
private string $extension;
private string $cacheFile;
private bool $enabled;
public function __construct(
string $cacheRoot = '/var/www/cache',
int $ttl = 1800,
string $extension = '.html'
) {
$this->cacheRoot = rtrim($cacheRoot, '/');
$this->ttl = $ttl;
$this->extension = $extension;
$this->enabled = $_SERVER['REQUEST_METHOD'] === 'GET';
if ($this->enabled) {
$this->initCachePath();
}
}
private function initCachePath(): void {
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$key = md5($uri);
$subDir = $this->cacheRoot . '/' . $key[0]; // 一級分散
$this->cacheFile = $subDir . '/' . $key . $this->extension;
// 建立目錄
if (!is_dir($this->cacheRoot)) {
mkdir($this->cacheRoot, 0755, true);
}
if (!is_dir($subDir)) {
mkdir($subDir, 0755, true);
}
}
/**
* 嘗試讀取快取,命中則直接輸出並結束腳本
*/
public function tryServe(): void {
if (!$this->enabled) return;
if (is_file($this->cacheFile) && (time() - filemtime($this->cacheFile) < $this->ttl)) {
// 快取命中
readfile($this->cacheFile);
exit;
}
// 未命中或過期:啟用輸出緩衝,腳本結束時自動寫入
ob_start([$this, 'writeCache']);
}
/**
* 輸出緩衝回呼:將內容寫入快取檔案
*/
public function writeCache(string $content): string {
if (!$this->enabled) return $content;
$tmpFile = $this->cacheFile . '.tmp';
$bytes = @file_put_contents($tmpFile, $content, LOCK_EX);
if ($bytes !== false) {
@rename($tmpFile, $this->cacheFile); // 原子替換
@chmod($this->cacheFile, 0644);
$this->cleanExpired(); // 隨機觸發清理
}
return $content;
}
/**
* 清理過期快取檔案
*/
private function cleanExpired(): void {
// 簡單機率觸發,避免每次請求掃描
if (mt_rand(1, 100) > 5) return; // 5% 機率
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->cacheRoot, FilesystemIterator::SKIP_DOTS)
);
$now = time();
foreach ($iterator as $file) {
if ($file->isFile() && $now - $file->getMTime() > $this->ttl) {
@unlink($file->getPathname());
}
}
// 清理空目錄
$this->removeEmptyDirs($this->cacheRoot);
}
private function removeEmptyDirs(string $dir): void {
$items = scandir($dir);
if (count($items) <= 2) { // 僅 . ..
@rmdir($dir);
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
if (is_dir($path)) {
$this->removeEmptyDirs($path);
}
}
}
/**
* 手動清除特定 URI 快取
*/
public function purge(string $uri): bool {
$key = md5($uri);
$file = $this->cacheRoot . '/' . $key[0] . '/' . $key . $this->extension;
return is_file($file) && @unlink($file);
}
/**
* 手動清除所有快取
*/
public function purgeAll(): void {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->cacheRoot, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) @unlink($file->getPathname());
else @rmdir($file->getPathname());
}
}
}
<?php
require_once __DIR__ . '/FileCache.php';
$cache = new FileCache(
cacheRoot: __DIR__ . '/../cache', // 建議放在非公開目錄
ttl: 1800, // 30 分鐘
extension: '.cache.html'
);
$cache->tryServe(); // 命中則直接 exit
// ... 後續正常業務邏輯、資料庫查詢、模板渲染 ...
?>
<?php
// Laravel 風格中介層
public function handle($request, Closure $next) {
if ($request->isMethod('GET')) {
$cache = new FileCache();
$cache->tryServe(); // 命中直接返回 Response
}
return $next($request);
}
| 特性 | 實作細節 | 效益 |
|---|---|---|
| 一級子目錄分散 | md5($uri)[0] 建立 16 個子目錄 | 避免單目錄檔案過多導致 inode 耗盡、ls 緩慢 |
| 原子寫入 | 寫入 .tmp → rename() 覆蓋 | 避免並發讀取到半寫入檔案 |
| POST 自動失效 | 建構子檢查 REQUEST_METHOD | 表單提交後立即看到最新資料 |
| 機率性清理 | mt_rand(1,100) > 95 觸發 | 分攤清理成本,避免請求阻塞 |
| 輸出緩衝回呼 | ob_start([$this, 'writeCache']) | 零侵入式攔截輸出,不修改業務邏輯 |
| 非公開目錄 | cacheRoot 置於 public/ 之外 | 防止快取檔案被直接下載洩露資料 |
tag:user:123,更新用戶時掃描標籤清除。gzencode($content, 6) 寫入,readfile 配合 Content-Encoding: gzip 輸出。| 現象 | 原因 | 解決 |
|---|---|---|
| 快取不生效,每次都重新渲染 | 輸出前已有 echo/空白導致 ob_start 失效 | 確保 tryServe() 在任何輸出前呼叫 |
| 登入狀態被快取,不同用戶看到同內容 | URI 相同但內容因 Session 而異 | 快取鍵加入 Session ID:md5($uri . session_id()) 或改用片段快取 |
| 權限錯誤無法建立目錄/寫入 | PHP-FPM 執行使用者無權限 | chown www-data:www-data cache/、chmod 755 |
| 磁碟空間耗盡 | 清理機制失效、TTL 過長 | 降低 TTL、加入 cron 定時清理、監控磁碟使用率 |
結語:檔案快取類別以 ob_start 回呼實現零侵入式頁面快取,配合目錄分散、原子寫入、機率清理、POST 失效等生產級特性,可大幅降低資料庫壓力、提升首屏渲染速度。隨著流量增長,建議平滑遷移至 Redis 等分散式快取,介面設計保持相容即可無縫切換。