开场:Slack 与 GitHub 如何扩展
我们需要 50 万个活跃的 WebSocket 连接。我们有个方案,是在一大批 Kubernetes Pod 里启动无头 Chrome,把 Slack 当成一个浏览器实例运行。每个无头 Chrome 实例跑五小时负载测试要花 37 美分。
所以那一次测试花了 15 万美元。
GitHub 所有 Pull Request 和 Issue 一共只有 16 个分片。全 GitHub 就 16 个,这数量不算多,考虑到这个数据域关联的数据量,我们本来会觉得还可以继续增加。结果得到的回答是:不行,因为散射查询已经太多了。
AI 帮不上你的工作哪些部分?
性能优化基本上完全帮不上,至少在调查问题这一步是这样。我觉得现在的大语言模型比较擅长……
当一个产品每天有数百万人使用,而底层架构开始撑不住时,该怎么扩展它?今天的嘉宾在 Slack 和 GitHub 都做过这件事。Maude Lemaire 是首席工程师,也是《大规模重构:重新掌控代码库》(Refactoring at Scale: Regaining Control of Your Codebase)一书的作者。她在 Slack 工作了八年,在 GitHub 做数据库扩展,也曾在 Cursor 改进首个 Token 的生成时间。
今天我们会聊到:如何为 50 万个 WebSocket 连接做负载测试;为什么用无头 Chrome 实例会耗费太多资源、成本也太高;为什么 GitHub 所有 Pull Request 和 Issue 只有 16 个数据库分片;以及散射查询为什么会成为 GitHub 这种大型 Rails 单体应用里的大麻烦。我们还会聊产品工程师和平台工程师为什么应该在团队间轮岗,以及更多内容。如果你想了解我们每天都在用的产品内部是怎样扩展的,这期节目适合你。
本期由 turbopuffer 赞助。它是构建在对象存储上的向量与全文搜索引擎,速度快、成本低,也极易扩展。本期也由 Antithesis 赞助,它帮助团队更有把握地发布软件。
今天还会谈到:系统规模扩大时,如何重写大型生产系统。这种工程挑战就像在半空中重造一架 747。使用 AI 编程代理时,为什么不能只靠代码审查?因为代码审查正变得越来越天真、越来越无效。Antithesis 会在对抗性模拟中运行整个系统,既做定向测试,也做模糊测试。模拟能在用户发现之前找出 Bug;而且它完全确定,所以不仅能发现问题,还能精确重现每个问题,让修复容易得多。
我第一次听说 Antithesis 时,觉得自动发现 Bug 和完全确定性的测试听起来像科幻;但底层其实是扎实的工程。Jane Street、Superbase、Rapid Railway 和 Datadog 都在使用它,因此可以放心发布代理生成的代码,因为他们知道这些代码经过 Antithesis 验证。举例来说,Datadog 在重写事件平台时用 Antithesis 验证新架构;这个系统每天收集超过 100 万亿个事件。如今,Antithesis 是 Datadog 用于其编程代理的一系列纠错策略之一。想看更多案例与细节,请访问 antithesis.com/pragmatic。
Mo,欢迎来到节目。
谢谢你邀请我。
很高兴你能来。
早年经历与软件工程之路
这一直是我人生愿望清单上的一项,真的特别期待。
这还是本节目第一次这样开场。我通常会先提问,但这次不打算先问。我给你看张照片,你说说看到了什么。
我看到一个宝宝。看起来几个月大?
大概一岁,可能还差一点。坐在电脑前,发型跟现在一样。我想问:你是不是生下来就和电脑在一起?
差不多吧。其实还有更小的时候的照片,可能才几个月大,我连头都快抬不起来,爸爸就让我靠在他腿上坐着。他当时大概在批改作业之类的吧。我也不清楚;他搬来美国之前教了 17 年计算机科学。
嗯。
我还有一堆小时候抱着 ASCII 表的照片,现在回想起来很好笑。不过我后来做计算机其实是个意外。你可能会觉得,所有环境因素都应该让我顺理成章地走上一条线性职业道路,但事实完全不是这样。想想看,我爸肯定早就盼着我学这个了,可我当时觉得计算机听起来很无聊。我很喜欢生物课,还以为自己会成为遗传学研究者,那是我的计划。我爸妈总叫我别太着急,因为我总是计划一大堆。
上大学时我读的是微生物学,选了分子生物学、有机化学之类的课程。我已经修够了 AP 学分,美国的听众应该懂,所以按学分算我已经是大二,而不是大一。
哇。也就是说,你想速通大学?
对,我当时已经准备好了。
跳过好玩的几年,直接去工作。
我后来确实一直这么干。我有个学期修了 19 个学分,后来某一刻还想:我为什么要这么选,真是个糟糕决定。不过做起来倒是挺开心的。可能我觉得好玩的方式跟别人不太一样。
入学大概三周后,有几件事让我很介意。首先,擅长一件事不代表你喜欢做它。我很长时间都把这两件事混为一谈了。当然,公平地说,你才 18 岁,能懂多少呢?我把很会背东西,和喜欢背东西混为一谈了。上过分子生物学的话就知道,要背的东西非常多,比如凯库勒循环。我本来打算转去学有机化学,再转化学工程。那属于麦吉尔大学的另一个学院。学校很大,有理学院和工学院。我看了工学院大一的课程要求,发现有编程课和计算机科学导论,于是下学期就把一门生物课换成计算机科学导论。
计算机科学?
我告诉妈妈,我把课换成了计算机科学。还求她千万别告诉爸爸,因为我怕自己不喜欢,他会难过——他太喜欢计算机了。
对他来说这是真爱。
妈妈说“好吧”,但我敢肯定她两分钟后就告诉爸爸了。这就是我妈妈的作风,我爱她,不过我想她知道这不会是什么灾难。后来我告诉他们我在上这门课,才三周我就说:“我真的很喜欢。”跟分子生物学完全相反。
早年经历与软件工程之路(续)
我当时就觉得自己真的喜欢它。有种感觉:我面前只有一台电脑,我敲几下键盘,它就开始做事,我简直像个神。
后来你的第一份工作——你做过实习,不过你的第一份正式工作……
Rent the Runway 的第一份工作
你的第一份实习是在 Rent the Runway,对吧?
是的。
这家公司挺有名的,很多工程经理大概都知道 Camille Fournier 写的《经理之道》(The Manager’s Path);她也在那里工作过。
没错。我实习后想回去,很大一部分原因是当时很少看到女性担任 CTO 这种职位。我妈妈差点成了时装设计师,所以我们从第一季起就一起看《天桥骄子》(Project Runway)。那是我唯一被允许熬夜看的节目。Rent the Runway 把我喜欢的两件事结合起来:编程和时尚。我那时主要做前端开发,还能很便宜地租衣服,想穿舞会礼服出门也行,特别酷。
团队里女性很多,我想这跟公司所在的行业有关。不过,跟 Camille 共事过的人都知道,她一进房间就很有气场,我非常敬佩。即使我离她隔着好几层,也很想向她学习,欣赏她的气场和自信。所以回去工作很容易说服我。后来我回去几个月后她就离开了,但一年后她仍然帮了我很多,让我顺利进入 Slack。
HipChat 数据泄露
那是 2016 年的 Slack。我记得很清楚,因为我也是 2016 年加入 Uber。那时我们还在用 Atlassian 的 HipChat,也就是 Slack 的竞品。很多公司都在用,后来它发生了两起糟糕、完全无法辩解的数据泄露。Uber 几乎立刻就迁走了。我记得当时他们评估过 Slack,但 Slack 还撑不住规模。
我原以为那是后来、2019 年 Uber 转用 Slack 的事。我在 Twitter 上发过一串讨论,说我们建了一个叫 UAP 的内部产品,它是基于 Mattermost 做的。
对,对。我当时想:是不是我们的内部团队只是想自己做东西,所以才说 Slack 扛不住?但开发 UAP 的一位工程师和 Mattermost 团队合作,把开源产品扩展了。他说他们曾和 Slack 坐下来谈过,Slack 当时表示无法支持一个房间里有四万或五万人。
所以先交代一下背景:2016 年 Slack 可能连一个房间里一万人都很难服务;如果我没记错,也许能到两三万人。
我记得 Comcast 大概是三万人。
但他们能做到四万人。这就是 Slack 早期的状态。它已经非常受欢迎,用户很喜欢,但团队功能还在萌芽阶段,我甚至不确定当时有没有。
对,我想当时还没有。
加入 Slack 与扩展挑战
你加入 Slack 后,具体做了哪些问题?
我在 Rent the Runway 时就对性能着了迷,只是得用自己的时间研究。加入 Slack 后,我的入职伙伴是一位资深工程师,当时专门处理性能问题。我做完一些入门任务后就问他在忙什么,他给了我几个工单,问我想不想做性能工作。我当然说想,于是接了几件事。
在创业公司里,你为了走到下一步、拿下下一个客户或做出下一个可能带来增长的功能,会做很多短期决定。你心里知道以后可能得回来处理。我们当时做的许多性能工作,就是回头补这些捷径——那些经过衡量、但也知道迟早会反噬我们的捷径。
那时 Slack Enterprise 还没正式发布。我入职时距离正式推出还有几个月,IBM 已经在测试产品。IBM 是家大公司,如果我没记错,有十多万人。
那时一个频道里有几万人同时在线也没问题?
我们会把他们分散到同一个 Slack 组织里的不同团队中。他们也能互相私信,其他一些用法也还算顺利。但 IBM 还是经常崩溃。我没有参加当时的决策,不过事后看来,正式发布时我们约定他们之后会开始扩大使用规模、增加 Slack 用户。正式发布后,我们就没法再限制他们加人了。于是他们开始很积极地增加用户。最后我们成立了真正的企业性能团队。我当时正好在做各种零散的性能工作,就加入了。我是团队里资历最浅的人,但我很喜欢这种状态。我职业生涯里有很长时间都是团队里最初级的那个人;近几年可能没那么常见了,但我从中学到最多,也一次次被推离舒适区。这种经历最有意思。
能不能介绍一下 2016、2017 年的 Slack 架构?当然,Slack 后来变化很大,现在完全不是那样。一个工作区里有频道、私有群组和私信;数据库怎么组织?消息发送和扇出又是怎么处理的?
企业版之前、以及刚有企业版时,所有东西都属于一个公司,也就是一个 workspace 或 team。数据也按 team 分片。如果某个团队特别大,我们就给它更大的数据库;最后总会遇到你能花钱向 Amazon 买到的最大数据库上限。
所以曾经是大公司有自己的数据库实例,而且一段时间内运转良好;小公司则共用一个实例?
对,小公司是多租户共用一个数据库实例。但同一个客户的数据不会散布到多个数据库分片,而是都在同一个分片上。消息分发也是类似的。用户登录 Slack、建立 WebSocket 连接时,会连到一台和其公司关联的服务器。因此,如果全公司的人都很活跃、都在 Slack 上聊天,他们都在同一台服务器上。对于特别活跃的客户,我们也可以给他们单独的聊天服务器实例。
这种架构很长时间都还行。企业版让情况以一些特殊方式复杂起来。创业公司常会做一些当下没问题、以后却扩展不了的决定。我们当时假设大公司内部的部门就像小公司:比如 IBM 的市场部可以当成一家公司,大家登录 IBM 的市场 workspace;工程部门可能还要细分成 IBM 工程里的某个子集。听起来像是把大问题拆成小问题,这样现有架构就能继续运作。
加入 Slack 与扩展挑战(续)
所以分片架构曾经有效,大公司各自有数据库实例,短期内不会塞满;小公司则在同一实例上多租户共用。每个客户的数据不跨分片。消息也按公司分布:登录 Slack 建立 WebSocket 连接时,用户连到和公司绑定的服务器。同一公司的所有人都可能落到同一台服务器上,特别繁忙的客户还能有自己的聊天服务器实例。
对,这种方式很长时间都还算可行。企业版带来了一些复杂情况。创业公司往往会在当下做出合理、但不一定能扩展的决定。我们假设大公司的不同部门可以视作小公司:IBM 的市场部门有自己的 workspace,工程部门则可以再取一个子集。这样像是把大问题拆小,让现有架构继续工作。
但这有点一厢情愿,问题并不会因为拆成小块就消失。与此同时,公司里还需要一个覆盖所有团队的上层组织。
所以我们创建了 Enterprise,它其实只是 team 表里的一行,标记为企业组织。属于这个企业的各个团队都有 enterprise ID。可在 IBM 内部,不同工程团队的人也应该能协作、互相私信——大家明明在同一家公司,理应可以互发消息。我们把公司的各个团队假装成独立公司,却又得再造一个上层实体,让不同团队能互通。事情很快就变得复杂。
更糟的是,IBM 所有人——市场部、工程部等等——会同时查询企业分片,处理私信和公司级大频道。Slack 启动时,左侧栏会列出你加入的频道。你可能以为只要按用户 ID 找到所有频道成员记录就行,但实际并非如此。
这套设计最初也有合理之处:私信看起来和普通公开频道不同,所以放在单独的表里;公开频道又是另一张表。从产品角度能理解;从产品安全角度也能理解,因为查询私有频道时应该有更多检查,于是私有频道也用了单独的表。后来出现群组私信,看起来像私有频道,就并进了私有频道表。于是填充侧边栏时,实际要做一大串表连接与联合查询。
那当你们发现这些表结构不合理时,接下来怎么办?毕竟客户很多,包括 IBM 这样的公司……
加入 Slack 与扩展挑战(续)
当大量用户都在用这些功能、系统承受很大负载时,怎么把正在被持续使用的三张表迁移成一张?
这个需求最初来自计算未读提醒徽标。那是个复杂查询,要跨这些表做连接,而且对延迟特别敏感。我们已经尽可能减轻了负载,但有个负责 @ 提及功能的队友说:“这些表必须合并,能做的优化已经差不多了,再拖就得动真格。”我就说,好吧,那我来试试这个大家都不太敢碰的事情。现在回头看,我很感激当时有机会做这件事。
如果你看 GitHub 上 Cal Henderson 在写完 Flickr 后开源的 Blame 项目,会明白 Slack 早期的许多情况。
我很推荐,应该还找得到,值得一读,能解释很多事情。
当时代码组织很过程化。每个 API 入口,比如 `chat.lo`,就有对应的 API 代码和一些辅助函数。很多函数长得惊人,里面直接嵌着 SQL 查询。那时没有 ORM,也没有数据访问层。大家对要不要把 SQL 隐藏起来有很多不同看法。现在我能理解为什么他们坚持那种立场,也理解我们最后采用了极简做法:除非你被迫理解查询在调用场景里的实际作用,否则 SQL 很容易一再让你踩坑。
但当时有四百多个调用点,调用到各种频道数据上。
这些调用点就是代码里的 SQL 语句?比如从 `teams_channels` 里按 `team_id` 和 `user_id` 查数据?
对。人们会直接复制现有模式。迁移总是这个问题:你得把新旧模式的界线画得足够清楚,告诉大家别再用旧做法,可代码里到处都是旧例子。我先用正则表达式找出所有查询这些表的位置。幸运的是,表名很好识别。然后我把每个调用点逐个复制到另一个文件里。
也就是建了一层做同样事情的封装?
基本上就是个类。我没有做任何优化,只是把 SQL 原样复制出来,改成调用一个函数,而不是直接写 SQL。算是非常薄的一层抽象,已经有点冒险了。我尽量让函数名清楚地说明它在做什么,也说服了比较保守的同事接受这个方案,这让我觉得是个胜利。之后我把所有调用迁过去,每个都用功能开关控制;最后我都记不清有多少个开关了。
加入 Slack 与扩展挑战(续)
我设置了很多单独的功能开关,尽可能谨慎。即使这样,过程中还是搞砸了一些地方。我以为差不多完成、准备开始切生产流量时,先跑了所有回填。毕竟我要把数据迁到新表;我们也长期双写,确保数据跟得上。
我们先做双写,确认数据看起来合理;还做了影子读取,也就是执行新操作、对比结果是否一致。
对。我们先双写并回填,确认数据都齐了,再做影子读取。发现差异时丢弃新查询结果,继续读旧表。准备把 Slack 全部切到新表时,你会觉得这是个低风险变更,因为各项数据看起来都对。我还有数据仓库仪表板,能看到两套表之间有多少差异。
那天是星期四,公司当时有个下班后的 Gather Hour,大家会聚在一起吃点东西。我当时想:已经影子读取验证过了,应该没事。可一打开开关,我马上发现有些数据不对。我之前经常看到差异,习惯了直接运行脚本删掉底层数据,再重新回填全部或一部分;说实话,这比逐个调用点像做手术一样定位问题容易多了。
所以我忘了功能开关还开着,就直接删了数据。
哪个开关?
让大家从新表读取频道成员关系的开关。我忘了 Slack 内部也开着它——不是所有外部用户,只有我们自己。我看到差异后想,哦对,我修好了,那我把我们团队的表清掉再回填就行了。这只花了 25 分钟。
那期间大家都看不到消息了吗?
对,Slack 里所有人的侧边栏都空了大约半小时。Slack 自己什么都用 Slack,但当时大家都在 Gather Hour,所以还好。只有一个一直在带我做这件事的同事 Kevin 给我发私信问侧边栏怎么了。我赶紧道歉,把开关切回旧数据;新表回填完成后再切回去。
负载测试与惊群效应
你在 Slack 学到哪些负载测试经验?现在所有系统的负载都在上升,AI 代理会生成更多 Pull Request 和代码,给系统增加压力;其他地方也可能会有更多数据。Slack 当时有个客户想接入 50 万用户,原本规划了 18 个月来做架构准备,后来疫情发生,时间表被提前了。我更想聊你们当时搭建的 Koi Pond 负载测试:在真实负载到来前,怎样测试服务能不能承受?你用过哪些方法,现在还会用哪些?
“在生产环境里测试。”当然,方法有很多。很多团队都能做到的最简单办法,取决于你担心产品面对哪种使用方式。先弄清楚使用模式,以及你最担心哪里会撑不住,这决定了该怎么模拟负载。
Slack 当时的使用模式是什么?
有几种关键场景让系统过去经常出问题,其中一种就是惊群效应(thundering herd)。很多系统都会碰上:某件事发生后,所有人突然同时涌进来做同一件事。
给不熟悉这个词的人解释一下:惊群效应就是大家突然同时出现。比如假设你在运营 Google 搜索,某个 Bug 让所有机器都挂了。你有一千台机器——Google 当然不止这么多,这里只是举例——然后开始一台一台重启。结果每台刚恢复就立刻承受全部流量,又再次崩溃。
对,这是经典案例。Slack 的例子是:CEO 在有 10 万活跃用户的公司大频道里发消息:“大家这个季度干得不错,周五放假。”通知发出去,你看到了,特别兴奋,想给消息配个很棒的自定义表情。你点开通知,马上进入频道,读取频道历史,然后加上表情回应。可所有人都一样兴奋,在同一时间给那条消息加自定义表情。
就在同样的五秒钟内。通知消息通过 WebSocket 发来,再扇出给 10 万名活跃用户。几秒钟内,这 10 万人都进频道、读取频道,然后发出表情回应。每个回应又通过 WebSocket 扇出,一个自定义表情会变成发给所有在线用户的 10 万条表情事件。加上十个表情,规模就会不断膨胀,简直是流量爆炸的典型例子。
我们知道表情扇出确实很贵,所以做过一些工作,按重要性给 WebSocket 消息排序。真正的文字消息最重要;高负载时表情事件就会被丢弃。这显然是合理的取舍。
负载测试与惊群效应(续)
不过我得先说,下一代人可能会不同意,他们会觉得表情比文字更重要。
我得知道别人有没有回应我的消息。
我懂。不过按负载大小排序确实有道理。
是的。负载测试不能只从工程角度考虑,你必须戴上产品的帽子。文字消息比表情重要,这是产品层面的判断,不只是工程决定。我会告诉你:“我们必须丢弃一些东西”,然后一起讨论丢什么才合理。有时答案可能出乎意料。我总提这个例子,因为我在 Slack 内部和很多人讨论过:应该直接限制一条消息最多能有多少个表情。
据我上次确认,Slack 目前仍限制每条消息最多有 23 种不同的自定义表情反应,但每种表情上的“+1”人数可以无限增加。为什么呢?一万个“+1”就已经够了,应该在某个点封顶。问题之一可能是没有人真正负责自定义表情。大概是消息团队,但它对 Slack 的体验太无处不在,以至于没人觉得自己拥有它。那到底该由谁决定合理上限?惊群效应一直是我们重点关注的问题之一。
我想问,事件洪流和惊群效应是一回事吗?
不是,是两种不同的问题。
事件洪流
什么是事件洪流?
事件洪流是客户端被 WebSocket 连接传来的事件压垮。前面那个例子既有惊群效应,也可能造成事件洪流:大家回应表情后,你的客户端会收到大量消息。不过,事件洪流也可能是这样:有人在你加入的 16 个频道里同时发布“周五放假”的消息。其他人也许不在那么繁忙的频道里,但你刚好都在。
IBM 有位主要负责 Slack 的联系人 Jeff Robkey,后来还成了 Slack 的产品经理。我觉得这是件很有意思的事,因为他经常承受事件洪流,我总觉得替他难受。有时我也在那些频道里,心想:“快跟 Jeff 说声抱歉。”
我还是不太明白事件洪流的实际感受,能举例说明吗?
在后来重构之前,用户登录的每个 team 都有不同的 WebSocket 连接。Jeff 是 IBM 的管理员,所以他加入了几百个 workspace。
我记得那时最多只能同时登录三十来个 workspace。IBM 有市场、财务、客户等不同团队;作为管理员,他全都加入了?
对,他都在里面。假如你收到企业级私信,或者收到一个在他登录的 30 个 workspace 里都共享的频道消息,他就会收到 30 次,完全没有去重。
那他的客户端、电脑和通知都会被淹没,不只是处理能力的问题,界面上也根本看不清发生了什么,挺烦人的。
是,而且他的客户端经常直接卡死。
这可不好。
事件洪流是特定用户才会遇到的问题,但对重度用户尤其痛苦。他们总是碰到这种事,而重度用户又往往是 Slack 最热心的拥护者。这很矛盾:我们给了最支持产品的人最糟糕的体验,可他们还是喜欢 Slack,还在努力让更多人采用它。真的很难受。
Mo 刚才提到,Slack 的重度用户反而会遇到最严重的事件洪流。今天 Git 的重度用户可能就是并行推代码的代理。这也引出本季赞助商 Entire。几周前我说过 Entire 正在开发 Git forge,现在终于上线了。你可以在美国、欧洲、印度和澳大利亚直接托管私有仓库。Entire 也为代理时代重新设计了 Pull Request:代理写 PR 的速度比人审查的速度快,所以他们用 Trail 取代它。Trail 把工作的意图、代码变更和代理会话集中在一起,也能跨多个仓库,因此后端和 Web 应用的变更可以一起审查。你可以配置自定义评估,例如代码审查、安全审查、Lint 审查等。它们像飞机上的抬头显示器,会在每次推送时为工作打分;你能看到每个代理通过或未通过哪些门槛,再决定何时合并。你可以控制代理什么时候获准合并代码……
事件洪流(续)
只有在人审完成或所有门槛都通过后,才能合并代码。还有一件事:Entire 提供持续记忆功能,让代码仓库变成一个“脑库”,代理可以搜索过去的编程会话,找到先前改动背后的上下文,也就是回答代码为什么这么改。记忆会在团队和仓库间共享。想看看原生支持 AI 的 Git forge,可以访问 entire.io/pragmatic,了解 Trail。
我还想介绍本期主赞助商 turbopuffer。他们正在重建存储引擎,让搜索在本期讨论的规模下更快、更便宜。新系统 turbopuffer V3 还没有上线生产,但已经能通过 CI。去年 9 月开始重写时,它的性能远不如当前生产版本 V2。团队一直在优化查询计划,直到速度达到或超过 V2;他们把进展公开记录,直到 V3 正式发布。能看到这个过程很有意思:新系统最初在大多数指标上慢 10 到 100 倍,现在在冷向量搜索、冷文本搜索、计数等操作上已经快好几倍。V3 在热全文搜索等方面仍然慢一些,但现在只慢一到两倍,而性能优化刚开始时曾慢 100 倍。
团队还在开发日志里公开分享优化方法,包括向量化查询执行、分组计数聚合、压缩列编码等。为什么要彻底重写?因为搜索在变,turbopuffer 也在变。它最初是向量数据库,但如今已经发展成高性能搜索数据库。V3 去掉了向量主索引,所有索引共用同一个列式存储系统。每个查询都会在向量化查询引擎上运行;搜索数据库里通常不这么做,但这能让搜索乃至整个数据库都更快。turbopuffer 团队在记录整个过程,可以到 turbopuffer.com/v3 跟进。
Slack 如何改进负载测试
回到 Maude 和 Slack:当时有个客户预计会有 50 万用户,你们怎么模拟预期负载?你们知道大客户可能带来 50 万甚至 100 万用户,却不知道 Slack 是否扛得住;想在他们全部上线前弄清楚。你们是自己做了负载测试工具吗?
之前我们有几种还不成熟的负载测试方案,主要测试 API 吞吐量,确认底层系统——尤其是数据库——能不能承受负载。比如加载频道会发出一个 API 请求,可能是 `conversations.view`(除非后来改名了),它会对频道分片上的数据库做 SQL 读取。我们想知道系统能否承受对应的每秒请求数。
但此前所有负载测试方案都没有活跃的 WebSocket 连接,而我们刚才说的两种问题,某种程度上都出在 WebSocket 上。通过 WebSocket 发送消息本身几乎没有延迟问题;那些有状态服务做得很好,有位联合创始人是这些系统背后的关键设计者,他投入了大量时间和精力,让它们尽可能高效,值得称赞。瓶颈通常是客户端如何处理消息,或者下游因为你看到并收到消息而触发的事件。因此,要充分测试整个流程,我们需要活跃的 WebSocket 连接,而不只是发 API 请求或往数据库里灌流量。
所以要测试 50 万用户,就需要 50 万个活跃 WebSocket 连接?
是的,确实需要。这不容易。那位客户加入之前,我们已经有个方案——我有九成把握,现在公开信息能确认客户是 Amazon——在一大堆 Kubernetes Pod 里启动 Slack 无头 Chrome 实例,然后跑一点 JavaScript 脚本、点击按钮,模拟 Slack 用户。
嗯。
这个方案还算能用,但你要是在 Kubernetes 里调度五万个以上无头 Chrome 实例,那可不是什么好差事。我想大多数听众都没试过。问题是,无头 Chrome 实例本身就很吃资源。你在笔记本上开着 Slack 和两百个 Chrome 标签页就已经够难受了,还可能在上面跑大语言模型,把机器逼到极限。现在每个 Pod 里还试图跑一万个 Slack 无头 Chrome 实例;Slack 本身就是很吃内存、也吃 CPU 的应用。我们花在 EC2 实例上的钱……
问题似乎是每个节点或虚拟机能跑的实例数有限,比如十个、二十个、五十个、一百个,具体看机器大小;然后你还得为每台虚拟机、每个节点付费。
Slack 如何改进负载测试(续)
Slack 融了不少风险投资,所以我们知道钱花到哪里去了。
对,还有客户的钱。
这确实贵得离谱。
是的。每个无头 Chrome 实例跑五小时负载测试要花 37 美分。
所以那一次测试就是 15 万美元。
对,经济上根本不划算,确实行不通。
我来唱个反调:如果有个能带来 50 万席位的客户,就算打折,每个月也会付 500 万美元。为了确保服务正常、别让客户不满,投入一些钱也说得过去。当然我不知道临界点在哪里,但一次测试花 15 万美元还是很夸张。
对,而且这只是五小时,还是假设五小时内能跑完所有想测的功能。实际上做不到。
因为一半时间都在把崩溃的 Pod 重启起来?
是的。后来我们找到更好的办法,保真度低一些,但效果肯定更好。我们放弃了无头 Chrome。那是保真度最高的方式,最能真实模拟 Slack,但我们改为启动一大批小型 Go 进程。每个进程建立一个 WebSocket 连接,再跑一系列 API 请求,模拟真实客户端。
它们仍通过 WebSocket 收发消息,也会连到客户端所用的常规 API 和边缘缓存。我们还得测试边缘缓存的延迟。打开 Slack 时,有些数据必须加载得非常快;比如切换频道时,频道列表会放在更靠近用户的边缘缓存里,因为主要 API 请求仍然都会打到美国东部一区。万一那边宕机,我们就完了。我们也要测试访问这些边缘缓存的请求延迟,于是把相应的请求模式也模拟出来。
听下来负载测试的第一步是弄清产品怎么被使用、什么情况最可能把它压垮。知道这一点,就不用漫无目的地压测,而是针对关键路径建一个足够逼真的模拟,同时考虑资源限制。这里不用在无头 Chrome 里启动完整的 Slack,而是实现你关心的关键流程。结合领域知识,知道产品怎么运行、以前容易在哪些地方崩溃,就能合理地相信:即使它再出问题,也不会是同一种原因。测试的成本、可重复性、轻量程度和可控性也很重要;以后发现另一种故障模式,改一下模拟就行,不是什么大工程。
对,非常轻量,只做必须做的事情。后来我们不断演进系统,可以编写一系列操作流程,交给这些“虚拟用户”按顺序执行。一开始只是很简单的 JSON 配置,写着先调用这个,再调用那个。
Slack 如何改进负载测试(续)
后来我写了一个迷你录制工具,可以在 Slack 里输入斜杠命令开始录制,然后走完一整套流程。它会记录我发出的每一个 API 请求,包括普通 Web 应用后端和边缘缓存的请求,也会记录每一条进出 WebSocket 的消息。之后我从记录里挑出想模拟的部分,再用另一个工具把它转换成脚本,发给模拟客户端运行。
另一半工作是搞清楚做某个产品流程时到底会调用哪些 API——数量可不少,而且要确保全都捕获到。录制工具在这方面特别有用,我们一次又一次地用它。
我还要继续强调产品思维:优秀的产品经理会意识到性能和可靠性是每个功能的重要部分,也会在意并为此优化。我在 Slack 最后几年一直尝试把更多工具交给产品经理。我会说:“你想让这个使用场景自动纳入 Koi Pond 负载测试吗?这是录制工具。把你想测的流程录下来,把文件发给我们,我们会放进系统,按你希望的频率运行。”产品经理知道,比如打开 Canvas 时文件会出现一些特殊行为。我不一定像大多数用户那样使用 Canvas,但你是产品经理,了解用户用法;把流程录下来交给我们,我们就能放进系统。
说到可靠性,离开 Slack 后,你在 2024 年加入 GitHub,担任首席软件工程师……
2024 年加入 GitHub
当时 GitHub Copilot 已经很火,Cursor 等竞争者也开始崛起,GitHub 服务负载增加,可靠性明显变得不稳——虽然不像最近这么糟。你加入时内部是什么氛围?GitHub 建在庞大的 Ruby 单体应用上,你能介绍一下它的背景,以及你入职时的情况吗?
那是个很有意思的时期。我以前在 Rent the Runway 的一位朋友已经在 GitHub 工作多年,我们一直关注彼此的职业发展。她跟我说:“Maude,我们的空间快用完了。”当时 GitHub 的主要负载仍在自有数据中心,虽然之后逐步迁往云端。
他们以前确实有自有数据中心。现在迁往 Azure,这是他们公开分享过的信息。
对,迁往 Azure 是重点,但当时仍有相当多负载经过自有数据中心,整个团队都在管理硬件。她说空间快不够了,我们既得想办法迁到云端,也得想办法少做些无谓的工作。每家公司都有很多不该做的蠢事;提升现有系统效率,能多争取一些时间。我觉得这问题特别有意思,很想参与。
不过当时我已经怀孕七个月了。能安排好的话,这其实是个加入公司的好时机:你可以很强势地提出一堆意见,却不用马上承担所有权责;两个月后去生孩子,回来再看看事情变成什么样。
入职头几周,我在不同团队之间了解情况。我在 Slack 做过很多数据库迁移,也做过一些我们还没聊到的工作:把按 team 切分、以租户为单位的模型,迁移到用 Vitess 更动态地分片。结果发现一个 team 还是太大了。
你提到 Vitess,它是什么?
Vitess 本质上是 MySQL 上的一层封装,让你能更动态地做分片。传统情况下,MySQL 跑在一台数据库上;数据多到放不下时,手动搬表或复制数据会是一场噩梦。Vitess 能让你在数据增长、超出容量时,更顺畅地分散到多台机器上,也更方便重新分片、重新分配数据,或换用不同的分片键。
Slack 以前所有数据都按 team 分片。有些数据仍适合按 team 取用,但后来发现最小、最合理的单位其实是 channel。于是我们把不少数据改成按 channel ID 而不是 team ID 取用,这样就能做更好的水平分片。我们的想法是没有哪个频道会大到单台超大型数据库都装不下——也许某一天会有吧。机器人还在频道里不停发消息……
2024 年加入 GitHub(续)
……也许总有一个频道会大到无法放进单台超大型数据库,不过按频道分片从扩展角度看合理得多。GitHub 也用上了 Vitess 的一些能力。
我面试 GitHub 时,跟后来会共事的几位首席工程师聊过。他们已经有 Vitess,却觉得没有发挥出应有的潜力,于是问了我很多关于我们怎么配置 Vitess、怎么使用它的问题。先说明一下,我从没实际运维 Vitess,也没处理它的内部机制。我更偏产品平台,关注的是“这个东西不该再按这种方式分片了,我们应该迁移”。这也是他们大致共有的看法。
入职后我开始研究 Vitess 的配置,心想:“我们到底在做什么?”我习惯每个分片键有成千上万个分片。比如按频道分片,频道数量多得不得了,应该把负载均匀分散开。可我当时看到 GitHub 所有 Pull Request 和 Issue 加起来只有 16 个分片。
是全 GitHub 都只有 16 个?
对,全 GitHub 就 16 个。考虑到这个数据域关联的数据量,这数量太少了。我第一反应是:“等一下,为什么只有 16 个?这让我很不安。”其中一个解释是不能再加,因为散射查询已经太多。
什么是散射查询?
就是查询里没有主键。比如你要查一个 Pull Request 或 Issue,却没有它的 ID,而数据是按 Pull Request ID 分片的,那就得把它找出来,逐个匹配。比如查“我创建的所有 Pull Request”——我不确定这是不是一个真实的散射查询场景,只是假设一下——因为没有 Pull Request ID,查询就得打到全部 16 个分片。
这会在很多层面上都很糟。很多人都这么查的话,我明白为什么不想增加分片:分片越多,每次查询都要打更多分片,几乎像线性扫描。
对,效率非常低。我不确定刚才这个是假设场景是不是实际查询,但确实有很多重要的散射查询要打到几乎每个分片,才能完成查询并返回结果。因此我明白为什么它会限制增加分片。可你也没法把分片做得太大,它们已经承受很重的负载。
我本来准备照搬过去几年用的办法:把散射查询都清掉,直接别再有散射查询。
别做散射查询就行,听起来没那么难。
结果在 Rails 单体应用里,这件事可能非常难。
还有 Active Record。我一直觉得有趣,Slack 早期有很多前 Twitter 的人,大家都开玩笑说 Active Record。我那时想:“怎么了?在他们搞 Scala、Java 那一套之前,不也都这样吗?”直到我自己真正碰上它,才明白:“哦,我真不喜欢这个,太糟糕了。”所以 Active Record 是 Rails 的抽象层……
2024 年加入 GitHub(续)
……所以 Active Record 是 Rails 的抽象层,用来……
Rails 单体应用的挑战
Active Record 就是定义模型,再映射到数据库;不过微调查询可能更难。
它确实很会把底层数据模型藏起来,这有利有弊。我以前习惯为了性能手写 SQL,后来却进入一个抽象层一层套一层的世界。
GitHub 有段时间在做领域拆分:怎么把 Pull Request 相关功能从 Rails 单体应用里抽出来,放进独立的 Go 子系统?一种常见迁移做法是请求仍先经过单体应用,然后立刻转发到另一个系统。我们从 Stars 功能开始,后来这个项目好像被放弃了。
我和一位 Rails 经验很丰富的同事结对。我以前读的代码都很直接,抽象层不多;碰到层层抽象确实有点吓人。我们在 Rails 单体应用里找 Stars 查询,找到创建该查询的根调用后,发现它只有五行。我对着 Zoom 上的代码问 Kate:“调用到底在哪里?SQL 是在哪生成的?这五行函数究竟做了什么?”她说:“Mo,这可是价值百万美元的问题。”
我当时很困惑,因为我本以为至少能看到类似 Prisma 的东西:不一定是引号里的原始 SQL 字符串,但能看出生成的 SQL 查询结构。可这里完全看不到,只像普通函数调用。那一刻我明白,为什么这个问题一直难以解决,为什么会恶化:没人看得见新写一行代码造成的影响。
现在要修也不是改改抽象层就行了。可能得改变代码怎么写、怎么调用,开始显式调用执行 SQL 的函数。这项技术在规模小时很好用;只要系统线性增长,也能不断扩展。
对。最让我震惊、既让我佩服他们过去管理系统的方式、又让我很担忧的一件事是:你在 GitHub 内部工作时能看到管理员视图。打开一个 GitHub 页面,顶部有个栏会列出页面渲染时执行的所有 MySQL 查询、访问了哪些集群,以及执行耗时;这在员工视图里都能看到。
对。我说的应该是文件消歧页面,也就是带 README 的仓库主页。
对,在我离开前看的时候,那一页……
你差不多一年前离开的?
其实没那么久,不过总之,在某个时候它需要跑一百多个 MySQL 查询来生成页面……
Rails 单体应用的挑战(续)
……而且跨了至少三个不同的 MySQL 集群。Active Record 只有在查询完全一样时才会自动缓存结果。比如先按用户 ID 执行 `SELECT * FROM users WHERE user_id = ?`,之后再执行同样的查询并多加一个条件,Active Record 不会回头复用第一个查询的缓存结果,因为两个查询并不完全相同。
据我离开前观察,GitHub 页面上几乎所有计数都是实时计算的,没有缓存。这样做其实有不少原因,主要是产品层面的考量。但那是我最先会问“为什么”的事情之一。
回到我举的自定义表情例子:一万零一个表情,谁会在乎?如果数量很少,比如一个没什么人用的小仓库里有三个未关闭的 Pull Request,准确计数或许有意义。但到了 99,就直接显示“99+”就好了,何必继续数?为什么要计算这个?
很有意思,到目前为止我们聊了你在 Slack 和 GitHub 做过的事情。你后来还参与了 Copilot Code Review……
Maude 如何使用 AI
你后来也在 Cursor 工作,他们在 AI 工具辅助编程上走得很前沿。我们之前没自然地聊到 AI,现在想问:你怎么看 AI?你经常使用这些工具,而且用得越来越深入。哪些工作 AI 帮不上忙?哪些方面它确实有帮助?它改变了你的工作方式吗?
天哪。它基本帮不上我做性能优化,至少帮不上我调查问题。知道要做什么以后,具体实现可能会快一点。
但搞清楚要做什么还是得自己来。
对。几个月前我在 Cursor 负责缩短首个可见 Token 的生成时间(time to first visible token)。影响因素很多。你输入提示词并点击提交后,系统要花不少时间为请求构建上下文;有些数据可以缓存,有些因为产品、隐私或负载与成本原因不能缓存。把你曾经在 Cursor 查询过的所有内容都放进 Redis 缓存 24 小时会非常贵,所以我们不会这么做。
之后请求会发给推理代理,再由它转发给底层大语言模型或模型供应商。前后有大量流程,而且很多环节不像用户体验呈现得那么同步。常规流程里,如果你能用 HTTP/2——有些人还只能用 HTTP/1——客户端会和后端服务器建立 gRPC 流式连接,再把连接拆成四条或更多并发流,在各流上传送不同信息。整个过程很复杂。
AI 在给系统做监控埋点方面也没大家想得那么好。它们以为自己会写测试,实际上往往只会确认自己刚写的代码能跑。我写了一些技能,让它们对代码做大量变异测试,迫使大语言模型发现:“哦,我其实没测任何边界情况”,然后重写测试。
现在的大语言模型能做基础监控,但不擅长判断异步环境下应该监控什么。我很喜欢分布式追踪,它能提供很好的调试信息,尤其是性能方面。我们在 Cursor 遇到过追踪太多、又太少的问题。所谓“太多”,是单条追踪里有数百万个 Span,完全没法做有意义的分析;“太少”则是我们犯了几乎人人都会犯的经典错误:为了省成本,在多个地方分别采样。
Maude 如何使用 AI(续)
……比如在把追踪数据送到 Datadog 前随便选个地方采样。别这么做,要始终在同一个地方采样;重复数据在这个场景下只会让问题严重得多。
我做的第一批工作之一,就是手动为很多流程加监控,自己读代码,弄清楚哪些处理是同步的、哪些是异步的,再把信息拼起来。我让模型生成了一张非常大的时序图,描述从点击提交到收到首个可见 Token 的整个流程。图大得不得了。我去 Staples 打印店把它印在大幅蓝图纸上,因为我还是个很传统的人。然后我边看代码,边在纸上标注哪些流程正确、哪些不对,哪些部分可能能优化,以及哪些步骤必须先于其他步骤发生。
我发现 AI 另一个特别容易做错的地方——“错”可能不是最准确的词。
我们就直接说它做错了,我喜欢这个态度。
它会很天真地看见一堆互不依赖、又对延迟敏感的任务,然后套用经典解法:并行执行。
把它们并行化。
对。但要用产品思维:如果几乎没人会同时做这四件事,为什么要把它们全都并行跑?这会浪费资源、增加负载,实际也不一定减少延迟。我记得 Opus 对发现这个并行机会特别兴奋,我第一眼也觉得不错。但即便并行,最长的任务仍然是瓶颈,而且它通常比其他三个长得多。只有 1% 的用户会触发另外三项任务,最后这优化完全浪费了时间,根本不是它说的那个缩短首个 Token 时间的灵丹妙药。
真正有用的改进反而很普通,比如在合理范围内延长 Redis 里数据的 TTL。
你怎么使用大语言模型?听你讲职业经历,你会先理解问题、想出解法,有时失败或犯错,再从中学习,也会向别人学习。现在 AI 无处不在,但你在改进系统可靠性和性能时,仍然投入大量思考。感觉你把模型当作辅助,在旁边生成想法,你会判断哪些不对、哪些值得采用。很多工程师会说,以前做的很多工作现在都交给模型了,所以能做更多、更高层次的事情;我从你这里没听到这种感觉。
我确实一天到晚都在跟模型聊天,但我仍做很多普通工程工作,比如值班、修一些很小的 Bug。我会做很多初步探索,不过之后仍会手动验证。
Maude 如何使用 AI(续)
我感觉你即使拿到模型生成的结果,也会验证并确保自己理解它。你不会直接认定它对或错,而是需要检查,对吗?
对。我做的很多是产品平台问题,重点是整体解决问题,打好可供后续构建的基础。如果地基只盖好了房子的 80%,那有什么意义?另外 20% 没有地基,结构就会摇摇欲坠,也会影响建在地基上的其他部分。如果已经有一部分房子不在地基上,没人能阻止大家再往外多搭 10%,问题就会越滚越大。
尤其现在,AI 很容易到处复制一份实现,因为看起来可行;它不太会追问“为什么”,更不会一层层追问背后的根本假设。它更倾向于说:“好,我就在地基旁边再加一间浴室。”因为你让我造浴室,它就在那里造,而不会质疑:浴室到底该不该建在没有地基的地方?这些根本问题我一直会问自己,我想这部分来自我做产品平台的视角。
我感觉产品平台工作会越来越重要,因为系统承载的负载和要求还会继续上升……
给想从事产品平台工作的工程师的建议
……可靠性和性能只会更重要,因为未来的系统会承受更多负载、更多要求。对于一位可能经验不多、但对性能和可靠性感兴趣,也希望将来进入平台团队的软件工程师,你有什么建议?你见过哪些人成功转入平台团队?他们是怎么转型的?
我非常支持让更多产品工程师参与产品平台工作,也支持产品团队和平台团队之间加强流动。我见过很多产品平台团队,是由重视基础能力的产品工程师组建起来的。他们本来就关心可靠性和性能,也一直在设法改进,并把这些要求融入产品开发。
我合作过的优秀产品平台工程师,往往是作为产品工程师,在做不同项目时反复遇到同一个模式,于是决定深入解决它:“这个问题我做过两三次、三四次,理解得很透彻。我想从根本上把它解决,让所有人都能用。”他们厌倦了重复劳动,可能加入现有产品平台团队,也可能和其他人一起组建新团队。很多人后来就留在产品平台团队,继续喜欢这类工作。
但待久了,他们可能逐渐脱离实际产品开发的感受。产品平台团队和产品团队之间就会产生鸿沟,因为彼此接触得不够。要弥合这道鸿沟,我见过一种有效做法:在团队间设轮岗。平台团队保留一批负责维护核心系统和平台的人,同时始终安排产品团队成员轮岗加入平台团队。他们会带来一线感受:“我不知道你们这里都在做什么,但产品团队现在天天用 AI 随便生成代码;你们读代码的方式是什么?我们的代码审查快撑不住了。”这两边的情况完全不同。如果不了解产品团队正在经历什么,平台也就不知道该怎样提供最好的支持。
我觉得在现在这个阶段,产品平台团队的职责是把护栏做得比以往任何时候都更好。产品团队需要——“需要”这个词说得有点重——但理想情况下,应该可以随心所欲地快速产出 AI 生成的代码,而平台应该拦住或警告他们。
所以你的建议是,如果你现在在产品团队为客户开发、交付产品,当然要理解业务在意什么、尽量做好质量;同时也要问自己:你有没有关注性能和可靠性?如果没有,为什么?如果开始关心这些问题,可以和平台团队合作、跟他们交流,看看能否帮忙解决你遇到的问题。有机会的话,转去平台团队也很有价值。如果你已经在平台团队,也应该找机会和产品团队一起实现功能,理解他们为什么交付得这么快、看起来有时不够谨慎。两边都这样做,才会明白我们需要双方合作。尤其现在产品团队被要求更快发布,而且他们的确正在加速,手边也有新工具;有些好用,有些不好用。这样我们才能成为更好的工程师,构建更好的系统,让我们交付可靠、优质的体验。我非常支持这一点,也很讨厌看到软件质量下降。
给想从事产品平台工作的工程师的建议(续)
对,我们可以阻止质量下降,也可以真的努力把软件做得更好。
是的,我也这么想。这让我想到嵌入式 SRE 模式。它之所以有效是有原因的,我觉得产品平台工作也有真正相似之处。
我一直跟团队说,我们过去就得努力对付意大利面代码,这已经够难了。但现在意大利面代码像是有了自己的生命,主动想从盘子里溢出来。以前大家还会想办法把它留在盘子里;现在它自己就想往外跑,我们几乎得对自己的系统做红队演练。
没错。Mo,非常感谢你参加这次对话。
谢谢你邀请我。能从内部了解大家熟悉并每天使用的 Slack、GitHub 和 Cursor 是怎样扩展的,真的很棒。
到今天为止,我从没想过自定义表情也会造成规模问题。不过 Maude 举的例子很有画面感:CEO 给 10 万名用户发消息,说大家周五放假。几百个人添加自定义表情,造成一场事件风暴,自定义表情的二进制数据和消息在网络上四处发送。Slack 用一个非常简单的办法解决:每条消息最多允许 23 种自定义表情回应。我相信他们也能做更完善的修复,但反正谁会注意到第 24 个表情没显示呢?
GitHub 的 16 个分片,以及不能再增加的原因,也很值得了解:散射查询没有主键,必须先在每个分片上执行,才能返回结果。分片更多只会让查询更慢。理想解法当然是不要有缺少主键的查询;但遗留系统里可能有几百甚至几千条这样的查询,要逐条重写要花不少时间。而且在重写过程中,还可能继续新增没有主键的查询。
最后,Maude 虽然显然深度使用 AI,在 GitHub 和 Cursor 都工作过,但仍会亲自验证几乎所有东西。她说做产品平台工作需要稳定的基础。她也发现,大语言模型写出的测试往往只是在确认它刚写的代码能跑。现在她会用变异测试,逼它们覆盖边界情况。节目简介里还有与 Cursor、GitHub、可靠性和性能相关的 Pragmatic Engineer 深度文章,欢迎查看。如果你喜欢这期播客,请在常用播客平台和 YouTube 订阅;也欢迎给节目评分。谢谢收听,下期再见。