“设计 YouTube”是一道很受欢迎的系统设计题,因为有意思的部分并不在人们以为的地方。Web 服务器很普通。真正的难点在于:把几个 GB 的数据从手机可靠地送进存储,把一次上传变成能在各种设备和网络上播放的十几个版本,再通过 CDN 分发这些字节,让你自己的服务器几乎碰不到它们。
本文按顺序走一遍这条流水线:上传、转码、封装、分发。最后给出一张可以改造的架构图,以及值得写在图上的决策。
是两个系统,而不是一个
视频平台其实是共享一个数据库的两个系统:
- 摄取: 创作者上传,平台转码并封装。延迟以分钟计,没人在意。
- 播放: 观众按下播放,第一帧必须在几秒内出现,而且不能卡顿。
把它们画成两条泳道。它们按不同的信号扩容(每小时上传量与同时在线观众数),出故障的方式也不同。
上传:绝不经过你的 API 服务器
视频文件很大,移动网络会断。由此得出两条规则:
- 直接上传到对象存储。 上传 API 对创作者做认证,创建视频记录,并返回预签名 URL。客户端把字节直接发送到存储桶。你的 API 从不代理几个 GB 的数据。
- 支持断点续传。 使用分片上传,连接断开时只需重试失败的那一片。例如 Amazon S3 分片上传 会独立上传各个分片,并在客户端完成上传时把它们组装起来。
上传完成后,存储事件(或客户端的完成调用)会把一个转码任务放进队列。
转码:一次上传,生成一套自适应码率阶梯
播放器会根据带宽切换清晰度,所以每个视频都要编码多次:这就是 自适应码率(ABR)阶梯。典型的阶梯从 240p 或 360p 左右一直到 1080p 或 4K,每一级都有自己的码率。
转码非常吃 CPU,而且极易并行:
- 在关键帧处把源文件切成几秒长的片段。
- 把每个片段、每个版本作为独立任务,在一组工作进程上编码,通常使用 FFmpeg。
- 把结果拼接起来,或者直接保留为分片。
这样,一部两小时的电影只需几分钟就能转码完成,而不是几个小时;失败的任务也只需重试一个片段。用视频、版本和片段作为任务的键,让任务保持幂等,重试时覆盖而不是产生重复。
所有内容共用一套阶梯会浪费码率:静止的幻灯片演示所需的码率远低于一场足球比赛。Netflix 推广了按片编码(per-title encoding),根据每个视频的复杂度为它选择阶梯。你可以先用固定阶梯,以后再改为按片编码。
封装:HLS、DASH 与 CMAF
播放器不会下载一个巨大的文件。它们读取一个列出各版本和分片的清单,然后通过普通 HTTP 获取每段几秒长的分片。
| 格式 | 标准 | 常见播放器 |
|---|---|---|
| HLS | RFC 8216 | Apple 设备,以及通过 hls.js 等播放器的大多数浏览器 |
| MPEG-DASH | ISO/IEC 23009-1 | Android、智能电视,以及通过 dash.js 和 Shaka Player 的浏览器 |
| CMAF | ISO/IEC 23000-19 | 一套分片 MP4,同时用于 HLS 和 DASH 清单 |
CMAF 正是封装器重要的原因:有了它,你只需存一套分片、写两份清单,而不是每个视频都存两份。短分片起播更快、切换清晰度更早;长分片的请求数更少。Apple 的 HLS 制作规范 给出了分片时长、关键帧和码率阶梯的具体建议。
付费内容还要加上 DRM。封装器用通用加密(CENC)对分片只加密一次,各平台的 DRM 系统(Widevine、FairPlay、PlayReady)再通过许可证服务器向播放器发放许可证。
分发:活儿由 CDN 来干
分片是静态文件,这正是关键所在:CDN 在离观众很近的地方缓存它们,一个热门视频几乎完全由边缘缓存提供。源站是存放各版本的存储桶,通常放在源站护盾(origin shield)后面,这样 50 个边缘节点的缓存未命中,到了存储那里只变成一次请求。
播放 API 保持精简:检查观众是否有权观看该视频,返回清单 URL(私有内容则带签名),并记录播放事件供分析使用。
直播是另一条流水线
直播复用封装和 CDN,但替换了摄取部分。编码器通过 RTMP 或 SRT 等协议把流推送到摄取服务器,服务器实时转码出整套阶梯,每隔几秒写出新的分片。延迟来自分片时长加上播放器的缓冲;HLS 和 DASH 的低延迟模式会在分片完成之前就交付其中一部分,以接近几秒的延迟。把直播和点播作为两条独立的流水线,共享 CDN、播放器和元数据。
架构图
图表内容
- Creator
- Upload API
- Raw uploads
- Transcode jobs
- Transcode workers
- Packager
- Renditions
- Video metadata
- Viewer
- Playback API
- CDN
- DRM license server
- 1. upload URL
- 2. multipart
- 3. done
- ladder
- segments
- ready
- manifest URL
- cache miss
- license
这张图的读法如下:
- 上方泳道,摄取: 创作者从上传 API 获取上传 URL(1),把分片直接上传到原始上传存储桶(2),上传完成后转码任务进入队列(3)。工作进程编码整套阶梯,封装器把分片写入版本存储桶,并在元数据存储中把视频标记为就绪。
- 下方泳道,播放: 观众向播放 API 请求清单 URL,然后从 CDN 获取分片播放。缓存未命中时从版本存储桶读取。
- DRM 画成点线,因为只有受保护的内容才需要它:播放器从许可证服务器获取许可证。
- Markdown 块 展示了一个示例阶梯。你的数字应该来自你的内容和观众的带宽。
图上应该画什么
- 字节怎么流动。 画出上传和分片流向存储和 CDN,而不是经过你的 API。
- 任务单元。 “每个版本、每个片段一个任务”能让评审者明白重试和并行是怎么工作的。
- 格式与保护。 写明 HLS、DASH 或 CMAF,如果用了 DRM 也要写上。
- 就绪信号。 封装完成之前视频无法播放;标出这个状态存在哪里。
同样的“队列加工作进程”结构也出现在 事件驱动架构一文 中。在云的一侧,AWS 多区域架构图 介绍了如何从多个区域提供服务,无服务器架构图 则展示了存储事件如何触发函数,而不是使用一组工作进程。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的两个 人员 节点,分别表示创作者和观众。
- 为上传 API、转码工作进程、封装器和播放 API 添加 容器 节点,再添加一个 数据库 存放视频元数据。
- 添加云节点包中的两个 对象存储 节点和一个 CDN,数据节点包中的一个 队列 用于转码任务,以及一个 外部系统 表示 DRM 许可证服务器。
- 把摄取画在上方泳道、播放画在下方泳道,在连线上标出上传步骤的编号,再添加一个基础节点包中的 Markdown 块,写上你的码率阶梯。
这个示例共用 13 个图表节点,在 Free 套餐的限制之内。
常见问题
什么是自适应码率阶梯? 一个视频被编码成的一组版本,每个版本有自己的分辨率和码率。播放器选择观众网络能承受的最佳版本,并随着带宽变化而切换。
应该用 HLS 还是 DASH? 通常两个都用。用 CMAF 分片封装一次,写一份 HLS 清单和一份 DASH 清单,这样无需存两份,就能同时覆盖 Apple 设备和其他播放器。
为什么要直接上传到对象存储? 大文件经过你的 API 服务器会占用连接和内存,连接一断,上传就得从头再来。预签名的分片上传直接进入存储,并能逐片续传。
转码如何并行? 在关键帧处把源文件切成片段,把每个片段、每个版本作为独立任务编码,然后拼接或封装结果。
视频需要 CDN 吗? 只要观众不是极少数,就需要。分片是静态文件,CDN 会从离观众近的缓存响应重复请求,而不是每次都访问你的存储。