速成课 · No. 24
性能这件事,每次出错都是同一个套路:有人猜哪里慢,优化了错的地方,白白堆上了复杂度。该有的纪律恰好相反——靠测量找到真正的 bottleneck,只修那一个,然后再测一遍。搞清楚到底是什么让软件变慢,以及描述它的那些词,「让它更快」就从一句猜测变成了一套方法。
只讲精髓 · 每个想法一个画面 · 掌握术语
性能的第一条规则,也是人人都会破的那条:别信你对哪里慢的直觉。几乎所有白费的优化,都来自跳过这一步。
你对慢的直觉通常是错的
一个医生不做任何检查,凭猜测就开刀——自信、利落,而且太常对着完全不该动的地方下手。
开发者出了名地不擅长猜时间花在哪。你笃定慢的那一块往往没事;真正的祸首藏在你从没怀疑过的地方——一个被调用了上百万次的小函数,一次被藏起来的数据库调用。凭直觉动手,意味着你优化了错的地方,堆上了复杂度,程序还是慢。第一步永远不是「把它修好」——而是搞清楚时间到底花在哪。
用 profile 找到真正的开销
一张逐项列清的账单,一行一行地写明钱到底花在哪——这样你就不再靠猜,而是看清是那一笔、而不是你以为的那几笔,把预算吃光了。
profiler 是一种工具,它一个函数一个函数地测量你的程序实际把时间花在了哪里。它把「我觉得这里慢」变成「就这一个函数占了 80% 的运行时间」。有了它,你修的是真正要紧的东西,而不是你想象出来的东西。无论靠的是 profiler、计时日志,还是你的可观测性指标,道理都一样:让测量、而不是直觉,把你指向问题。
测量、修改、再测一遍
一个科学家只改一个变量,重跑实验,再核对结果——从不假定某个改动有效,永远去确认它确实有效。
性能是一个循环:测量找出慢的部分,改掉它,然后再测一遍,确认这个改动真的有帮助——而不是只是把问题挪了个地方,或者让它更糟。少了第二次测量,你就只是在猜自己有没有改进,而「优化」就是这样悄悄把东西变慢的。把每一次修改都当成一个你要验证的假设,跟给 AI 做 evals、给代码写测试是同一种纪律。
对慢的直觉通常是错的。用 profile 找出时间真正花在哪,修掉它,再测一遍来确认——永远别凭猜测去优化。
动手优化之前,先弄清你真正想要的是哪一种「快」。两个不同的目标老是被混在一起,而改进其中一个,对另一个可能毫无作用。
latency 是单件事的等待
从下单到端上杯子,一杯咖啡要多久——一个顾客的体验,从头到尾地量。
latency 是一次操作从开始到结束要花多久——一个请求、一次页面加载、一条查询。它是单个用户感受到的东西:那份等待。当有人说「这站点很慢」,他们几乎总是在说 latency——你做了某件事到拿到响应之间的延迟。降低 latency,就是让每一件单独的事更快地发生。
throughput 是你每秒能处理多少
整间咖啡馆一小时能出多少杯咖啡——不是一杯要多久,而是流经这家店的总量。
throughput 是系统在单位时间里能处理多少活儿——每秒多少请求,每分钟多少任务。它讲的是总容量,而不是单件的等待。一个系统可以有很高的 throughput(一次伺候上千人),而每个用户还是要等上一阵(高 latency);也可以 latency 很低,但容量有限。它们是不同的目标,而你在追哪一个,会改变你该修什么。
它们不一样,有时还互相牵制
一辆摩托车把一个人最快送到(低 latency);一辆公交车每趟运的人最多(高 throughput)。最好的车取决于你需要哪一种。
latency 和 throughput 是相互独立的,优化一个可能伤到另一个。batching——把活儿凑成一批——往往能抬高 throughput,却会增加 latency,因为每一件都要等这一整批。所以你得在这里决定哪个要紧:一个面向用户的页面,成败全在 latency;一条后台数据管道,在意的是 throughput。点明你真正的目标,能让你不去优化那个对用户根本不重要的数字。
latency 是单次操作的等待;throughput 是你每秒能处理多少。它们是相互独立的目标——而 batching 常常用 latency 去换 throughput。
性能并不是均匀摊开的。几乎总是有一个部分占掉了大部分时间——在你修好那一个之前,优化任何别的都是白费力气。
一个慢的部分通常占大头
一条链子的强度,恰好等于它最弱那一环——加固结实的几环毫无用处;只有最弱那一环决定它会不会断。
在大多数慢的系统里,单个 bottleneck 占掉了大部分时间——一条查询、一个慢的服务、一个糟糕的循环。其他一切其实都已经够快了。这正是猜测如此浪费的原因:去优化一个只占运行时间 2% 的部分,你最多也只能赢回 2%。找到占 80% 的那个部分,修好它就改变一切。整套本事就是定位那个占大头的开销,而不是改进本来就快的部分。
系统的快慢只取决于它最慢的那一步
一条一路畅通的高速公路,直到某处一条车道封闭,每辆车都在那爬行——整段行程的速度由那一个堵点决定,而不是畅通的那些路段。
一个流经十个步骤的请求,受限于最慢的那一步。把九个快步骤再加速,几乎挪不动总时间;慢的那一步决定节奏。所以你专门去打那个堵点。还要注意,修好一个 bottleneck 往往会露出下一个:去掉最慢的那一步,一个新的最慢步骤就冒出来。性能这活儿,就是不断地找出并清掉当前那个限制速度的步骤。
优化别处是白费力气
车胎瘪着,你却在擦亮奖杯——力气花在改变不了任何结果的地方,而真正拦住你的那个东西被晾在一边。
花在优化 bottleneck 以外任何东西上的时间,按定义就是帮不上什么忙的时间。它常常还把事情弄得更糟,为一份看不见的收益堆上复杂度和 bug。这正是测量所强制的纪律:它拦住你,不让你深情地优化那块你看得懂的部分,逼你转向那块真正花钱的部分。修好 bottleneck,别管其余——直到其余变成那个 bottleneck。
通常一个 bottleneck 占掉了大部分时间,而系统的快慢只取决于它最慢的那一步。修那一个东西;优化别的都是白费,直到它变成新的 bottleneck。
当你真找到了 bottleneck,修法通常就是少数几种经典套路里的一个。这几种套路占了现实世界里压倒性多数的提速。
更好的 algorithm 胜过更快的机器
通往同一座城市的两条路:跑在又长又绕的路上的快车,照样输给跑在笔直近道上的慢车。路线比引擎更重要。
最大的提速往往来自更好的 algorithm 或数据结构,而不是更快的硬件或微调。把一个 O(n²) 的循环换成一次 O(n) 的哈希表查找,能把几分钟变成几毫秒——这是再快的机器也比不上的改变。在优化那些小东西之前,先问问这套办法是不是错了。正如数据结构那门课所讲,一个更好的形态,是世上最便宜、也最大的提速。
别再跑那么多趟 round trips
为一件件单品分二十趟跑去商店,而不是带张清单一趟办完——吃掉你整个下午的是路上来回,不是买东西本身。
大量的慢,来自 too many round trips——一遍又一遍地调用数据库或服务,单看每一次都便宜,攒到一起却是毁灭性的。最经典的就是 N+1 query:先取一份列表,然后每一项再多发一条查询,把一趟变成了几百趟。修法是批量地取——一次把所有东西都要来。减少趟数往往胜过把每一趟加速,因为 round trip 本身就是那份开销。
能跳过或复用的活儿就别干
每次有人要这道菜,你都从头重新做一遍,而不是把一锅一直温着——最快的活儿,就是你不去重复的那份。
还有两个经典。caching——把一个昂贵的结果记下来,省得你重新算一遍——是世上最大的几个杠杆之一(它自己有一门课)。还有少干活:能晚算就晚算,只在真正需要时才算;别去加载你用不上的数据;那些算出来你又会扔掉的活儿,跳过它。最快的操作,就是你压根不去跑的那个。最好的优化往往不是把活儿干得更快——而是干脆不去干。
多数提速都是那几个经典:更好的 algorithm、更少的 round trips(干掉那个 N+1)、把昂贵的结果做 caching,以及干脆少干活。最快的活儿,就是你跳过的那份。
你怎么测量性能,可能会骗到你。average 是最让人安心、也最容易误导的那个数字,而学会越过它去看,正是把真正的性能工作和一厢情愿区分开来的东西。
average 把慢的那些情况藏了起来
一个房间里九个人很舒服、一个人身上着了火,平均温度却很宜人——这个均值抹掉了真正要紧的那个情况。
把性能报成一个 average 是危险地令人宽心:它把快的大多数和慢的极少数,搅成了一个皆大欢喜的数字。但用户体验到的不是 average——每个用户体验到的是他自己那一个请求,而慢的那些,每一毫秒都被感受到。一个 200ms 的平均响应时间,可能藏着二十个人里有一个要等五秒。这个均值告诉你系统没事,可实实在在的一拨用户正在受罪。
percentile 显示真实的体验
一份报告不只说典型的等待时长,还说「最慢的那 1% 的人等了这么久」——把糟糕的体验点出名来,而不是把它们平均掉。
percentile 抓住了 average 藏起来的东西。p50(中位数)是典型情况——一半的人比它更快。p99 是那条慢尾巴——99% 的人比它更快,所以它大致就是一个真实用户撞上的最糟体验。盯着 p99、而不只是 average,你才能看见那些真正在受罪的用户。痛苦活在那条尾巴里,而 percentile 就是你让它显形的办法。
tail latency 才是用户记得的
在一个人的记忆里,一顿糟透了的饭,分量盖过十几顿还不错的——塑造人们如何评判你的,是最糟的那些体验,而不是平均。
那些慢请求——也就是尾巴——会不成比例地塑造用户对你产品的观感,因为糟糕的体验记得牢。而在规模之下,尾巴打到的人比看上去的更多:要是每个页面都发很多次调用,那么至少有一次慢的概率就涨得飞快,于是几乎每个用户最终都会尝到那条尾巴。这就是为什么认真的团队把目标定在 p99、而不是 average 上:驯服最糟的那些情况,才是让一个产品感觉稳定地快的关键。
average 藏起了用户真正感受到的那些慢情况。盯着 percentile——p50 看典型,p99 看那条难受的尾巴——因为人们记住的,是最糟的那些体验。
有一种失败模式跟无视性能正相反:太早、到处地去追它,在你还不知道它要不要紧之前就动手。那句关于它的名言出名,是有道理的。
premature optimization 是万恶之源
在还没人搬进来之前,就为一场也许永远不来的地震加固房子的每一面墙——巨大的力气,花在一个你还没有的问题上。
那句名言——「premature optimization 是万恶之源」——警告的是:在你还不知道要优化什么、或者到底要不要优化之前,别去优化。优化那些并不慢、甚至根本没怎么被跑到的代码,什么也换不来,代价却一大堆:复杂度、bug,还有从要紧的事上偷走的时间。大多数代码不需要快;它需要正确而清晰。提速的功夫,是留给那些被测量证明是热点的部分的。
先做到清晰、正确,只在要紧处求快
你把菜谱写得人人都能照着做,只把那一个你一天要做一百遍的步骤精简掉——不是每一步,只那个热点。
顺序是:先把它做正确,再把它做清晰,最后只在测量证明要紧的地方把它做快。清晰的代码之所以日后更好优化,恰恰是因为你看得懂它;而缠成一团的「快」代码,等真正的 bottleneck 在别处冒头时,是很难改的。为速度而优化几乎总要赔上可读性,所以你只在那份代价能换回一个真实、有测量支撑的提速时才去花它——其余的,保持简单。
但也别在设计上就无视性能
你不会给一个棚子做抗震,但你也不会把一座摩天楼盖在沙地上——有些选择,趁早做对很便宜,晚了再修则代价惨重。
这句警告不是「永远别想性能」。在一个 O(n) 同样轻松可得的地方却选了 O(n²) 的 algorithm,或者选了一个撑不起规模的结构,是一个你日后要付惨重代价的设计错误。这个分寸是:别太早做微优化,但也别做那些设计上就慢、且改起来昂贵的架构选择。趁早花小钱把大的形态定对;只在测量提出要求时,才去优化细节。
premature optimization 拿清晰换来没人需要的速度。先把它做正确、做清晰,只在测量证明要紧的地方做快——但别选一个设计上就慢、让你日后后悔的形态。
性能做得好,是一套冷静、可重复的方法,而不是一场英雄式的手忙脚乱。整套实践就装在一个简短的循环里,只在有真正理由时才去跑它。
那个循环:测量、修掉 bottleneck、重复
一个技师用仪器诊断,修好那一个出故障的部件,然后再测一遍——从不抱着「噪声会消失吧」的指望随便换零件。
方法简单而有纪律:测量找到 bottleneck,修掉那一个东西,再测一遍来确认、并露出下一个 bottleneck,等够快了就停。每一遍都瞄准当前那个占大头的开销,无视其余一切。这个循环让性能工作保持诚实而高效——你总知道自己在动那个真正要紧的东西,也总知道自己到底有没有帮上忙。
定一个 target,然后停手
你给房子做保温,做到够暖就停——你不会越过谁都感觉不到的那个点,永无止境地一层层往上加。
性能没有天然的终点——你总能把某样东西再快上那么一丁点。所以定一个 target(「页面在 p99 下低于 300ms」),撞到它就停。没有目标,优化就变成一个收益递减的无底时间坑,复杂度为了谁都察觉不到的收益悄悄爬进来。知道「够快」对你的用户、你的场景意味着什么,才是让你恰好把性能工作做到位、然后腾出手去造别的东西的关键。
- 我测量过时间到底花在哪了吗,还是在猜? - latency 还是 throughput——我真正需要的是哪一种快? - bottleneck 是什么——占掉大部分时间的那一个部分? - 它是不是一个经典——糟糕的 algorithm、太多 round trips、缺了 cache、没必要的活儿? - 我盯的是 p99 吗,而不只是那个藏起慢尾巴的 average? - 有没有一个 target 让我在朝它优化,好让我知道何时收手?
- profiler / 测量-修改-测量——找到真实开销,以及确认一次修改的那个循环。 - latency / throughput——单件事的等待,对比每秒的处理量。 - bottleneck——占掉总时间大头的那一个慢部分。 - algorithm / round trips / N+1 / caching——慢的那些经典来源,以及对应的修法。 - average / percentile / p50 / p99 / tail latency——为什么均值会骗人、尾巴会伤人。 - premature optimization——在测量证明你需要之前就去追速度。 - batching——把活儿凑成一批来抬高 throughput,代价是 latency。
- 你在动任何东西之前先测量,动完再测一遍。 - 你修那个 bottleneck,无视那些本来就够快的部分。 - 你的提速来自 algorithm、更少的 round trips 和 caching——而不是微调。 - 你用 p99、而不是一个好看的 average 来评判速度。 - 你朝一个 target 优化、然后停手,让其余部分保持清晰而正确。
性能是一个冷静的循环:测量、修掉那一个 bottleneck、再测一遍、到了 target 就停。只在它被证明要紧的地方才动手,其余一切保持清晰。