直接 readfile() 會將整個檔案載入記憶體,大檔案易導致 OOM。正確做法:分塊讀取輸出、支援 Range 請求 (斷點續傳/多執行緒下載)、正確設定 HTTP 標頭。
<?php
$filePath = '/data/files/report.pdf';
$fileName = 'report.pdf';
if (!is_file($filePath)) { http_response_code(404); exit('File not found'); }
$fileSize = filesize($filePath);
$chunkSize = 1024 * 1024; // 1MB
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($fileName) . '"');
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes'); // 宣告支援範圍請求
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: public');
$handle = fopen($filePath, 'rb');
while (!feof($handle) && connection_status() === CONNECTION_NORMAL) {
echo fread($handle, $chunkSize);
ob_flush(); flush(); // 立即推送給客戶端
}
fclose($handle);
<?php
function sendFile($filePath, $fileName = null) {
if (!is_file($filePath)) { http_response_code(404); return false; }
$fileName ??= basename($filePath);
$fileSize = filesize($filePath);
$chunkSize = 1024 * 1024;
// 處理 Range 標頭
$rangeHeader = $_SERVER['HTTP_RANGE'] ?? '';
$start = 0;
$end = $fileSize - 1;
$partial = false;
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = (int)$matches[1];
$end = $matches[2] !== '' ? (int)$matches[2] : $fileSize - 1;
if ($start > $end || $start >= $fileSize) {
header('HTTP/1.1 416 Range Not Satisfiable');
header('Content-Range: bytes */' . $fileSize);
return false;
}
$partial = true;
}
$length = $end - $start + 1;
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($fileName) . '"');
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
if ($partial) {
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
}
$handle = fopen($filePath, 'rb');
fseek($handle, $start);
$remaining = $length;
while ($remaining > 0 && connection_status() === CONNECTION_NORMAL) {
$readSize = min($chunkSize, $remaining);
echo fread($handle, $readSize);
ob_flush(); flush();
$remaining -= $readSize;
}
fclose($handle);
return true;
}
// 使用
sendFile('/data/files/large.zip', 'large.zip');
| 標頭 | 作用 |
|---|---|
Content-Disposition: attachment | 強制瀏覽器彈出下載對話框,而非預覽 |
Content-Length | 告知瀏覽器總大小,支援進度條 |
Accept-Ranges: bytes | 宣告支援斷點續傳 |
Content-Range | 206 回應必須,指示本次傳輸區間 |
Cache-Control: no-cache | 避免代理快取導致下載損壞 |
X-Accel-Redirect: /protected/files/xyz,由 Web Server 高效發送靜態檔案,零 PHP 記憶體佔用、支援 sendfile 系統調用、自動處理 Range。