YouTube 用户涨了 50 倍却没换掉 MySQL:Vitess 如何给一个本不该扩展的数据库做分片

4 minute read

Published:

English version

这是 🚀 Extreme Scale 系列的第四篇。

先看一个本不该成立的数字。Vitess 诞生于 2010 年的 YouTube,起因是 MySQL 已经扛不住了——而按项目自己的历史记录,此后 YouTube 的用户规模涨了 50 多倍,却没有抛弃 MySQL。那个”注定无法扩展”的数据库,至今仍在一家全球最大的视频平台底下运转。只不过它不再是裸奔的。

直觉的读法是:YouTube 一定把 MySQL 本身做快了很多倍,或者悄悄换掉了它。都没有。它做的是改变 MySQL 被问到的问题:一个路由器,决定每条查询到底该由哪个分片回答;一个单机守卫,拒绝让坏查询和无限并发碰到存储引擎;一台重分片机器,让数据在流量不停的情况下在分片之间搬家。每个部件的存在,都对应它所取代的那套系统的一个具体失效模式。而它修不了的失效模式同样有启发——Slack 已经在公开场合、在全量规模下演示过其中两个。

背景:YouTube 爬过的梯子,以及梯子尽头的东西

Vitess 的历史页把这段经历写成一串临时方案,每一级都争取了时间、然后失效。这个顺序很重要,因为它几乎是每个 MySQL 团队都会爬的同一架梯子,而 Vitess 只有放在最后一 级之后才讲得通。

第一级:副本。 峰值流量快要超过单台数据库的承载能力时,YouTube 做了读写分离——一个 master 扛写,一个 replica 扛读。直到读流量单独就能压垮 replica 为止,这招都有效。

第二级:更多副本。 加副本,直到读负载舒服为止。直到写流量单独超过单个 master 的能力,这招都有效。再多的副本也修不了写瓶颈,因为每次写还是落在同一个 master 上。而且数据量本身迟早也会超过单机。

第三级:应用层分片。 YouTube 改了应用代码:每次执行数据库操作之前,代码先算出这条查询该去哪个分片。这确实能扩展:写和数据都分散到了多个 master 上。但它把数据库最难的那部分塞进了每一个应用里。每个新功能、每条查询、每个工程师都得懂分片方案。而一个不断增长的系统迟早需要的操作——把过热或过大的分片切开——变成了一次全应用协调、带停机的事件。分片映射住在代码里,所以改分片映射就意味着改代码、发版、屏住呼吸。

Vitess 的洞见是:分片是基础设施,不是应用逻辑。它的核心动作,是把分片映射、路由、连接管理和重分片机器从应用里抽出来,放进一层代理——应用面对的看起来还是一个 MySQL 数据库,而代理去操心这背后到底是多少台 MySQL。

graph TD; A["应用 - 普通 MySQL 协议"] -->|"不带分片逻辑的查询"| B["VTGate - 无状态路由器与规划器"]; B -->|"读取分片映射与 tablet 类型"| C["拓扑服务器 - keyspace 与分片元数据"]; B -->|"定向查询到单个分片"| D["VTTablet - 分片 1 的主节点"]; B -->|"散射查询到所有分片"| E["VTTablet - 分片 2 的主节点"]; D -->|"受保护的连接池与超时"| F["MySQL 分片 1"]; E -->|"受保护的连接池与超时"| G["MySQL 分片 2"]; D -->|"异步复制"| H["副本 tablet - 承载读流量"]; E -->|"异步复制"| I["副本 tablet - 承载读流量"];

这张图里有两个设计决策扛起了大部分工作,都值得原样说清楚。

第一,VTGate 是无状态的。它不存数据、不持有关键会话;它从拓扑服务器读分片映射、规划查询、转发。无状态意味着路由层本身可以水平扩展:VTGate 成了瓶颈,就加 VTGate。一个不能扩展的代理,只是一个穿了风衣的单点故障。

第二,每台 MySQL 前面都有一个 VTTablet,一对一,通常就在同一台机器上。它不是装饰性的 sidecar。它是唯一被允许跟 MySQL 对话的东西,也就是唯一能执行策略的地方:允许多少并发事务、一条查询最多跑多久、哪种 tablet 类型服务哪种流量。下一节的所有保护都住在这里,而它们之所以有效,恰恰是因为没有任何路径可以绕过它。

机制一:按 keyspace ID 路由——把分片映射变成一等公民

Vitess 不是把一行直接哈希到某台服务器。它先通过 vindex 把分片键映射成一个 keyspace ID,每个分片拥有一段连续的 ID 区间。Keyspace 是逻辑数据库——应用以为自己在对话的东西——而分片是它的物理切分。从表到 vindex、从 ID 区间到分片的映射住在 vschema 里,VTGate 从拓扑服务器读取它。

这层间接就是全部诀窍,它在三个具体的地方 payoff。

定向查询只花一个分片。 查询带了分片键,VTGate 算出 keyspace ID,找到唯一包含它的分片,只把查询发到那里。写得好的查询的代价不随分片数增长。这才是分片之所以能扩展、而不只是切分的原因:加分片就是加容量,不给已有查询加税。

散射查询花掉所有分片。 查询不带分片键,VTGate 不知道数据在哪,只能把查询发给全部所有分片再合并结果。这是对的,而且代价是反扩展的:每条散射查询同时消耗所有分片的容量,所以整个系统的散射容量大约等于单个分片的容量,分片再多也没用。带排序的散射查询还会多取:每个分片各取前 50 行、最后合并成全局前 50,意味着最多取到每分片 50 行、再丢掉大部分。路由器藏不住这个代价——它本来就是固有的——但它让代价可见、可归因:你能看到哪些查询在散射、多频繁。

第二条访问路径要多付一次写。 有时你需要按分片键之外的东西找一行——按 ID 找消息、按单号找订单。Vitess 的答案是 lookup vindex:一张把另一个键映射到 keyspace ID 的表,让路由器用两次跳转代替散射。代价付在每次插入和删除上,那些操作必须同时写 lookup 条目。这是一笔干净、诚实的交易——把读的扇出换成写的放大——而且应该由团队按表主动选择,而不是在生产环境里才发现。

这里的因果链是关于决策住在哪。应用层分片时,路由决策住在成千上万个调用点,改它就要改全部。Vitess 里,路由决策住在一个地方——vschema 加 VTGate 的规划器——所以改分片映射就是改元数据,应用完全无感。而这正是下一个机制得以成立的前提:如果每个应用都把旧的分片映射编译在里面,在线重分片根本无从谈起。

路由修不了的东西:它没法让一个糟糕的分片键变好。如果某个键吸引了不成比例的流量,它的那个分片就是热的,其他分片再多也没用,路由器会忠实地把全部负载送到同一个地方。路由是在键之间分配负载,不是在键之内重新分配负载。这个区别,下面 Slack 的故事会用后果把它讲清楚。

机制二:VTTablet 当保镖——快速失败,而不是缓慢崩溃

Vitess 要打破的第二个失效模式更古老、也更丢人:一条坏查询拖垮整个数据库。在 Vitess 之前,YouTube 每次因为有人写了条坏查询而宕机,团队就会往 VTTablet 里加一段保护代码。最后得到的是一组守卫——看起来只是配置,直到你看到它们打断的崩溃。

崩溃是这样发生的。MySQL 里一个连接的代价不只是一个 socket——每个连接都带线程栈、缓冲和会话状态;而在争用之下,并发事务会在锁和 InnoDB 的版本历史(version history)上互相挤压。并发一过引擎的舒适工作点,每条查询都会变慢,于是查询堆积得更久,于是并发更高,于是更慢。这是一个带悬崖的正反馈循环:吞吐量不是优雅下降,而是断崖式跌落。而引擎前面一个无限排队的队列保护不了它;它只保证引擎永远以超过消化能力的速度被喂活,永不停歇。

VTTablet 在三个点上打断这个循环,Vitess 文档里的默认值就勾勒出了这套策略的形状:

并发事务上限。 每个 tablet 的事务池默认上限是 20。第 21 个事务不会无限排队——它短暂等待(池超时默认 1 秒,等待者上限 5000),然后带着错误失败。这个失败就是功能本身。它把存储引擎内部无限的堆积,变成了边缘处有界的错误,客户端可以在此退避重试或主动降载。一个永远排队的线程池只是把堆积从引擎挪到了队列里;一个快速失败的池子让反馈循环根本形不成。

失控查询的 kill 开关。 查询超时——文档建议 OLTP 查询大约 15–30 秒——会杀掉任何超时的查询;事务超时(默认 30 秒)会杀掉被崩溃客户端遗弃的事务。一条本来要跑几分钟、全程 holding 锁并不断拉长版本链的查询,几秒钟后就被清掉,它的锁也随之而去。

不同负载用不同池子。 普通查询、流式(OLAP 风格)查询和事务走不同的池子(默认值分别是 16、200 和事务上限),所以一次大批量分析扫描不会饿死短事务查询的连接。一个通道的池子耗尽,不会关掉其他通道。

PlanetScale 自己写的一次生产事故展示了这套机制按设计工作的样子:一个批处理任务开了一个事务、十五分钟不提交,而那些平时毫秒级的读,开始因为 InnoDB 要穿越越来越长的版本历史来构建快照而冲破执行上限。事务超时杀掉那个长事务之后,锁被释放,十六分钟的积压大约三十秒就排空了。超时没有让数据库变快。它只是自动清掉了那个让其他一切变慢的事务——而且不需要人发现、人干预。

保镖修不了的东西:它是通过让查询失败来卸负载的,而失败会传导到客户端。如果客户端立刻、不加节制地重试,它会在网关处重建堆积——同一个反馈循环,只是向外挪了一跳。快速失败只有在调用方把错误当成背压时才成系统:有界重试、指数退避、熔断器。Vitess 给了数据库说”不”的能力;它没法强迫应用听进去。

机制三:在线重分片——把可怕的操作变无聊

这是让整个项目成立的机制,因为它攻击的正是应用层分片搞得最可怕的那个操作。旧世界里,切分一个分片意味着协调停机:停流量、拷数据、改应用指向、重启。Vitess 世界里,重分片是一个工作流——Reshard,以及它的兄弟 MoveTables(在 keyspace 之间搬表)——建立在 vreplication 之上,全程流量不停。

graph TD; A["源分片 - 继续服务实时流量"] -->|"拷贝阶段 - 按批拷贝行"| B["目标分片 - 新的 keyspace ID 区间"]; A -->|"binlog 追尾 - vreplication 回放新写"| B; B -->|"VDiff - 逐行比对源与目标"| C["流量切换 - 先读后写"]; C -->|"反向复制保持在线"| D["回滚路径 - 把流量切回去"]; C -->|"清理"| E["源表删除"];

一步步走一遍,因为每一步都是为了堵一个具体的洞:

  1. 拷贝阶段。 用新的 keyspace ID 区间建好目标分片,vreplication 按批从源拷贝已有行。流量仍然走源。拷贝可以限流,避免压垮生产源——文档明确警告工作流可能对生产源 tablet 造成冲击,并提供了限制拷贝阶段影响的开关。
  2. 复制追尾。 大批量拷贝完成后,vreplication 继续追源的 binlog、把新写应用到目标,于是目标先追上源、再近实时地跟着源走。目标永远不是一个陈旧的快照;它是一个恰好拥有更窄键区间的副本。
  3. VDiff。 切流量之前,Vitess 逐行比对源和目标。这一步把”我们觉得拷对了”变成”我们验证过拷对了”,而它之所以可以理直气壮地要求,正因为切换还没发生。
  4. 流量切换,先读后写。 读先切到目标——读切错边顶多得到一个旧答案,不会丢数据——读被证明健康之后,写再切。顺序本身就是有意的:分阶段控制爆炸半径。
  5. 反向复制作为回滚路径。 切换之后,复制可以反向运行,让旧源继续从新目标同步。出问题时,流量切回去即可,不需要恢复。直到操作员明确完成迁移、删掉源表之前,整个迁移都是可 revert 的。

因果链是这样的:重分片过去又少见又可怕,因为它要停机、没有安全回滚,于是团队一直拖到分片已经着火——那时操作既紧急又危险。在线、可验证、可 revert 的重分片打破了这个循环,因为它让操作便宜到可以在着火之前做。一个周二下午就能切开的分片,就会在周二下午被切开;一个需要维护窗口的分片,会在一次事故里被切开。

重分片修不了的东西:拷贝阶段仍然要从生产源读数据,所以一个巨大的分片无论如何都要付出真实的 I/O 和时间;限流只能管理它,不能消除它。而且重分片切的是键的区间——它切不开单个热键。如果一个明星账号、一个巨型 workspace 或一个失控任务占了一个分片负载的大头,在它周围切区间只会让这个热键留在原地,身边连稀释它的邻居都没了。区间切分修的是键之间的倾斜,不是键之内的倾斜,工作流再顺滑也改变不了这一点。

机制四,简述:拓扑与故障切换只是元数据操作

最后一块最不性感、但对运维最决定性:拓扑服务器(etcd、ZooKeeper 或 Consul)持有分片映射、tablet 类型和复制关系图,vtctld 负责编排对它的变更。主节点挂了,Vitess 通过一次基于这份元数据的 planned reparenting 把副本提上来——而 VTGate 读的正是同一份元数据来路由查询。故障切换于是变成一次元数据更新加路由更新,而不是一场翻应用配置的 scramble。Slack 的案例研究写于约 45% 查询负载跑在 Vitess 上时,测得路由开销平均大约一毫秒——这层间接的代价,被量出来了,而且跟把拓扑 baked 进每个应用的替代方案比,可以忽略不计。

在其他公司的规模下的证明

YouTube 是起源故事,但 Vitess 的通用性主张要靠负载形态不同的采纳者来背书。Slack 最有启发:到 2016 年秋天,它已经跑着上千个分片 MySQL 主机、每秒数十万次查询,而它的问题不是总容量,而是分布——最忙的主机因为服务最大的客户而越来越热,成千上万台其他主机却基本空闲。按 workspace 分片反而集中了负载,而不是分散它。Slack 团队从白板到生产用了大约七个月;CNCF 案例研究写成时,约 45% 的查询负载——每天约 200 亿次查询,峰值接近每秒 50 万次——跑在 Vitess 上,所有新功能只写 Vitess。解决倾斜问题的正是上面那套机制:按一个能分散负载的键重分片(消息按 channel,而不是按 workspace),用在线工作流而不是重写每个应用。

京东——中国最大的零售商之一——在双十一峰值时有约 3500 万次查询每秒 跑过 Vitess(PlanetScale 的数据)。而项目本身在 2019 年 11 月从 CNCF 毕业——继 Kubernetes、Prometheus、Envoy 之后第八个毕业的项目,2018 年 2 月进入孵化。2012 年开源(内部名字叫 Voltron,公开名取自法语”速度”),Vitess 头五年基本只有 YouTube 一个用户,然后成了那些想留着 MySQL、又想扩展它的团队的默认答案。

Vitess 修不了的东西:用两起 Slack 事故划边界

陈述 Vitess 局限最诚实的方式,是展示它在场、按设计工作、仍然不够的时刻。

2022 年 2 月:散射查询撞上冷缓存。 Slack 对 2022 年 2 月 22 日事故的复盘描述了一场从缓存层开始的级联:Consul agent fleet 的百分比滚动——2 月 22 日那一步覆盖了 25% 的机器,之前一周两步同样的 25% 都平安无事——不断把缓存节点从服务目录里摘掉,而缓存控制平面不断提拔空的备用节点、清空重回的节点。命中率掉了。突然涌向数据库的读里,有一条客户端启动查询:列出某个群组私聊(GDM)里的用户,读的是一个按用户分片的 keyspace。从按用户分片的表里找某个 GDM 的成员,意味着要查所有分片,而 Vitess 对这种查询的设计行为就是散射到每个分片。平时这个低效是看不见的,因为 GDM 成员关系不可变、缓存 TTL 很长、几乎总是由缓存服务。可当相当一部分缓存不可用时,几乎每个正在启动的用户都需要对每个分片成功执行一次查询,读负载随缓存缺失率超线性上涨,而超时的查询又没法把缓存填回去——两个层锁进了不健康状态:低命中率导致过载,过载又阻止回填。客户端重试又在上面加了一层负载。整个循环持续了大半个工作日——约九小时才完全恢复功能。Vitess 没有故障:路由器不可能知道一条没有带分片键的查询的数据在哪个分片,而散射查询的代价按构造就是分片数的倍数。修复同样不在 Vitess 里:节流客户端启动、把查询改成只从 Vitess 读缓存里确实缺失的那部分数据、再加上——因为数据容忍旧值——同时从副本而不只是主节点读。教训是架构性的,不是配置性的:一条处在流量顶部的散射查询就是一把上了膛的枪,而分片切得越细越糟,因为它放大了扇出。像盯错误率一样盯你的散射率。

2022 年 10 月:热分片撞上批量任务。 Slack 的后续文章《The Query Strikes Again》描述了一次批量清理任务——把用户从 workspace 里移除——让写请求激增,而某个分片里集中了约 6% 的订阅数据。写负载把 tablet 顶出内存,直到内核 OOM 杀掉 MySQL,副本追不上主节点,替换节点又循环崩溃。那一刻切区间帮不上忙,因为负载是集中的、不是分散的;那个分片的问题是它的内容,不是它的边界。Vitess 给了你切分片的工具,但没有给任何切开一个键的工具。当一个键就是全部工作负载时,修复在数据模型里——给键加桶、复制数据、或者换一个分片键——而且必须在批量任务到来之前做好,不是进行时。

两起事故共享一个值得命名的形状:Vitess 会把你喂给它的模式放大。 喂它定向好的查询,它放大你的容量。喂它散射查询,它按分片数放大你的负载。喂它一个热键,它高效、无怨言地把全部热量送到一个地方。代理可以在边界上执行策略;它修不了一个集中负载的数据模型,也没法给一个它定位不了的行凭空造出一个分片。

值得偷走的教训

  1. 分片是基础设施,不是应用逻辑。 分片映射一旦住进应用代码,每次重分片就是一次发版,每个新功能都要交分片税。把路由抽到一个读元数据的层里,分片映射就变成可以不碰应用就改的东西。单是这一 move,就让其他一切——在线重分片、故障切换、连接池策略——变得可运维。
  2. 在边缘快速失败,保护数据库。 存储引擎前面的无限并发不会优雅降级;它会崩溃,因为更慢的查询堆积成更高的并发。并发事务上限、失控查询的 kill 开关、按负载分池,把崩溃变成有界的错误——前提是客户端把这些错误当成背压,而不是重试成新的堆积。
  3. 把可怕的操作变无聊,否则它会在事故里发生。 需要停机、没有回滚的重分片会被一直拖到分片着火。拷贝加 binlog 追尾加验证加分阶段切流量加反向复制,把它变成常规操作,而只有常规操作才真正会被按时执行。
  4. 盯散射率和最热的键,不只盯总 QPS。 对分片系统来说,总容量是错的数字。真正重要的是最差查询的扇出和最热键的集中度,因为路由器会把这两个量放大。一个有 100 个分片、却有一条散射严重的启动查询的系统,对那条查询而言,有效读容量就是一个分片。
  5. 留着你懂的数据库,为让你能留着它的那一层付费。 YouTube、Slack、京东都留在了 MySQL 上。替代方案——迁到一个原生分布式的数据库——是用一个已解决的运维问题,换一个未解决的问题:新的查询语义、新的故障模式、新的专业知识。Vitess 的赌注是:关系模型从来不是瓶颈,缺的是它周围的机器。十六年的生产流量表明这个赌注是对的——上面那些边界条件要白纸黑字地附在每次采纳后面。

Vitess 真正的教训不是”MySQL 能扩展”。而是规模通常是数据库周围那一层的属性,不是数据库本身的属性——而那一层之所以值钱,是因为它让三个具体的操作变得安全:把一条查询路由到一个分片、拒绝一条会伤害引擎的查询、不停机地搬数据。其他的都是注脚。

Sources

  • Vitess documentation, “History.” https://vitess.io/docs/archive/13.0/overview/history/ — 2010 年诞生于 YouTube;副本、分片、应用层分片的演进顺序;用户规模增长超过 50 倍;2018 年 2 月进入 CNCF 孵化、2019 年 11 月毕业。
  • Sugu Sougoumarane and Mike Solomon, “Vitess: Scaling MySQL at YouTube Using Go,” USENIX LISA, December 2012. https://www.usenix.org/conference/lisa12/vitess-scaling-mysql-youtube-using-go — 原始论文;Vitess 位于 YouTube MySQL 服务基础设施核心,用 Go 编写。
  • The Changelog, “The story of Vitess” (interview with Deepthi Sigireddi, Vitess maintainer and tech lead), episode 485. https://changelog.com/podcast/485 — 2012 年开源,内部名 Voltron,头五年基本只有 YouTube 一个用户,约 2015 年起其他公司开始采纳。
  • Vitess documentation, “MoveTables” and “Reshard.” https://vitess.io/docs/archive/18.0/reference/vreplication/movetables/ and https://vitess.io/docs/archive/13.0/reference/vreplication/v1/reshard/ — 拷贝阶段、VDiff 验证、先读后写的流量切换、反向复制、拷贝阶段限流开关。
  • Vitess documentation, “VTTablet and MySQL” and “VTTablet Connection Pools and Sizing.” https://vitess.io/docs/archive/11.0/user-guides/configuration-basic/vttablet-mysql/ and https://vitess.io/docs/archive/11.0/reference/features/connection-pools/ — 事务上限默认 20、事务超时默认 30 秒、查询超时约 15–30 秒的指导值、连接池大小、max_connections 配置规则。
  • CNCF, “How Slack leverages Vitess to keep up with its ever-growing storage needs,” November 25, 2019. https://www.cncf.io/blog/2019/11/25/how-slack-leverages-vitess-to-keep-up-with-its-ever-growing-storage-needs/ — 2016 年秋的规模(每秒数十万次查询、上千个分片主机)、热主机倾斜、白板到生产约七个月、45% 负载约每天 200 亿次查询、峰值每秒 50 万次、约一毫秒的额外延迟。
  • PlanetScale, “Horizontal sharding for MySQL made easy.” https://planetscale.com/blog/horizontal-sharding-for-mysql-made-easy — 京东双十一峰值约 3500 万次查询每秒;Slack 完成全量迁移、扛住 2020 年远程办公流量激增。
  • PlanetScale, “Concurrency vs. Throughput: why more parallelism can make databases slower.” https://planetscale.com/blog/concurrency-vs-throughput-vitess-mysql — 十五分钟未提交的事务、超时触发后十六分钟积压约三十秒排空、快速失败的事务池对比无限排队。
  • Laura Nolan et al., “Slack’s Incident on 2-22-22: Double Trouble with Datastores,” Engineering at Slack, April 5, 2022. https://slack.engineering/slacks-incident-on-2-22-22/ — Consul 滚动的临界点、按用户分片 keyspace 上的 GDM 散射查询、读负载随缓存缺失率超线性上涨、缓存与数据库的反馈循环、修复措施(启动节流、只读缓存缺失的数据、从副本服务)。
  • Slack Datastores EMEA team, “The Query Strikes Again,” Engineering at Slack. https://slack.engineering/the-query-strikes-again/ — 2022 年 10 月 12–13 日批量清理写激增、单个分片持有约 6% 订阅数据、tablet OOM 杀死与主节点替换循环。