前阵子接了个需求:在内网环境做一个视频分享平台,用户要把几百MB甚至上GB的教学视频传上去,然后给同事发个链接就能在线看。技术栈定的是PHP,这让我一开始有点打鼓——PHP处理大文件上传本来就是老大难,再加上央企内网的安全合规要求,ActiveX控件加载不了,物理路径不能直连,操作还得审计留痕。做完之后回头看,整个链路里最核心、也最值得拿出来讲的就是分片上传。
这篇文章我把完整的思路、参数设计、核心代码和踩坑记录都整理出来,给同样在传统PHP项目里做视频大文件上传的朋友做个参考。不管你是刚被分配到类似任务的开发,还是想优化现有上传模块的架构师,这篇都值得看完。
1. 项目背景与整体设计思路
1.1 央企应用环境里的上传难点
先说环境。央企或者大型国企的内网应用,和互联网产品有很明显的区别:
第一,浏览器环境不统一。虽然大家都在慢慢转向国产浏览器或Chrome系,但有些老旧终端还在用IE内核的兼容模式,ActiveX控件基本是装不上的。早年很多系统靠这类插件实现大文件上传,现在这套路子走不通了,必须回到纯Web方案。
第二,网络链路复杂。内网访问通常经过多层代理、防火墙策略,普通上传请求如果长时间占用连接,容易被中间设备掐断。加上高峰期跨部门访问,链路带宽未必有想象中那么充裕,单请求一次传完一个大文件成功率不高。
第三,安全合规要求高。系统要过等保测评,操作要留痕,文件要防篡改防注入,上传目录不允许执行脚本,临时文件要清理干净。这些约束直接影响技术选型和代码写法。
第四,业务上对“分享”有明确要求。视频传完不是放服务器就完了,还要生成一个内网可访问的链接,定期失效、控制访问权限、记录谁看了。
在这些前提下,目标就很清晰了:用PHP实现一个纯浏览器端、稳定可靠、支持断点续传、带安全审计的视频上传与分享模块。
1.2 为什么一定要用分片上传
很多人会问:PHP不是有现成的 $_FILES 上传吗,把 upload_max_filesize 调大不就行了?理论上是这样,但实际在央企内网环境里跑几个G的视频,单次HTTP上传会遇到几个现实问题:
- 请求占用时间长。中间代理、防火墙可能有超时策略,传一半断掉,整个文件报废。
- 失败重传成本极高。一个1GB的视频断了一次,又要从头传,用户体验直接崩溃。
- 服务器内存压力大。虽然PHP接收文件是写到临时目录的,但如果代码里用 file_get_contents 或 base64 处理大文件,内存马上爆。
- 没有进度反馈。浏览器原生上传只能拿到“上传中”的简单状态,无法精确显示百分比,也不方便暂停。
- 无法断点续传。单请求模型下,传了一半再续上基本不可能。
把文件切成若干分片后,每个分片是独立的HTTP请求,体积可控、并发可控、失败后可单独重传。前端还能显示真实进度条,用户中断后重新进入可以继续传,体验立马不一样。
这正是分片上传的核心价值:把“大而不可控”的单次传输,拆成“小而可管理”的多次传输。
1.3 技术路线取舍:传统PHP-FPM够不够用
方案设计时也纠结过要不要上Swoole、Workerman这类常驻内存方案。后来评估下来,上传场景的瓶颈通常在磁盘IO和网络带宽,PHP-FPM的进程阻塞问题并不是主要矛盾。每个分片请求处理很快:接收文件、移动到分片目录、更新一个Redis标记,整个生命周期不到一秒,PHP-FPM完全扛得住。
另外,央企内网很多运维团队对Swoole并不熟悉,部署和排障都麻烦。传统PHP-FPM + Nginx + Redis 组合兼容性最好,出了问题也好和运维沟通。所以最终架构定成:
- 前端:Web Worker 计算MD5和切片上传,避免页面卡死
- 后端:PHP接收分片,Redis记录上传状态,MySQL存文件元数据和分享信息
- 中间层:Nginx转发请求,合并完成后用 X-Accel-Redirect 实现大文件下载与视频播放
这套方案生产环境跑下来很稳,也方便维护。
2. 核心细节解析与关键参数设计
2.1 分片大小的选择逻辑
分片大小不能拍脑袋定,要综合考虑内网带宽、Nginx和PHP配置、前端并发数。定得太小,请求数量暴增,一个1GB文件如果每片1MB要发1024个请求,服务端压力大;定得太大,又回到了单请求风险高的问题,而且出错重传成本反而高。
我常用的判断方法是:以内网千兆带宽为参考,一般单分片控制在10MB到50MB之间比较合适。视频教学类场景,文件大、对实时性要求不高,我更倾向于用20MB一片。这样1GB文件大概50个分片,并发5个请求,十分钟内能传完,分片数量和单请求耗时都均衡。
| 网络环境 | 建议分片大小 | 并发数 | 说明 |
|---|---|---|---|
| 内网千兆 | 20MB-50MB | 5-10 | 主要面向央企内网,链路稳定 |
| 跨分支专线 | 5MB-10MB | 3-5 | 链路波动大,小分片便于重传 |
| 公网/弱网 | 1MB-5MB | 3 | 极少场景,作为兼容方案保留 |
分片数量还要考虑MD5计算的耗时。传统浏览器里用SparkMD5计算一个1GB文件的MD5,在主流配置电脑上大约10到30秒,如果放到主线程会明显卡页面,必须用Web Worker在后台算。后面代码部分会专门讲。
2.2 秒传与断点续传的设计
分片上传不只是把文件切了传上去这么简单,还要解决上传过程中断怎么办、重复上传同一个文件怎么办。我的做法是三层状态记录:
第一层,文件整体识别。前端把整个文件的MD5算出来,连同文件名、大小、分片大小、总分片数一起传给后端。后端拿到后先查数据库,如果文件已经存在且状态是“已合并”,直接返回一个已有的文件ID,这就是秒传。
第二层,分片状态跟踪。后端每收到一个分片,除了保存分片文件本身,还要把“这个分片序号已上传”这个状态记到Redis里。前端重进页面时,先调一个接口查询哪些分片已经传过,后端从Redis里读取已上传分片集合,把缺失分片列表返回给前端,前端只重传这些,这就是断点续传。
第三层,合并状态锁。分片全部上传完毕后,前端调用合并接口。合并是个敏感操作,如果用户手滑点了两次合并,会重复生成文件。所以合并接口要用Redis锁,拿到锁才执行,执行完释放,避免重复操作。
这里有个经验:Redis里的分片状态要设置一个合理的过期时间,比如24小时。不然用户传了一半放那儿不管,Redis里积压一堆毫无意义的key。过期时间也不用太长,正常一个视频从开始传到合并完成很少超过半天,超过一天的话,前端重新计算完分片还是会发现状态丢了,提示用户重新上传即可。
2.3 前端Worker并发上传方案
很多PHP项目的前端还在用传统的 input file + FormData 一把梭,但在这个场景下不行。大文件切片后,要用 Worker 做两件事:一个是后台计算MD5,另一个是控制分片上传并发,避免主线程卡死。
Web Worker 传入文件对象后,在后台用 FileReader 读取分片数据,通过 fetch 或者 XMLHttpRequest 发请求。注意 Worker 里不能直接操作DOM,只能通过 postMessage 和主线程通信。上传进度通过每个分片的成功回调累加,比如50个分片,每完成一个进度加2%。
并发数设置很关键。并发太高,客户端内存和连接数激增,浏览器可能直接崩掉;并发太低,又发挥不了带宽优势。实测下来,内网环境并发控制在5个左右最舒服,弱网环境降到3个。之前有次我把并发调到20,用户上传到一半浏览器标签页就没了,教训深刻。
2.4 后端临时分片目录与清理策略
后端接收分片时,先在服务器上创建一个以文件MD5命名的临时目录,每个分片按序号命名保存,比如:
/data/upload/chunks/{file_md5}/ 0.part 1.part 2.part ...这里有个容易踩的坑:分片目录的写权限。PHP-FPM的进程用户(通常是 www)如果对这个目录没有写权限,上传接口会静默失败,表现为接口返回成功但分片没写进去,或者直接报Permission denied。创建目录时要显式给0755权限,最好在项目初始化脚本里把这几个目录一次性建好,而不是依赖代码在运行时去mkdir。
另外必须设计清理机制。用户传了一半不传了,或者传完但合并失败,临时分片就留在磁盘上。时间一长,磁盘会被这些垃圾文件塞满。我在运维侧加了一个crontab任务,每天凌晨扫一遍分片目录,删除最后修改时间超过24小时的目录。合并成功的目录,代码里已经删掉了,所以这个清理任务只处理异常残留,量不会太大。
3. 实操过程与核心代码实现
3.1 运行环境配置注意点
开始写代码之前,先把环境配置检查一遍。PHP的 php.ini 里有几个参数必须调整,不然分片接口会莫名报错:
upload_max_filesize = 128M post_max_size = 160M max_execution_time = 300 max_input_time = 300 memory_limit = 256M注意这里 upload_max_filesize 只要大于单个分片的大小就行,不需要设置成几十个G。我设成128M是留了余量,给以后分片调大留空间。
Nginx 方面,关键的是请求体大小限制。默认 client_max_body_size 是1M,如果忘了改,分片稍大一点就会返回413错误。我是在上传接口的location里设置的:
location /api/upload { client_max_body_size 50m; client_body_timeout 300s; proxy_read_timeout 300s; proxy_send_timeout 300s; }还有一个隐藏比较深的Nginx配置:如果系统前面还有一层反向代理,比如通过负载均衡到后端,那每一层都要调 client_max_body_size,而且代理层的 proxy_request_buffering 建议关掉,避免代理先把整个请求缓冲完再转发。默认情况下Nginx是开缓冲的,但分片请求本来就不大,开不开影响不明显,不过为了减少内存占用,我习惯在视频上传的路由里关掉缓冲。
3.2 分片接收接口完整实现
这里以Laravel框架为例写核心逻辑,原生PHP思路完全一样。接口接收的参数包括:文件标识file_key(前端算出的文件MD5)、分片序号chunk_index、总分片数total_chunks、以及分片文件本身。
public function uploadChunk(Request $request) { $fileKey = $request->input('file_key'); $index = (int) $request->input('chunk_index'); $total = (int) $request->input('total_chunks'); $file = $request->file('file'); if (!$file || !$file->isValid()) { return $this->error('分片数据无效'); } // 分片大小校验,防御恶意上传超大请求 if ($file->getSize() > 50 * 1024 * 1024) { return $this->error('分片大小超过限制'); } // 校验文件扩展名白名单 $ext = strtolower(pathinfo($request->input('file_name'), PATHINFO_EXTENSION)); if (!in_array($ext, ['mp4', 'avi', 'mkv', 'mov', 'flv'], true)) { return $this->error('文件类型不允许'); } $chunkDir = storage_path('upload/chunks/' . $fileKey); if (!is_dir($chunkDir)) { mkdir($chunkDir, 0755, true); } // 保存分片,文件名用序号 $file->move($chunkDir, $index . '.part'); // Redis记录已上传分片 $redis = Redis::connection(); $redis->sadd('upload:' . $fileKey . ':chunks', $index); $redis->expire('upload:' . $fileKey . ':chunks', 86400); // 所有分片都传完时,打个标记 $uploadedCount = $redis->scard('upload:' . $fileKey . ':chunks'); if ($uploadedCount >= $total) { $redis->set('upload:' . $fileKey . ':merge_ready', 1, 'EX', 3600); } return $this->success(['index' => $index, 'uploaded' => $uploadedCount]); }这块有几个关键点:
第一,用 move 方法而不是把文件内容读出来再写。PHP对 $_FILES 已经做了临时文件处理,move_uploaded_file 是原子操作,效率高且省内存。
第二,文件扩展名校验不能省。视频分享系统最怕有人传一个PHP文件上来,配合上传目录的解析漏洞直接拿到服务器权限。扩展名白名单是第一道防线。
第三,Redis的 sadd 和 scard 配合,天然适合记录分片集合。查询缺失分片时,用已有的序号和 total 做差集就行。
3.3 分片合并与一致性校验
所有分片传完后,前端会调用合并接口。这个接口的核心逻辑是:检查分片完整、按序号合并、清理碎片、落库记录。贴一下关键代码:
public function merge(Request $request) { $fileKey = $request->input('file_key'); $total = (int) $request->input('total_chunks'); $fileName = $request->input('file_name'); $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $chunkDir = storage_path('upload/chunks/' . $fileKey); // 防止重复合并 $lockKey = 'upload:' . $fileKey . ':merge_lock'; $lock = Redis::connection()->set($lockKey, 1, ['NX', 'EX' => 60]); if (!$lock) { return $this->error('合并任务已存在,请勿重复提交'); } try { // 校验分片数量 $chunks = glob($chunkDir . '/*.part'); if (count($chunks) != $total) { // 找到缺失分片序号返回给前端 $uploaded = array_map(function ($path) { return (int) basename($path, '.part'); }, $chunks); $missing = array_diff(range(0, $total - 1), $uploaded); return $this->error('分片不完整', ['missing' => array_values($missing)]); } // 创建最终存储目录 $finalDir = storage_path('upload/videos'); if (!is_dir($finalDir)) { mkdir($finalDir, 0755, true); } $finalName = date('Ymd') . '_' . uniqid() . '.' . $ext; $finalPath = $finalDir . '/' . $finalName; // 流式合并,避免一次性读入内存 $dest = fopen($finalPath, 'wb'); for ($i = 0; $i < $total; $i++) { $partPath = $chunkDir . '/' . $i . '.part'; $part = fopen($partPath, 'rb'); stream_copy_to_stream($part, $dest); fclose($part); } fclose($dest); // 删除临时分片目录 foreach (glob($chunkDir . '/*.part') as $partFile) { unlink($partFile); } rmdir($chunkDir); // 清除Redis状态 $redis = Redis::connection(); $redis->del('upload:' . $fileKey . ':chunks'); $redis->del('upload:' . $fileKey . ':merge_ready'); // 记录文件元数据 $fileId = $this->createFileRecord([ 'file_name' => $fileName, 'real_path' => $finalName, 'file_size' => filesize($finalPath), 'file_md5' => $fileKey, 'uploader_id' => auth()->id(), ]); // 可选:触发视频转码/压缩任务 $this->dispatch(new VideoConvertTask($fileId)); return $this->success(['file_id' => $fileId]); } finally { Redis::connection()->del($lockKey); } }合并顺序必须严格按照0到total-1的顺序执行,不能拿glob返回的顺序直接合并,否则视频会花屏或者时长错乱。glob返回的文件名是按照字典序排列的,如果分片序号是两位数以上,0、1、10、11、2这种顺序就会错乱。我在这块吃过亏,后来直接按序号遍历。
合并完成后,临时目录里的分片文件立刻删除。如果合并途中报错,finally块里只释放了Redis锁,分片目录留在原地,交给运维侧的定时清理任务处理。
3.4 分享链接生成与视频播放鉴权
文件上传完成后,进入分享环节。这里的设计是生成一个带随机token的链接,用户点开链接后,PHP先做鉴权,再通过Nginx的 X-Accel-Redirect 将视频文件发送给浏览器。
分享记录表最关键的是这几个字段:token、file_id、创建人、有效期、最大访问次数、已访问次数、访问IP白名单。生成链接时,token直接用 random_bytes 生成随机串,不用顺序ID,避免别人猜到链接。
public function createShare(Request $request) { $request->validate([ 'file_id' => 'required|integer', 'expire_days' => 'required|integer|min:1|max:30', 'max_views' => 'nullable|integer|min:1|max:1000', ]); $token = bin2hex(random_bytes(16)); Share::create([ 'token' => $token, 'file_id' => $request->input('file_id'), 'created_by' => auth()->id(), 'expire_at' => now()->addDays($request->input('expire_days')), 'max_views' => $request->input('max_views', 100), 'view_count' => 0, ]); $shareUrl = rtrim(config('app.url'), '/') . '/s/' . $token; return $this->success(['share_url' => $shareUrl]); }播放请求的处理函数里,先查分享记录,校验有效期和访问次数,再查文件表拿到真实文件名,最后设置 X-Accel-Redirect 头。
public function play($token) { $share = Share::where('token', $token)->firstOrFail(); if ($share->expire_at < now()) { abort(403, '分享链接已过期'); } if ($share->max_views && $share->view_count >= $share->max_views) { abort(403, '访问次数已达上限'); } $file = VideoFile::find($share->file_id); if (!$file) { abort(404); } $share->increment('view_count'); return response('', 200, [ 'X-Accel-Redirect' => '/protected_video/' . $file->real_path, 'Content-Type' => 'video/mp4', 'Accept-Ranges' => 'bytes', ]); }Nginx配置里,把 protected_video 目录设为 internal,也就是只能由Nginx内部跳转访问,外部直连会被拒绝:
location /protected_video/ { internal; alias /data/upload/videos/; }这个方案最妙的地方在于:PHP只做权限判断和日志记录,真正的文件传输交给Nginx来做,既不占PHP进程内存,又能天然支持 Range 请求。有了 Range 支持,浏览器里的 video 标签才能拖进度条,不然点播放半天没反应,用户以为卡死了。
4. 常见问题与排查技巧实录
4.1 高频错误速查表
| 错误现象 | 根本原因 | 解决办法 |
|---|---|---|
| 上传返回413 | Nginx client_max_body_size 过小,或反向代理层未配置 | 所有代理层统一调大 body 大小限制 |
| 上传返回500,日志里有POST Content-Length exceeds limit | php.ini 的 post_max_size 小于分片大小 | 调大 post_max_size,保持比 upload_max_filesize 大20%左右 |
| 合并时内存耗尽 | 用了 file_get_contents 读取分片或最终文件 | 改用 fopen + stream_copy_to_stream 流式读取 |
| 视频花屏或播放时长不对 | 合并顺序错乱 | 按分片序号0到total-1顺序合并,不要用glob顺序 |
| 上传后刷新页面,进度清零 | Redis过期时间太短,状态丢失 | 设置合理过期时间,并在每次分片上传时续期 |
| 分享链接能打开但视频不动 | 服务端不支持 Range 请求 | 使用 X-Accel-Redirect 或自行处理 Range 头 |
| 上传中途断网,重新进入后一直传前几个分片 | 前端没有先查询已上传分片 | 上传前先调用接口获取缺失分片列表 |
4.2 我实际踩过的三个坑
第一个坑是运行时权限问题。分片接口部署到生产环境后,测试时发现小文件没事,一旦切到20MB一片,接口就开始假死。排查到最后是分片目录的属主和PHP-FPM用户不一致,导致 move 操作失败。表面上看服务没什么报错,但分片文件没写进去,Redis里却把分片序号记上了,造成状态和数据不一致。后来我调整了目录权限归属,并且在写入Redis之前先检查文件是否真的移动成功,解决了这个隐患。
第二个坑是前端并发数过大。第一次上线时我把并发数调到10,结果一台配置较低的办公电脑上传1GB视频,浏览器直接卡死,只能强杀进程。后来把并发降到5,内存占用平稳,上传速度反而没慢多少,用户体验好了很多。如果用户的办公电脑配置比较老,建议在前端做一个自适应并发,根据机器内存或网络状态动态调整。
第三个坑是分享链接被内部扫描器访问。内网有安全扫描工具会定期爬取所有可达接口,分享链接如果没做访问频率限制,可能被扫描器给“看”掉访问次数,导致真正用户看不到。处理办法是:默认不记扫描器的访问次数,只在文件实际被播放超过一定时长后才计数。同时增加IP白名单和部门权限过滤。
4.3 央企内网环境的特殊注意事项
给这种环境做系统,有几个互联网项目不会遇到的细节:
一是代理与DNS问题。前端域名和后端接口域名如果不一致,跨域配置必须把预检请求(OPTIONS)处理好,否则浏览器直接拦截。我在Nginx里对所有OPTIONS请求返回204,并带上正确的跨域响应头。
二是浏览器兼容。有些用户用的还是老旧浏览器版本,对 FormData 和 Blob.slice 支持不完整。我在前端做了特性检测,如果不支持,就降级成普通上传并提示用户升级浏览器。工人领域里有些终端不是主流系统,兼容性测试建议多花点时间。
三是审计日志要往数据库和文件双写。文件上传、合并、分享、播放这四个关键动作都要记录操作人、IP、时间、文件指纹。内网安全小组随时可能要求导出某个用户的全部操作记录,提前设计好日志结构能省去后期很多麻烦。如果应用已经接了统一日志平台,也可以用标准的JSON日志格式输出。
5. 安全加固与后续扩展
5.1 上传环节的安全策略
除了前面提到的扩展名白名单,还有几层防护建议一起加上:
上传目录必须禁止执行任何脚本。即使攻击者费尽心思传了一个伪装的PHP文件,只要目录里没有解析权限,文件就是一堆死字节。这个可以通过Nginx配置实现:
location ~* ^/data/upload/.*\.(php|php5|phtml|jsp|aspx)$ { deny all; }MIME类型校验不能只看扩展名。有些视频文件的扩展名和实际格式对不上,后端拿到分片后,可以用 finfo 函数读取真实文件类型:
$finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($finalPath); // 再用视频MIME白名单判断文件保存时文件名一定要随机化。我前面代码里用的是 uniqid 加日期,实际生产环境可以换成 UUID,避免用户上传的文件名包含路径穿越特殊字符。
5.2 分享环节的权限控制
视频分享不能只是生成一个链接就完事。央企场景里,一个培训视频可能只有特定部门或特定岗位的人能看,所以分享参数最好是多维度的:
- 有效期:短则一天,长则30天,过期自动失效
- 访问次数:限制最大播放次数,防止链接被无限转发
- 登录限制:强制用户登录后才能访问,至少能识别出谁看过
- 部门/角色限制:仅允许目标部门或角色组访问
- 操作审计:谁在什么时间访问了哪个视频,要有完整链路
我在生成分享链接时,会显式让创建者选择以上这些参数。默认是7天有效、不限次数、仅内部登录用户可看。这样既保证了便利性,也守住了权限底线。
5.3 后续值得做的扩展方向
这个项目做完后,我又想到了几个可以继续深化的点:
第一个是转码压缩。热词里有人搜“PHP视频压缩”,确实,教学视频传上来通常有几百MB,转成HLS切片之后,可以大幅降低存储和播放带宽成本。PHP这边可以通过消息队列派发FFmpeg任务,转码完成后自动替换原文件。注意PHP本身不适合做转码,它是任务调度方,真正干活的是FFmpeg子进程。
第二个是异步合并。如果未来用户量大、视频也大,合并操作可以从同步接口改成后台任务。前端轮询合并状态,合并完成后再返回文件ID。这样能避免超大视频合并时HTTP请求超时,也更符合大型系统的设计规范。
第三个是引入对象存储。虽然目前内网环境用本地磁盘够用,但如果视频量上来,建议把存储层抽象出来。本地目录、NFS挂载、云对象存储(内网自建的兼容S3存储)都统一封装成同一个Storage接口,后续迁移成本会小很多。
第四个是支持更多文件格式。目前白名单里只有视频常见扩展名,实际使用中用户可能会传Word、PDF、压缩包等文档。分片上传的逻辑本身不限文件类型,加几个白名单就能支持,但分享预览这块要按文件类型分开处理,视频走播放器,文档走在线预览组件。
最后分享一点个人体会
这个项目前后做了一个多月,代码量不算大,但环境适配和安全审查花了不少精力。对我个人来说,最大的收获是认识到在传统PHP技术栈里,靠合理的架构设计同样能优雅地解决大文件传输问题。分片上传不是多复杂的算法,难的是把各种细节都考虑到:分片大小、并发控制、断点信息记录、合并顺序、权限校验、清理策略,每一个环节都值得认真打磨。
如果你正在做类似的项目,我建议先把整体流程图画清晰,再动手写代码。前端和后端的接口约定要提前定好,特别是file_key、chunk_index这些字段的命名和类型,一旦上线再改,前后端联调成本会成倍增加。上传过程中遇到问题时,先看Nginx的 access log 和 php-fpm 的 error log,再判断是网络链路的问题还是业务代码的问题,不要一上来就怀疑PHP性能不行。生产环境没有银弹,把基础细节做扎实,就是最好的方案。