ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaWeb视频上传系统:分片上传与断点续传的工程实践

JavaWeb视频上传系统:分片上传与断点续传的工程实践 做工地监控回传系统那阵子我被一个问题反复折磨明明摄像头录得好好的视频文件在本地也完整可一传到服务器就断断了只能从头传。塔吊上的全景、深基坑里的隐蔽部位、关键工序的留档这些视频动不动几个GB现场4G网络稍微抖一下前面传的就全白干了。后来我整个重构了上传链路JavaWeb做服务端统一的分片上传接口配合视频解析和断点续传逻辑才真正把这个问题按住。这套方案跑下来现场从“人肉U盘拷视频”变成了“挂机自动传”监理要查资料也不用再等半天。这篇文章就把这套逻辑的完整落地过程拆给你看涉及视频解析、跨平台接入、分片续传的细节都会讲到适合正在做工地视频回传、工程远程监管这类系统的开发同学参考。1. 工程现场的链路现状为什么监控上传这么难1.1 工地网络的真实写照先说一个很多人容易忽略的事实工地的网络环境和办公室完全是两个物种。你以为的“有网”是光纤稳定、上下行对等、延迟低于10毫秒工地现场的“有网”可能是运营商基站飘过来的4G信号或者项目部拉了一条共享宽带上行带宽被十几个办公终端瓜分干净。我实地测过几个工地上行带宽看着有20Mbps实际传视频时经常掉到3-5Mbps而且波动特别大。塔吊上的全景摄像头通过无线网桥汇聚中间有大臂遮挡或者天气影响链路随时会闪断深基坑里干脆是信号死角视频只能先缓存在边缘盒子等信号恢复再补传。更麻烦的是监控录像机大多在NAT后面外网根本没法主动拉流唯一可靠的方式就是让设备主动把视频文件“推”出来。这就引出了第一个矛盾视频文件大、网络不稳定、还只能靠上行推送。很多项目组一开始的方案是简单地用FTP上传或者HTTP POST结果就是传一半断掉、文件损坏、服务器上留下一堆无法播放的半截文件。断点续传听起来是锦上添花在这种场景下其实是保命底线。1.2 视频文件与格式的现实差异工地上常见的监控视频和海康、大华、宇视的录像文件打交道最多。这些设备导出的文件大多是H.264或H.265编码的MP4/AVI封装码率从2Mbps到8Mbps不等。码率4Mbps的1080P视频录一个小时就是1.8GB一天的素材轻松超过40GB。真正让人头疼的还不是文件大而是格式不统一。有的设备输出的是标准MP4有的输出的是带私有头的AVI有的录像文件存在坏帧——直接拷贝到后端播放器可能花屏、音画不同步甚至解码失败。你还需要考虑GOP结构监控视频为了压缩效率帧序列里只有I帧是完整图像后面的P帧/B帧都要依赖I帧才能解码。如果上传时把一个视频从中间硬生生切开切点不是在I帧边界上合并后就会出现一段画面直接马赛克或者卡住。所以“视频解析”不是简单地把文件读一遍就算完。它要做的事情是识别封装格式、确认编码类型、拿到时长帧率、标记I帧位置、剔除坏帧、必要时重新转码统一参数。这一步做扎实了后面的分片、上传、合并才有意义。1.3 参与上传的“跨平台”设备都有谁说“跨平台”很多人第一反应是浏览器兼容性但工地场景远比这个复杂。我接触过的实际客户端有四种Windows工控机跑现场视频汇聚程序把NVR里的录像按计划转出来上传Linux嵌入式盒子装在铁皮箱里负责采集边缘视频处理器资源有限Android手机或平板监理人员在外面用App拍完现场照片、短视频随手回传Web后台管理人员在总部浏览器里补充上传第三方视频材料。这四种客户端语言各不相同运行环境也五花八门但它们的诉求是一样的把一个完整视频文件可靠地传到服务端。跨平台的核心不是“给每个平台各写一套上传代码”而是抽象出一套基于HTTP的标准化接口协议所有平台都遵守同一个约定服务端只用JavaWeb实现一套逻辑就能覆盖所有端。2. JavaWeb在视频汇聚链路中的角色2.1 为什么选JavaWeb做服务端这个方案里JavaWeb不是唯一选项但它是综合成本最低的选项。原因有三一是生态成熟Spring Boot做上传接口、状态管理、任务调度都很顺手相关的文件处理库、视频解析封装都有现成方案二是部署灵活工程现场的服务器可能是Linux也可能是Windows ServerJava的跨平台特性让你不用太纠结底层系统三是团队熟悉工程行业做信息化的大部分Java团队引入新语言反而增加维护负担。Python Flask写起来快Node.js异步IO也适合高并发上传但它们在工程行业的落地没有Java这套完整的中间件生态。尤其是后面要做到任务状态机、断点校验、分片合并、对接MySQL和RedisJava全家桶确实最稳。2.2 整体架构拆解实际落地时我的架构是这么拆的摄像头 / NVR ↓ RTSP/ONVIF/私有SDK 边缘采集端Windows/Linux/Android ↓ 分片上传 HTTP接口 JavaWeb汇聚服务Spring Boot ├── 上传接口创建任务、上传分片、查询状态、合并通知 ├── 视频解析服务FFprobe读取元数据 / FFmpeg转码抽帧 ├── 任务状态库MySQL存储任务元数据Redis记录分片状态 └── 存储层本地磁盘/对象存储 ↓ 业务系统查询回放、AI分析、监理审查采集端把原始监控视频先做一次本地解析和标准化处理转码、抽帧、按I帧切分然后以分片形式上传。JavaWeb服务端只做四件事记录任务、接收分片、校验状态、合并文件。文件合并完成后再交给后端的视频解析/AI分析服务去处理抽帧、识别、转码等重活。这么设计的好处是链路解耦上传只关心文件完整到达解析只关心视频质量互不拖累。如果在一个接口里又收文件又做解析上传反而会被拖慢而且解析一旦卡死整个上传任务就堵住了。2.3 核心数据模型设计凡是跟文件上传沾边的系统第一步都是把“一次上传”建模成一张任务表。我先把这个表设计出来后面所有逻辑都是围绕它转CREATE TABLE upload_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL UNIQUE, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, chunk_size INT NOT NULL, total_parts INT NOT NULL, status TINYINT NOT NULL COMMENT 0-已创建,1-上传中,2-已完成,3-失败, md5 VARCHAR(64), create_time DATETIME NOT NULL, update_time DATETIME NOT NULL );task_id是客户端创建任务时由服务端生成的唯一编号后面上传分片、查询进度、发起合并都靠它关联。chunk_size和total_parts在创建任务时就确定不允许后面改。这个模型是所有断点续传方案的基石客户端把整个文件按照固定大小切成N个分片服务端记录“哪些分片已经到位”上传过程中任何一端重启只要这个记录还在就能接着传。3. 视频解析在上传链路里到底要做什么3.1 解析监控视频的四个目标很多人觉得“视频解析”就是把视频放到转码服务里跑一遍但在我这套方案里解析服务于上传这个具体目标要做的事情精确多了。第一识别封装和编码。拿到一个文件先用FFprobe读出它的容器格式、编码格式、分辨率、帧率、时长。为什么这个重要因为不同录像机的文件结构不一样你只有知道它的真实参数才能决定后续怎么切分、是否需要转码。第二确认GOP结构找到I帧位置。前面提过监控视频被硬切成不齐I帧的分片合并后很大概率花屏。解析时把这个信息摸清切分时就有依据。第三剔除坏帧和异常段。工地的录像经常因为断电、存储写入异常产生一些有损片段。如果直接当作好视频传上去后端播放或分析时会出各种诡异问题。解析环节可以把异常帧标记出来严重的话转码时直接丢弃。第四生成辅助校验信息比如首帧缩略图、文件哈希。缩略图用于人工确认这段视频确实是有内容的哈希用于后续完整性校验。3.2 Java侧实现解析的两种方式Java生态里做视频解析最常见的是两条路线。路线一引入javacv直接用Java API调FFmpeg。依赖长这样dependency groupIdorg.bytedeco/groupId artifactIdjavacv/artifactId version1.5.9/version /dependency读取元数据的代码很简洁FFmpegFrameGrabber grabber new FFmpegFrameGrabber(file); grabber.start(); double duration grabber.getLengthInTime() / 1000000.0; double frameRate grabber.getFrameRate(); int width grabber.getImageWidth(); int height grabber.getImageHeight(); grabber.stop();javacv的优点是纯Java调用不依赖服务器上单独安装FFmpeg部署简单。缺点是依赖体积大一个javacv带一堆native库动辄几十MB而且版本升级频繁不同版本API有变化出了问题社区排查也费劲。路线二服务端装好FFmpeg/FFprobeJava用ProcessBuilder去调命令行。我用得最多的是这种因为FFmpeg在多媒体处理上就是事实标准命令行稳定可靠Java侧只负责组装参数、解析输出接收退出码。ProcessBuilder pb new ProcessBuilder( ffprobe, -v, quiet, -print_format, json, -show_format, -show_streams, filePath ); Process process pb.start(); String result new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); // 用 Jackson 解析 JSON提取时长、编码、宽高、帧率两条路线的取舍很简单如果现场服务器是内网隔离环境不允许随便装软件用javacv打进去如果服务器归你管或者有Docker环境用命令行FFmpeg最省心。我自己在Docker镜像里直接打包ffmpegJava应用通过ProcessBuilder调用稳定跑了很久。3.3 解析结果如何指导上传分片解析出不代表只停留在读取层面关键要让解析结果指导下一步操作。比如分片切分。常规做法是客户端拿到文件后按固定大小硬切比如每片10MB。但监控视频按GOP边界切才是更专业的做法。实际操作中我常常用FFmpeg直接切好分片再传ffmpeg -i input.mp4 -c copy -f segment -segment_time 30 -reset_timestamps 1 output_part_%03d.mp4-c copy是不重新编码直接按流拷贝速度极快-segment_time 30是每段30秒FFmpeg切分时会自动对齐I帧。这样生成的分片即使客户端网络极差导致某个分片没传完重新传那个分片就行最终合并后视频是完好的。还有一层我会在解析阶段生成缩略图和分片哈希。缩略图供上传后人工验收哈希用来做分片级的MD5校验确认某个分片确实完整到达而不是只凭着“文件大小对了”就判断成功。4. 断点续传的核心逻辑拆解4.1 分片上传的基本模型断点续传的原理说到底就是一句话把一个大的上传任务拆成多个独立的小任务记录每个小任务的完成情况失败后只重做未完成的部分。可以用一个搬家的例子来理解一整箱书从一楼搬到十楼一次搬一整箱走到五楼摔了整箱书得重新搬如果先拆成十个袋子走到五楼摔破一个袋子只要把摔破的那袋重新搬就行。分片上传就是这个思路——文件被切成N块每一块独立传输只有传输失败的那块需要重传。逻辑模型上上传流程被拆成四个阶段初始化客户端请求创建任务服务端生成task_id记录文件总大小和分片数量上传分片客户端拿着task_id逐个上传分片服务端每收一个就记录一个查询进度网络断了也好客户端主动退出也好重新连接后先查询“哪些分片已经传过”合并完成所有分片到位后客户端通知服务端合并文件。4.2 服务端状态校验流程服务端的核心责任是“确认每一个分片都完整地到达过”。我的校验逻辑是这样设计的客户端上传分片时接口接收三个关键参数taskId、partNo、文件本身。服务端处理步骤如下第一步校验taskId存在且任务状态是“上传中”第二步校验partNo是不是落在合法范围内1到total_parts之间第三步把收到的分片写入临时目录路径规则是temp/{taskId}/part_{partNo}第四步写入完成后在Redis里用HSET upload:task:{taskId} {partNo} 1标记这个分片已上传第五步返回成功客户端收到成功就继续传下一个分片。这里有一个关键点分片上传必须设计成幂等的。同一个partNo可能因为客户端超时重传而到达两次服务端不能因为重复上传就报错而应该直接覆盖旧分片并返回成功。否则弱网环境下光处理重试请求就够你喝一壶。合并接口在收到“完成通知”后先去查分片状态确认已上传分片数 total_parts然后按partNo从小到大把临时分片流式写入最终文件。写完后再判断最终文件大小是否等于任务表里的file_size不一致就返回失败让客户端重新补传缺失的分片。4.3 跨平台客户端的断点信息持久化服务端把已上传的分片记录在案客户端本地也不能什么都不管。因为用户在弱网环境下每上传一个分片都要在本地持久化记录进度——写进本地配置文件、SQLite或一个小JSON文件里。我习惯在客户端本地保存一份这样的记录{ taskId: 20250315_001, fileName: tower_cam_20250315_1400.mp4, totalParts: 120, finishedParts: [1, 2, 3, 5, 6, 9] }重新启动上传程序时客户端先读这份记录再调服务端GET /api/upload/status?taskIdxxx接口把服务端记录的分片和自己本地的记录做一次并集跳过已经传过的分片。为什么要拿服务端做准因为可能存在“客户端记录成功但服务端实际没落盘”的极端情况以服务端返回的已完成列表为准可以避免漏传。Android端用Kotlin写一个UploadRecordStore往SharedPreferences里写Windows端用C#或Java写JSON文件Linux盒子用Shell脚本记录。实现细节各不一样但逻辑完全一致这本身就是跨平台上传的标准姿势。4.4 并发分片与乱序合并现场网络带宽有限但有时为了充分利用多路通道我会让客户端同时并发传4-6个分片。注意分片顺序和上传顺序不保证一致Windows客户端可能先传完了第10片再传第3片。所以服务端合并时绝对不能依赖“谁先到谁排前面”必须按partNo显式排序。合并代码用流式顺序写入public void mergeParts(String taskId, int totalParts, Path target) throws IOException { ListPath partFiles Files.list(tempDir.resolve(taskId)) .filter(Files::isRegularFile) .sorted(Comparator.comparingInt(p - parsePartNo(p.getFileName().toString()))) .toList(); if (partFiles.size() ! totalParts) { throw new IllegalStateException(分片数量不匹配: {} / {}); } try (OutputStream out Files.newOutputStream(target)) { for (Path part : partFiles) { Files.copy(part, out); } } }并发还有一个副作用服务端同一时间收到多个分片请求写临时文件时会同时操作多个小文件这本身没问题。真正的坑在合并时不能有其他线程还在写分片否则文件被占住或者数据不一致。所以我专门在任务状态里加了一个“MERGING”状态合并开始后拒绝新分片上传从状态机上锁死这种竞态。5. 实操代码骨架从接口到文件合并5.1 后端接口定义与参数约定接口定义是整个跨平台方案的协议契约所有客户端都按这套接口对接。我用了最通用的REST风格加multipart/form-data文件上传任何语言都能实现接口方法关键参数返回/api/upload/initPOSTfileName, fileSize, chunkSizetaskId, totalParts/api/upload/partPOSTtaskId, partNo, file成功/失败/api/upload/statusGETtaskId已上传分片列表/api/upload/completePOSTtaskId, md5最终访问路径约定上有一个细节需要特别说明文件名必须使用URL编码后的UTF-8字符串避免不同的操作系统Windows的GBK和Linux的UTF-8对中文文件名处理不一致导致同一个文件在各平台解析出不同的名字。这个问题我在项目里踩过曾经一个“塔吊2025-03-15.mp4”在Windows端生成的任务到了Android端补传时因为文件名编码不一致服务端直接把它当成了两个文件折腾了一下午。5.2 Spring Boot核心实现Controller层很简单负责接收请求、做参数基础校验、把业务逻辑交给ServiceRestController RequestMapping(/api/upload) public class VideoUploadController { PostMapping(/init) public Result init(RequestBody InitRequest req) { return uploadService.initTask(req.getFileName(), req.getFileSize(), req.getChunkSize()); } PostMapping(/part) public Result uploadPart(RequestParam(taskId) String taskId, RequestParam(partNo) Integer partNo, RequestParam(file) MultipartFile file) throws IOException { uploadService.savePart(taskId, partNo, file.getInputStream()); return Result.success(); } GetMapping(/status) public Result status(RequestParam(taskId) String taskId) { return Result.success(uploadService.listUploadedParts(taskId)); } PostMapping(/complete) public Result complete(RequestBody CompleteRequest req) throws IOException { String filePath uploadService.mergeAndVerify(req.getTaskId(), req.getMd5()); return Result.success(filePath); } }savePart方法是整个方案的心脏我实际实现的逻辑是public void savePart(String taskId, int partNo, InputStream in) throws IOException { TaskRecord task taskMapper.findByTaskId(taskId); if (task null || task.getStatus() ! 1) { throw new BizException(任务不存在或已无法继续上传); } Path partFile tempDir.resolve(taskId).resolve(String.format(part_%04d, partNo)); // 直接覆盖写入天然支持重复上传 Files.copy(in, partFile, StandardCopyOption.REPLACE_EXISTING); // 记录分片状态到Redis并续期 String key upload:task: taskId; redisTemplate.opsForHash().put(key, String.valueOf(partNo), 1); redisTemplate.expire(key, Duration.ofDays(7)); }Redis记录分片状态时一定要带上过期续期。工地大视频可能传几小时甚至一整天如果初始化任务后没有续期动作Redis里的分片记录可能在上传途中过期客户端一查状态发现“什么都没传过”只能从头传那就真成事故了。5.3 前端与客户端如何配合Web端如果用现成的上传组件vue-simple-uploader是目前用得最多的选择。它自带分片、断点续传、文件校验、并发控制前端只需要写很少的代码重点是要和服务端的协议对齐。vue-simple-uploader里有一个关键配置testChunks: true。开启后每个分片上传前会先向后端发一个校验请求服务端返回“该分片是否已存在”如果已存在前端直接跳过不再重复上传断点续传的效率就直接拉满了。对应的服务端要单独实现一个分片检查接口GetMapping(/check) public Result checkChunk(RequestParam(taskId) String taskId, RequestParam(partNo) Integer partNo) { // 查Redis或临时目录返回该分片是否已存在 boolean exist redisTemplate.opsForHash().hasKey(upload:task: taskId, String.valueOf(partNo)); return Result.success(exist); }Android端用的是OkHttp本质上也是同样的分片逻辑自己管理taskId、partNo、文件读取上传完成再请求合并。Windows端和Linux盒子如果嫌原生代码麻烦甚至可以写个小脚本循环调用同一套接口。跨平台最终考验的不是语言能力而是协议设计的统一性和健壮性。6. 常见问题与排查技巧实录6.1 分片大小怎么定才合理分片大小没有绝对标准但要结合现场带宽和超时时间推算。带宽越差分片应该越小否则一个分片传太久中间一次普通网络抖动就会导致超时。我常用的经验值内网传输分片可以到50MB公网传输常规用5-10MB。一个4G弱网环境下10MB分片在3Mbps上行带宽下大约需要30秒配合60秒超时时间比较稳妥。后端服务又有另一个约束分片太小会导致接口请求次数暴增并发高了之后服务端的线程和IO开销变成瓶颈分片太大又会导致内存占用飙升MultipartFile解析时大文件容易撑爆堆内存。综合下来我用10MB作为默认值内网项目调到20MB弱网项目调到5MB并且把这个参数暴露在初始化接口里不同客户端可以自己调整。6.2 服务端重启后任务状态丢失这是线上最容易炸的问题。Redis是内存存储服务端一重启分片状态全没了。更尴尬的是客户端还在傻乎乎地继续上传服务端却认为任务不存在直接拒绝。我的解决办法是把任务主信息全部持久化到MySQLRedis只做“当前上传中任务”的缓存加速。服务启动的时候加一个恢复机制扫描临时目录里还有分片文件的taskId把对应任务状态重置为“上传中”同时把Redis里的分片状态重新从文件系统刷一遍。临时目录里的分片文件本身就是最可靠的“状态存储”——文件在就说明传过文件不在就是没传完。数据库文件系统双保险比单纯依赖Redis靠谱得多。6.3 断点续传的校验逻辑拖慢速度如果你加了分片级MD5校验速度会有明显下降因为每个分片都要先读完整个文件计算哈希再上传一遍。监控视频一个文件几十个分片计算哈希的累积时间不可忽视。我的替代方案是上传前只校验分片文件大小不计算MD5完整文件合并后用文件大小抽样校验代替全量哈希。具体做法是在解析阶段就明确知道视频的关键帧偏移位置合并后抽头、中、尾三个位置各解码一帧能正常解码就认为文件完整。这样既控制了完整性风险又不会因为算哈希把上传时间拉长到无法接受。当然如果有客户明确要求强校验还是得做分片MD5那就是拿时间换安全感要提前告知业务方。6.4 视频解析时FFmpeg进程挂死视频解析服务用ProcessBuilder调用FFmpeg时有个隐蔽的坑进程不主动退出Java的process.waitFor()永远等不到结果。原因通常是FFmpeg在等待输入或者解码卡在一个坏帧上。我的处理方式是给ProcessBuilder设置超时超时直接destroy进程强制释放FutureProcess future executor.submit(() - process.waitFor()); try { future.get(30, TimeUnit.SECONDS); } catch (TimeoutException e) { process.destroyForcibly(); throw new BizException(视频解析超时); }另外要注意FFmpeg的日志输出如果stderr缓冲区不读进程可能在写日志时被阻塞表现也是“卡死”。正确做法是把process.getErrorStream()异步消费掉或者直接用-loglevel error减少日志输出。6.5 合并后的视频花屏怎么排查分片上传完成后发现视频花屏最直接的原因是切分点没对齐I帧。这类问题排查路径很固定第一步先用ffprobe检查合并后文件的编码参数确认是不是所有分片参数一致。第二步切分时是否用了-c copy如果用了确认原始文件本身是否存在坏帧。第三步看服务端合并顺序确认是按partNo排序而不是按上传时间排序。最后用FFplay在花屏位置前后逐帧看确认是否每次都在同一位置花屏——如果花屏位置固定基本可以断定是切分点问题。一个额外提醒监控视频是流式结构文件末尾往往有索引区合并多段时如果用简单拼接索引区会错乱。所以我对监控视频的约定是一律用FFmpeg预切分生成标准MP4分片服务端只做物理拼接不做数据结构上的二次组装这样可以从源头绕开索引问题。6.6 上传任务长时间不完成怎么办施工现场经常会发生“视频传了一半设备断电了”的意外。服务端不能无限挂着这些半截任务。我维护了一个定时任务扫描超过72小时未完成的任务在数据库里标记为“失败”同时清理临时分片文件。客户端下次启动时查询到任务状态为失败会重新从init接口创建一个新任务新任务使用新的taskId旧的临时文件则按时间周期清除防止磁盘被占满。这套逻辑看下来你会发现核心没那么玄乎解析解决“视频能不能切、怎么切”跨平台解决“不同端怎么对接同一套协议”断点续传解决“断了之后怎么接着走”JavaWeb解决“服务端怎么承接这一切”。但每一环都有无数个细节稍不注意就会在线上翻车。我一路踩过来的经验是不要一开始就搞高并发分片和花式校验先把一个客户端、一个分片、一次断点续传跑通再加并发、加平台、加校验。系统复杂了以后定位问题的时间成本会指数级上升前期的克制后期都是回报。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进