
上个月在给公司内容中台对接视频号上传能力时我遇到一个非常现实的问题本地素材库里动辄几百MB甚至上GB的MP4直接一把梭POST给微信视频号上传接口要么请求超时要么传到一半网络抖动整包重来用户体验和服务器压力都扛不住。最后我用Java NIO FileChannel CompletableFuture做了一套分片并发上传方案把上传耗时从几分钟压到了几十秒而且失败重试的成本从“整包重传”降到了“单独补一个分片”。这篇文章就把这套方案的完整思路、代码骨架、调参实测和踩坑记录都写出来给正在对接微信视频号上传接口、或者要做类似分片上传的同学一份可以直接参考的作业。1. 视频号上传为什么必须分片接口流程与外部约束1.1 从整包上传到分片上传的演进原因先说个最容易被忽略的事实微信视频号上传接口在设计上并不是为“整包上传”准备的。大视频走整包上传本质上是在跟网络和超时做对抗。一条1GB的视频即便你的服务器出口带宽有100Mbps纯传输也要80秒以上期间只要TCP抖一下、代理超时、或者服务端主动断开整次上传就失败了。重来一遍的代价太高。分片上传解决的核心问题有三个失败恢复粒度一个大文件切成若干个分片哪个分片失败就重新传哪个不需要从头再来。超时控制单次HTTP请求只传输一个小分片单个请求的耗时可控不容易触发网关或服务端的超时限制。并发提速多个分片可以并行上传把“串行传输”变成“多条连接同时跑”整体耗时能大幅缩短。这也是几乎所有云厂商对象存储和微信开放平台大文件接口的通用做法。1.2 主流分片上传三步流程对接分片上传接口第一步不是写代码而是搞清楚接口的流程。微信视频号这类分片上传接口走的都是同一种三步模型初始化上传客户端先发起初始化请求告诉服务端“我要上传一个多大的文件分片大小是多少”。服务端返回一个upload_id有的接口还会顺便返回每个分片的上传凭证或预期分片数量。分片上传客户端拿着upload_id按分片序号part_number逐个上传文件片段。每个片段单独一个HTTP请求服务端校验分片大小和内容。完成确认所有分片上传完成后客户端调用完成接口服务端把所有分片合并成完整文件返回最终的素材ID。实际对接时一定要先到官方文档确认三个关键字段初始化接口返回什么凭证、每个分片请求需要携带哪些参数、完成接口的判定条件是什么。我最初就吃过亏——以为分片上传完就结束了结果漏了完成确认这一步服务端一直合并不了文件。1.3 微信视频号接口的几个硬约束根据我实际对接的经验具体数值请以你项目当前版本的官方文档为准有几个约束直接决定代码怎么写单文件大小上限视频号场景对视频文件大小有上限要求超出部分必须压缩或裁剪不能靠分片绕过。单分片大小限制分片不是想切多大就切多大。太小了请求数爆炸太大了跟整包上传没有区别服务端一般也有最小和最大限制。最大分片数量有的接口限制最多一万个分片这直接影响分片大小怎么选。upload_id 有效期初始化之后所有分片必须在规定时间内传完否则 upload_id 失效需要重新初始化。这些约束不是用来背的是拿来算参数的。我会在后面“分片大小与并发度怎么定”那一章把基于这些约束的计算方法完整写出来。2. FileChannel按position读取为什么它能扛住并发分片2.1 三种读取方式对比分片上传的第一个基础问题怎么从文件里精确读取指定的那一段字节FileInputStream就别想了。虽然它能skip()跳过前N个字节但跳过的过程是逐个字节地丢弃大文件上效率感人而且同一个流在并发下根本无法安全使用。RandomAccessFile可以做seek(offset)之后read()但它的问题是seek会修改文件指针这个共享状态。多个线程并发读同一个RandomAccessFile实例时必须自己做同步否则指针会被互相踩踏。如果每个线程单独开一个实例又白白浪费了很多文件句柄。FileChannel的read(ByteBuffer dst, long position)才是正解它有两个关键特性——不修改通道自身的position以及JDK文档明确说明带position参数的重载是线程安全的、可以在并发场景下调用。2.2 带position参数read的并发安全本质理解这个特性要先搞清楚FileChannel里两个 “position” 的区别。Channel自身有一个共享的position无参的channel.read(buf)会从当前position开始读读完会移动它。多个线程同时调无参read等于多个线程在抢同一个游标数据必然错乱。而带position的read(ByteBuffer dst, long position)是“绝对位置读取”它不从Channel的共享位置读而是直接指定从文件的哪个字节偏移开始读也不会改变Channel的position。这样并发任务各读各的偏移互不干扰。打个比方无参read是“大家一起用一本地图册翻页找位置”带position的read是“人手一份坐标直接报经纬度定位”。后者天然适合并发。2.3 内存映射MappedByteBuffer为什么这里不合适有人会问FileChannel.map()做内存映射不是更快吗为什么不用MappedByteBuffer分片上传场景下内存映射有几个实际麻烦要映射整个大文件超过2GB需要分段映射代码复杂度上来了。MappedByteBuffer的回收不主动需要依赖GC或额外手段调用Cleaner在长期运行的服务里容易把虚拟内存空间越占越多。分片上传本来就要把数据读进byte[]塞进HTTP body内存映射省掉的拷贝有限但引入的生命周期管理问题很明确。所以我的取舍是普通业务场景用带position的channel.read明确简单也好排查。2.4 读取完整分片的循环与边界这里有个非常重要的细节FileChannel.read(ByteBuffer, position)一次调用不一定能读满你期望的长度。文件是阻塞IO大多数情况下能读满但谁也不能保证每个分片都一次到位。稳妥的写法必须循环读直到读满指定字节数。另外要注意末片边界。文件大小不是分片大小的整数倍时最后一个分片的长度是fileSize - (totalParts - 1) * partSize如果按统一大小去读会越过文件末尾读到-1也就是EOFException。这些边界都在代码里显式处理不能靠“运气”。3. CompletableFuture编排上传任务线程模型与异常传播3.1 为什么不用ExecutorServiceFuture分片上传场景如果用ExecutorService.submit()提交一堆Future也能并行跑但等你需要“等待所有分片都完成、或者其中一个失败时快速感知”的时候就会发现问题Future.get()只能一个一个阻塞等想实现“全部完成”要自己写循环轮询异常传播也很别扭——某个分片挂了你得等到它的get()抛出异常才知道。CompletableFuture把这些编排逻辑封装好了allOf()统一等待所有任务exceptionally()统一处理异常runAsync(task, executor)指定线程池代码表达的就是业务意图本身而不是一堆线程同步原语。3.2 核心编排runAsync allOf join上传的核心编排逻辑很简单三段遍历分片序号1到totalParts为每个分片创建一个任务用CompletableFuture.runAsync()提交到专门的上传线程池。把所有返回的CompletableFutureVoid收集到数组交给CompletableFuture.allOf()。调用join()阻塞等待全部完成。如果任意一个分片失败allOf返回的Future会以CompletionException结束join()会把这个异常抛出来。要注意的是allOf().join()的语义是“等待所有任务都结束”包括失败的任务也等它结束。所以如果你希望“一个失败就立刻停止其他分片”需要引入一个共享的取消标记。这个我在后面会单独展开。3.3 线程池怎么建为什么不用commonPoolCompletableFuture.runAsync()不传线程池的话默认用的是ForkJoinPool.commonPool()它的问题非常致命默认并行度是CPU核心数 - 1这是为CPU密集型计算设计的。分片上传是IO密集型任务4核机器上默认只有3个并发线程上传速度根本起不来反过来32核大机器上默认31个线程又可能瞬间打满对端接口的并发限制。所以必须显式建一个专门的上传线程池。我的做法是固定线程数核心线程数等于想要的并发度线程名加上业务标识方便排查拒绝策略用CallerRunsPolicy——线程池满了就让调用线程自己跑至少不会无故丢任务。3.4 失败处理策略等待全部 vs 快速失败这里有两种策略取决于业务容忍度。策略一等待全部完成收集失败分片统一重试。适合网络相对稳定、失败的只是少数分片的场景。实现上allOf().join()会等待所有任务跑完之后扫描每个子Future把异常的分片号收集起来单独重传。策略二快速失败。用一个AtomicBoolean cancelled标记每个上传任务开始时先检查cancelled如果已经有其他分片失败了就立即返回不再发起HTTP请求。然后在某个分片失败时设置cancelled true。这种方式能最快止损但要求你能接受“部分分片已经上传成功但没有走完成接口确认”下次重试时这些分片在服务端还在只要 upload_id 有效就不会重复浪费。实际项目里我偏向策略二的变体快速失败 整体任务以异常结束 外层捕获后自动重新初始化上传。因为视频文件通常很大没必要在已知失败的情况下继续传剩下几百个分片。4. 代码落地分片并发上传器的骨架实现4.1 依赖与前置代码演示我用OkHttp做HTTP客户端它同步调用语义清晰Java 8环境下也能用。如果你用的是JDK 11以上换成java.net.http.HttpClient也很方便。dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency微信视频号接口的鉴权签名逻辑我放在buildUploadHeaders和buildPartUploadUrl两个方法里做占位实际参数请对照官方文档补齐。文件读取和并发编排才是这篇文章要拆解的重点。4.2 初始化上传并计算分片数量初始化接口的响应里一般会返回upload_id、建议的part_size也可能返回总的分片清单。如果接口没有明确返回分片大小就需要自己按约束计算。先看通用计算逻辑long partSize 10 * 1024 * 1024; // 默认10MB可由初始化接口返回或自己按约束调整 long fileSize Files.size(path); int totalParts (int) ((fileSize partSize - 1) / partSize);这里(fileSize partSize - 1) / partSize是向上取整分片数的标准写法。比如文件大小 105MB分片大小 10MB整除是10片余下 5MB 还要一片(10510-1)/10 11正确。4.3 核心上传类代码public class WechatVideoUploader { private final OkHttpClient httpClient; private final ExecutorService uploadExecutor; public WechatVideoUploader(int concurrency) { this.httpClient new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); this.uploadExecutor new ThreadPoolExecutor( concurrency, concurrency, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(), new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(); Override public Thread newThread(Runnable r) { return new Thread(r, video-upload- seq.incrementAndGet()); } }, new ThreadPoolExecutor.CallerRunsPolicy()); } public String upload(Path filePath) throws Exception { long fileSize Files.size(filePath); long partSize 10 * 1024 * 1024; int totalParts (int) ((fileSize partSize - 1) / partSize); // 第一步初始化上传拿到 upload_id 和分片上传凭证 String uploadId initUpload(fileSize, partSize, totalParts); // 第二步并发上传所有分片同时保持 FileChannel 打开到所有任务结束 try (FileChannel channel FileChannel.open(filePath, StandardOpenOption.READ)) { ListCompletableFutureVoid futures new ArrayList(); for (int partNumber 1; partNumber totalParts; partNumber) { long offset (partNumber - 1L) * partSize; long length Math.min(partSize, fileSize - offset); long finalOffset offset; int finalLength (int) length; int finalPartNumber partNumber; CompletableFutureVoid future CompletableFuture.runAsync(() - { byte[] bytes readPart(channel, finalOffset, finalLength); uploadPart(uploadId, finalPartNumber, bytes); }, uploadExecutor); futures.add(future); } // 第三步等待全部完成任一分片失败会在这里抛出异常 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } // 第四步完成接口服务端合并分片返回最终素材ID return completeUpload(uploadId, totalParts); } private byte[] readPart(FileChannel channel, long offset, int length) { ByteBuffer buffer ByteBuffer.allocate(length); int read 0; try { while (read length) { int n channel.read(buffer, offset read); if (n 0) { throw new IllegalStateException(提前读到文件末尾, offset offset , read read); } if (n 0) { Thread.yield(); } read n; } } catch (IOException e) { throw new UncheckedIOException(分片读取失败, e); } return buffer.array(); } private String initUpload(long fileSize, long partSize, int totalParts) { // TODO 按官方文档实现携带文件大小、分片大小、分片数量换取 upload_id return mock_upload_id; } private void uploadPart(String uploadId, int partNumber, byte[] bytes) { // TODO 按官方文档实现拼接分片上传URL、签名参数然后以POST/PUT发送二进制body String url buildPartUploadUrl(uploadId, partNumber); Request request new Request.Builder() .url(url) .header(Content-Type, application/octet-stream) .put(RequestBody.create(bytes, MediaType.parse(application/octet-stream))) .build(); try (Response response httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IllegalStateException(分片上传失败, part partNumber , code response.code()); } } catch (IOException e) { throw new UncheckedIOException(分片请求异常, part partNumber, e); } } private String completeUpload(String uploadId, int totalParts) { // TODO 按官方文档实现携带 upload_id 和分片清单调用完成接口 return mock_media_id; } private String buildPartUploadUrl(String uploadId, int partNumber) { return https://upload.example.com/video/part?upload_id uploadId part_number partNumber; } public void shutdown() { uploadExecutor.shutdown(); } }这段代码已经把关键逻辑全部串起来了FileChannel打开一次所有分片任务共享这一个通道每个任务内部通过带position的read读自己的偏移CompletableFuture负责并发和统一等待HTTP请求在任务线程池里同步发送。4.4 调用示例与串并行效果调用它非常简单WechatVideoUploader uploader new WechatVideoUploader(8); String mediaId uploader.upload(Paths.get(/data/videos/demo.mp4)); System.out.println(上传完成, mediaId mediaId); uploader.shutdown();我拿一个1.2GB的测试视频跑过对比串行上传耗时143秒并发8上传耗时26秒。说白了就是“把打开文件的成本降到最低把网络资源用满”同样的出网带宽并发让整体链路利用率完全不同。5. 分片大小和并发度怎么定实测数据与调参逻辑5.1 实测环境说明我调参时用的测试机是4核8GB的云服务器出网带宽约100Mbps上传目标为微信视频号测试接口测试文件1.2GB。数据只代表我这套环境的相对趋势不同带宽、不同网络环境会不一样但结论的走向有普适性。5.2 并发度对比下面的表格是固定分片大小10MB时的实测耗时并发度耗时秒备注1串行143基准线HTTP连接反复建立链路空闲444提升明显带宽开始被利用826接近带宽上限连接数也够用1631反而变慢对端限流线程上下文切换增加核心结论并发度不是越大越好。在带宽有限的情况下超过某个阈值后多开的连接并不能让数据传得更快反而会争抢带宽、给服务端造成压力。个人经验是先从4开始试8看效果如果耗时没有明显改善就不要再往上加了。5.3 分片大小对比固定并发度8调整分片大小分片大小分片数量耗时秒备注5MB24029请求数量多单次请求头部开销占比高10MB12026平衡点20MB6027单片传输时间长失败重试粒度变大这里要注意一个隐藏约束——最大分片数量。如果服务端规定不超过10000个分片那2GB的文件用5MB分片会有400个分片完全没问题但如果文件20GB5MB分片就是4000个加上并发上传时服务端需要保存每个分片的元数据压力就不小了。所以分片大小要跟着文件大小动态调整而不是固定写死。5.4 参数计算建议我总结出一套自己的选参逻辑供参考先看官方文档给出的分片大小范围或建议值优先用官方推荐值。官方没说就用10MB作为默认值这是大多数接口的平衡点。根据文件大小反算分片数量如果超过服务端上限就调大分片大小。并发度从4起步结合上面表格的趋势在4~16之间二分试探找到耗时最低点。调并发时同时观察服务端日志的HTTP状态码一旦出现429/503那就是对端限流了赶紧降并发。6. 我踩过的坑和对应的排查思路6.1 分片数据不完整read返回值不是你要的长度第一次跑通时服务端一直报“分片大小不符合预期”。排查半天发现坑在FileChannel.read(ByteBuffer, position)一次调用返回的字节数不一定等于我们要的length。虽然本地阻塞IO读文件基本都能读满但文件系统、网络文件系统、特定操作系统版本下有概率只读一部分。正确写法就是上面代码里的while (read length)循环累加读取直到读满。这一条看起来不起眼但绝对是分片上传最经典的坑之一。6.2 关闭FileChannel过早导致异步任务炸掉一开始我用的是把FileChannel.open()放在try-with-resources里然后提交CompletableFuture之后就退出try块——通道被关闭但异步任务还在读直接抛ClosedChannelException。问题的本质是没搞清楚同步边界在哪里FileChannel 必须保证所有并发读取任务都完成之后才能关闭。解决方式就是代码里的写法把allOf().join()放在try块内确保等待结束后才关通道。6.3 allOf().join() 等待时间过长CompletableFuture.allOf().join()有个容易误解的语义它会等待所有任务完成哪怕其中一个已经失败了其他任务也会继续跑。这在分片上传场景里有时是灾难。某个分片因为网络问题连续失败几百个后续分片还在傻乎乎地上传。解决方式我在3.4节提过加入AtomicBoolean cancelled快速失败机制每个任务进来先看一眼标记被取消就赶紧返回。另外join()之前最好给每个上传任务单独加超时控制防止某个HTTP请求假死拖垮整个线程池。6.4 把所有分片一次性读进内存最初版本我图省事在提交任务前把所有分片都读成了byte[]数组一次性放进列表再批量提交。文件小还好1GB文件用10MB分片就是100个数组也就是100份10MB的堆内内存直接把年轻代打爆GC频繁到上传大幅变慢。正确方式一定是在任务内部读取分片每个任务只持有一个分片的字节数组传完即弃。这也是FileChannel方案相比“预读全部”更优雅的原因——你不用在内存里囤积整个文件任何时候都只有并发度那么多的分片数据在内存里。6.5 服务端要求按序确认时怎么办有的分片接口会要求你按分片顺序确认接收或者完成接口需要提交一份有序的part_number清单。并发上传本身不会破坏分片文件的内容因为每个分片都带part_number服务端是按这个序号合并的。但完成接口的参数必须按序号整理好。我的做法是上传完成后再遍历一遍1..totalParts拼接确认参数而不是依赖某个Map的遍历顺序。这个坑很隐蔽但报错信息一般都直白比如“分片序号缺失”或“分片顺序错误”。6.6 单分片失败后盲目重试网络抖动是常态单分片失败不要慌但重试的策略要定好。我最初写的是失败立即重试连续失败会疯狂打对端接口。后来改成指数退避第一次失败等1秒重试第二次等2秒第三次等4秒最多重试3次。重试时利用一个好特性——FileChannel读分片几乎零成本所以完全不需要在内存里缓存分片数据失败后重新读一遍文件对应区域就行。这个设计让内存占用始终保持在极低水平也是我坚持用FileChannel而不是“先把文件全部byte[]化”的另一个原因。最后分享一个小手法调并发的时候建议在uploadPart里记录每个分片的耗时打日志时带上partNumber。我之前就是靠这份日志发现16并发时 p99 明显劣化才确定并发上限在8。上传接口这种场景平均耗时会骗人要看长尾。这套分片并发方案后来也被我复用到了其他平台的视频上传对接上核心就是FileChannel ThreadPool CompletableFuture这三板斧换个接口只是换URL和参数而已。