跳转至

EP18:录像与回放:给直播加上 VOD

在 YouTube 打开

上集回顾 EP17《从零部署:15 分钟跑起你自己的 CDN》
下集预告 EP19《压力测试方法论:从单机到全球压测》

0. 本集目标

看完这一集,观众应该能讲清楚三件事:

  1. 需求由来:为什么真人视讯、竞拍、在线课堂这类场景要的是"按场次"录像,而不是把整条直播流从头录到尾。
  2. 录像 API 怎么用:两阶段协议(开始/结束)具体长什么样,以及一个容易被忽略的约束——一路流同一时刻只能被一个录像会话占用,不支持两个业务方同时抢着录同一路流。
  3. 回放地址从哪来:录像文件怎么从节点落盘走到一个可以直接打开的 URL,以及为什么这个地址是单 Region 的。

1. 需求由来:为什么是"按场次",不是"全程"

真人视讯、竞拍、在线课堂这几类场景有一个共同点:直播是连续的,但"值得回看"的内容永远是某一局、某一场、某一节课,不是整条 7×24 小时的流。出了纠纷要复核,查的是"这一局牌是怎么发的",不是从开播到收播的全部录像。

如果选择全程录像,会立刻撞上两个问题:

  • 存储和上传成本随时长线性增长:一场直播可能开着好几个小时,但真正被回看的也许只有其中几分钟,为了这几分钟把全程都存下来,大部分存储都是浪费。
  • 没有天然的切片边界:事后想找"第 5 局"的录像,只能在一条几小时长的文件里按时间轴去猜,不如直接拿到一个只包含这一局的独立文件。

所以 PPCDN 的录像能力从一开始就按"场次"设计:不是"开播就一直录",而是由业务后端在每一场开始和结束的那一刻,显式地告诉录像系统"从这里开始算一段""到这里结束、把这段存下来"。

flowchart LR
    subgraph A["全程录像"]
        A1["开播"] --> A2["持续录制 7×24 小时"] --> A3["收播"]
        A3 -.->|"事后想找第 5 局"| A4["在几小时长的文件里<br/>按时间轴人工定位"]
    end
    subgraph B["按场次录像(PPCDN)"]
        B1["业务后端喊开始"] --> B2["只录这一场"] --> B3["业务后端喊结束"]
        B3 --> B4["直接拿到<br/>这一场的独立文件"]
    end

为什么必须由业务后端来喊开始/结束,而不是录像系统自己判断? 因为"一场"的边界是业务语义——一局游戏什么时候算开始、一场拍卖什么时候落槌,只有业务系统自己知道,媒体服务端从画面和音频里猜不出来。所以协议把这件事交给调用方显式表达,录像系统只负责"在两次调用之间,把这段流准确地切出来、存好"。


2. 录像 API:两阶段协议,一路流只能被一个录像会话占用

2.1 两次调用,一个接口

POST https://api.pp-cdn.org/api/record/split

是否带 gameRound 字段,决定这次调用是"开始"还是"结束":

阶段 gameRound 效果
场次开始(round-start) 留空 把这路流标记为"正在被我(这个调用方)录像",开始累积录像分段
场次结束(round-end) 传具体值 校验调用方就是当初开始这场次的那一方,把分段重命名为最终文件,触发异步上传
# 场次开始
curl -X POST https://api.pp-cdn.org/api/record/split \
  -H "Authorization: $AUTH" -H "Content-Type: application/json" \
  -d '{"time":"1757280000","appId":"app_xxx","tableId":"table1","gameId":"p2w001"}'

# ...这一场进行中...

# 场次结束
curl -X POST https://api.pp-cdn.org/api/record/split \
  -H "Authorization: $AUTH" -H "Content-Type: application/json" \
  -d '{"time":"1757280600","appId":"app_xxx","tableId":"table1","gameRound":"gc001","gameId":"p2w001"}'

鉴权用的是调用方自己 appId 对应的 appSecret 签名(md5(appSecret + time + appId + tableId + [gameRound] + gameId)),appId 本身也参与签名防篡改——这和 EP06 讲的"签名绑定具体身份和资源"是同一个思路。

2.2 关键约束:一路流,同一时刻只能有一个录像会话

这是接入前必须理解的一条规则:录像的加锁对象是"这一路流"本身(由 appId + tableId 唯一确定),同一时刻只能有一个调用方是它的录像 owner。 谁先调用 round-start,谁就拿到这路流当前这一场的录像权;在它调用 round-end 释放之前,别的调用方想对同一路流再发起 round-start,会被直接拒绝。

sequenceDiagram
    participant BE1 as 业务后端 A
    participant BE2 as 业务后端 B(同一路流)
    participant R as 录像 API

    BE1->>R: round-start(table1)
    R-->>BE1: 200 OK,table1 的录像 owner = A
    BE2->>R: round-start(同一个 table1)
    R-->>BE2: 500,table1 已被另一场占用
    BE1->>R: round-end(table1,gameRound=gc001)
    R-->>BE1: 200 OK,切片+重命名+触发上传,释放 owner
    BE2->>R: round-start(table1)
    R-->>BE2: 200 OK,table1 的录像 owner = B

被拒绝时返回的是 500(不是 409),msg 是具体文本,例如 game "p2w002" already has an active recording on table "table1"——接入时要按这段文本判断冲突,不能只看状态码,因为真正的服务端异常也走 500。

反过来,不同的流之间完全不受影响:同一个 appId 下不同的 tableId,或者干脆是不同 appId,各自拥有自己独立的 owner 和录像状态,可以同时各自进行——"一路流只能一个录像会话"说的是单路流的互斥,不是整个系统同一时间只能服务一个用户。多路流并发录像是常态,被这条规则限制住的只是"两个人同时抢同一路流"这一种情况。

几个实战中会踩到的细节:

  • 对一个从没开始过的场次调用 round-end:返回 200,静默成功,不报错——结束信号本身是无条件发出的。
  • 这路流当前没有任何人在推流:返回 code=50001,和真正的服务端异常区分开,不用去猜文本。
  • 开始之后别立刻结束:两次调用间隔如果短于一个分段时长,还没有新数据写进去,结束会以"没有可收尾的内容"失败——正常场次的时长远超这个量级,只有联调脚本容易踩到。

3. 从直播流到回放地址:上传 S3,地址是单 Region 的

3.1 round-end 之后,录像节点自己把文件传上去

round-end 成功切完最终文件后,会异步、尽力而为地上传到对象存储——这一步不经过控制面,是录像节点自己直接用 S3 SDK 把文件 PUT 到对象存储里;上传失败不影响 round-end 已经返回的那个 200。

sequenceDiagram
    participant R as 录像节点
    participant S3 as 对象存储(OVH net-storage,region=sgp)
    participant PC as ppcenter

    R->>R: round-end:切片 + 重命名 + faststart 重封装
    R->>S3: 直接用 S3 SDK 上传(失败自动重试)
    S3-->>R: 上传成功
    R->>R: 用自己的域名+桶名拼出回放地址
    R->>PC: 上报 objectKey + 回放地址

3.2 回放地址长什么样,为什么说它是"单 Region"的

当前生产环境接的是 OVH net-storage,桶名 ppcdn-net-storage,region 是 sgp(新加坡),访问端点是 https://s3.sgp.io.cloud.ovh.net。录像节点上传成功后,直接拿这个域名 + 桶名 + 文件名拼出回放地址,原样上报给 ppcenter——不是现场签发的短期凭证,就是一个普通的公开 URL。真实生产环境录出来的一条地址长这样:

https://ppcdn-net-storage.s3.sgp.io.cloud.ovh.net/test/B01-frontView-gc1791970055.mp4

region(sgp)直接写死在域名里——这就是"单 Region"的字面意思:这个桶只存在于新加坡这一个区域,没有跨区域复制,也没有在前面架一层 CDN 做多地加速。 不管发起播放请求的观众人在哪里,这条链接都是直接打到新加坡这一个存储桶,请求路径不会因为观众所在地而发生任何变化。

flowchart TD
    V1["观众(亚太)"] --> S["唯一一个存储桶<br/>OVH net-storage · region=sgp"]
    V2["观众(欧洲)"] --> S
    V3["观众(美洲)"] --> S
    S -.->|"没有就近节点<br/>没有多 Region 复制"| Note["所有地区的回放请求<br/>都打到同一个 Region"]

这和直播本身的分发方式是两回事:直播观众拉流会被调度到就近的 Edge 节点(前几集讲过的节点池调度),但录像回放目前没有这一层——它就是一个固定 Region 的对象存储直链,离这个 Region 越远,访问延迟越依赖该区域对外的网络质量,没有就近加速兜底。接入前要清楚这一点:如果业务的观众分布很分散,回放体验在不同地区会不一样,这是当前架构的真实限制,不是临时故障。


4. 一张图串起完整链路

sequenceDiagram
    participant BE as 业务后端
    participant R as 录像节点
    participant S3 as 对象存储(单 Region:sgp)

    BE->>R: ① round-start(这路流还没人在录才会成功)
    R->>R: 对这路流持续切分录像分段
    BE->>R: ② round-end(必须是当初 start 的那一方)
    R->>R: 切片 + 重命名 + faststart 重封装
    R->>S3: ③ 直接上传(失败重试,不影响 round-end 的 200)
    S3-->>R: 上传成功
    R-->>BE: ④ 录像清单里出现这条记录,带回放地址
    Note over S3: 回放地址域名里带 region(sgp)<br/>全球观众都打到这一个 Region

5. 结尾与下集预告(逐字稿要点)

这一集讲清楚了"给直播加上 VOD"这件事:按场次录像是因为事后复核的永远是某一场,不是整条直播,所以把"开始"和"结束"的判断权交给唯一知道业务边界的一方——业务后端自己。两阶段协议背后有一条容易漏掉的约束:一路流同一时刻只能被一个录像会话占用,这是"单路流互斥",不是"全系统只服务一个用户",不同流之间完全可以并发录像。录完之后,文件由录像节点自己直传对象存储,拿到的回放地址是写死在一个 Region 里的公开链接,没有跨区域复制和就近加速——业务的观众越分散,这条限制就越值得提前规划。

下一集,我们回到一个经常被跳过、但绝对不能跳过的环节:压力测试——从单机压测到全球压测方法论,以及为什么单元测试通过不等于系统扛得住真实流量。