1. 项目概述:为什么大文件上传不能“一锤子买卖”
你有没有遇到过这样的场景:用户在后台要上传一个2GB的工程视频,刚拖进浏览器窗口,进度条卡在5%不动了;刷新页面重试,系统提示“文件已损坏”;换台电脑再传,结果又在87%时断网——前功尽弃。这不是个别现象,而是所有面向真实用户的Web系统迟早要直面的硬骨头。php大文件切片/分片上传思路+代码[支持断点续传]这个标题里藏着三个关键词:切片、断点续传、PHP后端支撑,它们共同指向一个核心问题:如何让大文件上传这件事,从“赌运气”变成“可预期、可恢复、可监控”的确定性操作。
我做过不下十个涉及媒体资产、医疗影像、CAD图纸上传的项目,最深的体会是:前端传得再漂亮,只要后端没设计好分片逻辑、没处理好文件合并的原子性、没校验每一片的完整性,整个上传链路就等于建在沙子上。很多团队一开始用$_FILES直接接收,发现50MB以上就超时、内存溢出、Nginx报413错误,然后慌忙加upload_max_filesize和post_max_size——这就像给漏水的船拼命加水泵,治标不治本。真正的解法,是从协议层重构上传流程:把一个大文件切成固定大小的“数据块”,每个块独立HTTP请求上传,服务端按序存储、校验、拼接,失败时只重传丢失的那几片,而不是从头再来。这个思路本身不新鲜,但落地时90%的PHP项目会栽在五个地方:分片哈希一致性校验缺失、并发写入冲突、临时文件清理机制失效、断点状态持久化不可靠、以及最关键的——没做服务端分片合并的幂等性保障。接下来我会把整套方案拆成可抄、可调、可验证的实操模块,不讲虚的,只说我在某高校数字档案系统、某工业检测平台里反复打磨过的那一套。
2. 整体架构设计与关键决策逻辑
2.1 为什么必须放弃“单次上传”而选择分片?
很多人觉得“切片”只是为了解决超大文件限制,这是严重误解。分片的本质,是把一个长时、高风险、不可控的I/O操作,拆解为多个短时、低风险、可验证的原子操作。我们来算一笔账:假设上传一个1.8GB的DICOM医学影像,网络平均速率5MB/s,理论耗时6分钟。但实际中,TCP连接可能因WiFi切换中断、运营商QoS限速、CDN节点抖动等原因,在第3分42秒突然断开。单次上传模式下,用户只能重头开始,再耗6分钟;而分片模式下,假设每片10MB,共180片,前112片已成功,只需重传剩余68片(约136秒),节省近5分钟。更重要的是,分片带来了三重确定性:
- 可验证性:每片上传后,服务端立即计算MD5并返回校验结果,前端可立刻判断是否需重传;
- 可暂停性:用户点击“暂停”,前端停止发送后续分片,服务端状态保持不变;
- 可并发性:前端可同时发送3~5个分片请求(受浏览器并发限制),大幅提升吞吐。
提示:不要盲目追求高并发数。我实测过Chrome下同时发10个分片请求,反而因TCP拥塞控制导致整体耗时增加17%。建议默认3并发,允许用户手动调节。
2.2 分片策略选型:固定大小 vs 文件特征驱动
市面上常见两种分片方式:一是按固定字节数切(如每片5MB),二是按文件结构切(如按ZIP文件的central directory对齐)。对于通用型PHP上传服务,固定大小分片是唯一合理选择。原因有三:
- 实现简单,边界清晰:前端JS用
File.slice()即可精准截取,无需解析二进制格式; - 服务端无状态:每个分片只依赖
file_id + chunk_index两个参数,不需维护文件元信息映射表; - 容错性强:即使某片因网络丢包损坏,重传时只需原样再发一次,无需重新计算切点。
我曾尝试过“智能分片”方案——根据文件类型动态调整分片大小(文本类1MB、视频类10MB、压缩包5MB),结果在某次线上故障排查中发现:当用户上传一个被误标为application/octet-stream的PDF时,系统按1MB切片,导致合并后PDF头部损坏无法打开。最终回归到统一5MB分片,配合强校验,稳定性提升至99.997%。
2.3 断点续传的核心:状态持久化必须脱离内存
断点续传的“断点”二字,本质是服务端对“已接收哪些分片”的权威记录。很多初学者直接用PHP的apcu_store()或redis::set()存状态,这是危险操作。APCu在PHP-FPM进程重启后清空,Redis若配置为内存-only且未开启AOF,宕机即丢失。真正可靠的方案,必须满足:持久化、事务性、低延迟。我们采用“数据库+本地缓存”双写策略:
- 主存储:MySQL一张
upload_chunks表,字段含file_id(UUID)、chunk_index(INT)、status(ENUM: 'pending','success','failed')、md5_hash(CHAR32)、created_at; - 缓存层:Redis作为状态查询加速,键为
upload_status:{file_id},值为JSON数组[1,2,5,7,...](已成功分片索引); - 写入逻辑:分片上传成功后,先INSERT数据库(带
INSERT IGNORE防重复),再更新Redis缓存。数据库作为唯一真相源,Redis仅作读优化。
注意:
INSERT IGNORE不是银弹。某次压测发现,当同一文件的两个分片几乎同时到达(毫秒级),可能因MySQL间隙锁导致死锁。解决方案是在file_id + chunk_index上建唯一联合索引,并捕获1213 Deadlock错误后重试3次。
2.4 合并策略:为什么必须用fopen('a')而非file_put_contents()
分片全部接收完毕后,合并是最后一步,也是最容易出错的环节。常见错误是循环读取每个分片文件,用file_put_contents($final, $content, FILE_APPEND)追加。问题在于:file_put_contents()每次调用都会重新打开文件、定位末尾、写入、关闭,180片就要开闭文件180次,I/O开销巨大。更致命的是,若合并中途服务器崩溃,最终文件会是前N片的拼接,而系统状态已标记为“完成”,形成数据不一致。
正确做法是:用fopen($final, 'a')获取一个持久文件指针,循环fwrite()写入各分片内容,最后fclose()。这样整个合并过程只有1次文件打开和1次关闭,且PHP的fwrite()在POSIX系统上是原子的(小于PIPE_BUF的写入)。我们在某工业检测平台实测:合并1.2GB文件,传统方式耗时23.7秒,fopen方式仅需4.1秒,且崩溃恢复后可从断点继续。
3. 核心细节解析与实操要点
3.1 前端分片上传的健壮性设计
前端是用户第一触点,其健壮性直接决定体验上限。我们基于原生Fetch API构建,不依赖任何第三方库,确保最小化体积和最大可控性。核心逻辑分四步:
- 文件预检:读取文件
lastModified时间戳、size、type,生成全局唯一file_id = md5(file.name + file.size + file.lastModified),避免同名文件覆盖; - 分片计算:
chunk_size = 5 * 1024 * 1024(5MB),total_chunks = Math.ceil(file.size / chunk_size); - 并发控制:用
Promise.allSettled()管理3个并发请求队列,每完成一个即启动下一个; - 断点探测:上传前先GET
/api/chunk-status?file_id=xxx,获取已成功分片索引数组,跳过已传部分。
关键代码片段(精简版):
// 分片上传主函数 async function uploadChunks(file, fileId, chunkSize = 5*1024*1024) { const totalChunks = Math.ceil(file.size / chunkSize); const uploadedChunks = await getUploadedChunks(fileId); // 获取已传分片 const pendingChunks = Array.from( { length: totalChunks }, (_, i) => i ).filter(i => !uploadedChunks.includes(i)); // 3并发上传 for (let i = 0; i < pendingChunks.length; i += 3) { const chunkBatch = pendingChunks.slice(i, i + 3); await Promise.allSettled( chunkBatch.map(index => uploadSingleChunk(file, fileId, index, chunkSize)) ); } } // 单片上传 async function uploadSingleChunk(file, fileId, index, chunkSize) { const start = index * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('file_id', fileId); formData.append('chunk_index', index); formData.append('chunk', blob, `${fileId}_${index}`); const response = await fetch('/api/upload-chunk', { method: 'POST', body: formData, }); if (!response.ok) throw new Error(`Upload failed for chunk ${index}`); return response.json(); // 返回服务端校验结果 }实操心得:
file.slice()在Safari 14.1以下版本存在bug,对大于2GB的文件可能返回空Blob。解决方案是降级为ArrayBuffer读取:const arrayBuffer = await file.arrayBuffer(); const chunk = arrayBuffer.slice(start, end);。我们在线上环境强制检测浏览器版本,Safari<14.1时自动启用降级路径。
3.2 PHP后端分片接收的幂等性保障
PHP接收分片看似简单,实则暗藏杀机。最常被忽视的是重复提交问题:用户网络卡顿,前端可能因超时重发同一分片;CDN或代理服务器也可能重试请求。若后端不做幂等处理,会导致同一分片被写入多次,最终文件损坏。
我们的解决方案是“三重校验锁”:
- 数据库唯一索引锁:
upload_chunks表的file_id + chunk_index设为唯一键,重复INSERT直接失败; - MD5内容校验锁:接收分片后,立即计算
md5_file($_FILES['chunk']['tmp_name']),与前端传递的X-Chunk-MD5头比对,不一致则拒绝; - 临时文件原子重命名锁:分片存储路径为
/tmp/uploads/{file_id}/{chunk_index}.part,写入完成后执行rename(),该操作在Linux下是原子的,避免读写竞争。
PHP核心接收代码:
// api/upload-chunk.php $fileId = $_POST['file_id'] ?? ''; $chunkIndex = (int)($_POST['chunk_index'] ?? -1); $expectedMd5 = $_SERVER['HTTP_X_CHUNK_MD5'] ?? ''; if (empty($fileId) || $chunkIndex < 0 || empty($_FILES['chunk']['tmp_name'])) { http_response_code(400); echo json_encode(['error' => 'Invalid request']); exit; } $uploadDir = '/tmp/uploads/' . $fileId; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } $chunkPath = $uploadDir . '/' . $chunkIndex . '.part'; $tempPath = $_FILES['chunk']['tmp_name']; // 1. MD5校验 $actualMd5 = md5_file($tempPath); if ($actualMd5 !== $expectedMd5) { http_response_code(400); echo json_encode(['error' => 'Chunk MD5 mismatch']); exit; } // 2. 原子写入:先写临时文件,再重命名 $atomicPath = $uploadDir . '/' . $chunkIndex . '.part.tmp'; if (move_uploaded_file($tempPath, $atomicPath)) { if (rename($atomicPath, $chunkPath)) { // 3. 数据库记录(带唯一约束) $pdo->prepare(" INSERT IGNORE INTO upload_chunks (file_id, chunk_index, md5_hash, status) VALUES (?, ?, ?, 'success') ")->execute([$fileId, $chunkIndex, $actualMd5]); // 更新Redis缓存 $redis->sAdd("upload_status:{$fileId}", $chunkIndex); echo json_encode(['status' => 'success', 'chunk' => $chunkIndex]); } else { http_response_code(500); echo json_encode(['error' => 'Atomic rename failed']); } } else { http_response_code(500); echo json_encode(['error' => 'Move failed']); }注意:
move_uploaded_file()必须在rename()前调用,否则rename()会因源文件不存在失败。我们曾在线上遇到过move_uploaded_file()返回true但文件实际未落盘的情况(PHP Bug #79542),因此在rename()后额外执行file_exists($chunkPath)二次确认。
3.3 断点状态同步的实时性优化
前端需要实时知道“还剩多少片没传”,如果每次上传前都GET一次/api/chunk-status,在高并发场景下会造成数据库压力。我们的优化方案是“状态推送+本地缓存”:
- 服务端推送:当分片上传成功,PHP在写入数据库后,向Redis发布消息
PUBLISH upload_progress:{$fileId} '{"chunk":12,"total":180}'; - 前端订阅:用Server-Sent Events(SSE)连接
/api/progress-sse?file_id=xxx,实时接收进度更新; - 本地缓存:前端用
localStorage缓存{fileId: {uploaded: [1,2,5], total: 180}},页面刷新后优先读缓存,再用SSE补全。
SSE服务端代码(PHP):
// api/progress-sse.php header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); $fileId = $_GET['file_id'] ?? ''; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 订阅Redis频道 $redis->subscribe(["upload_progress:{$fileId}"], function($redis, $channel, $message) { $data = json_decode($message, true); echo "event: progress\n"; echo "data: " . json_encode($data) . "\n\n"; ob_flush(); flush(); });实操心得:SSE在Nginx反向代理下需配置
proxy_buffering off;和proxy_cache off;,否则消息会被缓冲。我们曾因此导致进度条卡住30秒,最终在Nginx配置中加入这两行解决。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖配置
在开始编码前,必须确保运行环境满足最低要求。我们以Ubuntu 22.04 + PHP 8.1 + Nginx 1.18为基准环境,所有配置均经过生产验证。
PHP扩展要求:
gd:用于后续图片缩略图生成(非必需,但推荐);mbstring:处理多字节文件名(如中文);opcache:提升脚本执行效率;redis:用于状态缓存(pecl install redis)。
关键PHP配置项(php.ini):
; 关闭脚本超时,由业务逻辑控制 max_execution_time = 0 ; 允许大POST数据(但不用于单次上传,仅防意外) post_max_size = 128M ; 上传临时目录权限 upload_tmp_dir = "/var/tmp/php-uploads" ; 内存限制需足够处理大文件哈希 memory_limit = 512MNginx配置要点(/etc/nginx/sites-available/upload.conf):
server { listen 80; server_name upload.example.com; # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; } # 上传接口特殊配置 location /api/upload-chunk { client_max_body_size 10M; # 单片最大10MB,匹配前端chunk_size proxy_pass http://php-fpm; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Chunk-MD5 $http_x_chunk_md5; # 透传MD5头 } # SSE接口配置 location /api/progress-sse { proxy_pass http://php-fpm; proxy_cache off; proxy_buffering off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 保持长连接 proxy_http_version 1.1; proxy_set_header Connection ''; } }提示:
client_max_body_size必须严格等于或略大于前端chunk_size,否则Nginx会在PHP处理前就返回413错误。我们曾将此值设为50M,结果前端传5MB分片时偶尔触发Nginx内部缓冲区bug,导致请求体截断。最终锁定为10M,留出5MB余量。
4.2 数据库表结构与初始化脚本
upload_chunks表是断点续传的基石,其设计直接影响性能和可靠性。我们采用InnoDB引擎,核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | BIGINT UNSIGNED AUTO_INCREMENT | 主键,无业务意义 |
file_id | CHAR(32) | 文件唯一标识,MD5值 |
chunk_index | INT UNSIGNED | 分片序号,从0开始 |
status | ENUM('pending','success','failed') | 当前状态 |
md5_hash | CHAR(32) | 分片MD5校验值 |
created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | 创建时间 |
updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
建表SQL:
CREATE TABLE `upload_chunks` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `file_id` char(32) NOT NULL, `chunk_index` int unsigned NOT NULL, `status` enum('pending','success','failed') NOT NULL DEFAULT 'pending', `md5_hash` char(32) NOT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_file_chunk` (`file_id`,`chunk_index`), KEY `idx_file_status` (`file_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;初始化检查脚本(init-db.php):
<?php $pdo = new PDO('mysql:host=localhost;dbname=upload', 'user', 'pass'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 检查表是否存在 $stmt = $pdo->query("SHOW TABLES LIKE 'upload_chunks'"); if (!$stmt->fetch()) { echo "Creating upload_chunks table...\n"; $pdo->exec(file_get_contents('create-table.sql')); echo "Table created.\n"; } else { echo "upload_chunks table already exists.\n"; } // 清理7天前的pending状态(防僵尸任务) $pdo->prepare("DELETE FROM upload_chunks WHERE status = 'pending' AND created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)")->execute(); echo "Cleanup completed.\n";注意:
UNIQUE KEY uniq_file_chunk是幂等性的物理保障,必须存在。我们曾在线上删除此索引调试,结果导致同一分片被写入37次,合并后文件体积膨胀37倍,用户投诉激增。
4.3 分片合并与最终文件生成
当所有分片接收完毕,触发合并流程。我们设计为“异步合并+状态轮询”,避免阻塞HTTP请求。核心逻辑:
- 前端检测到
uploaded_chunks.length == total_chunks,发起POST /api/merge-file; - PHP创建合并任务,写入
upload_tasks表,返回task_id; - 后台用
systemd定时器每30秒扫描待处理任务,执行合并; - 前端轮询
GET /api/task-status?task_id=xxx获取进度。
合并核心函数(merge-file.php):
function mergeFileChunks($fileId, $finalPath) { global $pdo, $redis; // 1. 从数据库获取所有成功分片 $stmt = $pdo->prepare(" SELECT chunk_index, md5_hash FROM upload_chunks WHERE file_id = ? AND status = 'success' ORDER BY chunk_index ASC "); $stmt->execute([$fileId]); $chunks = $stmt->fetchAll(PDO::FETCH_ASSOC); // 2. 校验分片连续性 $expectedCount = count($chunks); $maxIndex = $expectedCount > 0 ? max(array_column($chunks, 'chunk_index')) : -1; if ($maxIndex + 1 !== $expectedCount) { throw new Exception("Chunk sequence broken. Expected {$expectedCount}, got up to {$maxIndex}"); } // 3. 原子合并 $fp = fopen($finalPath, 'a'); if (!$fp) { throw new Exception("Cannot open final file: {$finalPath}"); } foreach ($chunks as $chunk) { $chunkPath = "/tmp/uploads/{$fileId}/{$chunk['chunk_index']}.part"; if (!file_exists($chunkPath)) { throw new Exception("Chunk file missing: {$chunkPath}"); } // 二次MD5校验 if (md5_file($chunkPath) !== $chunk['md5_hash']) { throw new Exception("Chunk MD5 mismatch: {$chunkPath}"); } $content = file_get_contents($chunkPath); if (false === fwrite($fp, $content)) { throw new Exception("Write failed for chunk {$chunk['chunk_index']}"); } } fclose($fp); // 4. 清理临时文件 $uploadDir = "/tmp/uploads/{$fileId}"; if (is_dir($uploadDir)) { exec("rm -rf {$uploadDir}"); } // 5. 更新任务状态 $pdo->prepare("UPDATE upload_tasks SET status = 'completed', result_path = ? WHERE file_id = ?") ->execute([$finalPath, $fileId]); }后台合并守护进程(merge-daemon.php):
#!/usr/bin/env php <?php // 此脚本由systemd定时启动,每30秒执行一次 while (true) { try { $pdo = new PDO('mysql:host=localhost;dbname=upload', 'user', 'pass'); $stmt = $pdo->prepare(" SELECT file_id, final_path FROM upload_tasks WHERE status = 'pending' LIMIT 1 "); $stmt->execute(); $task = $stmt->fetch(PDO::FETCH_ASSOC); if ($task) { mergeFileChunks($task['file_id'], $task['final_path']); } } catch (Exception $e) { error_log("Merge daemon error: " . $e->getMessage()); } sleep(30); }实操心得:
file_get_contents()读取大分片会占用大量内存。某次处理4GB文件时,PHP进程内存飙升至2.1GB。解决方案是改用fopen()+stream_copy_to_stream()流式复制:$src = fopen($chunkPath, 'r'); $dst = fopen($finalPath, 'a'); stream_copy_to_stream($src, $dst); fclose($src); fclose($dst);内存占用稳定在12MB以内。
4.4 完整上传流程演示与参数调优
我们以上传一个1.5GB的MP4视频为例,完整走一遍流程,标注关键参数和实测数据。
步骤1:前端初始化
- 文件名:
project_demo.mp4 file_id = md5("project_demo.mp4" . 1572864000 . 1528640000) = "a1b2c3d4..."(32位)chunk_size = 5MB→total_chunks = ceil(1572864000 / 5242880) = 300片
步骤2:并发上传
- 前端启动3个并发请求,每批处理100片(300/3)
- 单片上传耗时:网络RTT 45ms + 服务端处理120ms = 165ms(实测均值)
- 理论总耗时:
ceil(300/3) * 165ms = 16500ms ≈ 16.5秒 - 实际耗时:19.3秒(含DNS解析、TCP握手、SSL协商等)
步骤3:断点续传模拟
- 上传至第142片时,模拟网络中断(
kill -9前端进程) - 用户重启上传,前端GET
/api/chunk-status?file_id=a1b2c3d4...,返回[0,1,2,...,141] - 跳过前142片,仅重传剩余158片,耗时:
ceil(158/3) * 165ms ≈ 9.2秒
关键参数调优指南:
| 参数 | 推荐值 | 说明 | 调优依据 |
|---|---|---|---|
chunk_size | 5MB | 平衡网络传输与磁盘I/O | 小于1MB增加HTTP开销,大于10MB增加单次失败损失 |
concurrent_upload | 3 | 浏览器并发限制 | Chrome/Firefox默认6,但实际受TCP拥塞影响,3最稳 |
redis_ttl | 24h | 进度缓存过期时间 | 防止Redis内存无限增长,24h覆盖绝大多数上传场景 |
db_cleanup_interval | 7天 | pending状态清理周期 | 避免僵尸任务占满数据库,7天足够用户完成上传 |
提示:
chunk_size不是越大越好。我们测试过50MB分片,在4G网络下上传成功率99.2%,但在弱网(3G/200ms RTT)下降至87.3%。5MB分片在同等弱网下成功率98.6%,证明小分片对网络抖动容忍度更高。
5. 常见问题与排查技巧实录
5.1 上传卡在某个分片,日志显示“Connection reset by peer”
现象:前端上传到第87片时,Fetch请求一直pending,最终超时,Nginx error.log出现upstream prematurely closed connection while reading response header from upstream。
排查路径:
- 检查PHP-FPM慢日志:
tail -f /var/log/php8.1-fpm-slow.log,发现upload-chunk.php执行超时; - 查看该分片临时文件:
ls -lh /tmp/uploads/a1b2c3d4.../87.part,大小为0; - 追查
/var/log/php8.1-fpm.log,发现PHP Warning: move_uploaded_file(): Unable to move ... Permission denied。
根因:/tmp/uploads目录属主为root,而PHP-FPM以www-data用户运行,无写入权限。
解决方案:
# 创建专用上传目录,属主设为www-data sudo mkdir -p /var/tmp/php-uploads sudo chown www-data:www-data /var/tmp/php-uploads sudo chmod 755 /var/tmp/php-uploads # 修改php.ini upload_tmp_dir = "/var/tmp/php-uploads"经验:永远不要用
/tmp作为上传临时目录。/tmp可能被系统清理工具(如tmpwatch)定期清空,导致正在上传的分片被删。我们线上强制使用/var/tmp,该目录不会被自动清理。
5.2 合并后文件损坏,用file命令检测为“data”而非“MP4”
现象:前端显示“上传成功”,但下载的MP4无法播放,file project_demo.mp4输出project_demo.mp4: data。
排查路径:
- 检查合并日志:
grep "merge" /var/log/upload.log,发现Chunk MD5 mismatch: /tmp/uploads/a1b2c3d4.../222.part; - 手动计算该分片MD5:
md5sum /tmp/uploads/a1b2c3d4.../222.part,与数据库记录不符; - 检查该分片上传时的
X-Chunk-MD5头,发现前端计算的是blob.slice().md5,而服务端计算的是tmp_name文件MD5。
根因:前端用new Blob([arrayBuffer]).md5计算,但Blob构造可能引入填充字节;服务端用md5_file()计算磁盘文件,二者不一致。
解决方案:统一用ArrayBuffer计算MD5。前端改为:
// 读取ArrayBuffer后计算MD5 const arrayBuffer = await file.arrayBuffer(); const chunkArray = arrayBuffer.slice(start, end); const chunkMd5 = await calculateMd5(chunkArray); // 使用Web Crypto API服务端保持md5_file($tempPath)不变,因move_uploaded_file()保证字节完全一致。
注意:
calculateMd5()必须用SubtleCrypto.digest(),而非第三方MD5库,后者在大文件下可能内存溢出。我们封装了流式MD5计算函数,支持GB级文件。
5.3 Redis缓存与数据库状态不一致,前端显示“已传100%”但实际缺片
现象:用户看到进度条100%,点击“完成”却提示“文件不完整”,查数据库发现chunk_index=299缺失。
排查路径:
- 检查Redis缓存:
redis-cli SMEMBERS "upload_status:a1b2c3d4...",返回1,2,3,...,298(缺299); - 检查数据库:
SELECT * FROM upload_chunks WHERE file_id='a1b2c3d4...' AND chunk_index=299,记录存在且status='success'; - 检查Redis写入日志,发现
SADD upload_status:a1b2c3d4... 299命令执行成功。
根因:Redis缓存是SET类型,而SADD添加元素时,若客户端连接闪断,命令可能未送达。数据库是最终一致源,但前端只读Redis。
解决方案:前端状态校验升级为“Redis + DB双检”。在进度100%时,前端发起GET /api/verify-complete?file_id=xxx,服务端执行:
// verify-complete.php $stmt = $pdo->prepare(" SELECT COUNT(*) as cnt FROM upload_chunks WHERE file_id = ? AND status = 'success' "); $stmt->execute([$fileId]); $result = $stmt->fetch(PDO::FETCH_ASSOC); $total = (int)$_GET['total_chunks'] ?? 0; if ($result['cnt'] == $total) { echo json_encode(['complete' => true]); } else { echo json_encode(['complete' => false, 'missing' => $total - $result['cnt']]); }前端收到complete:false,自动触发GET /api/chunk-status全量拉取,修正本地缓存。
实操心得:永远不要信任缓存作为唯一真相源。我们线上所有状态查询接口,都设计为“缓存快速响应 + DB兜底校验”双模式,缓存命中率92%,DB校验耗时<15ms。
5.4 高并发上传时MySQL连接池耗尽,出现“Too many connections”
现象:100个用户同时上传,show processlist显示200+连接,upload_chunks表INSERT变慢,部分请求超时。
根因:每个分片上传请求都新建PDO连接,未复用。PHP-FPM默认max_children=50,但每个请求可能打开多个连接(分片上传、状态查询、合并触发等)。
解决方案:连接池化 + 查询合并。
- 使用
PDO::ATTR_PERSISTENT => true启用持久连接; - 将分片状态查询从“每片一次”优化为“批量查询”:前端上传前GET
/api/chunk-status-batch?file_id=xxx&chunks=1,2,3,4,5,服务端用WHERE chunk_index IN (1,2,3,4,5)一次查出; - 对
upload_chunks表添加复合索引KEY idx_file_status_chunk (file_id, status, chunk_index),加速范围查询。
优化后效果:MySQL连接数稳定在60以内,分片上传P95延迟从1