速成课 · No. 09

扩展不是让一切都变快——而是找出在负载下最先撑不住的那一处,把它拓宽,然后是下一处。大多数系统需要这么做的远比建造者担心的要少;真正的本事,是知道该伸手去拿什么、又在什么时候去拿。

只讲精髓 · 每个想法一幅画面 · 先测量再猜

§ 01

扩展不是你一按就灵的某个开关——它是一项持续的活儿:扛住更多负载而不倒下。它从一个想法起步:找出最先崩掉的那一处。

扩展意味着扛住更多而不崩

一家小咖啡馆运转良好,直到一辆旅游大巴停了下来——突然之间,那一个咖啡师、那一台收银机、那几张桌子都跟不上了。扩展就是为这辆大巴做准备。

扩展就是扛住增长——更多用户、更多请求、更多数据——同时还保持快、保持在线。它无关乎登峰造极的聪明;它关乎当负载翻倍时系统不会垮掉。而几乎每一个扩展问题,都归结为同一件事:某样东西成了那个跟不上的部分。

向上还是向外:垂直对上水平

要运更多货,你可以买一辆更大的卡车——但大也有个限度——或者多买几辆卡车:几乎没有上限,可现在你需要一个调度员了。

垂直扩展是一台更大的机器——更多 CPU、更多 RAM。简单,但它有一个硬性天花板,而且仍是一个单点故障。水平扩展是更多机器协同工作——几乎无上限,但它需要协调(负载均衡、共享状态)。垂直为你争取时间;水平才是真正的规模所在。

一切都是一场寻找瓶颈的狩猎

一条高速公路只跟它最窄的那一段一样快——把别的每条车道都拓宽,在你修好那个卡点之前,毫无用处。

一个系统只跟它最慢的那一部分一样快。把资源砸在错的地方就是白花钱;真正的活儿是找出实际的瓶颈——是数据库、某个慢服务,还是网络——拓宽它,然后找出下一个。扩展是一连串的瓶颈,而不是一次性的修补。

大多数系统永远用不上多少这些

你不会为林中的一间小屋修一条六车道的高速公路。一条好路,就足以承载所有曾经会来的人。

一台像样的服务器加一个数据库,服务的用户数量多得惊人。过早扩展——在你还没有那个负载之前就上 sharding、微服务、繁复的缓存——是你并不需要的成本和复杂度。先测量;在真正疼的时候,去扩展那个真正疼的东西。

扩展不是让一切都变快。它是找出瓶颈、把它拓宽,然后重复。

§ 02

扩展阶梯上头几步真正的动作:从一个大盒子里长出来,然后让应用能够跑成任意多个一模一样的盒子。

先垂直:更大的盒子

当厨房忙不过来时,第一招是更大的灶台和更多的台面——而不是开第二家餐厅。

最简单的扩展就是**一台更大的机器。**它不需要改代码,而且很快就买来实打实的余量——往往足够撑很长时间。但它有一个硬性天花板(你买不到一台无限大的服务器),而且那一个盒子仍是一个单点故障。用它来争取时间,而不是当作终点。

水平:许多盒子,一份活

一个收银台变成十个——现在十个顾客同时被服务,而如果其中一台收银机卡住了,另外九台照常运转。

真正的规模意味着跑你应用的许多份拷贝,并把活儿分摊到它们身上。它几乎没有上限,而且能在任意一台机器丢失时存活下来。难点在于协调:总得有什么东西来分散请求(下一章),而这些拷贝绝不能各自攥着自己的私有状态——这正是接下来那个关键的想法。

无状态服务器:任意盒子都能服务任意请求

一个呼叫中心,任何一名坐席都能接起任何一通电话,因为顾客的所有信息都活在共享系统里——而不是在某一名坐席的脑子里。

要让水平扩展成立,应用服务器必须是**无状态(stateless)**的:它们在两次请求之间,自己的内存里不留任何关于某个用户的东西。会话数据、上传、进度——全都活在某个共享的地方(一个数据库、一个缓存、一个客户端随身带着的 token)。这样一来,任何请求都能打到任何服务器上,而增加或丢失一个盒子毫无影响。正是无状态,让「再加些服务器就好」真正管用。

垂直争取时间;水平争取规模。而水平只有在你的服务器自己什么都不记得时才管用。

§ 03

一旦你有了许多服务器,就总得有什么东西站在前面,来决定每个请求由谁处理。那就是负载均衡器——一个已扩展系统里的交通警察。

负载均衡器:一道前门,许多服务器

银行里的一位排队管理员,把每个顾客引向哪个空闲的柜员——这样就没有哪一个柜员被淹没、而别的柜员却闲坐着。

一个负载均衡器(Nginx、HAProxy、某个云上的均衡器)是那个唯一的入口,它按简单的规则——round-robin,或者发给最不忙的那台——把进来的请求分摊到你那一池服务器上。客户端跟它说话,而不是跟单个服务器,所以你可以在它后面无形地增减机器。正是它,把一堆服务器变成了一个系统。

health check:别再往死掉的那台发请求

排队管理员注意到一名柜员离开了岗位,就干脆不再往那个窗口送人——没人会在一张空桌前傻等。

负载均衡器不停地核查哪些服务器是健康的,并绕开那些不健康的。一台崩溃或过载的盒子会被自动从轮换里摘掉,而流量流向其余的。这正是一个已扩展系统如何在某一台机器死掉时存活下来、而用户毫无察觉。

白捡的好处:零停机部署和 failover

你可以一次翻修一条收银通道,而其余的照常服务——店从不打烊。

一旦流量经由一个均衡器、流过那些可互换的服务器,强大的好处几乎是白捡的:零停机部署(一次更新几台服务器)、在某一台死掉时failover,以及快速回滚。均衡器加无状态服务器,是那根让系统既可扩展又有韧性的脊梁。

负载均衡器把许多服务器变成一个地址——并把一台垂死的机器变成一件无关紧要的小事。

§ 04

最快的活,是你不去干的那份活。在扩展那些干活的机器之前,先削减活儿本身有多少——靠缓存和一个 CDN。

缓存:别把同一个答案算两遍

一位厨师在早上把热门的酱料一次备好,而不是每来一单都从头做一遍。

一个缓存存下昂贵活儿的结果——一次数据库查询、一个算好的页面、一次 API 调用——这样下一个请求就能立刻拿到它,而不必重做一遍。一个放在数据库前面的快速内存存储(Redis、Memcached)能吸收掉大部分读。你那个过载的数据库真正需要的,往往是一个缓存,而不是更多服务器。

CDN:从就近处服务用户

一家全球连锁,把同样的货品囤在世界各地的本地仓库里——这样顾客是从家门口的路上拿到它们,而不是跨洋运来。

一个 CDN 把你静态内容的拷贝——图片、脚本、视频,越来越多还有整个页面——保存在世界各地的数据中心里,从最近的那个来服务每一个用户。它大幅削减延迟,并从你的 origin 上卸下巨量负载。对任何全球性又静态的东西,CDN 在你的服务器还没见到那个请求之前,就把重活干了。

难的部分是失效

冰箱上的日历比掏手机查看要快——直到有人在手机上改了计划,现在冰箱在撒谎。

一个缓存是真相的第二份拷贝,而一份拷贝会变陈旧。真正难的问题,不是加一个缓存——而是知道何时把它扔掉,好让用户看不到旧数据。缓存那些读远多于写的东西,设上合理的过期,并且永远清楚当缓存出错时会发生什么。(和数据库缓存是同一个教训。)

最便宜的请求,是你压根不去服务的那一个。把热的东西缓存起来,从 edge 发出静态内容,并在失效上下足功夫。

§ 05

迟早,数据库会成为瓶颈——它是真正难扩展的那一部分,因为它握着所有人共享的真相。

数据库通常是那堵墙

十个收银台都伸手进同一个、唯一的库房——收银员可以扩展,但那一道库房门成了卡点。

你可以跑一百台无状态应用服务器,但它们通常共享一个数据库——而那成了上限。和应用服务器不同,你没法就这么把它克隆出来,因为每一份拷贝都得在数据上达成一致。所以扩展数据库是扩展一个系统里最难、最需要小心的部分——也是那个要靠缓存能拖多久就拖多久的部分。

read replicas:为读复制一份

把参考书的复印件发出去,让许多人能同时读,而那唯一的母本是唯一一份谁都只在上面写字的。

大多数应用读远多于写。read replicas 是数据库的拷贝,用来服务读查询,把那份负载分摊到多台机器上,而写仍然走那一个 primary。这是数据库扩展第一招、也最简单的一招——但拷贝会稍稍落后于 primary,所以一个刚写入的值,可能不会立刻出现在某个 replica 上。

sharding:把写也拆开

一本满溢的账本变成好几本——A–M 在一本里、N–Z 在另一本里——这样两个文书能同时书写,而不必为争同一页打架。

当连写都长得超出一台机器时,你就 sharding:按某个 key 把数据拆到好几个数据库上,让每个处理一片。它解锁了巨大的规模,但是真的难——跨 shard 的查询变得痛苦,而 shard key 是一个近乎永久的选择。这是深水区;最后再走到这一步,等到 replicas 和缓存真的不够用为止。

应用服务器容易克隆;数据库不行。在你动手 sharding 之前,先缓存和复制很长一段时间。

§ 06

不是所有的活都得在用户等待时发生。把慢活从请求上推下去、并在各部分之间放上缓冲,正是一个系统保持快、并吸收峰值的办法。

把慢活从请求路径上移走

在餐厅里,他们记下你的点单,你就坐下了——不会让你站在柜台前一直等到饭做好。

当一个请求触发了某件慢的事——发邮件、生成报表、处理一个上传——别让用户等它。把它交给一个后台队列,然后立刻返回;一个 worker 稍后去干那个慢的部分。页面保持灵敏,而重活在一旁发生。(这就是协议和架构那两门课里讲的消息传递想法。)

队列吸收峰值

泛滥的河流和城镇之间的一座水库——洪峰灌进水库,而不是淹没街道,再以平稳的速率排出去。

一个队列也是一个**缓冲。**当流量突然飙升——一次发布、一个爆红时刻——活儿在队列里堆起来,而 worker 按自己的节奏把它排空,而不是一场洪水冲垮系统。没有那个缓冲,一波来得比你能处理还快的浪涌,会把一切都拖垮。队列把一个致命的峰值变成一个可控的积压。

解耦,好让一个慢的部分不会拖垮其余

船上的水密舱——若一处进水,隔舱让其余的保持干燥、让船浮着。

当各个服务通过队列和事件来交流、而不是直接的、阻塞式的调用时,一个慢的或失败的组件**不会冻住所有等着它的人。**就算邮件服务挂了,订单照样被记下;它只是稍后补上。正是解耦,让一个大系统在压力下优雅地降级,而不是一下子全盘失败。

只让用户等他现在需要的东西。别的一切都躲到一个队列后面——它在峰值来袭时还顺带救了你。

§ 07

扩展得好,主要是关于克制和次序:测量出真正的瓶颈,先爬那些便宜的梯级,并为更多机器带来的故障做准备。

先测量,再扩展

医生在动刀之前先做检查——因为猜错了而切进错的器官,对谁都没好处。

在添加任何东西之前,用指标和 profiling 找出真正的瓶颈——是数据库、一个慢查询、网络,还是 CPU?工程师常常优化错的东西,只因为它感觉慢。数据会告诉你系统实际疼在哪里;去扩展那个,别的什么都不动。(这正是可观测性挣回身价的地方。)

按次序爬那道便宜的阶梯

在你买第二台炉子之前,你先加保温、封好窗户——便宜的修补先来。

有一个次序,最便宜、最容易的先来:一个更大的盒子,然后是缓存和一个 CDN,然后是负载均衡器后面的无状态服务器做水平扩展,然后是 read replicas,然后是给慢活用的队列——而只在最远的那一端,才是 sharding。每一级都是更多活儿、更多复杂度。只爬到你的负载逼着你爬到的那么高。

更多机器意味着更多故障

某一晚一只灯泡很少会坏;在一座一万人的体育场里,总有几只是灭的。在规模上,总有什么东西坏着。

随着你添加机器,在任意时刻某样东西正在出故障的概率,直奔确定无疑而去。所以扩展和可靠性是同一个工程:冗余(没有单点故障)、health check 与 failover,以及优雅降级,好让一个坏掉的部分牺牲掉品质、而不是要了系统的命。为故障而设计,因为在规模上它是常态。

在扩展任何东西之前
  • 我是否测量过真正的瓶颈在哪里——而不是猜的? - 有没有更便宜的一级——一个更大的盒子、一个缓存、一个 CDN——排在这一级之前? - 我的应用服务器是否无状态,这样我才能加更多? - 数据库的负载是读(replicas、缓存)还是写(那条难路)? - 慢活能否移到一个队列后面? - 我是否真的有这个负载——还是我在为一个也许不会来的未来而建造?
你过度工程化了的气味测试
  • 给一个本可以轻松放进一个盒子的数据库上 sharding。 - 为一个只有一百个用户的应用上微服务和队列。 - 扩展那个不是瓶颈的部分,只因为它感觉慢。 - 一个缓存层绕得没人说得清它什么时候陈旧。 - 为你并不拥有的数百万用户、和一次还没发生的发布而建造。
你扩展得好的迹象
  • 扩展了那个测量出来的瓶颈,而下一个如今已经可见了。 - 服务器是无状态的;增加或丢失一个无关紧要。 - 读被缓存并复制了;数据库喘得过气来。 - 慢活跑在队列后面,而一个峰值变成一个积压,而不是一次崩溃。 - 没有单点故障——任意一台机器都能悄无声息地死掉。 - 你只爬到了负载所要求的那么高。

去扩展你测量出来的那个瓶颈,用那个能修好它的最便宜工具,并假定在规模上总有什么东西坏着。别的一切都是过早。

速成课结束 · 7 章 · 先测量再猜

接下来是深度:再读一遍《Designing Data-Intensive Applications》、《System Design Interview》系列,以及那些真正扛过规模的公司的工程博客。但把上面那个核心想法,举得高过那些套路——一个系统在负载下,只跟它最弱的那一点一样强。找出那一点,把它拓宽,而在你不得不动手之前,别去建造其余的。