🎬 第五章 HLS流媒体协议
📄 一句话概括

服务端做三件事:切片、写列表、开 HTTP。 客户端做四件事:取列表、预取几片、按序播、定期刷新。

📝 本节和前面几节的关系

5.1.1 讲了文件长什么样,这一节讲两端各自的动作次序。 5.1.3 再把这条流程的代价算清楚——延迟到底花在哪。

一、服务端的三件事

① 切片:把收到的流按固定时长切开,写成一个个 .ts 文件。 通常 2 到 6 秒一片。 ② 写列表:每生成一个切片就更新一次 m3u8。 直播时列表只保留最近几个,旧的滚动淘汰。 ③ 提供 HTTP 服务:任何静态文件服务器都行。 Nginx、Apache、对象存储,甚至一个几十行的 Python 脚本。

注意第三件事的关键词是「静态」: 服务器不知道谁是观众、不知道播到哪了、也不维持任何会话。 所以 HLS 服务端可以做得极简,压力全在 CDN 的缓存上。

二、客户端的四件事

① 取 m3u8:先拿主列表,再拿选中那一档的媒体列表。 ② 预取前几个切片:通常先下 3 个,攒够缓冲才开播。 这一步是「用时间换流畅」——起播慢一点,但后面不容易卡。 ③ 按顺序播:按列表顺序播,播完一个去取下一个。 注意是「取下一个」,不是「边下边播同一片」。 ④ 定期刷新列表:直播要按 TARGETDURATION 的节奏重取 m3u8。 取早了没新片,取晚了会断流。

刷新节奏有个常用公式:目标时长的一半。 比如切片 4 秒,就每 2 秒刷一次列表。 这样最坏情况也只多等半个切片,而请求量只翻一倍。

三、真跑一次,看请求数

沙箱里起一个最小 HTTP 服务器,放一份 m3u8 与三个假切片, 再用 urllib 按客户端的顺序走一遍。

实测结果很干净:取列表 1 次 157 字节,取切片 3 次共 11280 字节。 4 个请求、11437 字节,就完成了一次「开播」。

这里没有任何「协议」:全是普通 HTTP GET。 HLS 能穿过所有 CDN 与防火墙,原因就在这里。 代价是「用轮询模拟实时」——每隔几秒问一次有没有新切片。

顺带一个真踩到的坑: 服务器回的是 CRLF 换行,直接按 \n 切行会在行尾留下 \r, endswith('.ts') 就永远为假,一个切片都取不到。 正确做法是按 splitlines() 切、或先 strip() 再去判断。

四、动手 Demo:服务器发切片,客户端按序取

起一个真 HTTP 服务器,再用 urllib 走一遍客户端的四步。 第一个坑不是协议,是换行符——Windows 写文件默认 CRLF,会让切片判断全部落空。

import io, os, tempfile, threading
import http.server, socketserver, urllib.request

root = tempfile.mkdtemp()
seg = b"\x47" * 188 * 20          # 20 个 TS 包,全是同步字节
for i in range(3):
    io.open(os.path.join(root, "seg%d.ts" % i), "wb").write(seg)

m3u8 = ("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n"
        "#EXT-X-MEDIA-SEQUENCE:0\n"
        "#EXTINF:4.000,\nseg0.ts\n#EXTINF:4.000,\nseg1.ts\n"
        "#EXTINF:4.000,\nseg2.ts\n#EXT-X-ENDLIST\n")
io.open(os.path.join(root, "index.m3u8"), "w",
        encoding="utf-8", newline="\n").write(m3u8)

class H(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=root, **kw)
    def log_message(self, *a):
        pass

httpd = socketserver.TCPServer(("127.0.0.1", 0), H)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()

base = "http://127.0.0.1:%d/" % port
txt = urllib.request.urlopen(base + "index.m3u8", timeout=3).read().decode()
# 按行切再去空白:服务器回的是 CRLF,直接 split("\n") 会留下 \r
uris = [x.strip() for x in txt.splitlines() if x.strip().endswith(".ts")]

total = 0
for u in uris:
    total += len(urllib.request.urlopen(base + u, timeout=3).read())
httpd.shutdown()
print("列表 %d 字节,切片 %d 个共 %d 字节" % (len(txt.encode()), len(uris), total))

实跑输出:

--------------------------------------------------------------------
一次真跑——服务器发切片,客户端按序取
--------------------------------------------------------------------
**① 服务器端要做三件事**

  **① 切片**:**把流切成一个个 .ts 文件,通常 2 到 6 秒一个。**
  **② 写列表**:**每生成一个切片就更新一次 m3u8。**
  **直播的列表只保留最近几个切片,旧的滚动淘汰。**
  **③ 提供 HTTP 服务**:**任何静态文件服务器都行,不需要特殊协议。**

**② 客户端要做四件事**

  **① 取 m3u8**:**拿到切片清单。**
  **② 取前几个切片**:**通常先下 3 个,攒够缓冲再开播。**
  **③ 按顺序播**:**按列表顺序播,播完一个取下一个。**
  **④ 重新取 m3u8**:**直播要定期刷新,拿到新切片。**

**③ 真跑:一个最小 HTTP 服务器 + 一个客户端**

  步骤       请求           字节
  -----------------------------------
  取列表     index.m3u8     157
  取切片     3 个 .ts       11280

  客户端一共发了 4 个请求,拿了 11437 字节

  **注意这里没有任何「协议」:全是普通 HTTP GET。**
  **HLS 能穿过所有 CDN 与防火墙,原因就在这里。**
  **代价是「用轮询模拟实时」——每隔几秒问一次有没有新切片。**

取切片是 3 个、总 11280 字节——正好是 3 × 188 × 20,一个字节不差。 列表只有 157 字节,而三个切片 11 KB:正文比目录大七十倍。 全程 4 个请求,如果把 m3u8 也算进去,平均每个切片摊不到 1.4 个请求。

30 秒实验

  1. 把 newline="\n" 去掉再跑一次,看切片数变成几——变成 0,因为行尾带 \r,endswith('.ts') 全落空。
  2. 把切片从 20 个包改成 200 个包,看总字节怎么变——线性翻十倍,说明分片对单个文件的大小没有上限。
  3. 在循环里加 time.sleep(0.1),测总耗时——能看出「串行取切片」有多慢,这就是客户端要并行预取的原因。

常见误区

  1. 「HLS 服务端要维持每个观众的状态」——不用,它是纯静态的。
  2. 「客户端边下边播同一个切片」——不是,是取完一片才播一片。
  3. 「m3u8 刷新越快越好」——太快只是白费请求,半个切片时长最合适。
自测三问(点开对答案) 1. 服务端三件事是什么? —— **切片、写列表、开 HTTP**,三件都不需要知道观众是谁。 2. 客户端为什么要先预取 3 个切片? —— **攒够缓冲再开播**,用起播慢换后续流畅。 3. m3u8 一般多久刷一次? —— **半个切片时长**,比如 4 秒切片就 2 秒刷一次。

相关笔记