[控场AI]
· 9 分钟阅读· 4,590 字

Netflix级断点续播:Dragonfly DB替代Redis实战

Netflix级断点续播:Dragonfly DB替代Redis实战

用 Dragonfly 承接心跳热数据、Postgres 持久化,构建可扛 Netflix 级流量的断点续播系统。

文章以「继续观看」功能为切口,拆解了高并发断点续播系统的架构取舍。核心矛盾在于:每 5 秒一次的心跳会产生每秒数百万次写入,远超单台 Postgres 每秒 1 万到 5 万次的写入能力,而 WAL 落盘与行锁争用进一步放大了这一瓶颈。解决方案是冷热分离——用 Dragonfly DB 承接高频心跳热数据,通过 write-behind 缓存模式,由 flush worker 每约 60 秒批量落库 Postgres,使数据库写入量降低约 12 倍,代价是最多丢失一分钟数据且可从 Postgres 回填。文章还详解了 Hash、Sorted Set、String+TTL 三种结构配合 pipeline 提交,以及用 hash tag 保证同一用户 key 共槽的设计,并总结出五条可复用的系统设计原则。

Netflix级断点续播:Dragonfly DB替代Redis实战

「继续观看」看起来是视频产品里最不起眼的功能,却可能是系统设计面试中区分普通候选人和资深工程师的分水岭。一位YouTube技术博主用一整期视频拆解了如何用 Dragonfly DB、Postgres 和 ImageKit 播放器,从零搭建一个能扛住 Netflix 级流量的断点续播系统。本文提炼其中的架构取舍与工程细节,并结合代码实现给出分析。

一个功能,三个必须回答的问题

继续观看的核心逻辑可以归结为三个问题:用户停在哪里?各视频的观看顺序是什么?现在是否正在观看?这三个问题分别对应视频进度、最近观看列表和实时在线状态。

据博主给出的数据,Netflix 约有 3.25 亿付费订阅用户(他本人也承认这个数字可能已经过时,实际按 3 亿量级估算)。即便只假设 1000 万用户同时观看,且很多人同时在手机和电视上观看,每台播放器每 5 秒发送一次心跳请求,写入压力也会瞬间击穿单一数据库。

播放器心跳机制示意

这就是问题的起点:心跳不能不发,但绝不能直接写进关系型数据库。

Postgres 为什么扛不住心跳写入

单台 Postgres 大约能承受每秒 1 万到 5 万次写入,而 1000 万用户的 5 秒心跳意味着每秒数百万次写入请求。差距不是靠加索引或调参数能弥补的。

更深层的原因在于 Postgres 的设计哲学:它被构建为永不丢数据的系统。每次更新都要走 WAL(预写日志)落盘,还要获取行锁,旧行不会立即删除,而是留给 vacuum 进程清理。当同一账号从电视、手机、笔记本同时发来心跳时,行锁争用会进一步拖慢写入。这些机制保证了可靠性,却在超高频短生命周期写入场景下成为负担。

博主特别提到,这些分析本身就是系统设计面试的常见考点——面试官想听的正是「为什么不能直接写库」。

WAL(Write-Ahead Logging,预写日志) 是关系型数据库保证持久性与崩溃一致性的核心机制。任何数据修改在写入实际数据页之前,都必须先顺序追加到 WAL 日志并刷盘(fsync),这样即使进程崩溃或断电,重启后也能通过重放日志恢复未落盘的事务。代价是每次写入至少多出一次磁盘 I/O 和 fsync 调用,而 fsync 延迟通常在毫秒级。对于每 5 秒一次、每秒数百万次的高频小写入,WAL 的刷盘开销会被成倍放大,成为吞吐瓶颈。相比之下,内存数据库如 Dragonfly 不需要为每次写入同步刷盘,写入路径极短,这也是 write-behind 模式能把压力从 Postgres 卸下来的根本原因。

核心架构:热数据与持久数据分离

解决方案的关键洞察是把热数据和持久数据拆开。

  • 热数据:每 5 秒一次的心跳位置,变化极快、读取极频繁,但只关心最新值,无需保留历史。
  • 持久数据:用户两天后旅行回来还想接着看的位置,必须永不丢失。

热数据写入 Dragonfly DB(内存数据库),读取延迟在毫秒以下,覆盖写几乎零成本。首页的继续观看卡片直接从这里读取。一个 flush worker 每隔约 60 秒把最新位置批量写入 Postgres,这个间隔是博主随意选取的,可以配置为两分钟或五分钟。

系统架构与数据流

这个模式在系统设计里有个正式名字:write-behind caching(写后缓存)。数据先写缓存,再按时间窗口批量落库,而不是先写库再更新缓存。1000 万用户、5 秒心跳、60 秒落库窗口的组合,意味着数据库写入量直接降低 12 倍。

容错取舍:丢掉一分钟可以接受

如果 Dragonfly DB 重启导致内存清空怎么办?博主的回答很坦诚:几乎没影响。最多丢失尚未落库的约 60 秒数据。用户打开首页发现续播记录不见了,系统从 Postgres 拉取该用户未完成的视频,重新填充进 Dragonfly 即可。

这种「明确知道会丢什么、丢了怎么恢复」的态度,比追求绝对不丢数据更符合高并发系统的现实。

为什么选 Dragonfly 而不是 Redis

博主引用的对比数据是:Dragonfly 的 QPS 达到 Redis 的 25 倍。差异来自架构——Dragonfly 采用无共享(shared-nothing)的多线程架构,能利用机器的每一个 CPU 核心,而 Redis 的单线程模型在规模上去后只能靠堆集群解决,账单会非常可观。

对开发者而言最关键的一点是:Dragonfly 是 Redis 协议的直接替代品。代码里用的是普通的 ioredis 客户端,只需要改一个连接 URL,其余代码一行不动。

Dragonfly 还支持定期快照落盘,重启后可自动恢复内存数据,缓解了「Redis 纯内存、一断电就全没」的固有印象。博主演示了通过 Docker Compose 本地启动的配置,注意每个线程需要预留 256MB 内存,线程数按需相乘配置。

Redis 的单线程模型指的是其命令执行引擎由单个主线程串行处理所有客户端请求,好处是天然避免锁竞争、语义简单一致。但单线程意味着单实例只能利用一个 CPU 核心,当 QPS 逼近单核上限时,只能通过分片成多个 Redis 实例(集群)横向扩展,运维复杂度和云账单随之上升。Dragonfly 的无共享(shared-nothing)多线程架构则把键空间按哈希划分给多个线程,每个线程独立处理自己负责的键,线程间几乎不共享状态、不加全局锁,因此能同时吃满多核 CPU。需要注意的是,多线程设计对跨键操作(如事务、多 key 原子命令)的语义有额外约束,这也是理解后续 hash tag 与 pipeline 共槽要求的重要背景。

三个数据结构撑起整个功能

Dragonfly 中每个用户的三类数据分别用不同结构存储:

  • Hash:存储观看进度状态(位置秒数等字段)
  • Sorted Set:按时间排序的最近观看列表,worker 用 ZREVRANGE 取出最新的若干条
  • String + TTL:标记「正在观看」状态,设置约 30 秒的过期时间

这三个操作通过 pipeline 一次性提交,而不是分三次往返网络。博主强调,内存操作本就比磁盘快,如果再逐个写入、逐个等回执,就等于白白浪费了内存数据库的优势。一次网络往返、毫秒级返回,是这套设计的基本要求。

数据库 schema 中的用户与视频关联

Postgres 侧的 schema 很精简:videos 表存 ID、标题、URL、时长;watch_progress 表存用户 ID、视频 ID、位置秒数、最后观看时间和完成标记。没有引入认证体系,用户切换靠模拟实现。

Pipeline(管道) 是 Redis 协议层面的批量提交机制:客户端把多条命令一次性写入 TCP 连接,服务端按序执行后把结果打包返回,从而把多次「发送—等待回执」的网络往返压缩为一次。它不等于事务,不保证原子性,也不保证命令之间不被其他客户端插入,但能显著降低 RTT(往返时延)开销。在 Dragonfly 中,pipeline 的收益更明显,因为多线程引擎可以在一次批处理中并行调度不同槽位的工作。对本文场景而言,进度 Hash、顺序 Sorted Set、在线 TTL 三个写操作通过 pipeline 合并,是把毫秒级内存优势真正兑现到端到端延迟上的关键一步。

哈希槽:面试官最爱追问的细节

Redis 集群固定使用 16384 个哈希槽,这个魔数来自 Redis 团队的早期设计决策,至今在生产环境中广泛使用。取模对象固定为 16384 而非当前服务器数量,好处是扩容缩容时槽位映射不变。

但普通哈希有个陷阱:同一用户的三个 key(进度、顺序、在线状态)经过哈希后可能落到不同节点,而 pipeline 批量操作要求它们在同一节点上执行。解决办法是 hash tag——把 key 中需要共槽的部分用花括号包起来,例如 watch:{user1},哈希函数只计算花括号内的内容,同一用户的全部 key 必然落在同一个槽。

哈希与槽位分配示意

博主使用的哈希函数是 CRC16,而非 SHA 系列——后者为抗攻击设计,会消耗更多 CPU,在槽位分配场景下没有必要。

五条可复用的系统设计原则

博主在结尾总结了五条经验,值得单独列出:

  1. 冷热分离:不是每次写入都值得落盘,能延后写就延后写。
  2. 合并写入:从缓存批量拉取、批量落库,而不是逐条写。
  3. 选对数据结构:状态用 Hash、顺序用 Sorted Set、时效用 TTL。
  4. 预设缓存丢失的预案:明确丢失范围和回填路径,不让线上出现意外。
  5. 按用户分片:用 hash tag 保证同一用户的 key 不跨节点漂移。

播放器选型的隐藏工程点

演示用的 ImageKit 播放器是开源免费的,博主看重的是自适应码率流(HLS/DASH)能力——视频分块传输,根据网络状况在 360p 等清晰度间自动切换,避免一次性加载整个大文件。此外还提供字幕、章节、跳转预览缩略图和播放事件回调。上传一个 MP4 后,转码和 HLS 分发由平台自动完成。

一个值得一提的坑:React 严格模式下播放器组件在首次挂载时会被销毁一次,导致视频元素被移除、播放崩溃。博主在代码中关闭了严格模式并备注了原因,同时表示会向仓库提交 issue。生产构建本身不启用严格模式,因此不影响线上。

代码组织与 AI 辅助开发

项目采用 monorepo:api 应用包含心跳接收、Dragonfly 读写和 flush worker;web 应用负责播放器与首页卡片。worker 的逻辑是定时扫描 key、通过 Redis pipeline 批量取出、在一个数据库事务中写入 Postgres。

博主对 AI 编程的态度很务实:概念理解清楚后,代码交给 Claude、Codex 等代理生成即可,开发者需要掌握的是架构判断力和问题定位能力——比如为什么严格模式下会崩溃、为什么 key 要加花括号。他还提到仓库提供「复制给 LLM」的 Markdown 入口,方便代理端到端实现。

值得强调的是,博主坦言自己早年手工实现过类似功能,第一版直接把所有心跳写入 MySQL,两天内数据库就被压垮。这个教训恰好印证了本文的核心论点:继续观看的难点从来不在功能本身,而在规模下的每一次写入决策。

#Dragonfly DB#Redis 替代#继续观看 系统设计#write-behind caching#哈希槽#断点续播#Postgres 写入瓶颈
分享:

相关推荐