直播流 m3u8 与 HLS 协议详解(Java 后端视角)
适合人群:Java 后端开发工程师、音视频领域初学者
本文从协议本质讲起,结合 Java 后端实战代码,覆盖 HLS 工作原理、m3u8 文件解析、推拉流架构、CDN 分发、鉴权与防盗链、点播/直播差异等内容,帮你从零理解直播流技术体系。
目录
- [1. 流媒体协议概览](#1. 流媒体协议概览)
- [2. HLS 协议详解](#2. HLS 协议详解)
- [3. m3u8 文件结构深度剖析](#3. m3u8 文件结构深度剖析)
- [4. 直播系统整体架构](#4. 直播系统整体架构)
- [5. 环境搭建:Nginx-RTMP + HLS](#5. 环境搭建:Nginx-RTMP + HLS)
- [6. Java 后端实战:m3u8 解析](#6. Java 后端实战:m3u8 解析)
- [7. Java 后端实战:直播流代理与转发](#7. Java 后端实战:直播流代理与转发)
- [8. 自适应码率(ABR)实现](#8. 自适应码率(ABR)实现)
- [9. 直播鉴权与防盗链](#9. 直播鉴权与防盗链)
- [10. 点播 VOD 与直播 LIVE 的差异](#10. 点播 VOD 与直播 LIVE 的差异)
- [11. 低延迟 LL-HLS](#11. 低延迟 LL-HLS)
- [12. CDN 分发与回源](#12. CDN 分发与回源)
- [13. 监控、运维与排错](#13. 监控、运维与排错)
- [14. 最佳实践与常见问题](#14. 最佳实践与常见问题)
1. 流媒体协议概览
1.1 主流流媒体协议对比
作为后端开发者,理解协议的差异有助于做技术选型:
| 协议 | 全称 | 传输层 | 延迟 | 兼容性 | 适用场景 |
|---|---|---|---|---|---|
| RTMP | Real-Time Messaging Protocol | TCP | 1-3秒 | PC Flash(已淘汰)/推流 | 推流端、内部传输 |
| HLS | HTTP Live Streaming | HTTP/TCP | 6-30秒 | 全平台(iOS原生) | 点播 + 直播分发 |
| DASH | Dynamic Adaptive Streaming over HTTP | HTTP/TCP | 6-30秒 | 全平台(除iOS差) | 国际化大厂点播 |
| HTTP-FLV | FLV over HTTP | HTTP/TCP | 1-3秒 | PC/Android浏览器 | 国内直播延迟优化 |
| WebRTC | Web Real-Time Communication | UDP | <500ms | 全平台 | 连麦、互动直播 |
| SRT | Secure Reliable Transport | UDP | <1秒 | 专业领域 | 跨国传输、广电 |
1.2 直播链路全景
推流端 源站服务器 CDN 播放端
┌──────────┐ RTMP ┌─────────────┐ HLS拉 ┌────────┐ HLS拉 ┌──────────┐
│ 主播 OBS │────────→│ Nginx-RTMP │────────→│ CDN │────────→│ 浏览器/ │
│ 推流 SDK │ │ /SRS │ │ 边缘节点 │ │ App播放器 │
└──────────┘ │ 切片为 ts │ └────────┘ └──────────┘
│ 生成 m3u8 │ ↑ ↑
└─────────────┘ │ │
│ HTTP回源 自适应ABR
│ 切换码率
┌────┴─────┐
│ 转码服务 │
│ (FFmpeg) │
│ 生成多码率 │
└──────────┘
1.3 为什么 HLS 占据主流
- 基于 HTTP:天然适配 CDN 分发,运维成本低
- 跨平台:iOS 原生支持,Android/PC 主流播放器都支持
- 自适应码率:弱网下平滑切换,体验好
- 穿透防火墙:HTTP 协议不会被企业网络拦截
- 代价:延迟较高(6-30秒),依赖切片机制
2. HLS 协议详解
2.1 HLS 是什么
HLS(HTTP Live Streaming)是 Apple 在 2009 年提出的流媒体协议,核心思想是:
把音视频流切成一个个小的 ts 文件,用一个 m3u8 文本索引文件描述这些切片,客户端按顺序下载并播放。
2.2 HLS 核心组成
┌──────────────────────────────────────────────────────┐
│ HLS 协议组成 │
│ │
│ 1. m3u8 索引文件(文本) │
│ ├── Master Playlist(主索引,列出多码率) │
│ └── Media Playlist(媒体索引,列出ts切片) │
│ │
│ 2. ts 切片文件(二进制) │
│ └── MPEG-2 TS 封装格式,每片2-10秒 │
│ │
│ 3. 加密文件(可选,AES-128) │
│ └── key 文件 + iv 初始向量 │
│ │
└──────────────────────────────────────────────────────┘
2.3 HLS 工作流程
1. 客户端请求 master.m3u8
GET /live/stream/master.m3u8
2. 服务器返回主索引(多码率列表)
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720
720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360
360p.m3u8
3. 客户端根据带宽选择,请求 720p.m3u8
GET /live/stream/720p.m3u8
4. 服务器返回媒体索引(ts 切片列表)
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segment0.ts
#EXTINF:6.0,
segment1.ts
#EXTINF:5.8,
segment2.ts
5. 客户端按顺序下载 ts 文件并播放
GET segment0.ts
GET segment1.ts
...
6. 直播场景:客户端定期重新请求 m3u8 获取新切片
2.4 HLS 关键参数
| 参数 | 含义 | 推荐值 |
|---|---|---|
EXT-X-TARGETDURATION |
切片最大时长(秒) | 4-10 |
EXT-X-MEDIA-SEQUENCE |
切片起始序号 | 直播时递增 |
EXTINF |
单个切片的时长 | ≤ TARGETDURATION |
| 切片数量(直播) | m3u8 中保留的切片数 | 3-6 |
| 切片时长 | 单个切片秒数 | 2-6 |
| 总延迟 | 切片时长 × 切片数量 | 通常 6-30秒 |
💡 延迟公式:HLS 端到端延迟 ≈ 切片时长 × 缓冲切片数。要降低延迟,就减小切片时长。但太小会导致请求过于频繁,CDN 命中率下降。
3. m3u8 文件结构深度剖析
3.1 Master Playlist(主索引)示例
m3u8
#EXTM3U
#EXT-X-VERSION:6
# 音频流(独立分组)
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aac",NAME="中文",DEFAULT=YES,LANGUAGE="zh",URI="audio/zh.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aac",NAME="English",LANGUAGE="en",URI="audio/en.m3u8"
# 字幕流
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="中文字幕",LANGUAGE="zh",URI="subs/zh.m3u8"
# 视频流(多码率)
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2",AUDIO="audio-aac",SUBTITLES="subs"
1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2",AUDIO="audio-aac",SUBTITLES="subs"
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480,CODECS="avc1.4d401e,mp4a.40.2",AUDIO="audio-aac"
480p/index.m3u8
关键标签解读:
| 标签 | 说明 |
|---|---|
#EXTM3U |
文件头,必须为第一行 |
#EXT-X-VERSION |
HLS 协议版本 |
#EXT-X-STREAM-INF |
描述一个变体流 |
BANDWIDTH |
峰值带宽(bps),客户端据此选择 |
RESOLUTION |
分辨率 |
CODECS |
编码格式(H.264 + AAC) |
#EXT-X-MEDIA |
替代媒体(音频/字幕) |
3.2 Media Playlist(媒体索引)--- 直播
m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:1234 # 直播时递增
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXTINF:5.984,
segment_1234.ts
#EXTINF:6.000,
segment_1235.ts
#EXTINF:5.972,
segment_1236.ts
#EXTINF:6.008,
segment_1237.ts
💡 直播没有
#EXT-X-ENDLIST,客户端通过这个判断"还会有新切片",定期重新拉取 m3u8。
3.3 Media Playlist(媒体索引)--- 点播 VOD
m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PLAYLIST-TYPE:VOD # 点播标识
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
segment_0.ts
#EXTINF:10.0,
segment_1.ts
...
#EXTINF:8.5,
segment_99.ts
#EXT-X-ENDLIST # 结束标识,告知客户端无新切片
3.4 加密 m3u8 示例
m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key?token=xxx",IV=0x1234567890abcdef1234567890abcdef
#EXTINF:10.0,
segment_0.ts
#EXTINF:10.0,
segment_1.ts
加密机制:
1. 服务端用 AES-128 加密每个 ts 切片
2. m3u8 中通过 #EXT-X-KEY 提供 key 的 URI
3. 客户端先请求 key URI 获取密钥(此时可做鉴权)
4. 客户端用 key + IV 解密 ts 后播放
3.5 关键标签全集
| 标签 | 用途 |
|---|---|
#EXTM3U |
文件标识 |
#EXT-X-VERSION |
协议版本 |
#EXT-X-TARGETDURATION |
最大切片时长 |
#EXT-X-MEDIA-SEQUENCE |
起始切片序号 |
#EXT-X-PLAYLIST-TYPE |
VOD/EVENT |
#EXT-X-ENDLIST |
列表结束(点播) |
#EXTINF |
单切片时长 |
#EXT-X-DISCONTINUITY |
不连续标记(拼接广告等) |
#EXT-X-KEY |
加密密钥信息 |
#EXT-X-MAP |
初始化片段(fMP4) |
#EXT-X-STREAM-INF |
主索引中的变体流 |
#EXT-X-MEDIA |
替代媒体(音频/字幕) |
#EXT-X-PROGRAM-DATE-TIME |
切片对应的真实时间 |
4. 直播系统整体架构
4.1 完整链路
┌─────────┐ RTMP推流 ┌──────────┐
│ 主播端 │───────────→│ 接入边缘 │
│OBS/SDK │ │ (RTMP) │
└─────────┘ └────┬─────┘
│ 内部协议
↓
┌──────────┐
│ 转码集群 │ ──→ 多码率 (1080p/720p/480p)
│ (FFmpeg) │
└────┬─────┘
│
↓
┌──────────┐ ┌──────────┐
│ 切片服务 │ ──────→ │ 对象存储 │
│ (HLS) │ │ OSS/S3 │
└────┬─────┘ └──────────┘
│
↓
┌──────────┐
│ CDN 分发 │
└────┬─────┘
│
┌───────────────┼───────────────┐
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Web浏览器│ │ iOS App │ │Android │
└─────────┘ └─────────┘ └─────────┘
4.2 后端核心服务划分
作为 Java 后端,你通常会负责以下模块:
| 模块 | 职责 | 技术栈 |
|---|---|---|
| 流管理服务 | 房间/频道管理、推流鉴权、状态维护 | Spring Boot + Redis |
| 回调处理 | 接收 Nginx-RTMP 的 on_publish/on_play 回调 | Spring MVC |
| 转码调度 | 触发 FFmpeg 转码任务、状态跟踪 | Spring Boot + 消息队列 |
| m3u8 网关 | 鉴权 + URL 签名 + 防盗链 + 重定向 | Spring Cloud Gateway |
| 录制服务 | 直播录制为点播文件 | FFmpeg + 对象存储 |
| 统计分析 | 在线人数、卡顿率、码率分布 | Kafka + ClickHouse |
5. 环境搭建:Nginx-RTMP + HLS
5.1 Docker 一键搭建
bash
docker run -d \
--name nginx-rtmp \
-p 1935:1935 \
-p 8080:80 \
-v /tmp/hls:/tmp/hls \
tiangolo/nginx-rtmp
5.2 nginx.conf 配置详解
nginx
worker_processes auto;
events {
worker_connections 1024;
}
# RTMP 配置(推流)
rtmp {
server {
listen 1935;
chunk_size 4096;
application live {
live on;
# 开启 HLS 切片
hls on;
hls_path /tmp/hls;
hls_fragment 4s; # 切片时长
hls_playlist_length 20s; # m3u8 保留时长
hls_continuous on; # 重连后继续切片
hls_cleanup on; # 自动清理旧切片
# 推流鉴权回调(请求 Java 后端)
on_publish http://java-backend:8081/api/stream/auth;
# 推流结束回调
on_publish_done http://java-backend:8081/api/stream/end;
# 录制(可选)
record off;
record_path /tmp/recordings;
record_unique on;
}
}
}
# HTTP 配置(HLS 分发)
http {
server {
listen 80;
# HLS 切片访问
location /hls {
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
root /tmp;
# 跨域
add_header Access-Control-Allow-Origin *;
add_header Cache-Control no-cache;
# m3u8 不缓存(直播)
location ~ \.m3u8$ {
expires -1;
}
# ts 短缓存
location ~ \.ts$ {
expires 60s;
}
}
}
}
5.3 推流测试
bash
# 使用 FFmpeg 推流测试视频
ffmpeg -re -i test.mp4 \
-c:v libx264 -preset veryfast -tune zerolatency \
-c:a aac -ar 44100 \
-f flv rtmp://localhost:1935/live/stream001
# 或使用摄像头推流
ffmpeg -f avfoundation -i "0:0" \
-c:v libx264 -preset veryfast \
-f flv rtmp://localhost:1935/live/stream001
5.4 播放测试
bash
# m3u8 地址
http://localhost:8080/hls/stream001.m3u8
# 命令行播放
ffplay http://localhost:8080/hls/stream001.m3u8
# 浏览器(HLS.js)
# 或 VLC 直接打开 m3u8 URL
6. Java 后端实战:m3u8 解析
6.1 Maven 依赖
xml
<dependencies>
<!-- HTTP 客户端 -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
6.2 m3u8 数据模型
java
package com.example.hls.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 媒体级 m3u8 数据模型
*/
@Data
public class MediaPlaylist {
private int version;
private int targetDuration;
private long mediaSequence;
private boolean endList; // 是否点播
private String playlistType; // VOD / EVENT
private List<TsSegment> segments = new ArrayList<>();
private EncryptionKey key; // 加密信息(可选)
@Data
public static class TsSegment {
private double duration; // EXTINF
private String uri; // ts 文件地址
private long sequence; // 序号
private String programDateTime; // 真实时间(可选)
}
@Data
public static class EncryptionKey {
private String method; // AES-128
private String uri;
private String iv;
}
}
java
package com.example.hls.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 主索引 m3u8 数据模型
*/
@Data
public class MasterPlaylist {
private int version;
private List<VariantStream> variants = new ArrayList<>();
@Data
public static class VariantStream {
private long bandwidth;
private String resolution;
private String codecs;
private String uri;
}
}
6.3 m3u8 解析器实现
java
package com.example.hls.parser;
import com.example.hls.model.MediaPlaylist;
import com.example.hls.model.MediaPlaylist.EncryptionKey;
import com.example.hls.model.MediaPlaylist.TsSegment;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 媒体级 m3u8 解析器
*/
public class MediaPlaylistParser {
private static final Pattern EXTINF_PATTERN =
Pattern.compile("#EXTINF:(\\d+(\\.\\d+)?)(,.*)?");
private static final Pattern KEY_PATTERN =
Pattern.compile("#EXT-X-KEY:METHOD=([^,]+)(?:,URI=\"([^\"]+)\")?(?:,IV=([^,\\s]+))?");
public MediaPlaylist parse(String content) {
MediaPlaylist playlist = new MediaPlaylist();
String[] lines = content.split("\\r?\\n");
TsSegment currentSegment = null;
long sequenceCounter = 0;
for (int i = 0; i < lines.length; i++) {
String line = lines[i].trim();
if (line.isEmpty()) continue;
if (line.startsWith("#EXT-X-VERSION:")) {
playlist.setVersion(Integer.parseInt(line.substring(15).trim()));
} else if (line.startsWith("#EXT-X-TARGETDURATION:")) {
playlist.setTargetDuration(Integer.parseInt(line.substring(22).trim()));
} else if (line.startsWith("#EXT-X-MEDIA-SEQUENCE:")) {
long seq = Long.parseLong(line.substring(22).trim());
playlist.setMediaSequence(seq);
sequenceCounter = seq;
} else if (line.startsWith("#EXT-X-PLAYLIST-TYPE:")) {
playlist.setPlaylistType(line.substring(21).trim());
} else if (line.equals("#EXT-X-ENDLIST")) {
playlist.setEndList(true);
} else if (line.startsWith("#EXT-X-KEY:")) {
playlist.setKey(parseKey(line));
} else if (line.startsWith("#EXTINF:")) {
Matcher m = EXTINF_PATTERN.matcher(line);
if (m.matches()) {
currentSegment = new TsSegment();
currentSegment.setDuration(Double.parseDouble(m.group(1)));
}
} else if (line.startsWith("#EXT-X-PROGRAM-DATE-TIME:")) {
if (currentSegment != null) {
currentSegment.setProgramDateTime(line.substring(25).trim());
}
} else if (!line.startsWith("#")) {
// 这是一个 URI 行
if (currentSegment != null) {
currentSegment.setUri(line);
currentSegment.setSequence(sequenceCounter++);
playlist.getSegments().add(currentSegment);
currentSegment = null;
}
}
}
return playlist;
}
private EncryptionKey parseKey(String line) {
Matcher m = KEY_PATTERN.matcher(line);
if (!m.find()) return null;
EncryptionKey key = new EncryptionKey();
key.setMethod(m.group(1));
if (m.group(2) != null) key.setUri(m.group(2));
if (m.group(3) != null) key.setIv(m.group(3));
return key;
}
}
6.4 主索引解析器
java
package com.example.hls.parser;
import com.example.hls.model.MasterPlaylist;
import com.example.hls.model.MasterPlaylist.VariantStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Master Playlist 解析器
*/
public class MasterPlaylistParser {
private static final Pattern BANDWIDTH = Pattern.compile("BANDWIDTH=(\\d+)");
private static final Pattern RESOLUTION = Pattern.compile("RESOLUTION=(\\d+x\\d+)");
private static final Pattern CODECS = Pattern.compile("CODECS=\"([^\"]+)\"");
public MasterPlaylist parse(String content) {
MasterPlaylist master = new MasterPlaylist();
String[] lines = content.split("\\r?\\n");
VariantStream current = null;
for (String raw : lines) {
String line = raw.trim();
if (line.isEmpty()) continue;
if (line.startsWith("#EXT-X-VERSION:")) {
master.setVersion(Integer.parseInt(line.substring(15).trim()));
} else if (line.startsWith("#EXT-X-STREAM-INF:")) {
current = new VariantStream();
Matcher mb = BANDWIDTH.matcher(line);
if (mb.find()) current.setBandwidth(Long.parseLong(mb.group(1)));
Matcher mr = RESOLUTION.matcher(line);
if (mr.find()) current.setResolution(mr.group(1));
Matcher mc = CODECS.matcher(line);
if (mc.find()) current.setCodecs(mc.group(1));
} else if (!line.startsWith("#") && current != null) {
current.setUri(line);
master.getVariants().add(current);
current = null;
}
}
return master;
}
}
6.5 测试解析
java
package com.example.hls;
import com.example.hls.model.MediaPlaylist;
import com.example.hls.parser.MediaPlaylistParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class M3u8ParseDemo {
public static void main(String[] args) throws Exception {
// 拉取一个 m3u8
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/hls/stream001.m3u8"))
.build();
String content = client.send(request, HttpResponse.BodyHandlers.ofString()).body();
// 解析
MediaPlaylist playlist = new MediaPlaylistParser().parse(content);
System.out.println("版本: " + playlist.getVersion());
System.out.println("最大切片时长: " + playlist.getTargetDuration() + "s");
System.out.println("起始序号: " + playlist.getMediaSequence());
System.out.println("是否结束: " + playlist.isEndList());
System.out.println("切片数量: " + playlist.getSegments().size());
playlist.getSegments().forEach(seg ->
System.out.printf(" [%d] %.3fs %s%n",
seg.getSequence(), seg.getDuration(), seg.getUri()));
}
}
7. Java 后端实战:直播流代理与转发
很多业务场景需要 Java 后端代理 m3u8 请求,例如统一鉴权、URL 重写、跨域处理、统计观看数据等。
7.1 m3u8 代理 Controller
java
package com.example.hls.controller;
import com.example.hls.model.MediaPlaylist;
import com.example.hls.parser.MediaPlaylistParser;
import com.example.hls.service.AuthService;
import com.example.hls.service.SignService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
@RestController
@RequestMapping("/proxy")
@RequiredArgsConstructor
public class HlsProxyController {
private final RestTemplate restTemplate;
private final AuthService authService;
private final SignService signService;
private final MediaPlaylistParser parser = new MediaPlaylistParser();
/**
* 代理 m3u8 请求 - 鉴权 + URL 重写 + 签名
*/
@GetMapping(value = "/{streamId}.m3u8", produces = "application/vnd.apple.mpegurl")
public ResponseEntity<String> proxyM3u8(
@PathVariable String streamId,
@RequestParam String token,
@RequestParam(required = false) Long uid) {
// 1. Token 鉴权
if (!authService.validateToken(token, streamId, uid)) {
return ResponseEntity.status(403).body("# Forbidden");
}
// 2. 回源拉取真实 m3u8
String originUrl = "http://origin-server:8080/hls/" + streamId + ".m3u8";
String content = restTemplate.getForObject(originUrl, String.class);
if (content == null) {
return ResponseEntity.status(404).body("# Not Found");
}
// 3. 解析 + 重写 ts 地址(添加签名)
MediaPlaylist playlist = parser.parse(content);
String rewritten = rewriteWithSign(content, streamId, uid);
// 4. 记录观看行为(异步)
authService.logViewing(streamId, uid);
// 5. 返回(直播 m3u8 不缓存)
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("application/vnd.apple.mpegurl"));
headers.setCacheControl("no-cache");
headers.add("Access-Control-Allow-Origin", "*");
return ResponseEntity.ok().headers(headers).body(rewritten);
}
/**
* 重写 m3u8 中的 ts URI,加签名
*/
private String rewriteWithSign(String content, String streamId, Long uid) {
StringBuilder sb = new StringBuilder();
for (String line : content.split("\\r?\\n")) {
if (!line.startsWith("#") && !line.trim().isEmpty()) {
// ts 行:添加签名参数
String sign = signService.signTsUrl(streamId, line, uid);
sb.append(line).append("?sign=").append(sign)
.append("&uid=").append(uid).append("\n");
} else {
sb.append(line).append("\n");
}
}
return sb.toString();
}
/**
* 代理 ts 切片请求 - 签名校验 + 流式转发
*/
@GetMapping("/{streamId}/{tsName}.ts")
public ResponseEntity<byte[]> proxyTs(
@PathVariable String streamId,
@PathVariable String tsName,
@RequestParam String sign,
@RequestParam Long uid) {
// 1. 签名校验(防盗链)
if (!signService.verifyTsSign(streamId, tsName + ".ts", uid, sign)) {
return ResponseEntity.status(403).build();
}
// 2. 回源拉取 ts
String originUrl = String.format("http://origin-server:8080/hls/%s/%s.ts",
streamId, tsName);
byte[] tsData = restTemplate.getForObject(originUrl, byte[].class);
// 3. 返回
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("video/mp2t"));
headers.setCacheControl("max-age=60");
return ResponseEntity.ok().headers(headers).body(tsData);
}
}
7.2 推流鉴权回调(Nginx-RTMP 触发)
java
package com.example.hls.controller;
import com.example.hls.service.StreamAuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 接收 Nginx-RTMP 推流回调
* 配置: on_publish http://java-backend:8081/api/stream/auth;
*/
@RestController
@RequestMapping("/api/stream")
@RequiredArgsConstructor
public class StreamAuthController {
private final StreamAuthService authService;
/**
* 推流开始回调
* Nginx-RTMP 会以 form 形式 POST 以下参数:
* - app: 应用名(live)
* - name: 流名(streamId)
* - tcurl: rtmp://host/live
* - addr: 推流方IP
* - flashver, swfurl, pageurl 等
*/
@PostMapping("/auth")
public ResponseEntity<String> onPublish(@RequestParam Map<String, String> params) {
String app = params.get("app");
String streamName = params.get("name");
String addr = params.get("addr");
// 推流URL中通常携带token,如: rtmp://host/live/streamId?token=xxx
// Nginx-RTMP 会把 query string 解析为参数
String token = params.get("token");
// 验证推流权限
boolean valid = authService.validatePublishToken(streamName, token, addr);
if (!valid) {
// 返回非 2xx 即拒绝推流
return ResponseEntity.status(403).body("Unauthorized");
}
// 记录推流开始事件
authService.onStreamStart(streamName, addr);
return ResponseEntity.ok("OK");
}
/**
* 推流结束回调
*/
@PostMapping("/end")
public ResponseEntity<String> onPublishDone(@RequestParam Map<String, String> params) {
String streamName = params.get("name");
authService.onStreamEnd(streamName);
return ResponseEntity.ok("OK");
}
/**
* 拉流(播放)开始回调
* 配置: on_play http://java-backend:8081/api/stream/play;
*/
@PostMapping("/play")
public ResponseEntity<String> onPlay(@RequestParam Map<String, String> params) {
String streamName = params.get("name");
String addr = params.get("addr");
// 在线人数+1
authService.incrementViewer(streamName);
return ResponseEntity.ok("OK");
}
}
8. 自适应码率(ABR)实现
8.1 ABR 工作原理
客户端测速 → 检测带宽变化 → 自动切换 m3u8 中的不同码率分支
带宽充足 → 选择 1080p
带宽下降 → 切换 720p
继续下降 → 切换 480p
带宽恢复 → 切回更高码率
8.2 后端生成 Master Playlist
java
package com.example.hls.service;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class MasterPlaylistGenerator {
/**
* 多码率清单
*/
public static class Variant {
public final long bandwidth;
public final String resolution;
public final String codecs;
public final String uri;
public Variant(long bandwidth, String resolution, String codecs, String uri) {
this.bandwidth = bandwidth;
this.resolution = resolution;
this.codecs = codecs;
this.uri = uri;
}
}
/**
* 生成带多码率的 master.m3u8
*/
public String generateMaster(String streamId) {
List<Variant> variants = getVariants(streamId);
StringBuilder sb = new StringBuilder();
sb.append("#EXTM3U\n");
sb.append("#EXT-X-VERSION:6\n");
for (Variant v : variants) {
sb.append("#EXT-X-STREAM-INF:")
.append("BANDWIDTH=").append(v.bandwidth)
.append(",RESOLUTION=").append(v.resolution)
.append(",CODECS=\"").append(v.codecs).append("\"")
.append("\n");
sb.append(v.uri).append("\n");
}
return sb.toString();
}
private List<Variant> getVariants(String streamId) {
List<Variant> variants = new ArrayList<>();
// 实际场景从转码服务/数据库获取真实可用的码率列表
variants.add(new Variant(4000_000, "1920x1080", "avc1.640028,mp4a.40.2",
"/hls/" + streamId + "/1080p/index.m3u8"));
variants.add(new Variant(2000_000, "1280x720", "avc1.4d401f,mp4a.40.2",
"/hls/" + streamId + "/720p/index.m3u8"));
variants.add(new Variant(800_000, "854x480", "avc1.4d401e,mp4a.40.2",
"/hls/" + streamId + "/480p/index.m3u8"));
return variants;
}
}
8.3 FFmpeg 多码率转码命令
bash
# 一次推流转出多码率(实际生产由转码服务调用)
ffmpeg -i rtmp://localhost/live/stream001 \
-filter_complex \
"[0:v]split=3[v1][v2][v3]; \
[v1]scale=w=1920:h=1080[v1out]; \
[v2]scale=w=1280:h=720[v2out]; \
[v3]scale=w=854:h=480[v3out]" \
-map "[v1out]" -c:v:0 libx264 -b:v:0 4000k -map a:0 -c:a:0 aac -b:a:0 128k \
-map "[v2out]" -c:v:1 libx264 -b:v:1 2000k -map a:0 -c:a:1 aac -b:a:1 96k \
-map "[v3out]" -c:v:2 libx264 -b:v:2 800k -map a:0 -c:a:2 aac -b:a:2 64k \
-f hls \
-hls_time 4 \
-hls_list_size 6 \
-hls_segment_filename "/tmp/hls/stream001/%v/segment_%03d.ts" \
-master_pl_name "master.m3u8" \
-var_stream_map "v:0,a:0,name:1080p v:1,a:1,name:720p v:2,a:2,name:480p" \
/tmp/hls/stream001/%v/index.m3u8
9. 直播鉴权与防盗链
9.1 常见盗链方式
- 复制 URL 直接观看:拿到 m3u8 地址直接播放
- 下载切片重新上传:录制 ts 后转为点播文件
- 第三方播放器嵌套:在自己网站嵌入你的流
9.2 防盗链方案对比
| 方案 | 强度 | 实现复杂度 | 影响范围 |
|---|---|---|---|
| Referer 校验 | 低 | 简单 | 仅浏览器 |
| Token 校验(URL签名) | 中 | 中等 | 全平台 |
| 时效签名(带过期时间) | 中高 | 中等 | 全平台 |
| 客户端绑定(IP/UA) | 高 | 较高 | 全平台 |
| AES-128 加密切片 | 高 | 高 | 全平台 |
| DRM(Widevine/FairPlay) | 极高 | 极高 | 商业项目 |
9.3 URL 时效签名实现
java
package com.example.hls.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
@Service
public class SignService {
@Value("${hls.sign.secret}")
private String secret;
@Value("${hls.sign.expire-seconds:7200}")
private long expireSeconds;
/**
* 生成 m3u8 访问签名 URL
* 格式: /hls/{streamId}.m3u8?uid=xxx&expire=xxx&sign=xxx
*/
public String signM3u8Url(String streamId, long uid) {
long expire = (System.currentTimeMillis() / 1000) + expireSeconds;
String raw = streamId + ":" + uid + ":" + expire;
String sign = hmacSha256(raw, secret);
return String.format("/hls/%s.m3u8?uid=%d&expire=%d&sign=%s",
streamId, uid, expire, sign);
}
/**
* 校验 m3u8 签名
*/
public boolean verifyM3u8Sign(String streamId, long uid, long expire, String sign) {
long now = System.currentTimeMillis() / 1000;
if (now > expire) {
return false; // 已过期
}
String raw = streamId + ":" + uid + ":" + expire;
String expected = hmacSha256(raw, secret);
return constantTimeEquals(expected, sign);
}
/**
* ts 切片签名(基于 m3u8 token + ts 文件名)
*/
public String signTsUrl(String streamId, String tsUri, long uid) {
String raw = streamId + ":" + tsUri + ":" + uid;
return hmacSha256(raw, secret);
}
public boolean verifyTsSign(String streamId, String tsUri, long uid, String sign) {
String raw = streamId + ":" + tsUri + ":" + uid;
String expected = hmacSha256(raw, secret);
return constantTimeEquals(expected, sign);
}
private String hmacSha256(String data, String key) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] bytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
}
}
/**
* 常量时间比较,防止时序攻击
*/
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null || a.length() != b.length()) return false;
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
}
9.4 AES-128 加密切片(高级方案)
java
package com.example.hls.controller;
import com.example.hls.service.AuthService;
import com.example.hls.service.KeyService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/key")
@RequiredArgsConstructor
public class KeyController {
private final AuthService authService;
private final KeyService keyService;
/**
* 提供 AES-128 密钥(鉴权后才返回)
* m3u8 中的 #EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/key/{streamId}?token=xxx"
*/
@GetMapping("/{streamId}")
public ResponseEntity<byte[]> getKey(
@PathVariable String streamId,
@RequestParam String token,
@RequestParam Long uid) {
// 严格鉴权
if (!authService.canAccessKey(streamId, token, uid)) {
return ResponseEntity.status(403).build();
}
// 二次校验:用户付费状态、地域、设备等
if (!authService.checkUserPermission(uid, streamId)) {
return ResponseEntity.status(403).build();
}
byte[] keyBytes = keyService.getKeyBytes(streamId);
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/octet-stream");
headers.add("Cache-Control", "private, max-age=300");
return ResponseEntity.ok().headers(headers).body(keyBytes);
}
}
10. 点播 VOD 与直播 LIVE 的差异
10.1 核心差异对比
| 维度 | 直播 LIVE | 点播 VOD |
|---|---|---|
| m3u8 是否有 ENDLIST | ❌ 无 | ✅ 有 |
| m3u8 内容 | 滑动窗口(最近N片) | 完整列表 |
| 客户端拉取 | 定期重新拉取 | 一次性拉取 |
| MEDIA-SEQUENCE | 持续递增 | 通常为 0 |
| ts 文件存储 | 临时存储/CDN缓存 | 持久存储(OSS) |
| 后端关注点 | 切片实时性、延迟 | 转码效率、存储 |
| 缓存策略 | m3u8 不缓存,ts 短缓存 | 全部长缓存 |
10.2 后端缓存策略实现
java
package com.example.hls.controller;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/media")
public class CacheStrategyController {
/**
* 直播 m3u8:禁止缓存
*/
@GetMapping("/live/{streamId}.m3u8")
public ResponseEntity<String> liveM3u8(@PathVariable String streamId) {
String content = "..."; // 拉取真实内容
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
return ResponseEntity.ok().headers(headers).body(content);
}
/**
* 直播 ts:短缓存(CDN 内 60s,浏览器不缓存)
*/
@GetMapping("/live/{streamId}/{tsFile}.ts")
public ResponseEntity<byte[]> liveTs(@PathVariable String streamId,
@PathVariable String tsFile) {
byte[] data = new byte[0]; // 拉取真实数据
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "public, max-age=60, s-maxage=60");
return ResponseEntity.ok().headers(headers).body(data);
}
/**
* 点播 m3u8:长缓存
*/
@GetMapping("/vod/{videoId}.m3u8")
public ResponseEntity<String> vodM3u8(@PathVariable String videoId) {
String content = "...";
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "public, max-age=86400, s-maxage=86400");
return ResponseEntity.ok().headers(headers).body(content);
}
/**
* 点播 ts:永久缓存(文件名带版本号或哈希)
*/
@GetMapping("/vod/{videoId}/{tsFile}.ts")
public ResponseEntity<byte[]> vodTs(@PathVariable String videoId,
@PathVariable String tsFile) {
byte[] data = new byte[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "public, max-age=31536000, immutable");
return ResponseEntity.ok().headers(headers).body(data);
}
}
11. 低延迟 LL-HLS
11.1 LL-HLS 简介
传统 HLS 延迟较高(6-30秒),Apple 在 WWDC 2019 推出 Low-Latency HLS ,目标延迟降至 2 秒以内。
11.2 核心机制
| 机制 | 说明 |
|---|---|
| Partial Segments | 把 ts 切片再细分为更小的"部分"(约 200ms) |
| Blocking Playlist Reload | 客户端发起请求,服务端阻塞响应直到新 part 产生 |
| Preload Hints | 提示客户端下一个即将到来的 part |
| Delta Updates | m3u8 只返回增量变化部分 |
| HTTP/2 Push | 推送即将到来的 part 给客户端 |
11.3 LL-HLS m3u8 示例
m3u8
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,CAN-SKIP-UNTIL=12.0
#EXT-X-PART-INF:PART-TARGET=0.33
#EXT-X-MEDIA-SEQUENCE:100
#EXTINF:6.00,
segment100.ts
# 当前正在生产的切片,已有3个 part
#EXT-X-PART:DURATION=0.33,URI="segment101.0.ts"
#EXT-X-PART:DURATION=0.33,URI="segment101.1.ts"
#EXT-X-PART:DURATION=0.33,URI="segment101.2.ts"
# 提示下一个即将产生的 part
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="segment101.3.ts"
11.4 Java 后端处理 Blocking Reload
java
package com.example.hls.controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.async.DeferredResult;
import com.example.hls.service.LlhlsService;
import lombok.RequiredArgsConstructor;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/llhls")
@RequiredArgsConstructor
public class LlhlsController {
private final LlhlsService llhlsService;
/**
* Blocking Playlist Reload
* 客户端请求示例: /llhls/stream001.m3u8?_HLS_msn=101&_HLS_part=3
* 含义:阻塞等待 sequence=101 的第3个 part 产生后再返回
*/
@GetMapping(value = "/{streamId}.m3u8",
produces = "application/vnd.apple.mpegurl")
public DeferredResult<String> blockingReload(
@PathVariable String streamId,
@RequestParam(name = "_HLS_msn", required = false) Long msn,
@RequestParam(name = "_HLS_part", required = false) Integer part) {
// 设置超时(必须设置,避免无限阻塞)
DeferredResult<String> result = new DeferredResult<>(
TimeUnit.SECONDS.toMillis(30), "# Timeout");
if (msn == null) {
// 普通请求,立即返回
result.setResult(llhlsService.getCurrentPlaylist(streamId));
} else {
// 阻塞等待指定 msn/part 产生
llhlsService.waitForPart(streamId, msn, part, playlist -> {
result.setResult(playlist);
});
}
return result;
}
}
11.5 LL-HLS 部署注意事项
- 必须 HTTP/2 或 HTTP/3:HTTP/1.1 不支持流式响应
- CDN 必须支持:传统 CDN 可能将阻塞请求当作慢请求处理
- Nginx 需要 nginx-vod-module 或 SRS:原生 nginx-rtmp 不支持
- 服务端实现复杂:需要协程/异步框架(Spring WebFlux、Vert.x)
12. CDN 分发与回源
12.1 为什么需要 CDN
直播观看人数动辄百万级,源站无法承受,必须借助 CDN:
百万观众 → CDN 边缘节点 (近 10000 个) → 源站 (10 个)
↑
源站只服务 CDN
减负 99.9%
12.2 回源策略
| 策略 | 说明 |
|---|---|
| 首次回源 | CDN 第一次请求时回源,缓存后续请求 |
| 预热 | 主动推送热门流到 CDN |
| 过期回源 | TTL 到期后回源验证 |
| Cache Miss 回源 | 缓存未命中时回源 |
12.3 与 CDN 配合的 Cache-Control 配置
java
// m3u8: 不缓存(总是回源拉最新)
Cache-Control: no-cache
// ts: 短缓存(CDN 缓存 60s,节省源站带宽)
Cache-Control: public, max-age=60, s-maxage=60
// 点播 ts: 永久缓存
Cache-Control: public, max-age=31536000, immutable
12.4 回源鉴权(CDN 与源站之间的安全)
java
package com.example.hls.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Set;
/**
* 源站只允许 CDN 回源(IP白名单 + 共享密钥)
*/
@Component
public class CdnOriginFilter implements Filter {
@Value("${cdn.allowed-ips}")
private Set<String> allowedIps;
@Value("${cdn.shared-secret}")
private String sharedSecret;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// 只对回源接口生效
if (!req.getRequestURI().startsWith("/origin/")) {
chain.doFilter(request, response);
return;
}
// 1. IP 白名单
String clientIp = getClientIp(req);
if (!allowedIps.contains(clientIp)) {
resp.setStatus(403);
return;
}
// 2. 共享密钥校验(CDN 在请求头中携带)
String secret = req.getHeader("X-CDN-Secret");
if (!sharedSecret.equals(secret)) {
resp.setStatus(403);
return;
}
chain.doFilter(request, response);
}
private String getClientIp(HttpServletRequest req) {
String ip = req.getHeader("X-Forwarded-For");
if (ip != null && !ip.isEmpty()) {
return ip.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}
13. 监控、运维与排错
13.1 关键业务指标
| 指标 | 含义 | 后端如何采集 |
|---|---|---|
| 推流成功率 | on_publish 成功数 / 总数 | 推流回调统计 |
| 拉流成功率 | 200 响应数 / 总请求数 | 网关日志 |
| 卡顿率 | 客户端上报缓冲事件 | SDK 上报 + Kafka |
| 首帧时间 | 客户端从请求到首帧渲染 | SDK 上报 |
| 端到端延迟 | 推流时间戳 vs 播放时间戳 | PROGRAM-DATE-TIME 对比 |
| 在线人数 | 当前正在拉流人数 | Redis 计数 |
| 切片时长抖动 | EXTINF 真实值的标准差 | 切片服务上报 |
13.2 在线人数统计
java
package com.example.hls.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class ViewerCountService {
private final StringRedisTemplate redis;
/**
* 用户拉取 m3u8 时调用,记录在线状态(30秒过期)
* 用户每次拉m3u8(约每4秒一次)会续期
*/
public void heartbeat(String streamId, long uid) {
String key = "viewer:" + streamId + ":" + uid;
redis.opsForValue().set(key, "1", Duration.ofSeconds(30));
// HyperLogLog 统计累计观看人数
String hllKey = "viewer:total:" + streamId;
redis.opsForHyperLogLog().add(hllKey, String.valueOf(uid));
}
/**
* 当前在线人数(基于 Redis SCAN,大流量场景应改为定时聚合)
*/
public long getCurrentViewers(String streamId) {
// 简单实现:使用一个集合记录在线用户(带TTL不友好,生产建议用zset+过期清理)
String setKey = "viewers:online:" + streamId;
Long size = redis.opsForSet().size(setKey);
return size != null ? size : 0;
}
/**
* 累计观看人数(去重)
*/
public long getTotalViewers(String streamId) {
String hllKey = "viewer:total:" + streamId;
Long count = redis.opsForHyperLogLog().size(hllKey);
return count != null ? count : 0;
}
}
13.3 常见问题排查
Q1: 客户端播放卡顿
排查方向:
1. 切片是否产生及时?
→ 查看源站切片目录,对比时间戳
2. m3u8 是否更新?
→ curl m3u8 多次,看 MEDIA-SEQUENCE 是否递增
3. 网络带宽是否足够?
→ 检查 ts 下载速度 vs 切片码率
4. CDN 是否正常?
→ 查 CDN 命中率、回源延迟
5. 客户端缓冲区配置是否过小?
→ SDK 默认值通常2-3片
Q2: 切片时长抖动严重
原因:FFmpeg 编码参数不当
bash
# ❌ 不推荐:切片时长可能抖动
ffmpeg -i input.flv -c copy -hls_time 4 output.m3u8
# ✅ 推荐:强制 GOP 对齐切片
ffmpeg -i input.flv \
-c:v libx264 -preset veryfast \
-g 48 -keyint_min 48 -sc_threshold 0 \ # GOP=48 对应 24fps × 2s
-hls_time 4 \
-hls_flags split_by_time \
output.m3u8
Q3: 新观众进入延迟大
原因:m3u8 中保留切片过多
nginx
hls_playlist_length 20s; # 太长 = 新观众从老切片开始播 = 延迟大
# 改为 10s 或更短
Q4: 推流成功但播放 404
排查清单:
1. /tmp/hls 目录是否产生 m3u8 和 ts?
→ ls -la /tmp/hls
2. nginx 配置 hls_path 是否与 location root 一致?
→ hls_path 是绝对路径
→ location root + URI 决定文件查找路径
3. nginx 是否有读取权限?
→ 检查 worker 用户对 /tmp/hls 的权限
4. 防火墙/Selinux 是否拦截?
14. 最佳实践与常见问题
14.1 后端开发清单
- 推流鉴权:on_publish 回调强校验
- 拉流签名:URL 时效签名 + IP 绑定
- m3u8 不缓存 :
Cache-Control: no-cache - ts 短缓存:CDN 30~60s
- 跨域配置 :
Access-Control-Allow-Origin - 回源鉴权:源站只接受 CDN IP
- 流状态管理:Redis 维护推流/拉流状态
- 录制开关:业务可控的录制
- 多码率支持:转码服务输出 master.m3u8
- 监控告警:推流中断、卡顿率超阈值
- 熔断降级:源站异常时返回静态错误流
14.2 性能优化建议
1. m3u8 生成
- 缓存 m3u8 文本(短TTL,如1秒),避免每次解析重组
2. ts 转发
- 不要把 ts 文件读入 Java 堆内存再返回
- 使用 NIO/直接转发,或干脆不让 Java 代理 ts,直接 302 跳转
3. 鉴权
- Token 校验放 Redis/Caffeine 本地缓存
- 不要每次请求查数据库
4. 高并发场景
- m3u8 接口单独部署,与业务接口分离
- 使用 Spring WebFlux 异步处理高并发拉取
14.3 m3u8 + ts 分离的设计
实际生产推荐:
┌─────────────┐ m3u8请求 ┌──────────────┐
│ 客户端 │────────────→│ Java网关服务 │ ← 鉴权、签名、统计
│ │ │ (轻业务) │
│ │ ts请求 └──────────────┘
│ │────────────→ 直接走 CDN,不经过 Java
└─────────────┘ (CDN 用签名校验)
好处:
- Java 只处理 m3u8(QPS 远低于 ts)
- ts 流量直接走 CDN,节省 Java 服务带宽
- 鉴权逻辑集中在 m3u8 层
14.4 流名设计规范
不推荐: rtmp://host/live/stream001
(流名简单,易被恶意推流)
推荐: rtmp://host/live/{appId}_{uid}_{streamId}_{token}
解析示例:
appId: 业务标识
uid: 主播ID(推流回调时校验权限)
streamId: 流唯一ID(防重复推流)
token: 时效令牌
14.5 学习路径建议
入门阶段
├── 理解直播链路:推流→转码→分发→播放
├── 搭建 Nginx-RTMP,跑通一次完整推流播放
├── 掌握 m3u8 文件结构
└── Java 解析 m3u8
进阶阶段
├── 编写 Nginx-RTMP 鉴权回调
├── 实现 m3u8 代理与签名
├── 掌握自适应码率(ABR)
├── 防盗链:URL 签名 + AES-128 加密
└── 与 CDN 集成
高级阶段
├── LL-HLS 低延迟优化
├── 录制服务(直播转点播)
├── 大并发场景下的 m3u8 网关
├── 端到端延迟优化(HLS → HTTP-FLV → WebRTC)
└── 监控体系(卡顿、首帧、延迟)
附录
A. 常用 FFmpeg 命令速查
bash
# 查看流信息
ffprobe -v error -show_format -show_streams input.m3u8
# 推流(视频文件 → RTMP)
ffmpeg -re -i input.mp4 -c copy -f flv rtmp://host/live/streamId
# 拉流录制为 MP4
ffmpeg -i http://host/hls/stream.m3u8 -c copy output.mp4
# RTMP 转 HLS
ffmpeg -i rtmp://host/live/stream \
-c copy -f hls -hls_time 4 -hls_list_size 6 output.m3u8
# 多码率转码
ffmpeg -i input -c:v libx264 -b:v 2M -s 1280x720 -f hls 720p.m3u8
# 直播录制(带分段)
ffmpeg -i rtmp://host/live/stream \
-c copy -f segment -segment_time 600 -segment_format mp4 \
recording_%03d.mp4
# 截图(关键帧)
ffmpeg -i http://host/hls/stream.m3u8 -vframes 1 cover.jpg
B. m3u8 标签速查表
| 类别 | 标签 | 必需 | 说明 |
|---|---|---|---|
| 基础 | #EXTM3U |
✅ | 文件头 |
| 基础 | #EXT-X-VERSION |
推荐 | 版本 |
| 媒体 | #EXT-X-TARGETDURATION |
✅ | 切片最大时长 |
| 媒体 | #EXT-X-MEDIA-SEQUENCE |
推荐 | 起始序号 |
| 媒体 | #EXT-X-ENDLIST |
点播✅ | 列表结束 |
| 媒体 | #EXT-X-PLAYLIST-TYPE |
- | VOD/EVENT |
| 切片 | #EXTINF |
✅ | 单切片时长 |
| 切片 | #EXT-X-DISCONTINUITY |
- | 不连续标记 |
| 加密 | #EXT-X-KEY |
加密✅ | 密钥信息 |
| 主索引 | #EXT-X-STREAM-INF |
主索引✅ | 变体流 |
| 主索引 | #EXT-X-MEDIA |
- | 替代媒体 |
| LL-HLS | #EXT-X-PART |
LL-HLS | 部分切片 |
| LL-HLS | #EXT-X-SERVER-CONTROL |
LL-HLS | 服务端控制 |
| LL-HLS | #EXT-X-PRELOAD-HINT |
LL-HLS | 预加载提示 |
C. HTTP 头速查(HLS 相关)
http
# m3u8 响应
Content-Type: application/vnd.apple.mpegurl
Cache-Control: no-cache
Access-Control-Allow-Origin: *
# ts 响应
Content-Type: video/mp2t
Cache-Control: public, max-age=60
# fMP4 响应
Content-Type: video/mp4
D. 推荐学习资源
结语 :直播流技术看似神秘,本质上 HLS 不过是"切片 + 索引文本"的简单设计。作为 Java 后端,你的核心职责不是处理音视频编解码(那是 FFmpeg/转码集群的事),而是构建围绕流媒体的业务服务:鉴权、签名、计费、统计、状态管理、回调处理。把握住"流是数据,业务是核心"这个原则,你就能在直播领域游刃有余。