專案若不想引入 Symfony Cache、Laravel Cache 等重型庫,一個 < 100 行的檔案快取類即可滿足 90% 場景。本文實作符合 PSR-16 Simple Cache 介面的 `FileCache`,支援 TTL、標籤索引、原子寫入、鎖機制、GC 清理,並提供 Redis 遷移無縫切換介面。
<?php
namespace Psr\SimpleCache;
interface CacheInterface {
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool;
public function delete(string $key): bool;
public function clear(): bool;
public function getMultiple(iterable $keys, mixed $default = null): iterable;
public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool;
public function deleteMultiple(iterable $keys): bool;
public function has(string $key): bool;
}
?>
<?php
declare(strict_types=1);
namespace App\Cache;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
class FileCache implements CacheInterface {
private string $dir;
private string $tagIndexFile;
private int $defaultTtl;
private array $tagIndex = []; // tag => [key1, key2...]
public function __construct(string $cacheDir, int $defaultTtl = 3600) {
$this->dir = rtrim($cacheDir, '/\\').'/';
$this->tagIndexFile = $this->dir . '.tags.index.php';
$this->defaultTtl = $defaultTtl;
if (!is_dir($this->dir)) mkdir($this->dir, 0755, true);
$this->loadTagIndex();
}
private function loadTagIndex(): void {
if (is_file($this->tagIndexFile)) {
$this->tagIndex = include $this->tagIndexFile;
if (!is_array($this->tagIndex)) $this->tagIndex = [];
}
}
private function saveTagIndex(): void {
$tmp = $this->tagIndexFile . '.tmp';
file_put_contents($tmp, 'tagIndex, true) . ';');
rename($tmp, $this->tagIndexFile);
}
private function path(string $key): string {
$safe = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $key);
return $this->dir . $safe . '.cache.php';
}
private function write(string $path, mixed $value, int $expire): bool {
$data = [
'expire' => $expire,
'value' => $value,
'tags' => [], // 由 setWithTags 填入
];
$content = 'path($key);
if (!is_file($path)) return $default;
$data = include $path;
if (!is_array($data) || !isset($data['expire'], $data['value'])) return $default;
if ($data['expire'] > 0 && $data['expire'] < time()) {
$this->delete($key);
return $default;
}
return $data['value'];
}
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool {
$expire = $this->resolveTtl($ttl);
return $this->write($this->path($key), $value, $expire);
}
public function setWithTags(string $key, mixed $value, iterable $tags, null|int|\DateInterval $ttl = null): bool {
$expire = $this->resolveTtl($ttl);
$path = $this->path($key);
$data = ['expire' => $expire, 'value' => $value, 'tags' => []];
foreach ($tags as $tag) {
$tag = (string)$tag;
$data['tags'][] = $tag;
$this->tagIndex[$tag][] = $key;
}
$content = 'saveTagIndex();
return true;
}
public function delete(string $key): bool {
$path = $this->path($key);
if (!is_file($path)) return false;
$data = include $path;
if (isset($data['tags']) && is_array($data['tags'])) {
foreach ($data['tags'] as $tag) {
$this->tagIndex[$tag] = array_filter($this->tagIndex[$tag] ?? [], fn($k) => $k !== $key);
}
$this->saveTagIndex();
}
return @unlink($path);
}
public function clearByTag(string $tag): int {
$keys = $this->tagIndex[$tag] ?? [];
$count = 0;
foreach ($keys as $key) {
if ($this->delete($key)) $count++;
}
unset($this->tagIndex[$tag]);
$this->saveTagIndex();
return $count;
}
public function clear(): bool {
$files = glob($this->dir . '*.cache.php');
$success = true;
foreach ($files as $f) $success &= @unlink($f);
$this->tagIndex = [];
$this->saveTagIndex();
return $success;
}
public function getMultiple(iterable $keys, mixed $default = null): iterable {
$result = [];
foreach ($keys as $key) $result[$key] = $this->get($key, $default);
return $result;
}
public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool {
$ok = true;
foreach ($values as $k => $v) $ok &= $this->set($k, $v, $ttl);
return $ok;
}
public function deleteMultiple(iterable $keys): bool {
$ok = true;
foreach ($keys as $k) $ok &= $this->delete($k);
return $ok;
}
public function has(string $key): bool {
return $this->get($key, new class {}) !== new class {};
}
private function resolveTtl(null|int|\DateInterval $ttl): int {
if ($ttl === null) return $this->defaultTtl;
if ($ttl instanceof \DateInterval) return time() + (int)$ttl->format('%s');
return $ttl > 0 ? time() + $ttl : 0; // 0 = 永不過期
}
/** 執行垃圾回收 (建議 cron 定時呼叫) */
public function gc(int $maxFiles = 1000): int {
$files = glob($this->dir . '*.cache.php');
$deleted = 0;
foreach (array_slice($files, 0, $maxFiles) as $f) {
$data = @include $f;
if (isset($data['expire']) && $data['expire'] > 0 && $data['expire'] < time()) {
@unlink($f);
$deleted++;
}
}
return $deleted;
}
}
?>
<?php
$cache = new \App\Cache\FileCache(__DIR__.'/../runtime/cache', 3600);
// 基本
$cache->set('config:site', ['name' => 'MySite', 'theme' => 'dark'], 7200);
$config = $cache->get('config:site');
// 標籤
$cache->setWithTags('article:100', $article, ['article_100', 'cat_tech', 'author_5'], 3600);
$cache->clearByTag('cat_tech'); // 分類變更時清除所有相關文章快取
// 批次
$cache->setMultiple(['a'=>1, 'b'=>2, 'c'=>3], 300);
print_r($cache->getMultiple(['a','b','c','d'], 'missing'));
// GC
$cache->gc(500); // 每次最多檢查 500 個檔案
?>
| 優化 | 做法 | 效果 |
|---|---|---|
| OPcache | 快取檔案為純 PHP 陣列,OPcache 直接編譯快取 | 讀取速度接近原生陣列 |
| tmpfs 掛載 | `mount -t tmpfs -o size=512M tmpfs /var/www/runtime/cache` | I/O 完全記憶體化 |
| igbinary 序列化 | 改用 `igbinary_serialize`/`unserialize`,體積減 30-50% | 磁碟/記憶體雙省 |
| 分層快取 | 本地 FileCache (熱資料) + Redis (共享/大資料) | 單機極速、叢集一致 |
實作 `Psr\SimpleCache\CacheInterface` 介面即可無縫替換。生產環境建議使用 symfony/cache (支援 PSR-16/PSR-6、標籤、鎖、早期過期、多後端)。