
教育类网页应用里上传视频基本是个绕不开的需求录播课、实验演示、课件讲解、活动回放……文件一多一节课的视频动辄几百MB到1GB以上教师端网络又五花八门有办公室光纤也有偏远地区用手机热点撑的。用Java做后端的项目如果还是走传统的multipart整体上传一到集中提交作业、发布新课的高峰服务器就频繁超时、内存告警甚至直接把服务拖挂。分块上传就是来解决这个问题的把大文件切成若干小份逐份上报后端逐块接收、最终合并配合断点续传和秒传教育视频上传才能算真正可用。这篇文章不聊空泛的架构直接给出一个可以照着落地的Java分块上传方案包括接口设计、Spring Boot核心代码、前端切片配合以及我在生产环境踩过的坑。适合正在做在线教育平台、教务系统或者任何有大文件上传需求的Java后端同学参考。1. 教育视频上传的真实困境大文件直传为什么不能再用1.1 教育视频文件到底有多大先说个基本判断教育行业里的视频和普通用户传个手机拍摄的短视频完全是两个量级。一节45分钟的录播课720P编码后大概300-500MB1080P的课程录像1GB很正常如果是实验课的高帧率采集、或者教师上传的原片素材2GB以上也不稀奇。再叠加慕课平台要转码的母片、直播课的回放切片单文件体积普遍处于“超过常规HTTP上传安全阈值”的区间。更麻烦的是并发。教育平台的上传行为有明显的峰谷特征开学前两周老师们集中传新课周五下午教研活动结束后集中传录像期中期末前集中传作业视频。一下涌进来几十上百个大文件如果走的还是直传通道后端处理压力会非常难看。1.2 直传大文件的三道坎内存、超时、全量重传先说内存。Spring Boot默认用Tomcat接收multipart请求虽然spring.servlet.multipart可以配置大小上限但大文件在请求解析阶段仍然会在服务端产生临时文件缓冲。如果max-file-size设置不当文件一到阈值就直接抛MaxUploadSizeExceededException如果为了“能传”把它设成-1不限制又会把内存和临时磁盘都拖下水。我用过一段时间的“暴力直传”方案高峰期Tomcat堆外内存涨得飞快最后只能靠重启缓解。再说超时。大文件上传是个长期HTTP连接中间任何一环超时都会断Nginx的proxy_read_timeout默认60秒客户端等待响应超时校园网里一次Wi-Fi切换就可能让一个传了80%的任务瞬间报废。更致命的是直传模式没有“已传了多少”的概念断了就整个重来。一个800MB的文件传到80%断掉教师端看到进度清零心态直接崩溃然后开始疯狂重试形成恶性循环。最后是带宽。教育平台的出口带宽再大也经不住几十个GB级文件同时涌进来。直传意味着服务端必须全量接收每个请求的body少一个字节都不行而分块上传则可以配合并发控制和失败重传把“一个大文件占用整条链路”变成“多个小块交错上传”压力平滑很多。1.3 为什么教育行业更需要分块上传教育场景有几个特殊点决定了它不是“能传就行”而是必须体验稳定教师端设备差异大。有高配办公电脑也有老旧笔记本、平板、甚至手机传课件浏览器版本参差不齐不支持现代断点续传API的设备很常见。分块上传的兼容性比依赖单一fetch大文件请求要稳。网络环境复杂。课堂教学视频往往在校园网内上传但校园网的出口带宽并不总是充足有时候一个文件要传几十分钟。这种长任务如果没有“传了多少、还剩多少、断了怎么续”的机制教师的操作成本非常高。私有化部署多。很多K12学校、高校和培训机构要求数据不出校门服务器放在本地机房带宽和存储都是有限资源。比起公网环境可以借助云厂商的大带宽私有化环境更需要在上传协议层面做精细控制。所以结论很清楚Java后端支持视频分块上传不是“优化项”而是“必需品”。2. 分块上传方案的整体设计初始化、上传、合并三个关键接口2.1 三段式交互流程与四个接口分块上传的标准思路是把一个上传任务拆成三个阶段先“登记”再“逐块传”最后“合起来”。对应到HTTP接口上就是这样接口方法作用核心参数返回/api/upload/initPOST初始化上传任务登记文件元数据检测秒传fileName、fileSize、fileMd5、totalChunksfileId、是否需要续传/api/upload/chunkPOST上传单个分块fileId、chunkIndex、chunkMd5、file分块接收状态/api/upload/checkGET查询已上传分块列表支持断点续传fileId已上传的chunkIndex数组/api/upload/mergePOST合并所有分块为完整文件fileId、fileName最终存储路径这个设计有三个好处第一init接口承担了“判断任务是新是旧”的责任。前端先算好整个文件的MD5调用init时后端查一下数据库如果这个MD5之前已经传完过直接返回“秒传成功”省掉整个上传过程。这在教师重复上传同一节课视频时极其有用——很多老师传完A班又给B班补一份文件可能一模一样。第二upload接口解耦了“块与块之间的依赖”。每个分块都携带自己的fileId和chunkIndex后端只负责把这一小块落盘不需要关心其他块是否到达。哪个快丢了就补哪个非常灵活。第三check接口让续传逻辑变得简单。前端重新进入上传页时先调一下check拿回哪些分块已经传成功直接跳过剩下的只补缺的块。2.2 分块大小与并发度怎么定分块大小没有绝对标准但教育视频场景下我建议选4MB到8MB。分块太小比如512KB会导致请求数量爆炸。一个1GB文件切出2048个块每个块都要走一次完整的HTTP握手、鉴权、落盘、记录光是请求头的开销和数据库写入次数都够喝一壶。分块太大比如64MB又失去了分块的意义一个块出错就要重传64MB而且后端合并时的内存和临时空间压力也会变大。4MB意味着1GB文件约256个块8MB约128个块这个量级对这个方案里的数据库和文件系统都很友好。并发度建议控制在3到5个。前端如果一次并发10个分块请求看起来更快但实际上校园网环境下并发过高反而会触发TCP拥塞控制导致丢包率上升、每个请求都变慢整体吞吐不升反降。我在实际项目里用4个并发效果最稳定。2.3 数据表结构与存储目录设计分块上传需要记录两类信息文件整体状态和每个分块的状态。我的表结构设计如下CREATE TABLE upload_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_id VARCHAR(64) NOT NULL COMMENT 上传任务唯一ID, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, file_md5 VARCHAR(64) NOT NULL DEFAULT , total_chunks INT NOT NULL, uploaded_chunks INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-上传中 1-已合并 2-失败, storage_path VARCHAR(500) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_file_id (file_id), KEY idx_file_md5 (file_md5, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE file_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_id VARCHAR(64) NOT NULL, chunk_index INT NOT NULL, chunk_size BIGINT NOT NULL, chunk_md5 VARCHAR(64) NOT NULL DEFAULT , chunk_path VARCHAR(500) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_file_chunk (file_id, chunk_index) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;存储目录上我习惯按任务ID隔离/data/edu-video/ └── {fileId}/ ├── chunks/ # 分块临时目录 │ ├── 0.part │ ├── 1.part │ └── ... └── {fileName} # 合并后的最终文件分块文件直接用数字索引命名合并时按数字排序即可不要用UUID或者时间戳命名——后面你就知道合并逻辑直接用chunkIndex拼路径省一次解析。3. Spring Boot 后端实现可直接落地的分块上传核心代码3.1 基础配置与依赖我用的是Spring Boot 2.7.x MyBatis-Plus MySQL这块逻辑比较固定换成JPA也不影响理解。核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency配置文件里注意两点一个是multipart大小一个是上传根路径spring: servlet: multipart: max-file-size: 8MB max-request-size: 64MB datasource: url: jdbc:mysql://localhost:3306/edu_upload?useUnicodetruecharacterEncodingutf8 username: root password: root upload: dir: /data/edu-videomax-file-size要跟前端分块大小匹配。前端切4MB这里设8MB留出余量max-request-size设64MB是为了允许多个分块并发请求同时也兼容某个异常情况下的大请求。3.2 初始化接口与秒传检测初始化接口干三件事检查秒传、生成fileId、登记文件元数据。代码如下RestController RequestMapping(/api/upload) RequiredArgsConstructor public class UploadController { private final UploadFileMapper uploadFileMapper; private final FileChunkMapper fileChunkMapper; Value(${upload.dir}) private String uploadDir; PostMapping(/init) public Result init(RequestBody UploadInitRequest request) { // 1. 秒传检测全文件MD5一致且状态已完成直接返回 UploadFile existed uploadFileMapper.selectOne(new LambdaQueryWrapperUploadFile() .eq(UploadFile::getFileMd5, request.getFileMd5()) .eq(UploadFile::getStatus, 1) .last(limit 1)); if (existed ! null) { return Result.ok(秒传命中, existed.getFileId()); } // 2. 生成新的任务ID String fileId UUID.randomUUID().toString().replace(-, ); // 3. 登记元数据 UploadFile uploadFile new UploadFile(); uploadFile.setFileId(fileId); uploadFile.setFileName(request.getFileName()); uploadFile.setFileSize(request.getFileSize()); uploadFile.setFileMd5(request.getFileMd5()); uploadFile.setTotalChunks(request.getTotalChunks()); uploadFile.setUploadedChunks(0); uploadFile.setStatus(0); uploadFileMapper.insert(uploadFile); // 4. 创建分块存储目录 File chunkDir new File(uploadDir File.separator fileId File.separator chunks); if (!chunkDir.exists()) { chunkDir.mkdirs(); } return Result.ok(初始化成功, fileId); } }这里我特意放在selectOne查询里加了status1的限制目的是如果用户上次传了一半放弃了第二次进来不能算秒传要走续传逻辑。这个细节很容易漏漏了会导致服务端以为文件存在实际上chunks目录里只有残缺的分块。3.3 分块上传接口与校验落盘分块上传接口是整个方案的核心它要做的事包括校验分块合法性、计算并比对分块MD5、落盘、记录数据库。PostMapping(/chunk) public Result uploadChunk(RequestParam(fileId) String fileId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(value chunkMd5, required false) String chunkMd5, RequestParam(file) MultipartFile file) throws IOException { // 1. 任务必须存在且未合并 UploadFile uploadFile getUploadFile(fileId); if (uploadFile null || uploadFile.getStatus() ! 0) { return Result.error(任务不存在或已结束); } // 2. 索引合法性检查 if (chunkIndex 0 || chunkIndex uploadFile.getTotalChunks()) { return Result.error(分块索引越界); } // 3. 分块MD5校验可选但建议开启 if (StringUtils.hasText(chunkMd5)) { String realMd5 DigestUtils.md5DigestAsHex(file.getBytes()); if (!chunkMd5.equals(realMd5)) { return Result.error(分块数据校验失败); } } // 4. 落盘到 chunks/{fileId}/{chunkIndex}.part File chunkDir new File(uploadDir File.separator fileId File.separator chunks); if (!chunkDir.exists()) { chunkDir.mkdirs(); } File dest new File(chunkDir, chunkIndex .part); file.transferTo(dest); // 5. 记录分块元数据幂等已存在的直接忽略 FileChunk chunk fileChunkMapper.selectOne(new LambdaQueryWrapperFileChunk() .eq(FileChunk::getFileId, fileId) .eq(FileChunk::getChunkIndex, chunkIndex)); if (chunk null) { chunk new FileChunk(); chunk.setFileId(fileId); chunk.setChunkIndex(chunkIndex); chunk.setChunkSize(file.getSize()); chunk.setChunkMd5(chunkMd5); chunk.setChunkPath(dest.getAbsolutePath()); fileChunkMapper.insert(chunk); // 更新已上传分块计数 uploadFileMapper.update(null, new LambdaUpdateWrapperUploadFile() .eq(UploadFile::getFileId, fileId) .setSql(uploaded_chunks uploaded_chunks 1)); } return Result.ok(分块上传成功, null); }注意第5步里的幂等设计。前端并发上传时网络重试可能导致同一个分块被提交两次如果每次都insert新记录唯一索引会炸如果每次都update计数uploaded_chunks会虚高。这里的做法是“先查后插”重复提交直接忽略既不会报错也不会多计数。分块文件本身也可以做幂等transferTo会直接覆盖同名文件反正内容一样覆盖也无所谓。3.4 合并接口与断点续传查询合并接口处理的是所有分块到达后的聚合操作。我推荐用FileChannel.transferFrom零拷贝合并而不是把分块读进内存再写出来。后者在文件大了以后极易OOM前者是操作系统层面的文件复制效率高一个量级PostMapping(/merge) public Result merge(RequestParam(fileId) String fileId, RequestParam(fileName) String fileName) throws IOException { UploadFile uploadFile getUploadFile(fileId); if (uploadFile null) { return Result.error(任务不存在); } // 1. 校验分块数量是否齐全 Integer count fileChunkMapper.selectCount(new LambdaQueryWrapperFileChunk() .eq(FileChunk::getFileId, fileId)); if (count null || count ! uploadFile.getTotalChunks()) { return Result.error(分块不完整已上传 (count null ? 0 : count) / uploadFile.getTotalChunks()); } // 2. 合并分块到临时文件避免直接写最终文件导致读一半的情况 File dir new File(uploadDir File.separator fileId File.separator chunks); File tmpFile new File(uploadDir File.separator fileId File.separator fileName .tmp); try (FileChannel out new FileChannel.open(tmpFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i 0; i uploadFile.getTotalChunks(); i) { File partFile new File(dir, i .part); if (!partFile.exists()) { throw new RuntimeException(分块缺失索引: i); } try (FileChannel in FileChannel.open(partFile.toPath(), StandardOpenOption.READ)) { out.transferFrom(in, out.size(), in.size()); } } } // 3. 原子重命名 File finalFile new File(uploadDir File.separator fileId File.separator fileName); if (!tmpFile.renameTo(finalFile)) { return Result.error(合并文件重命名失败); } // 4. 清理分块目录更新任务状态 FileUtils.deleteDirectory(dir); uploadFileMapper.update(null, new LambdaUpdateWrapperUploadFile() .eq(UploadFile::getFileId, fileId) .set(UploadFile::getStoragePath, finalFile.getAbsolutePath()) .set(UploadFile::getStatus, 1)); return Result.ok(合并完成, finalFile.getAbsolutePath()); }transferFrom这个API有个容易被忽略的点每次写入的目标位置是out.size()——也就是当前已写入的总字节数而不是从0开始覆盖。所以循环里必须用out.size()作为position否则合并出来的文件内容会被后一个分块覆盖掉前一个。断点续传的查询接口就简单多了返回该fileId下所有已上传分块的索引列表GetMapping(/check) public Result check(RequestParam(fileId) String fileId) { ListFileChunk chunks fileChunkMapper.selectList(new LambdaQueryWrapperFileChunk() .eq(FileChunk::getFileId, fileId) .orderByAsc(FileChunk::getChunkIndex)); ListInteger uploadedIndexes chunks.stream() .map(FileChunk::getChunkIndex) .collect(Collectors.toList()); // 顺带返回处理进度前端可以直接用来渲染进度条 UploadFile uploadFile getUploadFile(fileId); MapString, Object data new HashMap(); data.put(uploadedChunks, uploadedIndexes); data.put(progress, uploadFile null ? 0 : (int) (uploadFile.getUploadedChunks() * 100.0 / uploadFile.getTotalChunks())); return Result.ok(data); }4. 前端切片配合并发控制、进度展示与断网续传4.1 前端切片核心逻辑后端接口定好了前端只需要做三件事切片、并发上传、失败重试。我用原生JavaScript写一个最小可用的实现不依赖第三方上传库方便你移植到自己的项目里。const CHUNK_SIZE 4 * 1024 * 1024; // 4MB const CONCURRENCY 4; async function startUpload(file) { // 1. 计算全文件 MD5大文件用分片方式避免一次性读入内存 const fileMd5 await computeFileMd5(file); const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 2. 初始化上传任务 const initRes await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, fileMd5, totalChunks }) }); const initData await initRes.json(); // 秒传命中直接结束 if (initData.code 200 initData.data 秒传命中) return; const fileId initData.data; // 3. 查询已上传分块实现断点续传 const checkRes await fetch(/api/upload/check?fileId${fileId}); const checkData await checkRes.json(); const uploadedSet new Set(checkData.data.uploadedChunks || []); // 4. 并发上传剩余分块 await uploadRemainingChunks(file, fileId, totalChunks, uploadedSet); // 5. 触发合并 const mergeRes await fetch(/api/upload/merge, { method: POST, params: { fileId, fileName: file.name } }); }大文件MD5计算建议用crypto-js或SparkMD5并且用分片增量追加的方式不要一把梭读整个文件——1GB的文件读到内存里直接卡死页面。示例如下async function computeFileMd5(file) { const spark new SparkMD5.ArrayBuffer(); let offset 0; while (offset file.size) { const chunk file.slice(offset, Math.min(offset CHUNK_SIZE, file.size)); const buf await chunk.arrayBuffer(); spark.append(buf); offset CHUNK_SIZE; } return spark.end(); }4.2 并发控制与失败重试并发控制用“开小窗滑”的方式实现维护一个运行中的任务数组数量没到上限就一直往进塞满了就用Promise.race等最先完成的一个然后继续推进。这个写法比用Promise.all一次性发完要稳也方便做重试。async function uploadRemainingChunks(file, fileId, totalChunks, uploadedSet) { let current 0; const running new Set(); while (current totalChunks) { while (running.size CONCURRENCY current totalChunks) { if (uploadedSet.has(current)) { current; continue; } const index current; const start index * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const blob file.slice(start, end); const task uploadSingleChunk(fileId, index, blob) .then(() { running.delete(task); }) .catch((err) { running.delete(task); // 重试逻辑最多重试3次指数退避 return retryUploadChunk(fileId, index, blob, 3); }); running.add(task); current; } if (running.size 0) { await Promise.race(running); } } await Promise.all(running); } async function uploadSingleChunk(fileId, chunkIndex, blob) { const fd new FormData(); fd.append(fileId, fileId); fd.append(chunkIndex, chunkIndex); fd.append(file, blob); const res await fetch(/api/upload/chunk, { method: POST, body: fd }); if (!res.ok) throw new Error(上传失败); }失败重试这里有个技巧重试时使用指数退避延迟300ms、600ms、1200ms递增并且重试3次后如果还失败只提示“第x块上传失败”而不是清空整个上传进度。教育场景里教师网速波动大一会儿掉线一会儿又连上分块粒度小重试的成本极低基本都能补齐。4.3 进度展示与弱网体验优化教育行业的上传进度条有讲究。前端可以根据check接口返回的uploadedChunks占总块数的比例来展示实时进度但这个数字只能反映“分块传上去”的进度合并阶段还有一段耗时。我的做法是整体进度 分块上传进度 * 80% 合并阶段进度 * 20%分块上传阶段每传完一个块就刷新一次进度合并阶段轮询check接口或让前端在merge请求发出后等待进度条显示为85%-100%的线性增长。这样教师端看到的进度条不会在一个数字上卡很久体验更真实。断网续传的前端处理也没那么复杂进入上传页时如果检测到本地有未完成任务把fileId存到localStorage重新上传时先调check拿到已上传的分块索引做成一个uploadedSet上传循环里直接跳过这些块。这样即使教师传了一半关机、第二天再打开进度还在不需要从头再来。5. 生产环境正确姿势踩坑记录、一致性保障与存储选型5.1 三个我踩过的坑坑一Nginx 限制没改分块上传直接413。后端辛辛苦苦调好max-file-size结果前端上传分块时Nginx直接返回413 Request Entity Too Large。原因是Nginx默认的client_max_body_size只有1MB4MB的分块必然被拒。需要在Nginx配置里显式调大location /api/upload/ { client_max_body_size 16m; proxy_read_timeout 300s; }proxy_read_timeout也要同步调大不然分块上传过程中后端处理稍慢Nginx就会掐断连接。这个坑排起来很隐蔽因为本地联调不走Nginx问题只在上线后出现。坑二合并时内存暴涨服务直接OOM。有段时间我图省事合并分块时用Files.readAllBytes()把每个分块读进内存再写出去遇到2GB的视频内存立刻告急。换成FileChannel.transferFrom后问题彻底解决。这个API在Linux底层走的是sendfile系统调用数据直接在内核态搬运不经过用户态内存大文件合并非常理想。坑三数据库显示分块已传完磁盘上文件却缺失。并发上传时某个分块请求实际上传失败了但前端重试时发现数据库记录里已经存在这个分块因为第一次请求可能已经写了记录但文件落盘没成功于是跳过它最后合并时才发现缺块。我的解决办法是合并接口在检查分块数量之后逐个验证分块文件是否存在且大小大于0check接口只把“文件存在且记录存在”的块返回给前端。一句话以文件系统为准数据库记录只是辅助。5.2 数据一致性与脏数据清理分块上传的元数据一致性核心矛盾是数据库记录和磁盘文件状态可能短暂不一致而后续操作必须从中恢复。我建议的保障策略有三层第一分块记录和分块文件同步写入。先落盘、后插库二者之间有极短的时间窗但check接口的查询逻辑会让前端感知不到这个窗口。第二合并前做“双校验”。合并接口不能只比对uploaded_chunks total_chunks还要遍历一遍分块文件的实际大小和数量发现缺块就返回明确的错误信息“第x块缺失”前端收到后自动补传缺失块。第三定期清理脏数据。教育平台的教师经常传一半就放弃这些残留的分块目录会一直占着磁盘。我写了定时任务每天凌晨扫描upload_file表中超过48小时仍然处于status0的任务连同对应的目录和分块记录一起清理。磁盘占用问题要靠这个机制兜底。5.3 自研方案与云厂商 OSS 分片上传的选型思考如果是公网部署、允许数据出校直接用阿里云OSS或腾讯云COS的分片上传API会省很多事云厂商自带断点续传、服务端签名、CDN加速完全不用自己维护合并逻辑和存储集群。这种情况下我建议不要自己写Java分块上传。但教育行业的私有化部署很常见。我接过几个项目服务器就在学校机房的超融合一体机上学校明确要求课件视频不能离开本地网络。这种环境里云厂商的SDK没法用自研的就是唯一选项。另外自研方案还有个隐蔽优势可以跟内部权限系统深度耦合。比如教师上传的视频直接关联课程ID、班级ID做行级权限控制这个用OSS反而要多费一番功夫去维护映射关系。5.4 上线前必须做的上传链路验证最后分享一个我吃过亏之后养成的习惯教育视频上传功能上线前一定用真实网络环境做一轮完整验证而不是只在开发环境测。具体分三个场景场景测试方法关注点正常有线网络上传1GB视频进度条平滑、合并耗时可控弱网模拟Chrome DevTools限制上行带宽至2Mbps分块重试是否正常、无卡死断网续传上传到50%时断开Wi-Fi再恢复重新上传后跳过已完成分块教育行业的老师群体里网速和设备差异比普通用户更大。开发环境里觉得“很流畅”的上传拿到一个上行带宽只有2Mbps的校园宿舍环境里可能就变成另一个故事。把弱网和断网这两个场景测好这个功能才算真正能用。我在做教育项目时分块上传的思路一直没变先把三个主接口init、chunk、merge跑通再补check续传和秒传这两个体验兜底最后用真实网络做弱网回归。这套组合下来不管是几百MB的课程录像还是高峰期的几百人同时上传基本都能撑得住。