一块晶圆在自动光学检测机里跑完一轮,视频记录动辄几个G。把这种大附件从车间传回服务器,最靠谱的路子就是分块上传;而在一个以PHP为技术栈的老平台里做分块上传,坑比想象中多得多。这篇文章我会用一次真实落地的“半导体产线视频管理系统”改造过程,把PHP如何接住几十 GB的检测视频、怎么做分块、合并、秒传、分享讲透。
我先说下背景。很多人一听到“芯片制造”四个字,就觉得后端怎么也得是 C++、Java 那套,跟 PHP 八竿子打不着。但实际进去看过就知道,半导体封测厂里的信息化系统非常杂:MES 核心调度是 Java,上层质量管理、设备点检、知识库、内部工具站,很多是历史上用 ThinkPHP 3.2.3 这种老框架堆出来的。设备一旦开始量产,AOI 光学检测机录下的晶圆表面视频、显微镜对焦过程录像、产线异常期间的监控片段,全是几百 MB 到几十 GB 的“大附件”。改一个独立系统不现实,最务实的方案就是在现有 PHP 平台上把视频上传链路做扎实。
这篇文章适合谁?一类是和我一样被老项目绑住的 PHP 工程师,项目跑在 ThinkPHP 3.2.x 上,不敢随便换框架,又要处理大文件传输;另一类是刚入行、想搞懂“分块上传到底是个什么流程”的同学。我会把协议设计、前端切片、后端合并、并发优化、生产环境踩坑一条龙讲清楚,代码可以直接拿走改。
1. 芯片产线里的庞然大物:为什么“传个视频”成了技术债
1.1 这不是段子:半导体车间和PHP的日常
半导体工厂内部,最核心的产线控制和数据采集系统通常是工控团队维护的,跑在 Windows 工控机或 Linux 边缘服务器上。但往上走一层,所有“给人看”的系统就五花八门了。质量管理平台要上传检测视频留证,设备工程团队要把异常录像分享给供应商分析,工艺组要调取历史视频做追溯。这些系统部署的时候多半是快速交付,开发团队顺手就用 PHP 搭了。版本停在 ThinkPHP 3.2.3 也不是什么稀罕事,模块之间耦合得厉害,swoole loader 加密过的文件还在线上跑着,你说“我们重写吧”,没人敢拍这个板。
所以“芯片制造中 PHP 怎么处理视频大附件上传”这个标题,看着违和,其实是个很真实的需求场景。检测视频不是普通的网站附件,它有这几个特点:
- 单个文件极大,4K 分辨率下 30 分钟的视频随随便便 5GB 以上;
- 文件数量多,一条产线一天能产生上百个视频;
- 使用者需要“分享”而不是“下载到U盘再拷贝”,因为工程师分布在不同厂区;
- 对完整性要求高,晶圆缺陷分析少一帧都可能导致误判。
这些特点决定了不能用传统表单上传硬扛。我接手时还没意识到问题的严重性,直到生产环境第一次传一个 7.2GB 的 AOI 检测视频,页面转圈转了十几分钟,最后浏览器直接报“连接已重置”。回到办公室打开 Nginx error log,满屏的 “client intended to send too large body”,那会儿才明白,这不是把 upload_max_filesize 改成 100G 就能解决的问题。
1.2 直接POST上传压垮的不仅仅是Nginx
先梳理一下,不分块直接 POST 上传大文件,到底会踩哪些墙:
| 瓶颈位置 | 默认情况 | 表现 |
|---|---|---|
Nginx client_max_body_size |
1m | 返回 413 Request Entity Too Large |
PHP upload_max_filesize |
2M | $_FILES 为空,文件根本没进入 PHP |
PHP post_max_size |
8M | POST 数据被丢弃,接口报错 |
PHP max_execution_time |
30s | 合并/上传处理超时被杀 |
| 浏览器 | 无超时机制 | 断网后整个文件需要从头传 |
| 用户体验 | 无进度 | 用户不知道传到哪一步,容易重复点击 |
就算你把 PHP 和 Nginx 的数值都调大,还有两个隐性问题。第一,传输链路中任何一个环节抖动,比如 Wi-Fi 断了几秒,整个文件作废重来,对于几个 GB 的视频几乎是灾难。第二,服务端接收大文件期间,PHP-FPM 进程会一直被这个请求占住,$_FILES 里存放的临时文件会瞬间吃掉一块磁盘空间,并发一上来,FPM 的进程池很容易被打满。
分块上传之所以是解决这类问题的标准姿势,核心就一句话:把“一次长连接搬大文件”拆成“多次短连接搬小分块”,每一块都是独立请求,失败了只重传那一块,服务端也能精确知道缺了哪几块。这套思路跟数据传输领域里的分包、ACK、重传本质是一模一样的,只不过我们是在 HTTP 层自己实现了一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分块上传的整体闭环:从任务初始化到分享链接
2.1 四大核心接口和状态流转
做分块上传不能只写一个“接收分块”的接口,否则后面会到处打补丁。我建议从一开始就设计四个接口加一个状态机,流程跑通了再优化细节。
| 接口名 | 作用 | 核心参数 |
|---|---|---|
| createTask | 前端上报文件名、大小、分块大小、MD5,服务端创建上传任务 | file_name, file_size, chunk_size, md5 |
| uploadChunk | 上传单个分块 | task_id, index, chunk 文件 |
| merge | 所有分块到齐后触发合并 | task_id |
| queryStatus | 查询任务进度和缺失分块 | task_id |
| share | 合并校验通过后生成下载分享 | task_id, expire_time |
任务状态机可以设计成:CREATED -> UPLOADING -> MERGING -> DONE / FAILED。
- 用户创建任务后进入
CREATED; - 上传第一个分块时切到
UPLOADING; - 调用 merge 后服务端把状态改成
MERGING,防止重复合并; - 合并完成且 MD5 校验通过变成
DONE,可以生成分享链接; - 分块缺失、校验失败、用户取消则进入
FAILED。
这套状态机的作用不只是好看。生产环境里,用户刷新页面、重复点击上传、并发触发多次 merge,都会导致状态错乱。没有状态机,很可能出现“分块还没传完就开始合并”,“合并进行中又被第二个请求再次触发”的灵异事件。
2.2 秒传与分块大小的确定
“秒传”是大附件场景里非常提升体验的设计。原理很简单:服务端维护一张文件指纹表,前端在创建任务前先计算整个文件的 MD5,把它随 createTask 请求一起发给服务端。服务端查一下文件指纹表,如果存在大小相同、MD5 相同的文件,就直接返回“文件已存在”,附带已有文件 ID,前端根本不需要真正上传。
但这里有个容易被忽略的问题:几个 GB 的视频,在浏览器里算完整 MD5 并不轻松,普通电脑可能要几十秒到几分钟。所以秒传检测一般不会放在“点击上传”的瞬间,而是:
- 用户选择文件后,在 Web Worker 里后台计算文件 MD5,UI 上显示“正在分析文件”;
- 计算完成后调 createTask;
- 如果命中秒传,直接进入“上传完成”状态。
至于分块大小,我建议设置在 1MB 到 10MB 这个区间。太大会触到 PHP upload_max_filesize 和网络超时的阈值,太小会让分块总数爆炸,HTTP 请求次数和磁盘 inode 压力都上去了。举个具体例子:5GB 的视频,取 5MB 一块,分块数是 1024。浏览器并发 5 路,大概需要 205 轮左右全部传完,每轮十几毫秒到几十毫秒,整体耗时取决于上行带宽,这个数量级是完全可以接受的。
2.3 为什么“先建任务再传块”比裸传好
我见过有人图省事,前端把文件切块后直接 POST 到同一个接口,服务端收到哪块存哪块,最后数一下目录里有多少块就合并。这样不是不行,但有几个问题很难收拾:
- 服务端无法预知总共有多少块,也就无法判断“块已经齐了”;
- 无法在任务开始前做秒传判断;
- 两个用户上传了同名文件,分块会混在同一个目录里;
- 上传中断后,无法告诉前端“你已经传了哪些块,继续传剩下的”。
先建任务的意义在于,服务端拿到了一份“合同”:文件名、总大小、分块大小、分块数量、文件 MD5。后续每次上传分块都在这个合同约束下进行,合并时缺哪块一目了然,文件去重和权限校验也有了统一的入口。这个设计付出的代价只是多一个 CREATE 请求,换来的是后续所有环节的确定性。
3. 前端切片与并发上传:让浏览器当好搬运工
3.1 Blob.slice切片与分块元数据
前端切片的 API 是浏览器原生的 Blob.prototype.slice,不需要任何第三方库。文件对象本身就是一个 Blob,所以直接 file.slice(start, end) 就能得到一个小 Blob,这个操作不会把整个文件读进内存,是由浏览器按需读取的。
核心代码长这样:
javascript复制const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
function buildChunks(file) {
const chunks = [];
const count = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < count; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push(file.slice(start, end));
}
return chunks;
}
上传单个分块的函数:
javascript复制async function uploadChunk(taskId, index, blob) {
const form = new FormData();
form.append('task_id', taskId);
form.append('index', index);
form.append('chunk', blob, 'chunk_' + index + '.part');
const response = await fetch('/index.php?m=Upload&a=uploadChunk', {
method: 'POST',
body: form
});
const json = await response.json();
if (json.code !== 0) {
throw new Error('chunk upload failed: ' + index);
}
return json;
}
注意 FormData 里第三个参数 filename 我给了固定的 chunk_{index}.part。原因有两个:一是服务端拿到这个文件名可以识别分块索引,二是避免中文文件名在旧版浏览器里出现编码问题。分块真正的索引以后端 $_POST['index'] 为准,不要依赖文件名去解析。
3.2 并发数控制与断点续传
分块上传单个请求虽然比整体上传轻,但也不能无脑并发。前端一次把 1024 个请求全发出去,Nginx 的连接池和 PHP-FPM 进程池都会被打穿,而且很多请求会超时重试,反而更慢。我实际测下来,并发控制在 3 到 5 路比较合适,局域网环境下 5 路最快,公网环境下 3 路更稳。
实现一个简单的并发池:
javascript复制async function runWithConcurrency(tasks, limit) {
const results = [];
const pool = new Set();
for (const task of tasks) {
const p = task().then((res) => {
results.push(res);
pool.delete(p);
});
pool.add(p);
if (pool.size >= limit) {
await Promise.race(pool);
}
}
await Promise.all(pool);
return results;
}
断点续传的逻辑要放在“并发池”外面做。每个分块上传成功后,把成功的 index 记录到 localStorage,key 用文件 MD5 拼上文件大小,避免不同文件串数据:
javascript复制const PROGRESS_KEY = 'upload_progress_' + fileMd5;
function saveProgress(doneIndexes) {
localStorage.setItem(PROGRESS_KEY, JSON.stringify(doneIndexes));
}
function loadProgress() {
try {
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) || [];
} catch (e) {
return [];
}
}
刷新页面后,重新 buildChunks,但只上传未完成的块。这里有一个安全细节:如果用户重新选择的文件跟上次不是同一个,文件 MD5 会变,localStorage 的 key 自然就换了,不会出现“新文件沿用旧进度”的错乱。但如果你用 task_id 做 key,就要特别小心,因为 task_id 是服务端生成的,重新选文件后可能返回的还是同一个 task 或新 task,本地进度容易混淆。
3.3 大文件MD5的车轮战
计算几个 GB 视频的 MD5,不是一件可以放在 UI 主线程里做的事。浏览器在计算期间会卡住页面,用户以为程序死了。推荐用 Web Worker 处理,代码大致思路是把文件分片读出来逐步累加计算:
javascript复制// web worker 内部
importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');
self.onmessage = (e) => {
const file = e.data.file;
const chunkSize = 2 * 1024 * 1024;
const spark = new SparkMD5.ArrayBuffer();
let offset = 0;
function readNext() {
if (offset >= file.size) {
self.postMessage({ type: 'done', md5: spark.end() });
return;
}
const reader = new FileReader();
reader.onload = (event) => {
spark.append(event.target.result);
offset += event.target.result.byteLength;
self.postMessage({ type: 'progress', percent: offset / file.size });
readNext();
};
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize));
}
readNext();
};
如果用原生 crypto.subtle.digest 计算 MD5 其实没有直接支持(它支持 SHA 系列),所以上面用 spark-md5 是更常见的兼容方案。老项目面向的产线浏览器可能是 Windows 7 上的 Chrome 旧版本,Web Crypto 的兼容性不一定好,spark-md5 更稳妥。
有一个值得注意的点:MD5 计算是 CPU 密集型,2MB 一块读 FileReader 再累加,几个 GB 文件在普通 i5 上大概耗时 20 到 40 秒,用户会觉得“怎么选完文件没反应”。所以在 UI 上要明确显示“正在分析文件,无需上传”,否则用户等不及会刷新重来。
4. 服务端接收与合并:ThinkPHP 3.2.3老项目的落地姿势
4.1 创建任务与秒传判断的实现
老项目用 ThinkPHP 3.2.3,控制器方法写起来不算优雅,但稳定够用。先看 createTask 的骨架:
php复制public function createTask() {
$fileName = I('post.file_name', '', 'trim');
$fileSize = (int)I('post.file_size');
$chunkSize = (int)I('post.chunk_size');
$md5 = strtolower(I('post.md5', '', 'trim'));
if ($fileSize <= 0 || $chunkSize <= 0 || empty($md5)) {
$this->ajaxReturn(array('code' => 10001, 'msg' => '参数错误'));
}
// 秒传判断
$exist = D('FileIndex')->where(array('file_md5' => $md5, 'file_size' => $fileSize, 'status' => 1))->find();
if ($exist) {
$this->ajaxReturn(array(
'code' => 0,
'fast_upload' => true,
'file_id' => $exist['id'],
'task_id' => ''
));
}
$taskId = md5(uniqid(mt_rand(), true));
$data = array(
'task_id' => $taskId,
'file_name' => $fileName,
'file_size' => $fileSize,
'chunk_size' => $chunkSize,
'chunk_count' => max(1, (int)ceil($fileSize / $chunkSize)),
'file_md5' => $md5,
'status' => 0, // 0 待上传
'create_time' => date('Y-m-d H:i:s'),
'update_time' => date('Y-m-d H:i:s'),
);
D('UploadTask')->add($data);
$this->ajaxReturn(array('code' => 0, 'task_id' => $taskId, 'chunk_count' => $data['chunk_count']));
}
这里有个容易踩的细节:chunk_count 不要只用 ceil($fileSize / $chunkSize),如果前端传的 chunk_size 是 0 或负数,ceil 会算出一个奇怪的结果,所以先强制转 int 再判断。file_name 一定不要直接拿来拼文件路径,后面会说为什么。
4.2 分块接收:宁可多校验也不能放裸奔
分块上传接口是整个系统里请求频率最高的接口,也是并发压力最大的地方。代码可以在保证安全的前提下尽量精简:
php复制public function uploadChunk() {
$taskId = trim(I('post.task_id', '', 'trim'));
$index = (int)I('post.index');
if (empty($taskId) || $index < 0) {
$this->ajaxReturn(array('code' => 10001, 'msg' => '参数错误'));
}
$task = D('UploadTask')->where(array('task_id' => $taskId))->find();
if (!$task) {
$this->ajaxReturn(array('code' => 10002, 'msg' => '任务不存在'));
}
if ($task['status'] != 0 && $task['status'] != 1) {
$this->ajaxReturn(array('code' => 10003, 'msg' => '任务状态不可上传'));
}
if ($index >= $task['chunk_count']) {
$this->ajaxReturn(array('code' => 10004, 'msg' => '分块索引越界'));
}
if (!isset($_FILES['chunk'])) {
$this->ajaxReturn(array('code' => 10005, 'msg' => '分块文件未上传'));
}
if ($_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
$this->ajaxReturn(array('code' => 10006, 'msg' => '分块上传错误' . $_FILES['chunk']['error']));
}
if ($_FILES['chunk']['size'] > $task['chunk_size']) {
$this->ajaxReturn(array('code' => 10007, 'msg' => '分块大小超限'));
}
$partDir = UPLOAD_PATH . 'tmp/' . $taskId . '/';
if (!is_dir($partDir)) {
mkdir($partDir, 0755, true);
}
$partFile = $partDir . str_pad($index, 6, '0', STR_PAD_LEFT) . '.part';
if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $partFile)) {
$this->ajaxReturn(array('code' => 10008, 'msg' => '分块保存失败'));
}
// 更新任务状态为上传中
D('UploadTask')->where(array('task_id' => $taskId))->save(array(
'status' => 1,
'update_time' => date('Y-m-d H:i:s'),
));
$this->ajaxReturn(array('code' => 0, 'index' => $index));
}
几个关键决策的原因:
- 用
str_pad($index, 6, '0', STR_PAD_LEFT)生成分块文件名,是为了让文件系统层面的排序天然按 0,1,2,...999999 的顺序排列,合并时直接glob出来就是正确的顺序。 - 用
move_uploaded_file而不是rename。这是 PHP 官方的要求,move_uploaded_file会校验上传文件是否确实来自 HTTP POST,避免攻击者伪造临时文件路径。 - 分块目录用 task_id 隔离,多个任务互不干扰。task_id 是服务端生成的 MD5 字符串,不包含用户输入,也就不会出现目录穿越问题。
4.3 分块合并:小细节决定成败
所有分块上传完成后,前端会调 merge 接口。这里最容易出问题,我给出一个可用于生产的合并方法:
php复制public function merge() {
$taskId = trim(I('post.task_id', '', 'trim'));
$task = D('UploadTask')->where(array('task_id' => $taskId))->find();
if (!$task) {
$this->ajaxReturn(array('code' => 10001, 'msg' => '任务不存在'));
}
if ($task['status'] == 2) {
$this->ajaxReturn(array('code' => 0, 'msg' => '已合并')); // 幂等返回成功
}
if ($task['status'] != 1) {
$this->ajaxReturn(array('code' => 10002, 'msg' => '任务状态错误'));
}
$partDir = UPLOAD_PATH . 'tmp/' . $taskId . '/';
$chunkCount = (int)$task['chunk_count'];
$missing = array();
for ($i = 0; $i < $chunkCount; $i++) {
$part = $partDir . str_pad($i, 6, '0', STR_PAD_LEFT) . '.part';
if (!file_exists($part)) {
$missing[] = $i;
}
}
if (!empty($missing)) {
$this->ajaxReturn(array('code' => 10003, 'missing' => $missing, 'msg' => '分块缺失'));
}
$destDir = UPLOAD_PATH . 'files/' . date('Ymd') . '/';
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
// 最终文件名用 task_id 而不是原始文件名,避免路径穿越和高危字符
$destFile = $destDir . $taskId . '.mp4';
$dest = fopen($destFile, 'wb');
if (!$dest) {
$this->ajaxReturn(array('code' => 10004, 'msg' => '无法创建目标文件'));
}
flock($dest, LOCK_EX);
for ($i = 0; $i < $chunkCount; $i++) {
$part = $partDir . str_pad($i, 6, '0', STR_PAD_LEFT) . '.part';
$in = @fopen($part, 'rb');
if ($in === false) {
fclose($dest);
@unlink($destFile);
$this->ajaxReturn(array('code' => 10005, 'msg' => '分块读取失败'));
}
// 每次最多复制 1MB,避免把整个分块一次性读进内存
stream_copy_to_stream($in, $dest, 1024 * 1024);
fclose($in);
@unlink($part);
}
fflush($dest);
flock($dest, LOCK_UN);
fclose($dest);
if (is_dir($partDir)) {
@rmdir($partDir);
}
// MD5 完整性校验
$realMd5 = md5_file($destFile);
if ($realMd5 !== strtolower($task['file_md5'])) {
@unlink($destFile);
D('UploadTask')->where(array('task_id' => $taskId))->save(array('status' => -1));
$this->ajaxReturn(array('code' => 10006, 'msg' => '文件校验失败'));
}
$fileId = D('FileIndex')->add(array(
'file_name' => $task['file_name'],
'file_path' => $destFile,
'file_md5' => $task['file_md5'],
'file_size' => $task['file_size'],
'status' => 1,
'create_time' => date('Y-m-d H:i:s'),
));
D('UploadTask')->where(array('task_id' => $taskId))->save(array(
'status' => 2,
'file_id' => $fileId,
'update_time' => date('Y-m-d H:i:s'),
));
$this->ajaxReturn(array('code' => 0, 'file_id' => $fileId));
}
合并里最重要的一点是:用 stream_copy_to_stream($in, $dest, 1024 * 1024) 而不是 file_get_contents + file_put_contents。后者会把分块甚至合并中的整个大文件读进 PHP 内存,一个 8GB 视频在默认 memory_limit=128M 下直接 OOM,进程静默被杀,连错误日志都看不到。
为什么合并后要删掉分块文件?如果保留,tmp/ 目录会无限膨胀。而且分块文件本身占用的空间和最终视频是“重复”的,不清理的话磁盘很快报警。
合并时的 flock($dest, LOCK_EX) 排它锁也是必要的。因为 merge 接口可能被前端重复请求,没有锁的话两个进程同时写同一个目标文件,会产生文件错乱。
4.4 文件落盘之后的分享与通知
视频上传不只是“存起来”,还要能分享给其他人。合并校验通过后,我在 file_index 表里登记一条记录,然后生成一个带 token 的分享链接:
php复制public function createShare() {
$fileId = (int)I('post.file_id');
$expireDays = max(1, min(30, (int)I('post.expire_days', 7)));
$token = md5(uniqid(mt_rand(), true) . $fileId);
D('FileShare')->add(array(
'file_id' => $fileId,
'share_token' => $token,
'expire_time' => date('Y-m-d H:i:s', time() + $expireDays * 86400),
'download_count' => 0,
'create_time' => date('Y-m-d H:i:s'),
));
$shareUrl = 'https://your-domain.com/index.php?m=File&a=download&token=' . $token;
$this->ajaxReturn(array('code' => 0, 'share_url' => $shareUrl));
}
下载接口有一个性能关键点:不要用 PHP 读文件然后 echo 输出,8GB 视频会让 PHP 内存和 CPU 全部爆炸。更稳妥的做法是配合 Nginx 的 X-Accel-Redirect:
php复制public function download() {
$token = trim(I('get.token', '', 'trim'));
$share = D('FileShare')->where(array('share_token' => $token))->find();
if (!$share || $share['expire_time'] < date('Y-m-d H:i:s')) {
$this->ajaxReturn(array('code' => 10001, 'msg' => '链接无效或过期'));
}
$file = D('FileIndex')->where(array('id' => $share['file_id']))->find();
if (!$file || !is_file($file['file_path'])) {
$this->ajaxReturn(array('code' => 10002, 'msg' => '文件不存在'));
}
// 交给 Nginx 的 internal 方式下载,PHP 只负责鉴权
header('X-Accel-Redirect: /protected_files/' . basename(dirname($file['file_path'])) . '/' . basename($file['file_path']));
header('Content-Disposition: attachment; filename="' . rawurlencode($file['file_name']) . '"');
exit;
}
这里的思路是:PHP 只做鉴权和定位文件,真正的文件读取和发送交给 Nginx,PHP 进程瞬间释放。这是生产环境跑大视频下载的标准姿势,比 readfile() 靠谱太多。
通知环节可以不依赖短信,简单点:合并完成后把 file_id 和任务信息丢到 Redis 队列,CLI 脚本消费后调用钉钉或企业微信机器人 webhook,把分享链接推给相关工程师。前端在上传页面轮询 queryStatus,状态变成 DONE 后自动跳转到“复制分享链接”页面,整个闭环就通了。
5. 高并发下的性能取舍:先动刀还是先加药
5.1 分块上传的QPS并没有想象中乐观
聊高并发之前,先算一笔账。5GB 视频分成 1024 个分块,这 1024 个请求不是一个用户点一下产生的,而是两分钟内由同一个浏览器持续打过来的。如果同时有 10 个工程师在上传,服务端在一段时间内会收到上万个小文件的写入请求。
PHP-FPM 在这种场景下的瓶颈非常明显:每个请求都要走一遍“FPM worker 获取 -> PHP 框架初始化 -> 路由分发 -> 控制器执行 -> 框架析构”,进程要反复创建和销毁。ThinkPHP 3.2.3 的初始化开销虽然不算大,但扛不住每秒几十个上传请求同时打进来。
因此,分块上传接口的第一原则是“轻”。我在生产环境里对 upload
