服务端做三件事:切片、写列表、开 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 秒实验
- 把
newline="\n"去掉再跑一次,看切片数变成几——变成 0,因为行尾带\r,endswith('.ts')全落空。 - 把切片从 20 个包改成 200 个包,看总字节怎么变——线性翻十倍,说明分片对单个文件的大小没有上限。
- 在循环里加
time.sleep(0.1),测总耗时——能看出「串行取切片」有多慢,这就是客户端要并行预取的原因。
常见误区
- 「HLS 服务端要维持每个观众的状态」——不用,它是纯静态的。
- 「客户端边下边播同一个切片」——不是,是取完一片才播一片。
- 「m3u8 刷新越快越好」——太快只是白费请求,半个切片时长最合适。
自测三问(点开对答案)
1. 服务端三件事是什么? —— **切片、写列表、开 HTTP**,三件都不需要知道观众是谁。 2. 客户端为什么要先预取 3 个切片? —— **攒够缓冲再开播**,用起播慢换后续流畅。 3. m3u8 一般多久刷一次? —— **半个切片时长**,比如 4 秒切片就 2 秒刷一次。- ☐ (本节完,下一节 5.1.3 HLS 优势及劣势)