Web 项目中,页面缓存是提升性能的常用手段。我原来使用的是一个简单的文件缓存插件【帝国cms PHP缓存网页代码 加速访问】,它把所有页面一视同仁地缓存下来。但实际业务中,有些页面(如首页)每天访问量很大,而另一些页面(如冷门文章)可能只有偶尔几次访问。
这种“一刀切”的缓存策略导致大量低价值缓存文件堆积,占用了磁盘空间和 inode 资源。我需要的是一套智能缓存清理机制:能够根据页面访问热度决定保留还是删除,并且保证缓存总数不超过预设上限。
二、整体设计思路
2.1 缓存存储
-
使用文件缓存,利用 PHP 的
ob_start捕捉输出,压缩后存储。 -
文件按 URL 的 MD5 值分散存储,避免单个目录文件过多。
-
目录结构:
cacheFile/{md5最后1位}/{md5最后2位}/{md5}.php,共 16 + 256 个目录。
2.2 访问计数
-
每次命中缓存时,在 MySQL 中记录该页面的访问次数。
-
计数周期为 7 天,超过 7 天且访问次数低于阈值(如 20 次)的缓存将被清理。
2.3 清理策略(三阶段)
-
阶段一:删除超过保留天数且访问次数不足的缓存(直接操作数据库和文件)。
-
阶段二:若总缓存数超过上限(如 100 万),按缓存时间从旧到新删除,直到数量达标。
-
阶段三:扫描文件系统,修复数据库与文件系统的不一致(新增缺失记录,删除多余记录),并控制扫描速度以免影响服务器。
三、数据库设计
只需一张表,字段极少:
CREATE TABLE cache_stat (
url_md5 CHAR(32) NOT NULL COMMENT 'MD5值',
access_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '访问次数',
cache_time INT UNSIGNED NOT NULL COMMENT '文件修改时间戳',
PRIMARY KEY (url_md5),
INDEX idx_cache_time (cache_time),
INDEX idx_access_count (access_count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-
cache_time存储文件的mtime,用于判断超时。 -
索引加速阶段一和阶段二的查询。
四、核心代码实现
4.1 缓存主文件 cache.php
该文件放在项目入口(如帝国 CMS 的 e/ 目录),负责拦截输出、生成缓存和更新计数。
<?php
/******************
* 缓存主逻辑(新目录结构)
* 所有文件统一放在 cacheFile/ 下,按 md5 后 1/2 位分两层
******************/
if (1 == 1) {
// ========== 可配置参数 ==========
define('CACHE_ROOT', dirname(dirname(__FILE__)) . DIRECTORY_SEPARATOR . 'cacheFile');
define('CACHE_FIX', '.php');
define('CACHE_COMPRESS_LEVEL', 6);
// 数据库配置(请按实际情况修改)
define('DB_HOST', 'localhost');
define('DB_NAME', 'cache_music');
define('DB_USER', 'cache_music');
define('DB_PASS', 'eb7rydwKyaxRW7Gs');
date_default_timezone_set("Asia/Shanghai");
// 创建缓存根目录
if (!file_exists(CACHE_ROOT)) {
mkdir(CACHE_ROOT, 0755, true);
chmod(CACHE_ROOT, 0755);
}
// ========== 数据库操作函数 ==========
/**
* 获取 PDO 连接(单例)
*/
function getDb() {
static $pdo = null;
if ($pdo === null) {
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
error_log("DB connect failed: " . $e->getMessage());
return null;
}
}
return $pdo;
}
/**
* 增加访问计数(命中缓存时调用)
* 增加 $mtime 参数,确保记录缺失时能存入正确的文件修改时间
*/
function incrementAccessCount($md5, $mtime) {
$pdo = getDb();
if (!$pdo) return;
$sql = "INSERT INTO cache_stat (url_md5, access_count, cache_time)
VALUES (:md5, 1, :mtime)
ON DUPLICATE KEY UPDATE access_count = access_count + 1";
try {
$stmt = $pdo->prepare($sql);
$stmt->execute([':md5' => $md5, ':mtime' => $mtime]);
} catch (PDOException $e) {
error_log("Increment failed: " . $e->getMessage());
}
}
/**
* 插入新缓存记录(生成缓存时),包含文件修改时间
*/
function insertCacheRecord($md5, $mtime) {
$pdo = getDb();
if (!$pdo) return;
$sql = "INSERT INTO cache_stat (url_md5, access_count, cache_time)
VALUES (:md5, 0, :mtime)
ON DUPLICATE KEY UPDATE cache_time = :mtime, access_count = 0";
try {
$stmt = $pdo->prepare($sql);
$stmt->execute([':md5' => $md5, ':mtime' => $mtime]);
} catch (PDOException $e) {
error_log("Insert record failed: " . $e->getMessage());
}
}
// ========== 缓存路径(新结构) ==========
/**
* 获取缓存文件路径
* 目录规则:cacheFile/{md5最后1位}/{md5最后2位}/{md5}.php
*/
function getCacheFilePaths() {
static $paths = null;
if ($paths !== null) return $paths;
$cacheKey = md5($_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
$last1 = substr($cacheKey, -1); // 第一层目录(16个可能值)
$last2 = substr($cacheKey, -2); // 第二层目录(16*16=256个可能值)
$cacheDir = CACHE_ROOT . DIRECTORY_SEPARATOR . $last1 . DIRECTORY_SEPARATOR . $last2;
$mainFile = $cacheDir . DIRECTORY_SEPARATOR . $cacheKey . CACHE_FIX;
$paths = [
'dir' => $cacheDir,
'main' => $mainFile,
'cacheKey' => $cacheKey,
];
return $paths;
}
/**
* 生成伪发布时间(近三天内,同页面同一天不变)
*/
function getTime181Value() {
$today = date('Y-m-d');
$url = $_SERVER['REQUEST_URI'];
$seed = $url . $today;
$hash = md5($seed);
$hex = substr($hash, 0, 8);
$offset = hexdec($hex) % 172800; // 0~2天的秒数
$base = strtotime($today . ' 00:00:00');
$fakeTime = $base - $offset;
return date('Y-m-d H:i:s', $fakeTime);
}
/**
* 读取主缓存内容(解压)
*/
function readMainCache($mainFile) {
if (!file_exists($mainFile)) {
return false;
}
$compressed = file_get_contents($mainFile);
if ($compressed === false) {
return false;
}
$decompressed = @gzuncompress($compressed);
return ($decompressed !== false) ? $decompressed : false;
}
/**
* 原子写入缓存文件(先写临时文件,再 rename)
*/
function atomicWriteCache($file, $data) {
$dir = dirname($file);
if (!file_exists($dir)) {
if (!mkdir($dir, 0755, true)) {
error_log("Cache mkdir failed: $dir");
return false;
}
chmod($dir, 0755);
}
$tmpFile = $file . '.tmp.' . uniqid();
if (file_put_contents($tmpFile, $data, LOCK_EX) === false) {
return false;
}
if (!rename($tmpFile, $file)) {
unlink($tmpFile);
return false;
}
chmod($file, 0644);
return true;
}
// ========== 缓存命中处理 ==========
$paths = getCacheFilePaths();
$mainFile = $paths['main'];
$cacheKey = $paths['cacheKey'];
if (file_exists($mainFile)) {
$content = readMainCache($mainFile);
if ($content !== false) {
// 增加访问计数,传入文件修改时间
$mtime = filemtime($mainFile);
if ($mtime !== false) {
incrementAccessCount($cacheKey, $mtime);
} else {
// 获取失败则用当前时间(降级)
incrementAccessCount($cacheKey, time());
}
// 输出替换占位符
$output = str_replace("{time181}", getTime181Value(), $content);
echo $output;
exit;
}
// 损坏文件删除
@unlink($mainFile);
}
// ========== 缓存未命中,生成新缓存 ==========
if (!file_exists($paths['dir'])) {
mkdir($paths['dir'], 0755, true);
chmod($paths['dir'], 0755);
}
function AutoCache($contents) {
global $paths;
if (http_response_code() === 200 && !empty($contents)) {
$compressed = gzcompress($contents, CACHE_COMPRESS_LEVEL);
if ($compressed !== false) {
if (atomicWriteCache($paths['main'], $compressed)) {
// 获取刚写入文件的修改时间
$mtime = filemtime($paths['main']);
if ($mtime !== false) {
insertCacheRecord($paths['cacheKey'], $mtime);
}
} else {
error_log("Cache write failed for " . $paths['main']);
}
} else {
error_log("Cache gzcompress failed for " . $paths['main']);
}
}
return str_replace("{time181}", getTime181Value(), $contents);
}
ob_start('AutoCache');
}
?>
4.2 清理脚本 cache_cleanup.php
独立 CLI 脚本,建议每天凌晨定时运行。
#!/usr/bin/env php
<?php
/**
* 缓存清理脚本(新目录结构)
* 阶段1:删除超时且访问不足的记录及文件
* 阶段2:若总记录数超限,删除最旧的记录及文件
* 阶段3:扫描文件系统修复不一致(控制速度)
*/
// ========== 配置 ==========
define('CACHE_ROOT', dirname(__FILE__) . DIRECTORY_SEPARATOR . 'cacheFile');
define('CACHE_FIX', '.php');
define('DB_HOST', 'localhost');
define('DB_NAME', 'cache_music');
define('DB_USER', 'cache_music');
define('DB_PASS', 'eb7rydwKyaxRW7Gs');
// 清理策略参数
define('RETAIN_DAYS', 7); // 保留天数
define('MIN_ACCESS', 20); // 最低访问次数
define('MAX_FILE_COUNT', 1000000); // 最大文件总数
// 阶段3控制
define('BATCH_SIZE', 1000); // 每批处理文件数
define('SLEEP_USEC', 500000); // 每批后暂停 0.5 秒
// ========== 数据库函数 ==========
function getDb() {
static $pdo = null;
if ($pdo === null) {
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
echo "DB connect failed: " . $e->getMessage() . "\n";
exit(1);
}
}
return $pdo;
}
/**
* 根据 md5 构造缓存文件路径(与 cache.php 规则完全一致)
* 规则:cacheFile/{md5最后1位}/{md5最后2位}/{md5}.php
*/
function getCacheFilePath($md5) {
$last1 = substr($md5, -1);
$last2 = substr($md5, -2);
return CACHE_ROOT . DIRECTORY_SEPARATOR . $last1 . DIRECTORY_SEPARATOR . $last2 . DIRECTORY_SEPARATOR . $md5 . CACHE_FIX;
}
/**
* 删除物理文件
*/
function deleteFile($path) {
return file_exists($path) && @unlink($path);
}
/**
* 删除数据库记录
*/
function deleteRecord($md5) {
$pdo = getDb();
$stmt = $pdo->prepare("DELETE FROM cache_stat WHERE url_md5 = ?");
return $stmt->execute([$md5]);
}
/**
* 插入数据库记录(访问次数为0)
*/
function insertRecord($md5, $mtime) {
$pdo = getDb();
$stmt = $pdo->prepare("INSERT IGNORE INTO cache_stat (url_md5, access_count, cache_time) VALUES (?, 0, ?)");
return $stmt->execute([$md5, $mtime]);
}
/**
* 统计总记录数
*/
function countRecords() {
return (int)getDb()->query("SELECT COUNT(*) FROM cache_stat")->fetchColumn();
}
// ========== 目录遍历辅助函数 ==========
/**
* 获取指定目录下的所有子目录(一级)
*/
function getSubDirs($dir) {
$result = [];
if (!is_dir($dir)) return $result;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) $result[] = $path;
}
return $result;
}
/**
* 获取目录下所有 .php 文件
*/
function getPhpFiles($dir) {
$files = [];
if (!is_dir($dir)) return $files;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_file($path) && substr($item, -4) === CACHE_FIX) {
$files[] = $path;
}
}
return $files;
}
/**
* 递归清理空目录(保留根目录)
*/
function cleanEmptyDirs($dir) {
if (!is_dir($dir)) return;
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) cleanEmptyDirs($path);
}
// 如果目录为空(只有 . 和 ..),且不是根目录,则删除
if ($dir !== CACHE_ROOT && count(scandir($dir)) == 2) {
@rmdir($dir);
}
}
// ========== 阶段1:删除超时且访问不足的记录 + 文件 ==========
function phase1() {
$pdo = getDb();
$threshold = time() - RETAIN_DAYS * 86400;
$sql = "SELECT url_md5 FROM cache_stat WHERE cache_time < ? AND access_count < ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$threshold, MIN_ACCESS]);
$md5List = $stmt->fetchAll(PDO::FETCH_COLUMN);
$count = 0;
foreach ($md5List as $md5) {
$file = getCacheFilePath($md5);
deleteFile($file);
deleteRecord($md5);
$count++;
}
echo "阶段1删除记录与文件数: $count\n";
return $count;
}
// ========== 阶段2:总数超限,删除最旧记录 + 文件 ==========
function phase2() {
$total = countRecords();
if ($total <= MAX_FILE_COUNT) {
echo "阶段2:未超限,无需删除\n";
return 0;
}
$need = $total - MAX_FILE_COUNT;
$pdo = getDb();
$sql = "SELECT url_md5 FROM cache_stat ORDER BY cache_time ASC LIMIT ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$need]);
$md5List = $stmt->fetchAll(PDO::FETCH_COLUMN);
$count = 0;
foreach ($md5List as $md5) {
$file = getCacheFilePath($md5);
deleteFile($file);
deleteRecord($md5);
$count++;
}
echo "阶段2删除记录与文件数: $count\n";
return $count;
}
// ========== 阶段3:扫描文件系统,修复不一致(控制速度) ==========
function phase3() {
$pdo = getDb();
// 获取所有数据库 md5
$dbMd5s = $pdo->query("SELECT url_md5 FROM cache_stat")->fetchAll(PDO::FETCH_COLUMN);
$dbMap = array_flip($dbMd5s);
$hexChars = ['0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'];
$processed = 0;
$added = 0;
$deletedFiles = 0;
$deletedRecords = 0;
foreach ($hexChars as $l1) {
$dir1 = CACHE_ROOT . DIRECTORY_SEPARATOR . $l1;
if (!is_dir($dir1)) continue;
foreach ($hexChars as $l2) {
// 二级目录名为 $l2 . $l1,即 md5 后两位
$dir2 = $dir1 . DIRECTORY_SEPARATOR . $l2 . $l1;
if (!is_dir($dir2)) continue;
$files = getPhpFiles($dir2);
foreach ($files as $file) {
$md5 = basename($file, CACHE_FIX);
$mtime = filemtime($file);
if ($mtime === false) {
// 无法获取时间,若记录不存在则删除文件
if (!isset($dbMap[$md5])) {
deleteFile($file);
$deletedFiles++;
}
continue;
}
if (isset($dbMap[$md5])) {
unset($dbMap[$md5]); // 已匹配
} else {
// 文件存在但无记录
if ($mtime < time() - RETAIN_DAYS * 86400) {
deleteFile($file);
$deletedFiles++;
} else {
insertRecord($md5, $mtime);
$added++;
}
}
$processed++;
if ($processed % BATCH_SIZE == 0) {
// 使用双引号字符串直接解析变量,避免拼接时漏写
echo "阶段3进度:已处理 {$processed} 个文件,新增记录 {$added},删除文件 {$deletedFiles}\n";
usleep(SLEEP_USEC);
}
}
}
}
// 遍历结束后,$dbMap 中剩下的 md5 是记录存在但文件缺失的
foreach (array_keys($dbMap) as $md5) {
deleteRecord($md5);
$deletedRecords++;
$processed++;
if ($processed % BATCH_SIZE == 0) {
echo "阶段3进度:已处理 {$processed} 个文件,删除记录 {$deletedRecords}\n";
usleep(SLEEP_USEC);
}
}
echo "阶段3统计:处理 {$processed} 个文件,新增记录 {$added},删除文件 {$deletedFiles},删除记录 {$deletedRecords}\n";
return ['added' => $added, 'deleted_files' => $deletedFiles, 'deleted_records' => $deletedRecords];
}
// ========== 主流程 ==========
function cleanup() {
echo "[" . date('Y-m-d H:i:s') . "] 开始清理...\n";
phase1();
phase2();
phase3();
cleanEmptyDirs(CACHE_ROOT);
echo "[" . date('Y-m-d H:i:s') . "] 清理完成。\n";
}
// ========== 脚本入口 ==========
if (php_sapi_name() === 'cli') {
ob_implicit_flush(true);
if (ob_get_level()) ob_end_flush();
cleanup();
} else {
header('Content-Type: text/plain; charset=utf-8');
echo "仅允许 CLI 运行。\n";
exit(1);
}
?>
五、部署与定时任务
-
将
cache.php放置到项目合适位置,并确保它在所有输出之前被包含。 -
执行建表 SQL。
-
将
cache_cleanup.php放到服务器,赋予执行权限。 -
添加 crontab 任务(每天凌晨 4 点执行):
cd /data/www/wwwroot/123.com && /usr/bin/php cache_cleanup.php
六、性能优化与注意事项
-
目录分散:利用 MD5 后两位,将文件均匀散列到 256 个二级目录,单目录文件数可控(总文件数/256)。
-
数据库索引:
cache_time和access_count索引保证了阶段一和阶段二的查询效率。 -
阶段三速度控制:
BATCH_SIZE和SLEEP_USEC可调,避免 I/O 突增。 -
内存使用:阶段三将全部 MD5 加载到
$dbMap,100 万条记录约占用 60-80 MB,需确保 PHP 内存限制足够。 -
异常处理:所有数据库操作都捕获异常并记录日志,不会导致脚本中断。
七、总结
这套缓存系统结合了文件缓存的性能和 MySQL 计数的灵活性,实现了基于热度的智能清理。三阶段清理策略既能有效淘汰冷门缓存,又能控制总容量,并提供了完善的修复机制。
经过改造,缓存目录不再包含无用的 pageType 层级,路径规则统一,清理脚本的复杂度大幅降低。在实际运行中,即使文件数达到百万级别,阶段一和阶段二也能在几秒内完成,阶段三在低峰期匀速扫描,对服务器压力极小。
如果你也面临类似的缓存管理困扰,不妨参考这个方案,根据自身需求调整阈值参数即可。










![表情[aoman]-红穆笔记](https://www.4s5.cn/wp-content/themes/zibll/img/smilies/aoman.gif)



暂无评论内容