三个月前接手一个在线课程平台的后端维护,用户反馈最集中的问题不是卡顿,而是进度条拖不动。视频本身能从头播到尾,可一旦想跳到第 47 分钟去看某个知识点,播放器就开始转圈,十几秒后干脆回到起点重新加载。前端同学查了半天 player 配置,最后把锅甩给了后端。抓了一次包才看明白:浏览器明明发了Range: bytes=1048576-,我们返的却是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: bytes=0- # 第二步:用户拖动进度条后,请求目标区间 GET /api/video/1024 HTTP/1.1 Host: example.com Range: bytes=52428800-53477375关键点在于,第一步那个Range: bytes=0-的语义不是"我要整个文件",而是"我支持范围请求,你先给我前面一段"。服务端如果老老实实回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头看起来简单,实际有四种语法形态,而且每一种的边界处理逻辑都不同。我第一次写的时候只处理了bytes=start-end,结果 iOS 上的 Safari 播放器频繁报错,查日志才发现它在某些场景下会发bytes=-1024。
Range: bytes=0-1023 # 明确起止,最常见 Range: bytes=1048576- # 从某位置到文件末尾 Range: bytes=-1048576 # 最后 1MB(后缀语法,注意减号在前) Range: bytes=0-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 一张表把必须的响应头列清楚
| Header | 200 全量响应 | 206 分段响应 | 416 越界响应 |
|---|---|---|---|
Accept-Ranges | bytes | bytes | bytes |
Content-Range | 不出现 | bytes start-end/total | bytes */total |
Content-Length | 整个文件大小 | 本次分片字节数 | 0 或不设 |
Content-Type | video/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 方案 A:ResourceRegion,二十行搞定
这是我最推荐的起点。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 ResponseEntity<ResourceRegion> 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 头 List<HttpRange> 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-Range?ResourceRegionHttpMessageConverter在写出ResourceRegion时,其实会自动补Content-Range,前提是它知道文件总长度。但因为我们主动做了CHUNK_SIZE截断,region 的长度和客户端请求的长度不一致,某些 Spring 版本下计算出来的Content-Range会和实际写出的字节数有细微偏差。手动写死最保险,反正就一行字符串拼接。这个坑我是在压测时发现的——Chrome 偶尔会报ERR_CONTENT_LENGTH_MISMATCH,日志里怎么看都正常,最后对比响应头才发现是Content-Range的 end 值差了 1。
MediaTypeFactory.getMediaType根据文件扩展名推断 MIME,mp4会得到video/mp4。如果是自定义扩展名,需要自己兜底。这里有个实践建议:MIME 必须准确,video/mp4和application/octet-stream在浏览器的处理路径上完全不同,前者会走媒体解码器,后者会走下载流程。
3.3 方案 B:RandomAccessFile+StreamingResponseBody
方案 A 的缺点是它把Resource完全交给框架,中间你能插入的逻辑有限。如果你需要精确限速(比如按用户等级限制 1MB/s 或 4MB/s),就得自己控制读取循环。
@GetMapping("/{videoId}/throttled") public ResponseEntity<StreamingResponseBody> 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; List<HttpRange> 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 两种方案的实测对比
| 维度 | ResourceRegion | RandomAccessFile+ 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后缀语法,或者不支持bytes=start-到末尾),播放器读不到尾部的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 的 MP4(H.264 + AAC),兼容性最好,浏览器、iOS、Android 全通吃。想要更高压缩率的可以额外生成一份 H.265 或者 AV1,但那属于第 7 节要讨论的 HLS 范畴了。