PHP通过readfile()提供文件时的客户端缓存问题排查
问题
在Apache服务器上通过PHP脚本向已认证用户提供存储在web根目录外的PDF文件,认证通过后用readfile()传输文件。因为文件体积较大,希望客户端浏览器能本地缓存以节省带宽,已设置缓存响应头,但仅Safari生效,Firefox和Chrome均无效,求排查遗漏点。
代码与响应头
脚本代码
<?php if(!defined('BASE_URI')){ require_once('../includes/config.inc.php'); require_once('../includes/utils.inc.php'); trigger_error("unauthorized direct module access"); print_json_error("something went wrong, we are working on it..."); exit(); } require_once('../includes/utils.inc.php'); require_once(DB); $auth = new \Delight\Auth\Auth($conn); if (!$auth->isLoggedIn()) { trigger_error("NOT LOGGED IN"); print_json_error("NOT LOGGED IN"); exit; } if (!$auth->hasRole(\Delight\Auth\Role::SUPER_ADMIN)){ trigger_error("WRONG CREDENTIALS"); print_json_error("WRONG CREDENTIALS"); exit; } if(getenv('REQUEST_METHOD') == 'GET'){ if(!isset($_GET['mime']) || !isset($_GET['path'])){ trigger_error("wrong input: ".$_GET); print_json_error("wrong input"); exit(); } $filepath = '../restricted/'.$_GET['path']; $path = getcwd().$filepath; $pathdir = dirname($path); $checkPath = realpath(pathinfo($path)['dirname']); if(strpos($pathdir, $checkPath) !== 0 || strpos($pathdir, $checkPath) === false) { trigger_error("INVALID GET PATH: ".$checkPath." ".$pathdir); print_json_error("INVALID GET PATH"); exit; } if($_GET['mime'] == 'pdf'){ header('Content-type: application/pdf'); header("Content-type: application/octet-stream"); header("Content-Disposition: attachment; filename=$filepath"); $seconds_to_cache = 360000; $ts = gmdate("D, d M Y H:i:s", time() + $seconds_to_cache) . " GMT"; header("Expires: $ts"); header("Pragma: cache"); header("Cache-Control: max-age=$seconds_to_cache, immutable"); ob_end_flush(); readfile($filepath); exit(); } else { trigger_error("UNSUPPORTED FILE TYPE: ".$_GET['mime']); print_json_error("UNSUPPORTED FILE TYPE"); exit(); } } trigger_error("INVALID REQUEST"); print_json_error("INVALID REQUEST"); exit(); ?>
响应头信息
HTTP/2 200 OK content-type: application/octet-stream date: Thu, 28 Jul 2022 13:21:35 GMT server: Apache x-frame-options: sameorigin x-content-type-options: nosniff content-disposition: attachment; filename=[..] expires: Mon, 01 Aug 2022 17:21:35 GMT pragma: cache cache-control: max-age=360000, immutable X-Firefox-Spdy: h2
问题排查与解决办法
- Content-Type冲突导致缓存策略限制:脚本先后设置
application/pdf和application/octet-stream,最终响应头仅保留后者。Chrome和Firefox对octet-stream这类通用二进制流的缓存策略更严格,默认倾向于不缓存。需只保留application/pdf,让浏览器识别文件类型,触发正常缓存逻辑。 - 缺少缓存验证标识头:Chrome和Firefox需要
ETag或Last-Modified来确认文件是否变更,进而决定是否使用缓存。需添加这两个头,并处理浏览器发送的If-None-Match/If-Modified-Since请求,返回304状态码直接复用缓存。 - Content-Disposition的filename格式问题:当前filename使用相对路径
../restricted/xxx.pdf,路径字符可能干扰浏览器的缓存键识别,建议只保留纯文件名(如xxx.pdf)。 - 缓冲区处理不彻底:
ob_end_flush()可能未完全清理缓冲区,残留内容可能导致响应异常,影响缓存判断。改用ob_clean(); flush();确保缓冲区干净后再输出文件。
修改后的关键代码片段
if($_GET['mime'] == 'pdf'){ // 只保留正确的PDF类型标识 header('Content-type: application/pdf'); // 提取纯文件名作为下载标识 $filename = basename($filepath); header("Content-Disposition: attachment; filename=$filename"); $file_mtime = filemtime($filepath); $seconds_to_cache = 360000; $ts_expires = gmdate("D, d M Y H:i:s", time() + $seconds_to_cache) . " GMT"; $ts_modified = gmdate("D, d M Y H:i:s", $file_mtime) . " GMT"; // 基于文件修改时间和大小生成唯一ETag $etag = md5($file_mtime . filesize($filepath)); header("Expires: $ts_expires"); header("Last-Modified: $ts_modified"); header("ETag: \"$etag\""); // 添加public标识,允许浏览器缓存 header("Cache-Control: public, max-age=$seconds_to_cache, immutable"); // 彻底清理缓冲区 ob_clean(); flush(); // 处理浏览器缓存验证请求,直接返回304复用缓存 if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] == "\"$etag\"") { header("HTTP/1.1 304 Not Modified"); exit(); } if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= $file_mtime) { header("HTTP/1.1 304 Not Modified"); exit(); } readfile($filepath); exit(); }
内容的提问来源于stack exchange,提问作者fayong lin
相关产品推荐
相关产品推荐

