直播流m3u8与HLS协议详解

直播流 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 常见盗链方式

  1. 复制 URL 直接观看:拿到 m3u8 地址直接播放
  2. 下载切片重新上传:录制 ts 后转为点播文件
  3. 第三方播放器嵌套:在自己网站嵌入你的流

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/转码集群的事),而是构建围绕流媒体的业务服务:鉴权、签名、计费、统计、状态管理、回调处理。把握住"流是数据,业务是核心"这个原则,你就能在直播领域游刃有余。