资讯动态

分片加密回溯上传:大文件视频安全归档实战

发布时间:2026/9/28 8:03:40 来源:尧图企业网站定制
去年年底我接手了一个军工科研单位的内部实验视频归档系统需求一页纸都写不满但真正做起来差点把头发熬光。实验过程要录制多路高清视频单路文件经常超过2GB多路同时上传要占满整个内网带宽。更别提数据敏感领导要求视频从离开摄像头那一刻起就必须有加密保护还要保证任何一段视频都能追溯到具体的实验时间点和上传链路。也就是这次我把从前端切片到后端合并这一串流程组合成了“分片加密回溯上传”方案。这篇博文就完整讲讲这个方案的设计、实现和踩坑适合正在做视频上传系统特别是对安全性和完整性有高要求的各行业数据管理人员参考。1. 实验视频上传的三座大山大文件、敏感性和不可断点1.1 2GB的4K视频为什么不能直接POST很多初接触视频归档的同事第一反应是直接用input typefile配合HTTP POST传上来不就行了吗如果你只传过几十MB的照片确实感觉不到问题。但实验视频一旦是4K分辨率录制十分钟就轻松超过2GB直接POST会遇到三座大山。第一浏览器和服务器内存压力巨大。传统表单上传PHP会先把整个文件读入临时文件但如果服务器同时接收几十路视频磁盘IO和内存都会被打爆。第二PHP配置和Web服务器限制。upload_max_filesize、post_max_size、nginx client_max_body_size这些默认值通常只有2M到几十M你得每个值都手动调大而且调大后任何一次上传失败都会产生一个巨大的error post请求浪费带宽。第三断点续传几乎不可能。一旦网络抖动、用户误关页面、服务重启整个文件从头再来几个小时的实验视频就白传了。所以必须走分片上传在浏览器端把大文件按二进制切片一个分片一个普通POST请求后端逐个接收。这样每个请求大小可控内存占用少还能记录哪些分片已接收哪片失败了下次只补失败的片就行。1.2 “回溯上传”的隐藏含义数据完整性审计刚开始听需求时领导说“要支持回溯上传”我以为就是断点续传结果被确认并不过关。在军工科研场景回溯并不仅仅是“上次没传完接着传”而是要回答三个问题第一某段视频是哪个用户、在什么时间、从哪个IP地址上传的第二这段视频在上传过程中有没有被篡改、有没有分片丢失、合并后是否完整第三实验复盘时我想精确看到第几分钟第几秒的画面系统能不能根据时间戳直接定位到对应分片也就是说“回溯上传”包含了断点续传的能力但更重要的是建立一套上传过程的审计链路让每一段视频都能从“实验时间点”一路追溯回“物理分片”和“网络事件”。这套逻辑做出来之后不仅满足领导要求还顺带解决了数据定责和故障定位的问题。1.3 技术栈选型为什么是HTMLPHP而不是Java/Node接到这个项目时单位内部已有的信息化系统大部分是PHP传统栈维护团队对PHP非常熟。我也清楚如果单论高并发Node或Java可能更强但在内网环境中同时并发上传的实验视频路径通常不超过30路PHP透过合理优化完全撑得住。更重要的是PHP的openssl扩展支持AES、RSA、SM4等加密算法配合file_put_contents、flock等函数处理分片落盘很直接开发效率高。当然选型有一个前提前端必须用现代浏览器也就是支持HTML5 File API和Web Crypto API的Chrome/Firefox内核浏览器。军工内网现在普遍用的是定制版Chromium这个门槛完全能接受。如果还在用老掉牙的IE那就得换方案了。下面所有代码示例都是基于原生JavaScript和PHP 7.4不涉及任何框架依赖方便你迁移到ThinkPHP或Laravel里。2. 前端分片与加密把视频切成可独立验证的碎片2.1 用Blob.slice()切片避免浏览器内存爆炸前端的核心是File对象。你可能知道File是Blob的子类但很多人写切片时会先读取ArrayBuffer再分割这是误区。正确做法是直接使用Blob.prototype.slice它只在逻辑上切分不会一次性把整个大文件载入内存。以下是一个基础切片循环const fileInput document.getElementById(videoFile); const file fileInput.files[0]; const chunkSize 10 * 1024 * 1024; // 10MB let start 0; let index 0; while (start file.size) { const chunk file.slice(start, start chunkSize); const filename chunk_${index}.bin; // 后续将chunk上传 uploadChunk(chunk, index, filename); start chunkSize; index; } console.log(共${index}个分片);切片过程中浏览器不会立刻读入全部字节而是等每个chunk真正被FormData提交时才读取对应那一段。所以切片几千个也不会让内存爆炸前提是你不要在某一个变量里同时保留所有chunk的引用。这里还有个细节切片大小选10MB是我压测后觉得比较稳的值。如果切片太小如1MBHTTP请求和校验的开销会很大如果太大如100MB又会回归大HTTP请求的整体风险一旦某个分片失败重传成本太高。对于实验视频你可能还需要记录每个分片对应的原始视频时间区间。但纯切片的方式无法直接拿到视频内部时间码一个变通办法是记录分片在文件中的字节偏移量后续用ffprobe在合并完成后去解析关键帧时间戳再映射到数据库。我们后端就是这么做的。2.2 前端加密AES-GCM优于CBCCryptoJS vs Web Crypto API实验视频一旦泄露就是事故所以必须在上传链路中加密。加密可以在前端做也可以后端做但领导要求“从离开摄像头的那一刻起就必须加密保护”所以最合理的是在前端完成视频分片加密传输全程都是密文。在选加密算法时我强烈建议用AES-256-GCM。很多人习惯用AES-CBC但CBC不提供完整性认证攻击者可以翻转密文某一位导致解密后数据发生可预测的变化等于“数据完整性没有保证”。而GCM是认证加密模式同时提供机密性和完整性解密时会校验认证标签任何一位被篡改都会导致解密失败。这在视频回溯审计里非常重要——我们不仅要防外部窃听还要防内网人员篡改实验数据。前端实现有两种路线CryptoJS纯JavaScript使用方便但AES-GCM支持需要额外插件而且依赖库本身不小。Web Crypto API浏览器原生接口支持AES-GCM性能好但有两个前提必须运行在HTTPS环境即使是内网也需要自建CA证书以及必须处理异步Key生成。军工环境很容易搭建内网HTTPS所以我选了Web Crypto API。核心加密代码如下async function encryptChunk(chunk, key, iv) { const algorithm { name: AES-GCM, iv: iv }; const cipherBuffer await crypto.subtle.encrypt(algorithm, key, chunk); return new Uint8Array(cipherBuffer); }每个分片都使用不同的随机IV12字节。密钥怎么来不能前端写死也不能每次上传都用同一个。我们采用的是“信封加密”前端先随机生成一个对称密钥32字节使用后端发布的RSA公钥或国密SM2公钥对对称密钥进行加密上传到init接口。后端保存的是加密后的对称密钥实际解密分片时在内存中还原。这个过程后面第5章详细展开。2.3 给每个分片加上“身份证”哈希与元数据设计加密后的数据块就是一堆二进制如果不做额外标记上传过程中根本不知道哪个分片属于哪个任务。所以必须为每个分片设计元数据。我定义了一个清单结构类似下面这样{ upload_id: a3f2c9e8-7b1e-4d11-82a0-123456789abc, index: 17, size: 10485760, checksum_sha256: 7d1ffa06..., iv_b64: dGhpc2lzMTJieXRlaXY, experiment_id: EXP-20231201-08, file_offset: 178257920, timestamp_hint: 35.2 }这里checksum_sha256是加密前原始分片的哈希当然也可以对加密后密文算哈希但原始哈希能在解密前确定数据是否被篡改更稳妥。iv_b64记录该分片加密时使用的IV解密时需要它。file_offset表示这个分片在源文件中的字节偏移量方便后期定位。timestamp_hint是实验开始时按分片估算的时间位置如果实验设备有专门日志这个字段可以更准。更重要的一点我建议在所有分片哈希上做一条“哈希链”分片N的元数据里不仅有自己的哈希还要带上分片N-1的哈希。这样如果某个分片缺失或者顺序被换在合并阶段能通过链式校验立刻发现。代码上就是每次构建哈希前先把上一个分片的哈希拼接进当前分片的原始字节末尾再计算。这个思想借鉴了区块链的防篡改逻辑用在实验数据回溯上非常有效。3. 后端PHP接口接收分片、校验、落盘、计算进度3.1 API设计init、upload、merge、retry后端我采用了5个RESTful接口分工非常明确接口方法职责/api/upload/initPOST创建上传任务生成upload_id返回分片大小建议和公钥/api/upload/chunkPOST接收单个分片写入临时目录更新进度记录/api/upload/statusGET查询某个upload_id已接收的分片索引列表供断点续传/api/upload/mergePOST校验所有分片合并成完整视频文件做二次校验/api/upload/retryPOST强制重传指定分片前端自动调用或手工触发为什么需要init接口因为它能提前生成upload_id、创建任务对应的目录、记录用户和实验元数据还可以根据服务端配置返回前端建议的分片大小比如配置文件改了分片大小可以动态下发。这个设计比纯粹在chunk接口里传一个文件名要严谨得多。init接口的核心PHP代码如下以便捷的方式描述路由public function init(Request $request) { $userId $request-user()-id; $experimentId $request-input(experiment_id); $uploadId Str::uuid()-toString(); $dir storage_path(uploads/{$uploadId}); mkdir($dir . /chunks, 0755, true); $status [ upload_id $uploadId, created_at now(), updated_at now(), experiment_id $experimentId, user_id $userId, total_chunks 0, received_chunks [], ]; file_put_contents($dir . /status.json, json_encode($status)); $publicKey file_get_contents(storage_path(keys/public_key.pem)); return response()-json([ upload_id $uploadId, chunk_size 10 * 1024 * 1024, public_key $publicKey, ]); }chunk接口是出镜率最高的后面会细说。3.2 分片临时存储策略目录结构、命名规则与防冲突临时目录结构很简单但命名规则必须小心。上传任务a3f2c9e8...下分片文件我命名为chunk_00017.bin索引补零到5位这样在glob()按字典拉取时顺序天然就是0,1,2,...,99...。如果索引位数不固定比如19、100、1000混一起字符串排序就会乱套。同时每个任务根目录下放一个status.json这个文件是所有分片状态的唯一权威记录。无论处分片怎么读写状态都以它为准。防冲突是分片上传最容易踩坑的地方。正常流程是前端先调status再只上传缺失片。但如果有两个客户端同时操作同一个upload_id或者前端有多个上传并发线程同一分片可能被同时提交两次。我们的方案是后端对每个分片的上传操作加文件锁flock并做“检查-写入”的原子操作。也就是先判断状态文件中是否已有该分片的哈希如果有且哈希一致直接返回“已存在”前端就跳过。3.3 断点续传的实现用状态文件记录已接收分片断点续传的可实现性完全取决于状态记录的可靠性。我们是这样更新状态的public function uploadChunk(Request $request) { $uploadId $request-input(upload_id); $index intval($request-input(index)); $file $request-file(chunk); $hash $request-input(checksum_sha256); $dir storage_path(uploads/{$uploadId}); $statusFile $dir . /status.json; $fp fopen($statusFile, c); flock($fp, LOCK_EX); $status json_decode(file_get_contents($statusFile), true); // 幂等逻辑 if (isset($status[received_chunks][$index])) { if ($status[received_chunks][$index][hash] $hash) { flock($fp, LOCK_UN); fclose($fp); return response()-json([status exists]); } // 哈希不一致则删除旧片重新接收 array_forget($status[received_chunks], $index); } // 保存分片文件 $chunkPath $dir . /chunks/ . sprintf(%05d.bin, $index); $file-moveTo($chunkPath); // 校验哈希 if (hash_file(sha256, $chunkPath) ! $hash) { unlink($chunkPath); flock($fp, LOCK_UN); fclose($fp); throw new UploadException(分片哈希不匹配); } // 更新状态 $status[received_chunks][$index] [ size $file-getSize(), hash $hash, iv $request-input(iv_b64), offset $request-input(file_offset), timestamp_hint $request-input(timestamp_hint), received_at now(), ]; $status[received_chunks] array_sort($status[received_chunks], index); file_put_contents($statusFile, json_encode($status, JSON_PRETTY_PRINT)); flock($fp, LOCK_UN); fclose($fp); return response()-json([status ok, received count($status[received_chunks])]); }注意几个关键点状态文件用c模式打开且全程持有独占锁。分片文件在锁内写避免“另一并发进程刚刚读完状态便写入覆盖了前一个进程的更新”。不要用MySQL来记录分片状态虽然也安全但每次上传都要几十次数据库写入临时文件阶段用JSON文件速度快得多合并成功后再写入MySQL。前端断点续传时先请求status拿到已接收的索引数组然后过滤掉这些分片再继续上传async function getUploadedChunks(uploadId) { const res await fetch(/api/upload/status?upload_id${uploadId}); const data await res.json(); return data.received; // [0,1,2,...,15] }如果用户关掉浏览器我们前端会把upload_id存进localStorage下次打开页面自动恢复任务。这个细节很关键否则断点续传就不是真正的续传而是重传整个大文件。3.4 合并与二次校验确保合成后的视频完整可用当客户端上传完所有分片后调用merge接口。合并时我们按照索引顺序逐块读取、写入最终文件public function merge(Request $request) { $uploadId $request-input(upload_id); $dir storage_path(uploads/{$uploadId}); $statusFile $dir . /status.json; // 再次确认所有分片就绪 $status json_decode(file_get_contents($statusFile), true); $expectedCount $status[total_chunks]; if (count($status[received_chunks]) ! $expectedCount) { throw new UploadException(分片不完整已接收{$expectedCount}中的 . count($status[received_chunks])); } $finalPath storage_path(videos/{$status[experiment_id]}_ . basename($uploadId) . .mp4); $outFp fopen($finalPath, wb); for ($i 0; $i $expectedCount; $i) { $chunkPath $dir . /chunks/ . sprintf(%05d.bin, $i); if (!file_exists($chunkPath)) { throw new UploadException(缺失分片{$i}); } $inFp fopen($chunkPath, rb); stream_copy_to_stream($inFp, $outFp, null, 1024 * 1024); fclose($inFp); } fclose($outFp); // 二次校验计算整体哈希并与前端记录的哈希链比对 $finalHash hash_file(sha256, $finalPath); if ($finalHash ! $status[final_sha256]) { unlink($finalPath); throw new UploadException(最终哈希校验失败请重传); } // 视频完整性验证 exec(ffprobe -v quiet -print_format json -show_format $finalPath, $probeOut); $format json_decode(implode(, $probeOut), true); if (empty($format[format][duration])) { unlink($finalPath); throw new UploadException(视频文件无法解析合并可能不完整); } // 记录数据库 DB::table(video_archives)-insert([...]); return response()-json([status ok, video_id $videoId]); }合并最忌讳的是用file_put_contents直接整个读入再追加那样会爆内存。stream_copy_to_stream是一个一个分片流的复制每次只占用1MB缓冲区。合并后一定要验证哈希如果前端在init阶段上传了原始视频的SHA-256此时能精确匹配如果没有就验证分片哈希链重算的结果。我们系统里两个都做了。ffprobe的验证也很值得做因为有时视频文件字节完全一致但封装格式损坏仍无法播放。ffprobe可以读取时长和编码信息和实验开始时间、预期时长对比防止实验录制设备本身出现丢帧。4. 回溯机制与审计追踪让每一次上传都有据可查4.1 回溯中的数据链路设计从实验时间点到物理分片“回溯上传”最终要给业务老师一个入口他们打开实验时间轴拖动时间指针系统直接调用对应物理分片或合并视频的某个区间。这个能力需要我们在数据库里建立一个映射关系。首先在视频归档表里记录每个视频的元数据包括实验ID、开始时间、结束时间由ffprobe计算、总大小、总时长、分片数。其次在分片表中记录每个分片的序号、字节偏移量在视频中的对应时间这个由解析结果后回填、文件路径、加密IV、哈希。当用户查询某个时间点时SQL可以这样定位SELECT * FROM video_segments WHERE video_id ? AND start_time ? AND end_time ? ORDER BY seg_index;对于单路连续录制的视频由于分片是按固定字节大小切的对应的时长并不等于固定秒数。更精确的做法是合并后运行ffprobe提取所有关键帧-show_frames -select_streams v生成时间戳-字节位置索引表。实验视频一般不超过2小时关键帧索引表也就几万行存MySQL完全没问题。我们用的是video_frames表记录帧时间、大小、所在分片。这样回溯有两种粒度粗粒度是实验时间轴上的秒级别通过分片定位细粒度是关键帧级别可以精确到每一帧。军工科研复盘时经常要看某个传感器触发瞬间的画面关键帧索引表帮了大忙。4.2 日志记录上传事件、时间戳、分片哈希、操作用户除了视频数据审计追踪还要求把所有上传事件记录成一条不可抵赖的日志。我们实现了一个简单但有效的日志体系每条日志包含日志ID、时间戳、用户ID、IP、请求路径、分片索引如果是分片操作、分片哈希、最终文件路径、请求结果以及“上一日志哈希”。每次写日志时都会读取最后一次日志记录的hash拼接当前记录内容后做SHA-256形成哈希链。任何人对历史日志的篡改会导致后续所有哈希不匹配审计一查就能发现。下面是日志写入核心代码function writeAuditLog(array $entry, $logFile audit.log) { $previousHash getLastLogHash($logFile); // 读取最后一行的“prev_hash”字段 $entry[ts] now(); $entry[prev_hash] $previousHash; $line json_encode($entry, JSON_UNESCAPED_UNICODE); $entry[hash] hash(sha256, $line); file_put_contents($logFile, json_encode($entry) . PHP_EOL, FILE_APPEND | LOCK_EX); }这种链式日志不需要引入区块链或复杂的签名服务但对内网审计已经足够。如果想更严谨可以用服务器的HSM对每条日志的hash做数字签名但考虑到性能和部署成本我们目前只用了哈希链配合文件权限只允许PHP进程写入安全等级已经很好了。4.3 问题复盘一次真实的上传中断和回溯过程这里分享一个实际发生的问题很说明回溯机制的威力。有一次一位实验人员在01号实验室上传多路视频传输到第37/100个分片时内网交换机故障网络中断。前端Model自动重试了3次失败页面上弹出“网络异常”但数据没有丢失。网络恢复后实验人员重新打开上传页面我们前端从localStorage读到上次的upload_id调用status发现服务器已接收了0-36分片第37分片因为没写入成功不在列表中。于是自动从第37分片继续传完。听起来很顺畅真实情况是交换机故障导致Status接口也访问不了前端本地以为所有36个分片传完了实际上其中有3个分片因为交换机故障在传输一半时就断了服务器根本没收完整。由于我们chunk接口做了哈希校验这三个分片的文件因为校验失败或半截写入在status.json里并没有被标记为success。前端通过status获取到的列表准确反映只有34个分片成功所以断点续传自动重新上传的是第35、36、37分片。这里没有翻车。但我们也遇到过另一个问题实验人员反映合并后的视频总时长比原始录制时长少了3秒。因为所有分片哈希都一致最终哈希也一致说明字节层面完全没丢。后来追查日志发现原始视频本身在录制过程中就出现了丢帧——实验室的磁盘阵列在某个时间点因电磁干扰速度下降编码器自动丢了3秒非关键帧。如果没有回溯日志这个问题会被归咎于我们的上传系统引发信任危机。但因为我们记录了分片哈希、原视频元数据以及ffprobe提取的关键帧时间可以证明字节和关键帧索引都正常再把问题定位到录制端。这个案例让我意识到实验数据管理不仅要有上传环节的完整性保障还要有能回溯到录制端异常的时间戳映射。5. 安全加固与性能调优从能用到好用5.1 加密密钥的管理前后端如何分配才安全加密密钥是整条链路的命门。如果直接在JavaScript里写死一个密钥搬运工就是失败的攻击者只要打开浏览器控制台就能拿到密钥加密形同虚设。我们采用的是“信封加密”方案流程如下前端调用init接口拿到RSA公钥或国密SM2公钥。前端使用crypto.getRandomValues()生成一个32字节的对称密钥KEK作为本次上传任务的数据加密密钥。用RSA公钥加密KEK把密文随init请求发送给后端。后端保存KEK的密文私钥不离开密钥服务器或HSM。之后所有分片的解密请求后端内存里用私钥解出KEK然后再用KEK解密每个分片。分片本身用AES-256-GCM加密每个分片的IV不同并保存在元数据中。这样做的好处是即使攻击者拿到了数据库里的上传文件和KEK密文没有RSA私钥也无法解出KEK无法解密视频。私钥可以进一步存放在加密机或独立Key Management Service中一般内网环境可以放在一台单独的机器上通过semanage等策略限制访问。如果单位要求使用国密算法可以把RSA换成SM2AES-GCM换成SM4-GCM。PHP的openssl扩展通常内置SM3/SM4但SM2可能需要额外安装sodium或专用扩展。军工环境对国密有硬性要求时建议优先走该路线。5.2 PHP进程并发与nginx配置对分片上传的影响分片上传虽好但服务端默认配置不改分片也传不上来。真正实践下来我认为有两个层面的配置必须调整。php.ini里需要调整的核心参数upload_max_filesize 20M post_max_size 20M max_execution_time 3600 max_input_time 3600 memory_limit 256M分片大小我们定为10MB所以upload_max_filesize和post_max_size设置为20M留些余量。max_execution_time以前默认30秒上传分片时网络稍微一卡就可能超时必须调大。内存限制设定256M是因为我们需要在内存中解压密文虽然可以流式解密但给点余量更稳。nginx.conf里需要调整的client_max_body_size 20m; client_body_timeout 3600; fastcgi_read_timeout 3600; proxy_read_timeout 3600;注意client_max_body_size设置为分片大小而不是整体文件大小。很多新手直接设置为8G这样一旦客户端以单请求上传大文件nginx会全盘接收再转发给PHP又回到了原始大文件上传的灾难。正确做法是限制20M逼着客户端走分片。并发控制方面为了防止一个用户开50个并发请求打爆服务器需要限制每用户同时上传的分片数前端可以写一个线程池控制并发数比如最大同时3个分片后端还可以用Redis计数器做准数据限流。5.3 大视频的场景压测结果与参数推荐系统上线前我在内网用10台客户端同时上传2GB视频每台1个任务共20GB服务端是8核16G的虚拟机NginxPHP-FPMPHP最大子进程数设为16。压测结果每个客户端的平均上传速度达到57MB/s基本占满千兆内网的传输上限整个任务20GB约7分钟完成。期间CPU平均在70%内存占用峰值1.2GB没有出现PHP进程崩溃。这里有几个参数推荐分片大小10MB前端最大并发上传数3不要超过5因为多个并发请求在内网会有TCP窗口竞争反而降低总吞吐PHP-FPM的pm.max_children建议设为CPU核数×2我们8核设16Nginx的worker_processes设为CPU核数前端并发控制代码我这里也贴一个简单的队列实现const queue []; let activeWorkers 0; const MAX_WORKERS 3; async function pushToQueue(fn) { queue.push(fn); if (activeWorkers MAX_WORKERS) { worker(); } } async function worker() { activeWorkers; while (queue.length) { const task queue.shift(); try { await task(); } catch (e) { console.error(e); } } activeWorkers--; }有了这个队列前端即使有几百个分片同一时刻也只会发送3个请求。后端压力大大缓解断点续传时也不会瞬间涌入大量请求。最后我还想多提醒一句状态文件status.json是所有分片上传的核心状态任何对它的读写都必须加文件锁。我们曾因为少写了一个LOCK_EX导致两个并发请求同时更新status其中一个把另一个的进度覆盖盖了最后合并时发现分片缺失。排查了几个小时才定位到是加锁问题。如果你用的是MySQL或Redis来做状态记录请务必设置唯一键和事务如果用JSON文件一定别省flock。这套系统上线至今扛住了每天几十个实验的多路视频上传和回溯查询没有再出现“传了一半全白费”的情况。我最大的体会是技术方案不是堆砌新框架而是把分片、加密、校验、审计这几件事按正确的顺序咬合起来。如果你也在做类似的数据归档系统可以先从分片上传和断点续传做起再慢慢把加密和回溯日志加进去别想着一口吃成胖子。真正踩过坑之后你会发现看上去最不起眼的索引命名和文件锁反而是整个系统能不能稳定跑一年的胜负手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑