前阵子接了个需求在内网环境做一个视频分享平台用户要把几百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-50MB5-10主要面向央企内网链路稳定跨分支专线5MB-10MB3-5链路波动大小分片便于重传公网/弱网1MB-5MB3极少场景作为兼容方案保留分片数量还要考虑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 高频错误速查表错误现象根本原因解决办法上传返回413Nginx client_max_body_size 过小或反向代理层未配置所有代理层统一调大 body 大小限制上传返回500日志里有POST Content-Length exceeds limitphp.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性能不行。生产环境没有银弹把基础细节做扎实就是最好的方案。