☰
PHP文件上传下载全链路:安全校验、分片上传与断点续传
2026/11/5 4:49:36 网站建设 项目流程

文件上传下载这东西,在PHP圈子里属于典型的"看着简单、踩起来要命"。我这些年经手过的PHP项目,大大小小十来个,几乎每一个都在上传下载上栽过跟头:有的是大文件传到99%断了,有的是用户把附件URL里的数字一改就下载到了别人的合同,还有的是图片传上去能看、下载回来打不开。所以这次我把PHP文件上传下载这套东西从头到尾拆一遍,不讲空话,直接对着源码和参数说事。

这篇内容适合三类人看:刚接触PHP、想搞明白$_FILES到底怎么运转的新手;手上有个半成品上传功能、被各种边界情况折磨的中级开发者;以及需要把上传下载做成可复用模块、考虑存储扩展和安全审计的老手。文章会覆盖上传链路设计、类型校验、分片上传、断点续传下载、权限控制、日志与清理策略,最后一节专门整理我实际遇到过的问题和排查路径。

1. 整体架构设计与方案选型

1.1 上传链路为什么要分层

很多人写上传,脑子里只有一个move_uploaded_file,接收完往目录里一扔就完事。这种写法在Demo里跑得通,一旦文件到几十兆、用户量上来、还要做安全审计,马上崩。我一般把上传链路拆成四层,每层职责明确,出问题好定位。

接入层负责把请求接住。这一层的坑基本都在配置上:Nginx的client_max_body_size默认只有1M,PHP的upload_max_filesize默认2M、post_max_size默认8M,三个值任何一个是瓶颈,请求都到不了你的业务代码。我习惯把这三个值对齐设置,比如都设成64M,避免出现"PHP说能传、Nginx先给你413"的诡异现象。

应用层做校验和业务逻辑:文件类型、大小、归属用户、业务场景(头像、合同、导入表格),以及要不要走分片。

存储层决定文件最终落在哪:本地磁盘、对象存储、还是NAS。这一层选错,后面扩容会非常痛苦。

元数据层是数据库里的一条记录:文件ID、原始文件名、存储路径、大小、哈希、上传人、业务类型、状态。元数据很重要,后面做去重、审计、清理全靠它。我见过只存路径不存哈希的项目,结果用户反复上传同一个文件,磁盘一年涨了几百G。

分层的价值在于:换存储层不用动业务代码,加审计不用改上传逻辑,排查问题时能快速判断是Nginx、PHP配置还是代码的问题。

1.2 本地磁盘还是对象存储:一份选型对照

这是我被问得最多的问题之一。没有绝对答案,看规模和团队能力。我把实际踩过的经验整理成一张表:

维度本地磁盘对象存储
接入成本极低,move_uploaded_file就完事需要SDK、密钥管理、网络配置
扩容方式加盘、做软链、迁目录,麻烦容量近乎无限,透明扩容
多机部署需要共享存储(NFS)或者自己写同步天然支持,各节点直连
下载性能走应用服务器带宽,容易成为瓶颈可走CDN,带宽成本可控
权限控制完全自控,灵活用签名URL,逻辑要重新设计
成本服务器磁盘成本按量计费,流量费要留意
运维负担备份、磁盘告警全靠自己服务商兜底,但要防误删

我的判断标准很简单:单机、内部系统、文件总量在几百G以内,本地磁盘完全够用;一旦涉及多台应用服务器、需要CDN分发、或者文件量预期会持续增长,早点上对象存储,别等迁移时再返工。迁移的痛苦程度远超过一开始多写几十行SDK代码。

这里有个细节容易被忽略:即使是本地磁盘,也建议把"存储路径"抽象成一个函数,比如storage_path($bizType, $fileId),所有代码通过它拿路径。将来换成对象存储,只改这一个函数加一个适配层,业务代码基本不动。

1.3 下载链路的三条路,别选错

下载这块,很多人只会readfile一把梭,文件一大就OOM或者超时。实际上按场景分三条路:

第一条,小文件直出。几兆以内的文件,readfile或者file_get_contents塞进响应即可,代码最简单。但要注意千万别在header之前有任何输出,哪怕是一个空格、一个BOM,都会导致文件损坏。

第二条,应用流式输出。大文件用fopen+fread循环,每次读8KB到1MB,边读边echo,配合ob_end_clean清掉输出缓冲。这样内存占用恒定,不受文件大小影响。缺点是占着PHP-FPM进程,大文件并发下载会拖垮应用服务器。

第三条,重定向或内部转发。Nginx有X-Accel-Redirect(X-Sendfile在Apache下),PHP只做鉴权,把真实的文件路径通过响应头告诉Web服务器,由它直接发文件。这是生产环境大文件下载的最优解,鉴权逻辑还在PHP里,但传输不占PHP进程。对象存储则用签名URL重定向,效果类似。

断点续传是下载绕不开的需求,本质是处理请求头里的Range。用户说"我从第1024字节开始要",服务端就要返回206 Partial Content,带上Content-Range: bytes 1024-2047/总大小。这套逻辑不难,但细节多:多区间请求要不要支持、If-Range怎么处理、Range越界怎么回。下面第3节我会给出完整实现。

选择哪条路,我通常这么判断:文件小于10M且并发不高,直出;文件大但下载频率低,流式;文件大且下载频繁,一定要走X-Accel-Redirect或者对象存储加CDN。

2. 文件上传核心细节解析与安全要点

2.1 从表单到$_FILES,中间发生了什么

先把这个链路说清楚,后面所有问题都能对上号。浏览器提交multipart/form-data请求,Nginx收到后先看client_max_body_size,超了直接413,PHP根本收不到。通过之后转给PHP-FPM,PHP按post_max_size判断整个请求体,超了会把$_POST和$_FILES都清空,注意是清空,不是报错,这就是很多人"表单提交了但$_FILES是空数组"的根源。再往下按upload_max_filesize判断单个文件,超了会在$_FILES['file']['error']里给个UPLOAD_ERR_INI_SIZE。

$_FILES的结构值得背下来:

$_FILES['file'] = [ 'name' => '合同.pdf', // 客户端提供的原始名,完全不可信 'type' => 'application/pdf', // 客户端提供的MIME,完全不可信 'tmp_name' => '/tmp/phpA1b2C3', // 临时文件路径 'error' => 0, // 错误码 'size' => 1048576, // 字节数 ];

name和type都是浏览器给的,改个请求头就能伪造,只能用来做展示和初步提示,绝不能作为校验依据。真正的校验必须基于tmp_name指向的实际文件内容。

错误码也要记牢,排查问题全靠它:

错误码常量含义与常见原因
0UPLOAD_ERR_OK成功
1UPLOAD_ERR_INI_SIZE超过upload_max_filesize
2UPLOAD_ERR_FORM_SIZE超过表单MAX_FILE_SIZE
3UPLOAD_ERR_PARTIAL只传了一部分,网络中断典型表现
4UPLOAD_ERR_NO_FILE用户没选文件
6UPLOAD_ERR_NO_TMP_DIR临时目录不存在或无权限
7UPLOAD_ERR_CANT_WRITE临时目录写不进去,磁盘满或权限问题
8UPLOAD_ERR_EXTENSION被某个PHP扩展中断

提示:错误码为4时不要直接报"上传失败",用户只是没选文件,提示语要区分开,否则体验很差。

2.2 类型校验的三道防线,少一道都不行

文件类型校验是最容易出安全问题的环节。我见过只判断扩展名的,改个后缀就绕过;也见过只信$_FILES['type']的,抓包改一下就行。我的做法是三道防线叠加,任何一道不过直接拒绝。

第一道,扩展名白名单。注意是白名单不是黑名单。黑名单永远补不完,php3、phtml、php5这些历史遗留后缀太多。白名单就是明确列出允许的:jpg、jpeg、png、gif、pdf、docx、xlsx。取扩展名用pathinfo($name, PATHINFO_EXTENSION),然后统一转小写再比对。

第二道,真实MIME检测。用finfo_file读取文件头判断真实类型,不依赖客户端:

$finfo = new finfo(FILEINFO_MIME_TYPE); $realMime = $finfo->file($tmpName); // 例如 image/png

这里要注意一个坑:finfo对某些格式的判断依赖系统magic库版本,比如某些docx可能被识别成application/zip,因为docx本质就是zip包。所以MIME白名单要留一些兼容空间,或者对Office文档用扩展名加文件头双重判断。

第三道,业务语义校验。图片用getimagesize确认能解析出宽高,能解析出来的才是真图片;这个函数顺带还能拿到图片类型,比MIME更可靠。文档类可以检查文件头魔数,比如PDF开头是%PDF-,PNG是\x89PNG。

三道防线走完,基本可以挡住绝大多数上传绕过。但还有一层容易被忽略:存储目录必须禁止脚本执行。哪怕校验全过了,万一有漏网之鱼,只要上传目录能执行PHP就完了。Nginx里针对上传目录加一条:

location ^~ /uploads/ { location ~ \.(php|php5|phtml|phar)$ { deny all; } }

2.3 大小限制与分片参数怎么算

普通上传的大小上限,本质是三个配置取最小值:Nginx的client_max_body_size、PHP的post_max_size、upload_max_filesize。想支持100M的文件,三个都得大于100M,还要考虑multipart编码的额外开销,一般留10%余量,设成110M。

另外两个参数经常被忽略:max_file_uploads默认20,一次传超过20个文件会被截断;max_execution_time默认30秒,大文件上传加后续处理很容易超时,需要适当调大或者用set_time_limit(0)(只对脚本执行时间有效,不含等待上传的时间)。

一旦文件超过几十兆,就该考虑分片上传了。分片的核心参数有三个:分片大小、并发数、分片数量上限。

分片大小我一般取2M到5M。太小了请求数多,HTTP开销大;太大了单个分片失败重传成本高,而且受单次请求大小限制。2M是个比较稳的折中。

并发数控制在3到5。浏览器对同域名并发有连接数限制,开太多也是排队,反而增加服务端压力。

分片数量要算一下:假设1G文件、2M分片,就是512片。如果业务上可能出现10G的文件,那就是5120片,元数据表会膨胀,合并时遍历也慢。所以要么提高分片大小(10G用10M分片=1024片),要么对超大文件单独走流式直传。

举个实际的计算例子:业务要求支持最大2G文件,单分片最大5M,那么分片数上限是2048。数据库里分片记录表按这个规模设计索引,upload_id + chunk_index建唯一索引,避免重复分片覆盖。

2.4 文件名和目录规划,这里最容易留隐患

原始文件名绝对不能直接用作存储名。原因有三个:中文和特殊字符在不同文件系统下表现不一致;用户可能传同名文件互相覆盖;文件名里可能带路径穿越字符(../)。我的处理方式是:

$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); $storedName = date('Ymd') . '/' . bin2hex(random_bytes(16)) . '.' . $ext;

原始文件名只存数据库,用于下载时还原给用户。存储名用随机串,天然防冲突、防穿越。

目录规划也要提前想。所有文件堆在一个目录里,文件数上万之后ls都卡,某些文件系统单目录文件数还有硬限制。我一般按业务类型加日期分桶:

storage/ contract/2024/06/15/xxxx.pdf avatar/2024/06/15/yyyy.png

按天分目录,单目录文件数可控,将来做冷热分离、按时间段归档也方便。如果是海量小文件场景,还可以再按哈希前两位分一层,比如ab/cd/xxxx,把单目录文件数压到更低。

注意:分桶规则一旦定下来就不要轻易改,历史文件路径已经写进数据库了。真要改,得写迁移脚本把老文件挪过去并更新记录。

3. 实操过程:从零搭一个可用的上传下载模块

3.1 环境准备与目录结构

先把地基打好。目录结构我习惯这样组织,职责清晰,将来抽成独立模块也方便:

project/ public/ index.php # 入口 uploads/ # 上传落盘目录,禁止脚本执行 src/ Upload/ ChunkUploader.php Validator.php Download/ FileStreamer.php storage/ tmp/ # 分片临时目录 files/ # 正式文件 config/ upload.php

配置集中放一处,避免散落在各个文件里:

// config/upload.php return [ 'max_size' => 2 * 1024 * 1024 * 1024, // 2G 'chunk_size' => 2 * 1024 * 1024, // 2M 'max_chunks' => 2048, 'allow_ext' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'docx', 'xlsx', 'zip'], 'allow_mime' => [ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip', ], 'storage_root' => __DIR__ . '/../storage/files', 'tmp_root' => __DIR__ . '/../storage/tmp', ];

临时目录一定要和正式目录分开。分片上传时临时文件会大量堆积,混在一起将来清理很麻烦,而且容易误删正式文件。

3.2 普通上传接口的完整实现

先写一个能直接用的普通上传处理类,把校验、落盘、写元数据串起来:

class Uploader { private array $config; public function __construct(array $config) { $this->config = $config; } public function handle(array $file, int $userId): array { // 1. 基础错误检查 if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) { throw new RuntimeException('上传失败,错误码:' . ($file['error'] ?? 'unknown')); } // 2. 大小校验(注意 max_size 与 PHP 配置取小) if ($file['size'] <= 0 || $file['size'] > $this->config['max_size']) { throw new RuntimeException('文件大小超出限制'); } // 3. 扩展名白名单 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (!in_array($ext, $this->config['allow_ext'], true)) { throw new RuntimeException('不支持的文件类型'); } // 4. 真实 MIME 校验 $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); if (!in_array($mime, $this->config['allow_mime'], true)) { throw new RuntimeException('文件内容类型不匹配'); } // 5. 图片语义校验 if (str_starts_with($mime, 'image/')) { $info = @getimagesize($file['tmp_name']); if ($info === false) { throw new RuntimeException('图片文件损坏或伪造'); } } // 6. 计算哈希,用于去重 $hash = hash_file('sha256', $file['tmp_name']); // 7. 生成存储路径 $dateDir = date('Y/m/d'); $stored = $dateDir . '/' . bin2hex(random_bytes(16)) . '.' . $ext; $absPath = $this->config['storage_root'] . '/' . $stored; $dir = dirname($absPath); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { throw new RuntimeException('目录创建失败'); } // 8. 移动文件 if (!move_uploaded_file($file['tmp_name'], $absPath)) { throw new RuntimeException('文件保存失败'); } // 9. 返回元数据,由调用方写库 return [ 'original_name' => $file['name'], 'stored_path' => $stored, 'size' => $file['size'], 'mime' => $mime, 'hash' => $hash, 'user_id' => $userId, ]; } }

这段代码有几个点值得单独说。mkdir那里用了三段式判断,是因为并发情况下目录可能已被别的进程创建,单纯is_dir再mkdir会有竞态。哈希用hash_file而不是hash加file_get_contents,前者流式读,不会把大文件整个塞进内存。

元数据入库时,hash字段建索引,上传前先查一次,命中就直接复用已有文件,只加一条用户与文件的关联记录。这样用户反复传同一个文件,磁盘上只存一份。这个优化在文档类系统里效果非常明显,我做过的一个项目靠它省了将近一半存储。

3.3 分片上传的完整流程

大文件必须走分片。整体流程是:前端先请求一个"初始化"接口拿到upload_id,然后按顺序或并发传分片,全部传完后请求"合并"接口,服务端按序拼接并校验完整性。

初始化接口:

public function init(array $params, int $userId): array { $size = (int)$params['size']; if ($size > $this->config['max_size']) { throw new RuntimeException('文件过大'); } $ext = strtolower(pathinfo($params['name'], PATHINFO_EXTENSION)); if (!in_array($ext, $this->config['allow_ext'], true)) { throw new RuntimeException('类型不允许'); } $uploadId = bin2hex(random_bytes(16)); $chunkSize = $this->config['chunk_size']; $total = (int)ceil($size / $chunkSize); if ($total > $this->config['max_chunks']) { throw new RuntimeException('分片过多,请提高分片大小'); } // 临时目录:storage/tmp/{upload_id} $tmpDir = $this->config['tmp_root'] . '/' . $uploadId; mkdir($tmpDir, 0755, true); return [ 'upload_id' => $uploadId, 'chunk_size' => $chunkSize, 'total' => $total, ]; }

分片接收接口,每个分片一个请求:

public function chunk(string $uploadId, int $index, array $file): bool { $tmpDir = $this->config['tmp_root'] . '/' . $uploadId; if (!is_dir($tmpDir)) { throw new RuntimeException('上传会话已失效'); } if ($file['error'] !== UPLOAD_ERR_OK) { throw new RuntimeException('分片上传失败'); } // 分片大小不能超过约定值 if ($file['size'] > $this->config['chunk_size']) { throw new RuntimeException('分片超出限制'); } $target = $tmpDir . '/' . sprintf('%06d', $index) . '.part'; // 用原子写:先写临时文件再 rename,避免读到半截分片 $tmp = $target . '.tmp'; if (!move_uploaded_file($file['tmp_name'], $tmp)) { throw new RuntimeException('分片写入失败'); } if (!rename($tmp, $target)) { throw new RuntimeException('分片落盘失败'); } return true; }

分片文件名用%06d补零,是为了合并时能正确排序。如果不补零,字符串排序下10.part会排在2.part前面,合并顺序就乱了,这是个非常经典的坑。

合并接口:

public function merge(string $uploadId, string $originalName): array { $tmpDir = $this->config['tmp_root'] . '/' . $uploadId; if (!is_dir($tmpDir)) { throw new RuntimeException('上传会话不存在'); } // 加锁,防止重复合并 $lockFile = $tmpDir . '/.lock'; $fp = fopen($lockFile, 'c'); if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) { throw new RuntimeException('合并进行中,请勿重复提交'); } try { $parts = glob($tmpDir . '/*.part'); sort($parts, SORT_STRING); // 已补零,字典序即序号序 if (empty($parts)) { throw new RuntimeException('没有可用分片'); } $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); $stored = date('Y/m/d') . '/' . bin2hex(random_bytes(16)) . '.' . $ext; $absPath = $this->config['storage_root'] . '/' . $stored; $dir = dirname($absPath); if (!is_dir($dir)) { mkdir($dir, 0755, true); } $out = fopen($absPath, 'wb'); if (!$out) { throw new RuntimeException('目标文件创建失败'); } $totalSize = 0; foreach ($parts as $part) { $in = fopen($part, 'rb'); if (!$in) { fclose($out); throw new RuntimeException('分片读取失败:' . $part); } stream_copy_to_stream($in, $out); $totalSize += filesize($part); fclose($in); } fclose($out); // 清理临时目录 array_map('unlink', $parts); @unlink($lockFile); fclose($fp); @rmdir($tmpDir); return [ 'stored_path' => $stored, 'size' => $totalSize, 'hash' => hash_file('sha256', $absPath), ]; } catch (Throwable $e) { flock($fp, LOCK_UN); fclose($fp); throw $e; } }

合并用stream_copy_to_stream逐片追加,内存占用恒定,不依赖文件总大小。锁文件那一段是为了防重复合并,这个场景真实存在:前端网络抖动重试,两次合并请求几乎同时到达,没有锁的话会生成两个文件或者把分片删两次报错。

注意:合并后一定要用hash_file算出最终哈希,和前端算的(如果前端有算)对比一次,不一致说明传输过程中分片损坏,要提示用户重传。我在一个跨地域传输的项目里就靠这个校验抓到过运营商层面的数据错误。

3.4 下载接口:流式输出加断点续传

下载这一块,我先给一个完整的、带Range支持的单文件流式输出实现:

class Downloader { public function stream(string $absPath, string $downloadName): void { if (!is_file($absPath)) { http_response_code(404); exit; } $size = filesize($absPath); $fp = fopen($absPath, 'rb'); // 基础头 header('Content-Type: application/octet-stream'); header('Accept-Ranges: bytes'); header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"'); $start = 0; $end = $size - 1; if (isset($_SERVER['HTTP_RANGE'])) { // 只支持单区间,形如 bytes=start-end if (preg_match('/^bytes=(\d*)-(\d*)$/', $_SERVER['HTTP_RANGE'], $m)) { if ($m[1] === '' && $m[2] === '') { http_response_code(416); header("Content-Range: bytes */$size"); exit; } if ($m[1] === '') { // 从末尾算:bytes=-500 表示最后 500 字节 $start = max(0, $size - (int)$m[2]); } else { $start = (int)$m[1]; if ($m[2] !== '') { $end = min((int)$m[2], $size - 1); } } if ($start > $end || $start >= $size) { http_response_code(416); header("Content-Range: bytes */$size"); exit; } http_response_code(206); header("Content-Range: bytes $start-$end/$size"); header('Content-Length: ' . ($end - $start + 1)); } } else { header('Content-Length: ' . $size); } // 清缓冲,避免 header 之后有输出污染内容 while (ob_get_level() > 0) { ob_end_clean(); } fseek($fp, $start); $remain = $end - $start + 1; $bufSize = 1024 * 1024; // 1M 一块 while ($remain > 0 && !feof($fp)) { $read = fread($fp, min($bufSize, $remain)); if ($read === false) { break; } echo $read; flush(); $remain -= strlen($read); } fclose($fp); } }

这段代码里有几个关键细节,我一个个说。

Content-Disposition里的文件名用rawurlencode处理,中文文件名才能正确显示。有些人用urlencode,空格会变成+,下载下来文件名里带加号,是个小坑。

Range解析只支持单区间,这是有意的简化。多区间请求(bytes=0-100,200-300)返回multipart/byteranges,实现复杂度高,而实际用户几乎不会触发,浏览器断点续传都是单区间。真遇到了返回416或者忽略Range返回全量都行,我选择忽略。

while (ob_get_level() > 0) ob_end_clean();这一句必须加。框架一般会开输出缓冲,不清掉的话header之后的echo会被缓存,导致客户端收到的内容和声明的大小对不上,文件损坏。这个坑我踩过一次,排查了两个小时。

缓冲区大小选1M是个权衡。太小了flush次数多,系统调用开销大;太大了单次内存占用高、客户端感知延迟大。1M在大多数场景下比较平衡。

生产环境如果文件特别大、下载特别频繁,还是建议把这段换成X-Accel-Redirect:

// 鉴权通过后 header('Content-Type: application/octet-stream'); header('X-Accel-Redirect: /protected-files/' . $storedPath); header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"'); exit;

然后在Nginx里配置内部location:

location /protected-files/ { internal; alias /path/to/storage/files/; }

internal表示这个location只能被内部跳转访问,外部直接请求会404。PHP只负责鉴权,文件传输交给Nginx,PHP-FPM进程立刻释放,大文件并发下载的能力提升非常明显。

4. 常见问题与排查技巧实录

4.1 上传失败类问题速查

上传失败的表现五花八门,但排查路径其实很固定。我整理了一张速查表,基本能覆盖九成情况:

现象可能原因排查动作
请求直接413Nginxclient_max_body_size不够调大并reload
$_FILES为空数组post_max_size超限,PHP清空了超全局变量调大post_max_size,同时加日志
错误码1upload_max_filesize超限调大该值
错误码3传输中断,网络或超时检查max_execution_time、代理超时
错误码6/7临时目录不存在或无权限检查upload_tmp_dir和磁盘空间
上传成功但文件0字节磁盘满或move_uploaded_file失败检查df -h和返回值
偶发失败,日志无记录多台机器配置不一致检查各节点php.ini是否一致

这里重点说两个坑。

第一个是"配置改了没生效"。PHP-FPM有自己的进程池,改了php.ini必须reload,而且要注意CLI和FPM读取的ini可能不是同一个。用php --ini看CLI的,用<?php phpinfo(); ?>看FPM的,两边对一遍再下结论。

第二个是"前端显示成功,后端没收到"。这种多半是前端用了FormData但没设processData: false和contentType: false(jQuery场景),请求体被转成了urlencoded,文件自然丢了。抓包看一眼Content-Type是不是multipart/form-data,一眼就能确认。

4.2 下载乱码、损坏、中断的排查

下载类问题的排查思路和上传完全不同,重点在响应头和输出流。

文件名乱码。最常见的是Content-Disposition里直接拼中文没编码。按RFC 5987规范应该写成filename*=UTF-8''<urlencode后的名字>,但浏览器兼容性参差,稳妥做法是同时给filename和filename*两个参数,或者统一用rawurlencode。我现在的做法是:

header("Content-Disposition: attachment; filename=\"{$asciiFallback}\"; filename*=UTF-8''" . rawurlencode($name));

文件损坏打不开。九成是header之前有输出,或者文件本身存的时候就不完整。排查方法:下载后对比文件的sha256和数据库里存的哈希,不一致就往前查输出污染。常见污染源有:文件开头的BOM、?>闭合标签后的空白、session_start之前的输出、var_dump忘了删。我现在写PHP一律不写?>闭合标签,就是为了避免这类问题。

下载到一半中断。看Content-Length和实际收到的字节数。如果Content-Length声明了但实际发的少,检查循环里的flush和ob处理;如果声明都没发,检查是否走了分块传输但中间有代理截断。另外fastcgi_read_timeout和Nginx的proxy_read_timeout也要看一下,超时会被截断。

提示:排查下载问题有一招很管用,用curl -I看响应头,再用curl -o /dev/null -w '%{size_download}'看实际下载字节数,两个数一对比,问题出在头还是出在体,立刻清楚。

4.3 并发与性能问题的应对

并发上传下载的性能问题,根子在PHP-FPM的进程模型上。每个请求占一个进程,大文件传输期间进程一直被占着,并发一高就排队。

我的应对顺序是这样的:先看是不是能异步化,下载走X-Accel-Redirect或者对象存储,上传大文件走分片(单片小、传完即释放进程)。这两招下去,PHP-FPM的占用会大幅下降。

然后是限流。上传接口一定要限流,按用户维度限制单位时间内的上传次数和总字节数,防止有人恶意刷。限流我用Redis做滑动窗口,简单可靠:

$key = "upload:rate:{$userId}"; $now = microtime(true); $redis->zRemRangeByScore($key, 0, $now - 60); $count = $redis->zCard($key); if ($count >= 30) { throw new RuntimeException('上传过于频繁,请稍后再试'); } $redis->zAdd($key, $now, uniqid('', true)); $redis->expire($key, 120);

最后是磁盘IO。分片合并是典型的顺序写,如果同时有多个合并在跑,磁盘IO会打满。我的做法是合并任务入队列,用单独的worker进程串行或小并发处理,别放在Web请求里同步做。用户看到的是"上传完成,处理中",几秒后状态变成"可用",体验反而更好,因为不会卡在请求里等合并。

4.4 我踩过的几个真实坑

说几个文档里不会写、但实际很折磨人的问题。

坑一:is_uploaded_file和move_uploaded_file在分片场景失效。分片上传时,如果你的分片是先经过某个中间层转发的,tmp_name可能不是真正的上传文件,move_uploaded_file会返回false。排查时打印$file结构,看tmp_name路径是否以/tmp/php开头,不是的话就要考虑是不是被中间件处理过了。

坑二:中文文件名在部分文件系统下变成问号。如果存储路径里包含了原始中文名(有些人为了"好找"会这么干),在某些环境下会出问题。我的原则是存储名一律ASCII,原始名只进数据库。

坑三:NFS挂载的上传目录,rename偶尔失败。NFS对rename的原子性支持依赖服务端配置,跨挂载点还会直接报错。如果用了NFS,临时文件和目标文件必须在同一个挂载点内,否则先copy再unlink。

坑四:getimagesize对超大图片内存爆掉。这个函数会尝试解析图片头,极大的图片(比如上亿像素)可能吃掉大量内存。可以在调用前先限制文件大小,超过10M的图片直接跳过这个校验改用finfo。

5. 权限控制、审计与存储治理

5.1 下载权限不能只靠"URL猜不到"

这是安全上最容易被低估的一环。很多系统觉得文件名是随机串,URL猜不到就等于安全。但URL会出现在日志、浏览器历史、分享链接里,一旦泄露就是越权。

正规做法是每次下载都过一次鉴权。请求带上文件ID,服务端查元数据,判断当前用户是否有权访问这个文件(归属校验、角色校验、业务关联校验),通过后才输出。不要在URL里直接放存储路径,URL里只放文件ID,路径由服务端根据ID查出来。

对于需要临时分享的场景,用一次性签名URL。生成时带上文件ID、过期时间、签名:

$expire = time() + 3600; $sign = hash_hmac('sha256', $fileId . '|' . $expire, $secretKey); $url = "/download.php?id={$fileId}&expire={$expire}&sign={$sign}";

校验时用hash_equals做签名比对,防时序攻击。过期时间建议短,1小时足够,分享场景可以放宽到24小时。

5.2 日志与审计:出了问题能查得清

上传下载必须留痕,这不是合规要求那么简单,出问题时能救你。我一般记这几类日志:

  • 上传成功:用户ID、文件ID、原始名、大小、MIME、哈希、IP、时间
  • 上传失败:用户ID、错误码、原始名、IP、时间(失败日志往往比成功日志更有价值)
  • 下载:用户ID、文件ID、IP、User-Agent、时间
  • 删除:操作人、文件ID、原因

日志写到独立的表或者日志系统,不要和业务表混在一起,避免影响主库性能。写入用异步或者批量,别在上传主流程里同步写日志拖慢响应。

审计的价值在一个真实场景里体现得很明显:某项目的用户投诉"我的附件被别人下载了",查下载日志,按文件ID一筛,发现是同一个IP在短时间内批量请求了连续的几个文件ID,明显是遍历。如果没有下载日志,这个问题根本定位不到,只能干瞪眼。

5.3 存储清理与生命周期管理

文件只增不减,是所有上传系统的通病。我见过运行三年的系统,磁盘里一半是没人再访问的临时文件和历史附件。

清理要分几类处理。临时分片:超过24小时没合并的分片目录直接删,用定时任务扫tmp_root下的目录,按修改时间判断。未关联的孤儿文件:上传成功但业务上没有真正关联到记录的(比如用户传了文件但没提交表单),定期扫元数据表,找状态是"临时"且超过N天的记录,删文件删记录。业务删除:业务记录删除时,同步标记文件为待删除,延迟一段时间(比如7天,防止误删恢复)后真正删除。

删除操作一定要有"软删除"阶段。直接unlink的风险太大,一旦误删没法恢复。我的做法是元数据加status字段,删除只是改状态,物理删除由后台任务执行,执行前再确认一次业务状态。

另外,对象存储的回源和生命周期策略也要配。比如设置"30天未访问自动转低频存储",能省不少成本。本地磁盘则要配磁盘使用率告警,超过80%就通知,别等写满了才发现。

我个人的体会是,上传下载这个模块,写出来可能一两天,但要写得能扛住真实业务,功夫全在这些"不那么起眼"的地方:配置对齐、校验三道防线、分片顺序、Range处理、缓冲清理、权限校验、日志留痕、定期清理。每一块单独看都不复杂,组合起来才是一个能长期跑的系统。最后分享一个小技巧:把上传下载的每一个异常分支都加一条带上下文的日志,包括文件名、大小、IP和错误码,等到线上真出问题的时候,你会庆幸当初多写了那几行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询