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 * 10245MBtotal_chunks Math.ceil(file.size / chunk_size)并发控制用Promise.allSettled()管理3个并发请求队列每完成一个即启动下一个断点探测上传前先GET/api/chunk-status?file_idxxx获取已成功分片索引数组跳过已传部分。关键代码片段精简版// 分片上传主函数 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);。我们在线上环境强制检测浏览器版本Safari14.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 EventsSSE连接/api/progress-sse?file_idxxx实时接收进度更新本地缓存前端用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.confserver { 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引擎核心字段如下字段名类型说明idBIGINT UNSIGNED AUTO_INCREMENT主键无业务意义file_idCHAR(32)文件唯一标识MD5值chunk_indexINT UNSIGNED分片序号从0开始statusENUM(pending,success,failed)当前状态md5_hashCHAR(32)分片MD5校验值created_atDATETIME DEFAULT CURRENT_TIMESTAMP创建时间updated_atDATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP更新时间建表SQLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;初始化检查脚本init-db.php?php $pdo new PDO(mysql:hostlocalhost;dbnameupload, 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-filePHP创建合并任务写入upload_tasks表返回task_id后台用systemd定时器每30秒扫描待处理任务执行合并前端轮询GET /api/task-status?task_idxxx获取进度。合并核心函数merge-file.phpfunction 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:hostlocalhost;dbnameupload, 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.mp4file_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_ida1b2c3d4...返回[0,1,2,...,141]跳过前142片仅重传剩余158片耗时ceil(158/3) * 165ms ≈ 9.2秒关键参数调优指南参数推荐值说明调优依据chunk_size5MB平衡网络传输与磁盘I/O小于1MB增加HTTP开销大于10MB增加单次失败损失concurrent_upload3浏览器并发限制Chrome/Firefox默认6但实际受TCP拥塞影响3最稳redis_ttl24h进度缓存过期时间防止Redis内存无限增长24h覆盖绝大多数上传场景db_cleanup_interval7天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手动计算该分片MD5md5sum /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_index299缺失。排查路径检查Redis缓存redis-cli SMEMBERS upload_status:a1b2c3d4...返回1,2,3,...,298缺299检查数据库SELECT * FROM upload_chunks WHERE file_ida1b2c3d4... AND chunk_index299记录存在且statussuccess检查Redis写入日志发现SADD upload_status:a1b2c3d4... 299命令执行成功。根因Redis缓存是SET类型而SADD添加元素时若客户端连接闪断命令可能未送达。数据库是最终一致源但前端只读Redis。解决方案前端状态校验升级为“Redis DB双检”。在进度100%时前端发起GET /api/verify-complete?file_idxxx服务端执行// 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_children50但每个请求可能打开多个连接分片上传、状态查询、合并触发等。解决方案连接池化 查询合并。使用PDO::ATTR_PERSISTENT true启用持久连接将分片状态查询从“每片一次”优化为“批量查询”前端上传前GET/api/chunk-status-batch?file_idxxxchunks1,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