
1. 视频流分段返回到底在解决什么问题第一次接触“视频流分段返回”这个需求是在一个后台管理系统里。前端要预览一段监控回放视频文件放在对象存储上单个文件动辄两三百兆。最开始的做法很粗暴后端一次性把整个文件读进内存再通过响应流写出去。本地测试没问题一上生产就出事——几个用户同时点预览服务的内存曲线直接拉成一座山紧接着就是频繁的 Full GC再然后接口大面积超时。这个场景其实非常典型。视频流分段返回说白了就是不要把整个视频文件当成一个普通的字节数组一次性吐给前端而是按照一定的粒度一段一段地返回。它解决的核心问题有三个第一降低单次请求的内存占用避免大文件把服务压垮第二支持前端的拖动进度条、断点续传这类交互因为播放器需要能按需拉取任意区间的数据第三提升首屏体验用户不用等整个文件下载完才能开始看。适合谁来参考这篇内容我觉得是三类人。一类是刚接手文件服务、还没踩过大文件坑的后端同学一类是前端做视频播放发现进度条拖不动、或者拖动后画面卡死想搞清楚服务端该怎么配合的同学还有一类是做物联网、安防、在线教育这类天然带视频场景的从业者。只要你的系统里存在“视频文件 网络传输 用户要预览或播放”这个组合分段返回基本是绕不开的一环。需要先明确一个概念边界这里说的“分段”和视频编码里的“分片”“切片”不是一回事。编码层面的切片是 HLS、DASH 那套东西是把视频转码成一个个 ts 或 m4s 小文件靠播放列表串起来。而我们这里讨论的分段返回是传输层面的事情文件本身可能还是一个大 mp4只是服务端在响应时按字节区间返回。两者可以叠加使用但解决的问题不在一个层面上。搞清楚这一点后面的方案选型才不会跑偏。2. 整体方案设计与技术选型思路2.1 为什么是 HTTP Range 而不是自己造协议一提到分段很多人的第一反应是“我自己定义一个接口传个 start 和 end 参数返回对应区间的字节”。这个思路能跑通但它有个致命问题浏览器原生的 video 标签不认你这套自定义协议。你用 video 标签的 src 指向一个普通 URL浏览器内部会自己发 Range 请求它期望服务端返回标准的 206 Partial Content 响应。如果你返回的是 200 加一段自定义 JSON播放器直接懵掉进度条拖不动甚至根本播不了。所以正确的做法是拥抱 HTTP/1.1 就定义好的Range 请求规范。客户端在请求头里带Range: bytesstart-end服务端识别后返回状态码206并在响应头里带上Content-Range: bytes start-end/total。这套东西是标准浏览器、播放器、下载工具全都认。你不需要发明任何新东西只需要把服务端实现对。我试过自己造协议前端用 fetch 拿 blob 再喂给 video结果就是内存里同时存在两份数据拖动进度条还得自己管理 buffer代码量翻了三倍最后老老实实回到 Range。这个坑我建议你直接跳过。2.2 分段粒度怎么定不是越小越好确定了用 Range下一个问题就是“段”到底多大。这里有个常见的误解觉得段越小越省内存。实际上段太小会带来两个副作用一是请求次数暴涨每个请求都有 HTTP 头开销和连接开销网络利用率反而下降二是服务端频繁地 seek 和 open 文件IO 压力上去了。我的经验值是1MB 到 4MB这个区间比较舒服。为什么因为大多数播放器在拖动进度条时会预取当前位置往后的一小段数据1MB 足够覆盖几秒的高清画面又不至于一次拉太多。如果是移动端弱网环境可以降到 512KB如果是内网高速场景8MB 也没问题。这个值不是拍脑袋定的你可以根据码率反推假设视频码率是 4Mbps也就是每秒 0.5MB那么 1MB 大约对应 2 秒的内容拖动时的响应延迟感知就比较自然。还有一个细节段的大小最好和文件系统的块大小、以及底层存储的读取粒度对齐。比如对象存储通常按 4MB 或 8MB 分块如果你的段大小是 3MB就可能出现跨块读取效率反而低。这个在自建存储上不明显但用云存储时值得留意。2.3 内存映射还是流式读取服务端读取文件区间常见有两种方式。一种是用RandomAccessFile配合seek然后一块一块读另一种是用FileChannel.map做内存映射。内存映射在读取大文件时性能确实好操作系统帮你管理页缓存但它有个坑映射的区域如果很大会占用虚拟内存地址空间而且在某些容器环境下映射文件的释放时机不好控制容易导致文件句柄泄漏。我个人的选择是流式读取 固定缓冲区。用RandomAccessFile.seek(start)定位然后用一个 8KB 或 16KB 的 byte 数组循环读取读到 end 为止边读边写进响应输出流。这种方式内存占用恒定就是缓冲区那点大小跟文件多大、段多大都没关系。虽然比内存映射多了一次数据拷贝但在 Web 场景下网络本身就是瓶颈这点拷贝开销可以忽略。提示如果你的服务部署在容器里且容器的内存限制比较紧强烈建议用流式读取。内存映射的页缓存算在容器的内存统计里容易触发 OOM Kill排查起来非常隐蔽。3. 核心实现细节与关键参数解析3.1 Range 请求头的解析逻辑Range 头的格式看着简单实际解析起来有一堆边界情况。标准格式是bytesstart-end但合法变体包括bytesstart-从 start 到文件末尾、bytes-suffix最后 suffix 个字节。而且客户端可能一次请求多个区间用逗号分隔比如bytes0-1023,2048-3071。多区间请求返回的是 multipart/byteranges实现复杂度陡增实际业务里几乎用不到我的建议是只支持单区间遇到多区间直接返回整个文件或者 416。解析的时候要特别注意几个点。第一start 和 end 都是闭区间bytes0-0表示第一个字节返回长度是 1。第二如果 end 超过了文件总长度要自动截断到total-1不能报错。第三如果 start 大于等于文件总长度要返回416 Range Not Satisfiable并在响应头里带Content-Range: bytes */total。第四如果 Range 头格式非法按规范应该忽略它返回 200 和完整文件而不是报 400。我见过不少实现栽在bytes-500这种后缀语法上直接当成 start0、end500 处理结果返回了文件开头而不是结尾。这种 bug 在正常播放时看不出来只有用户拖到末尾才暴露测试很容易漏。3.2 响应头的正确设置返回 206 的时候响应头有几个是必须的少一个播放器就可能罢工。响应头作用常见错误Content-Range告诉客户端这段数据在完整文件中的位置写成bytes start-end漏了/totalContent-Length本段数据的字节数不是文件总大小误填成文件总大小导致客户端一直等Accept-Ranges声明服务端支持区间请求只在 200 响应里带206 里忘了带Content-Type视频的 MIME 类型用默认的 application/octet-stream部分播放器不识别Content-Length这个坑我要单独说一下。它指的是本次响应体的长度也就是end - start 1。如果你填成了文件总大小客户端会认为后面还有数据没传完一直挂着连接等直到超时。这个 bug 在 curl 里测很容易发现但如果你只用浏览器测浏览器比较宽容可能就蒙混过去了。Accept-Ranges: bytes这个头建议在所有视频响应里都带上包括 200 的完整响应。它的作用是告诉客户端“我支持区间请求”客户端看到这个头才会在拖动进度条时发 Range 请求。如果只在 206 里带客户端第一次拿完整文件时不知道你支持后续拖动可能就不发 Range 了。3.3 文件定位与边界计算拿到 start 和 end 之后读取之前要做一次规范化。假设文件总长度是fileLength那么如果start 0按后缀处理start max(0, fileLength start)如果end fileLengthend fileLength - 1如果start end或start fileLength返回 416这里有个容易忽略的点文件长度要在读取前获取而且要用同一个文件句柄获取。如果你先file.length()拿到长度然后另开一个流去读中间文件被替换了长度就对不上了。虽然视频文件一般不怎么会变但在一些会做转码、覆盖写入的场景里这个竞态是真实存在的。稳妥的做法是打开RandomAccessFile之后用同一个实例的length()方法。读取循环的写法也有讲究。不要用readFully一次性读整个段因为段可能有几 MB一次性分配这么大的数组没必要。用固定大小的缓冲区循环读byte[] buffer new byte[8192]; long remaining end - start 1; raf.seek(start); while (remaining 0) { int toRead (int) Math.min(buffer.length, remaining); int read raf.read(buffer, 0, toRead); if (read -1) break; output.write(buffer, 0, read); remaining - read; }这段代码看着简单但remaining的递减和toRead的计算是核心。如果这里算错了要么多读越界要么少读导致客户端卡住。4. 完整实操流程与现场记录4.1 从零搭建一个支持分段的视频接口假设我们用最朴素的 Servlet 方式来实现不引入 Spring 那套东西这样你能看清每一步在干什么。实际项目里你可以套进任何框架逻辑是一样的。第一步确定接口路径和参数。路径设计成/video/{fileId}fileId 用来定位实际文件。这里要注意路径穿越问题fileId 绝对不能直接拼进文件路径必须经过白名单校验或者映射表查询。我见过直接把../拼进去读到系统文件的案例这是安全红线。第二步打开文件并获取长度。用RandomAccessFile以只读模式打开拿到fileLength。如果文件不存在返回 404。第三步解析 Range 头。如果请求头里没有 Range说明客户端要完整文件直接返回 200设置Content-Length为文件总长度Accept-Ranges: bytes然后从头到尾流式写出。如果有 Range走 206 分支。第四步计算区间并校验。按前面说的规则规范化 start 和 end非法区间返回 416。第五步设置 206 响应头。Content-Range、Content-Length、Content-Type、Accept-Ranges一个都不能少。第六步流式写出区间数据。用固定缓冲区循环读边读边 flush。这里有个优化点不要每读一次就 flush 一次flush 太频繁会降低吞吐。让容器自己管理缓冲或者每读满一个较大的块比如 64KB再 flush。第七步关闭资源。RandomAccessFile和输出流都要在 finally 里关顺序是先关输出流再关文件。用 try-with-resources 更省心。4.2 参数计算的实际演算拿一个具体例子走一遍。假设文件大小是104857600字节100MB客户端发来Range: bytes10485760-20971519。start 10485760end 20971519校验start fileLengthend fileLengthstart end合法本段长度 20971519 - 10485760 1 10485760 字节正好 10MBContent-Range:bytes 10485760-20971519/104857600Content-Length:10485760再假设客户端发来Range: bytes104857600-start 等于文件长度。这时候 start fileLength返回 416Content-Range 设为bytes */104857600。再假设Range: bytes-1024表示最后 1024 字节。start 104857600 - 1024 104856576end 104857599。本段长度 1024。这几个例子覆盖了最常见的三种情况你可以拿它们当单元测试用例。4.3 前端播放器怎么配合服务端做对了前端其实不需要写什么特殊代码。用原生 video 标签src 指向接口地址浏览器会自动处理 Range。但有两个地方要注意。一是跨域。如果前端和后端不同源需要配置 CORS而且Access-Control-Expose-Headers里要带上Content-Range、Accept-Ranges、Content-Length否则前端 JS 读不到这些头某些自定义播放器会出问题。二是鉴权。video 标签发起的请求默认不带自定义请求头所以你不能用Authorization头做鉴权。常见做法是把 token 放在 URL 查询参数里或者用 Cookie。用 URL 参数的话注意 token 会出现在日志里要做好脱敏。提示如果你的视频需要防盗链可以在服务端校验 Referer 或者签名参数。但签名参数要设计成一次一签且带过期时间否则链接被分享出去就失效了。5. 常见问题排查与避坑经验5.1 拖动进度条后画面卡死这是最高频的问题。表现是视频能正常播放但一拖进度条就转圈或者直接黑屏。排查思路按这个顺序走先看浏览器开发者工具的 Network 面板拖动后有没有发出新的请求。如果没有说明是前端播放器的问题可能是Accept-Ranges头没返回播放器认为不支持拖动。如果有请求看返回状态码。返回 200 说明服务端没识别 Range 头检查解析逻辑返回 416 说明区间计算有问题看 Content-Range 里的 total 对不对返回 206 但画面还是卡看 Content-Range 的 start-end 和请求的是否一致。我遇到过一次服务端返回的 Content-Range 里 total 写成了本段长度导致播放器算错了总时长拖动时定位全乱。这种问题看响应头一眼就能发现但如果不熟悉规范可能查半天。5.2 内存不降反升按理说分段返回应该省内存但有人反馈说改完之后内存反而更高了。这种情况通常是响应流没有及时 flush 或者缓冲区开太大。比如有人为了“提高性能”把缓冲区设成 1MB然后每个请求都分配一个 1MB 数组并发一上来内存就爆了。缓冲区 8KB 到 64KB 足够了别贪大。还有一种情况是用了内存映射但没释放。MappedByteBuffer的释放依赖 GC在高并发下映射对象堆积虚拟内存持续增长。如果非要用内存映射记得在 finally 里手动清理或者干脆换成流式读取。5.3 大文件读取时的 IO 瓶颈当并发用户数上去之后磁盘 IO 会成为瓶颈。每个请求都在随机 seek机械硬盘的磁头来回跳吞吐急剧下降。这时候可以考虑几个优化方向一是把视频文件放在 SSD 上二是用操作系统的页缓存让热点文件常驻内存三是在应用层加一层缓存把最近访问的段缓存在堆外内存里。不过要提醒一句应用层缓存视频段要非常谨慎。视频段的访问模式是长尾的缓存命中率可能很低反而挤占了其他业务的内存。如果要做建议用 LRU 加容量上限并且监控命中率命中率低于 30% 就果断关掉。5.4 常见问题速查表现象可能原因排查方法拖动无反应未返回 Accept-Ranges检查 200 响应头拖动后黑屏Content-Range 的 total 错误对比文件实际大小播放到一半卡住Content-Length 填成文件总大小检查 206 响应头并发高时 OOM缓冲区过大或内存映射未释放看堆内存和虚拟内存曲线请求返回 200 而非 206Range 头解析失败打印原始 Range 头末尾拖动失败后缀语法bytes-N处理错误用 curl 单独测后缀请求5.5 几个我踩过的坑第一个坑是中文文件名。如果接口路径里带文件名一定要做 URL 编码否则某些容器会解析乱码导致文件找不到。我建议干脆别在 URL 里放文件名用 ID 映射。第二个坑是文件被占用。在 Windows 环境下如果文件正在被其他进程写入RandomAccessFile打开可能失败。生产环境一般是 Linux这个问题少但如果你做的是桌面应用要留意。第三个坑是断点续传的 ETag。如果客户端做断点续传会用 ETag 或 Last-Modified 来判断文件有没有变。如果你返回的 ETag 每次都变比如用了随机数或者时间戳客户端会认为文件变了重新下载。ETag 应该基于文件内容或者 inode 修改时间生成保证同一个文件多次请求返回一致。第四个坑是日志打印。有些框架会把请求头全打出来Range 头本身没问题但如果 token 放在 URL 里日志里就会泄露。记得对敏感参数脱敏。6. 进阶优化与扩展方向6.1 结合 CDN 做边缘分段如果视频是给公网用户看的服务端分段只是第一步更好的做法是把分段能力下沉到 CDN。CDN 节点本身就支持 Range 回源你只需要保证源站正确响应 Range 请求CDN 会自动缓存各个段用户就近拉取。这时候源站的压力会小很多因为大部分请求被 CDN 挡掉了。配置 CDN 的时候要注意缓存键。默认情况下 CDN 可能不把 Range 头算进缓存键导致不同区间的请求命中同一个缓存返回错误的数据。需要在 CDN 配置里开启“按 Range 缓存”或者类似的选项具体叫法各家不一样但原理是通的。6.2 转码与分段的配合如果视频格式五花八门播放器兼容性差可以在上传后做一次转码统一成 H.264 AAC 的 mp4。转码后的文件用分段返回兼容性最好。更进一步可以转成 HLS 切片让播放器按 m3u8 播放这样天然就是分段的而且支持自适应码率。不过 HLS 的切片是另一套体系需要额外的转码服务和播放列表管理。如果你的场景只是简单的预览没必要上 HLSRange 足够了。如果要做直播或者大规模点播HLS 或 DASH 才是正解。6.3 监控与容量规划上线之后要盯几个指标单请求的平均响应时间、P99 响应时间、并发请求数、磁盘 IO 利用率、堆内存使用率。分段返回的响应时间应该和段大小成正比如果发现某个段的响应时间异常长可能是磁盘出现了坏道或者热点。容量规划上按最坏情况估算假设峰值并发 100每段 1MB响应时间 100ms那么带宽需求大约是 100 * 1MB / 0.1s 1000MB/s也就是 8Gbps。这个数字看着吓人但实际并发不会一直满而且 CDN 会分担大部分。源站按峰值的 20% 到 30% 规划就够了。6.4 一个容易被忽略的细节连接复用HTTP/1.1 默认支持 keep-alive分段请求会复用同一个 TCP 连接。但如果你的服务端在每次响应后都主动关闭连接客户端每次都要重新握手延迟会明显增加。检查一下响应头里有没有Connection: close如果没有特殊需求不要主动关连接。另外如果用了反向代理代理到后端的连接池也要配置合理。连接池太小请求排队太大后端压力大。一般按后端实例数乘以 20 到 50 来配。我在实际项目里把分段返回这套东西落地之后最直观的感受是内存曲线终于平了。以前一到晚上高峰期就提心吊胆现在单实例扛几百个并发预览毫无压力。真正花时间的不是写代码而是把那些边界情况一个个测出来——后缀 Range、越界 Range、非法 Range、并发下的文件句柄管理每一个都值得写一个测试用例。如果你正准备做类似的功能建议先把 HTTP Range 的 RFC 文档翻一遍把规范里定义的每种情况都覆盖到后面会省掉大量排查时间。