
三个月前接手一个在线课程平台的后端维护用户反馈最集中的问题不是卡顿而是进度条拖不动。视频本身能从头播到尾可一旦想跳到第 47 分钟去看某个知识点播放器就开始转圈十几秒后干脆回到起点重新加载。前端同学查了半天 player 配置最后把锅甩给了后端。抓了一次包才看明白浏览器明明发了Range: bytes1048576-我们返的却是200 OK加上完整的Content-Length整个 800MB 的文件从头开始吐。这就是典型的视频分段渐进式播放没做对——服务端不支持 HTTP Range播放器的随机定位能力直接归零。这篇文章聊的就是 Java 后端怎么把这套东西做扎实。它不是什么新框架、新中间件而是把 HTTP/1.1 里一个相当古老的特性Range/206 Partial Content在 Java 侧落地再叠加 MP4 容器格式、反向代理、并发连接管理这几层现实约束。适合正在做网课、监控回放、IM 语音视频、企业培训系统这一类业务的 Java 后端也适合前端同学想搞清楚为什么我这边 seek 不生效的根因。下面全部是实际项目里跑过的方案包括我踩过的坑和后来复盘出来的判断依据。1. 从进度条拖不动反推分段播放的服务端契约1.1 播放器到底向后端要了什么很多人以为视频播放是播放器拉一个 URL服务端把文件发过来这么简单。实际上现代浏览器和 Native 播放器在打开一个视频资源时第一步发出去的请求就带着Range头。我用 curl 复现过 Chrome 加载 MP4 的完整请求序列简化后大致是这样# 第一步探测性请求问服务端支不支持范围请求 GET /api/video/1024 HTTP/1.1 Host: example.com Range: bytes0- # 第二步用户拖动进度条后请求目标区间 GET /api/video/1024 HTTP/1.1 Host: example.com Range: bytes52428800-53477375关键点在于第一步那个Range: bytes0-的语义不是我要整个文件而是我支持范围请求你先给我前面一段。服务端如果老老实实回200 全量播放器就会得出结论这个资源不具备随机访问能力后续 seek 只能靠已经下载到本地的缓存超出部分就废了。所以服务端的契约非常明确——只要请求里带了Range且合法就必须回206 Partial Content并且用Content-Range精确告诉客户端这一块在整个文件里的位置。这个契约是分段播放的地基后面所有优化都是在这块地基上盖楼。1.2 一次抓包对比200 全量返回和 206 分段返回的差别我把两种返回的实际表现整理成了对照第一次看到这个差异的时候确实有点震撼——服务器代码只差十几行客户端体验差了一个数量级。对比维度返回 200 全量返回 206 分段首帧时间100MB 文件依赖边下边播通常 2 到 8 秒通常 300 到 800 毫秒进度条拖动只能跳到已缓冲区间越界即失败任意位置秒跳服务端单连接内存占用若用Files.readAllBytes则直接 100MB与分片大小同阶约 8KB 到 4MB带宽浪费用户只看前 5 分钟也要拉完整文件按需拉取通常只消费 10% 到 30%断线续播基本从头开始从断点继续天然支持最后一行断线续播是很多人忽略的收益。地铁里看网课信号断了再恢复206 方案下播放器只要重新发一个从断点开始的Range请求就行而 200 方案下浏览器会认为这个连接已经作废整个资源重下。1.3 为什么分段这两个字比渐进更重要社区里经常把渐进式播放和分段播放混着说但从后端实现角度这两个词指向的东西不一样。**渐进式Progressive**强调的是传输方式不落盘、边读边写、流式吐给客户端而不是先把整个文件读到内存再发。它解决的是内存和首字节延迟问题。**分段Segmented**强调的是请求粒度服务端主动把文件切成固定大小的块一次响应只回一块而不是客户端要多少就给多少。它解决的是长连接稳定性、限速精度和故障恢复成本问题。我在项目里最终采取的是两者结合用流式输出保证内存恒定用固定分片默认 2MB保证单个响应不会因为网络抖动挂太久。后面第 5 节会详细讲这个分片大小是怎么定下来的。2. HTTP Range 协议的边界条件比想象中多2.1 Range 头的四种写法少处理一种就会出问题Range头看起来简单实际有四种语法形态而且每一种的边界处理逻辑都不同。我第一次写的时候只处理了bytesstart-end结果 iOS 上的 Safari 播放器频繁报错查日志才发现它在某些场景下会发bytes-1024。Range: bytes0-1023 # 明确起止最常见 Range: bytes1048576- # 从某位置到文件末尾 Range: bytes-1048576 # 最后 1MB后缀语法注意减号在前 Range: bytes0-1023,2048-3071 # 多区间浏览器极少发但规范允许第四种多区间请求在浏览器视频播放场景里几乎不会出现一般只在下载工具的断点续传里才有。我的选择是不做multipart/byteranges多段响应只取第一个区间返回。这符合 RFC 7233 的宽松实现策略实测 Chrome、Safari、Edge、以及 Android ExoPlayer 都能正常接受。如果你的业务里有下载器场景那另说得老老实实实现多段拼装。2.2 后缀语法bytes-N很容易算错bytes-1048576的意思是文件最后 1MB不是从偏移 -1048576 开始。等价于bytes(total-1048576)-。而且如果 N 大于文件总长度要返回整个文件而不是报错。我在这上面栽过一次线上表现为视频能播但播到末尾前 1 秒卡死因为播放器读尾部 moov 信息时拿到的是空数据。安全上也有一点要注意后缀长度必须做上限校验。如果不限制客户端发bytes-999999999999你如果按字面去算偏移量很容易触发整数溢出算出负数长度进而抛出异常或者写出非法响应头。我的做法是统一走一个resolveRange方法把四种语法归一化成[start, end]二元组再做硬件校验。下面这段是我现在项目里在用的归一化逻辑直接可以抄public record ByteRange(long start, long end) {} public static ByteRange resolve(String rangeHeader, long totalLength) { // Range 头必须带 bytes 前缀否则视为非法 if (rangeHeader null || !rangeHeader.startsWith(bytes)) { return null; } String spec rangeHeader.substring(bytes.length()).trim(); // 多区间只取第一段后面的直接丢弃 int comma spec.indexOf(,); if (comma 0) { spec spec.substring(0, comma); } int dash spec.indexOf(-); if (dash 0) { return null; } String startPart spec.substring(0, dash).trim(); String endPart spec.substring(dash 1).trim(); long start; long end; if (startPart.isEmpty()) { // bytes-N 后缀语法取最后 N 字节 if (endPart.isEmpty()) { return null; } long suffix parseLongSafely(endPart); if (suffix 0) { return null; } start Math.max(0, totalLength - suffix); end totalLength - 1; } else { start parseLongSafely(startPart); if (start 0 || start totalLength) { // 起始位置越界直接判定不可满足 return null; } if (endPart.isEmpty()) { end totalLength - 1; } else { end parseLongSafely(endPart); // 客户端要的结尾可能超出文件按文件末尾截断 end Math.min(end, totalLength - 1); } } if (end start) { return null; } return new ByteRange(start, end); } private static long parseLongSafely(String s) { try { return Long.parseLong(s); } catch (NumberFormatException e) { // 超长数字字符串直接判为不可满足避免溢出 return -1L; } }注意parseLongSafely返回 -1 之后resolve里会走start 0分支返回 null最终得上层返回 416。这套逻辑看起来啰嗦但它把非法输入和合法但与文件无关的输入都收口到了一个地方后面排查起来非常省事。2.3 416 不是错误是协议的一部分当start totalLength时正确响应是416 Range Not Satisfiable并且必须带上Content-Range: bytes */totalLength。这个头很多实现会漏漏了之后播放器拿不到总长度行为就变得不可预测。我见过有些同学图省事416 的时候直接返回 200 全量文件想着反正你要不到我给你全部。这是错的。播放器收到 200 会误判为服务端不支持 Range之后所有 seek 都会失效。用户看到的现象就是跳到某个位置就没反应了。同理还有If-Range。客户端在续传时会带If-Range: etag或last-modified意思是如果文件没变就给我这个区间变了的话给我完整的。服务端如果文件没变正常返回 206如果变了必须返回 200 全量并且不能带Content-Range。这个语义在视频文件被重新转码覆盖的场景下会真实触发不是理论情况。2.4 一张表把必须的响应头列清楚Header200 全量响应206 分段响应416 越界响应Accept-RangesbytesbytesbytesContent-Range不出现bytes start-end/totalbytes */totalContent-Length整个文件大小本次分片字节数0 或不设Content-Typevideo/mp4等video/mp4等可省ETag/Last-Modified必须必须且与 200 一致建议带上Cache-Control按业务配置与 200 一致一般不缓存这里有个细节值得强调206 响应里的ETag必须和对应的 200 响应完全一致。因为客户端做If-Range的时候拿的是这个 ETag如果你 206 和 200 用了不同的 ETag 生成规则比如 206 里加了分片偏移量做盐值客户端的续传判断就会永远失败。我早期版本就是这么干的用文件大小修改时间start偏移拼 ETag结果 Safari 每次 seek 都会重新拉完整文件流量账单直接翻了四倍。3. 落到 Java 代码两种实现路线的取舍3.1 先确认你是不是在重复造轮子在动手写自定义 Controller 之前先做一件事把视频文件放到src/main/resources/static或者配置的静态资源目录下直接访问看看。Spring Boot 默认的ResourceHttpRequestHandler已经支持 Range 请求了它内部会调用HttpRange解析并且通过ResourceRegionHttpMessageConverter输出 206 响应。也就是说如果你没有鉴权、限流、时间戳防盗链这些需求最省事的方案就是让它做。那为什么我最后还是自己写了一层三个原因第一视频文件必须走鉴权不能裸奔在静态目录下第二需要给每个用户下发带有效期的签名 URL第三需要按用户等级做下载限速。这三点静态资源处理器都给不了。所以下面的两个方案都是建立在文件存在对象存储或本地私有目录必须由 Controller 接管这个前提下的。补充一句如果你的项目已经用了 Nginx 做静态资源代理那还有个更省事的做法——X-Accel-Redirect让 Java 只做鉴权把实际的字节传输交给 Nginx。这个方案我在第 6 节会展开。3.2 方案 AResourceRegion二十行搞定这是我最推荐的起点。Spring MVC 自带ResourceRegion类型和对应的消息转换器你只要把区间算出来剩下的头设置、状态码、流式写出全由框架负责。RestController RequestMapping(/api/video) public class VideoStreamController { private final VideoService videoService; // 单个响应最大返回 2MB超出部分由客户端再次发起 Range 请求 private static final long CHUNK_SIZE 2L * 1024 * 1024; public VideoStreamController(VideoService videoService) { this.videoService videoService; } GetMapping(/{videoId}) public ResponseEntityResourceRegion stream(PathVariable Long videoId, RequestHeader HttpHeaders headers) throws IOException { // 1. 鉴权 拿到文件资源 VideoFile vf videoService.loadAuthorized(videoId); Resource resource vf.resource(); long totalLength resource.contentLength(); MediaType mediaType MediaTypeFactory.getMediaType(resource) .orElse(MediaType.APPLICATION_OCTET_STREAM); // 2. 解析 Range 头 ListHttpRange ranges headers.getRange(); if (ranges.isEmpty()) { // 没有 Range 头回全量 200 ResourceRegion whole new ResourceRegion(resource, 0, totalLength); return ResponseEntity.ok() .contentType(mediaType) .header(HttpHeaders.ACCEPT_RANGES, bytes) .body(whole); } HttpRange range ranges.get(0); long start range.getRangeStart(totalLength); long end range.getRangeEnd(totalLength); long requested end - start 1; // 3. 主动分片一次最多吐 CHUNK_SIZE避免长连接被中间设备掐断 long regionLength Math.min(CHUNK_SIZE, requested); ResourceRegion region new ResourceRegion(resource, start, regionLength); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(mediaType) .header(HttpHeaders.ACCEPT_RANGES, bytes) .header(HttpHeaders.CONTENT_RANGE, bytes start - (start regionLength - 1) / totalLength) .body(region); } }为什么手动设置Content-RangeResourceRegionHttpMessageConverter在写出ResourceRegion时其实会自动补Content-Range前提是它知道文件总长度。但因为我们主动做了CHUNK_SIZE截断region 的长度和客户端请求的长度不一致某些 Spring 版本下计算出来的Content-Range会和实际写出的字节数有细微偏差。手动写死最保险反正就一行字符串拼接。这个坑我是在压测时发现的——Chrome 偶尔会报ERR_CONTENT_LENGTH_MISMATCH日志里怎么看都正常最后对比响应头才发现是Content-Range的 end 值差了 1。MediaTypeFactory.getMediaType根据文件扩展名推断 MIMEmp4会得到video/mp4。如果是自定义扩展名需要自己兜底。这里有个实践建议MIME 必须准确video/mp4和application/octet-stream在浏览器的处理路径上完全不同前者会走媒体解码器后者会走下载流程。3.3 方案 BRandomAccessFileStreamingResponseBody方案 A 的缺点是它把Resource完全交给框架中间你能插入的逻辑有限。如果你需要精确限速比如按用户等级限制 1MB/s 或 4MB/s就得自己控制读取循环。GetMapping(/{videoId}/throttled) public ResponseEntityStreamingResponseBody throttledStream( PathVariable Long videoId, RequestHeader HttpHeaders headers) throws IOException { VideoFile vf videoService.loadAuthorized(videoId); File file vf.localFile(); long totalLength file.length(); long maxRate vf.maxBytesPerSecond(); // 按用户等级算出-1 表示不限速 long start 0L; long end totalLength - 1; ListHttpRange ranges headers.getRange(); if (!ranges.isEmpty()) { HttpRange r ranges.get(0); start r.getRangeStart(totalLength); end r.getRangeEnd(totalLength); } final long fStart start; final long fEnd end; final long length fEnd - fStart 1; StreamingResponseBody body outputStream - { try (RandomAccessFile raf new RandomAccessFile(file, r)) { raf.seek(fStart); byte[] buffer new byte[64 * 1024]; long remaining length; long windowStart System.nanoTime(); long windowBytes 0L; while (remaining 0) { int toRead (int) Math.min(buffer.length, remaining); int read raf.read(buffer, 0, toRead); if (read 0) { break; } outputStream.write(buffer, 0, read); remaining - read; windowBytes read; // 限速按 100ms 一个窗口做平滑控制 if (maxRate 0) { long elapsedNs System.nanoTime() - windowStart; long expectedNs windowBytes * 1_000_000_000L / maxRate; if (elapsedNs expectedNs) { long sleepMs (expectedNs - elapsedNs) / 1_000_000L; if (sleepMs 0) { Thread.sleep(sleepMs); } } else if (elapsedNs 200_000_000L) { // 窗口超过 200ms 就重置避免长期累积误差 windowStart System.nanoTime(); windowBytes 0L; } } } outputStream.flush(); } catch (IOException e) { // 客户端断开是常态不要打 error 日志刷屏 log.debug(client aborted, videoId{}, start{}, videoId, fStart); } }; return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(MediaType.parseMediaType(video/mp4)) .header(HttpHeaders.ACCEPT_RANGES, bytes) .header(HttpHeaders.CONTENT_RANGE, bytes fStart - fEnd / totalLength) .contentLength(length) .body(body); }StreamingResponseBody的执行发生在 Spring MVC 的异步线程池里不是 Tomcat 的请求线程。这意味着你需要配置这个线程池否则默认的SimpleAsyncTaskExecutor每个请求都新建线程几百个并发上来直接把线程数打爆。配置方式很简单spring: mvc: async: request-timeout: 120000 # 异步请求超时单位毫秒 task: execution: pool: core-size: 32 max-size: 128 queue-capacity: 256 thread-name-prefix: video-stream-数值怎么来的我们的峰值并发是 400 左右每个流式任务在 2MB 分片、4MB/s 限速下平均存活 500ms用并发数 QPS × 平均耗时粗算核心线程 32、最大 128、队列 256 能覆盖突发的三倍流量。队列容量千万别设成无限一旦后端存储比如网络文件系统变慢任务会堆积在队列里用户看到的是点了播放没反应比直接报错还难排查。3.4 两种方案的实测对比维度ResourceRegionRandomAccessFile StreamingResponseBody代码量约 30 行约 70 行限速能力无需要在外层做精细可按用户/按接口内存占用框架内部缓冲可控完全自控64KB 缓冲区线程模型同步阻塞在请求线程异步线程池断连感知框架处理需要自己 catch IOException零拷贝潜力有底层可能走 FileChannel无走普通 read/write上手难度低中我现在的架构是两条路同时存在普通用户的点播走方案 A简单可靠付费高清晰度频道走方案 B因为要按会员等级限速而且高码率文件的并发压力更大需要更细的控制粒度。这不是过度设计是因为限速需求确实绕不开——不限速的话一个用户开三倍速下载就能把出口带宽吃掉一大半。4. MP4 容器格式埋的雷moov 在文件尾部4.1faststart决定了首帧时间HTTP Range 做对了进度条能拖了但还有个问题首帧时间依然很长。原因在 MP4 的文件结构里。MP4 是由一系列 box也叫 atom组成的其中moovbox 存放着整个文件的索引信息——每个视频帧、音频帧的偏移量、时间戳、关键帧位置。播放器必须先读到moov才能开始解码。问题在于很多编码器包括某些版本的 ffmpeg 默认配置、部分手机录制的视频会把moov放在文件末尾因为转码时不知道最终索引有多大只能边写边算最后追加。这种情况下播放器要拿到moov就必须先跳到文件末尾读几 KB然后才能回来读开头的帧。如果服务端的 Range 实现有 bug比如不支持bytes-N后缀语法或者不支持bytesstart-到末尾播放器读不到尾部的moov就会一直转圈。这就是我前面说的视频能播但播到末尾前 1 秒卡死的完整解释——它其实压根没播起来是在等索引。处理方式有两条路。第一条源头解决转码时加-movflags faststart。ffmpeg -i raw.mp4 \ -c:v libx264 -preset medium -crf 23 \ -c:a aac -b:a 128k \ -movflags faststart \ output.mp4这个参数的意思是转码完成后把moovbox 从尾部搬到文件头部并修正所有偏移量。加了这个参数之后播放器读开头 64KB 就能拿到完整索引首帧时间能压到几百毫秒。我们在视频上传后的异步处理流水线里强制加了这一步所有新入库的视频都是 faststart 的。有个细节要注意-movflags faststart需要 ffmpeg 做第二遍读写大文件会增加一次完整的 IO。我们的转码流水线本来就是异步的能接受如果是同步转码注意评估耗时。第二条兜底方案服务端识别并优先返回尾部分片。存量视频不可能全部重转所以服务端得扛住。思路是判断moov的位置如果它在尾部就在第一次请求时提前把尾部数据推给客户端。实现上我会在视频入库时扫描一次 box 结构把moov的偏移量和长度记到数据库里。/** * 扫描 MP4 box 结构定位 moov 偏移。 * 返回 moov 在文件中的起始位置和长度未找到返回 null。 */ public static long[] locateMoov(File file) throws IOException { try (RandomAccessFile raf new RandomAccessFile(file, r)) { long pos 0L; long fileLength file.length(); while (pos 8 fileLength) { raf.seek(pos); byte[] header new byte[8]; raf.readFully(header); long boxSize ((long) (header[0] 0xFF) 24) | ((header[1] 0xFF) 16) | ((header[2] 0xFF) 8) | (header[3] 0xFF); String type new String(header, 4, 4, StandardCharsets.US_ASCII); if (moov.equals(type)) { return new long[]{pos, boxSize}; } if (boxSize 1L) { // 64 位扩展大小后面 8 字节是真实长度 byte[] ext new byte[8]; raf.readFully(ext); long real 0L; for (byte b : ext) { real (real 8) | (b 0xFF); } pos real; } else if (boxSize 0L) { // boxSize 为 0 表示延伸到文件末尾 break; } else { pos boxSize; } } } return null; }拿到moov偏移之后在 Controller 里做一次判断如果客户端请求的第一个区间不包含moov可以在响应体里先插入 moov 数据再跟着返回请求的区间。不过说实话这个方案会让Content-Range语义变得不严谨因为响应体的字节流不再严格对应请求区间。比较稳妥的做法是改成自定义索引接口——前端 player 初始化时先调/api/video/{id}/meta拿到 moov 偏移后单独发一个 Range 请求把索引拉走然后再正常播放。我的实际选择是存量视频批量重转加 faststart新视频流水线强制加 faststart服务端只做健康检查不做插入。理由是自定义协议会增加前端复杂度而且一旦 player 升级可能不认这套。批量重转的成本是一次性的用晚上跑批完全能接受。4.2 顺带一提WebM 和 MOV 的差异WebM 是基于 Matroska 的它的索引元数据Cues通常也是可选的很多 WebM 文件甚至不带 Cues这种情况下播放器只能顺序解析seek 精度会很差。MOV 和 MP4 是同一套 box 体系处理方式一样。所以如果你的平台允许用户上传任意格式上传后的处理流水线必须做格式归一化。我们的策略是统一转成 faststart 的 MP4H.264 AAC兼容性最好浏览器、iOS、Android 全通吃。想要更高压缩率的可以额外生成一份 H.265 或者 AV1但那属于第 7 节要讨论的 HLS 范畴了。5. 并发上来之后内存和连接才是真正的瓶颈5.1 千