墙钟减 CPU 时间,就是「在等」的部分。 这一步不做,换什么都是猜。 同一份 256MB,不刷盘 0.19 秒、强制刷盘 4.02 秒——缓存会把瓶颈藏起来。 阿姆达尔定律:可优化部分只占三成时,那部分再快 100 倍,整体也只快 1.42 倍。
第 2 章讲系统分三段。这一章讲三段里某一段撑不住了怎么办。 教材 3.1 节:对瓶颈问题的分析。3.2 节讲三种瓶颈以外的问题(下一篇)。 这一篇只干一件事:教你怎么量出瓶颈在哪。 不量就优化,是这一行最常见的浪费。
一、先分清「在算」和「在等」
程序跑得慢,只有两种可能:要么在算,要么在等。 在算的时候,CPU 一直忙着,你手表走过的时间和 CPU 时间几乎一样。 在等的时候,CPU 闲在那里,可你手表照样在走。两个时间的差,就是「在等」。
演示里第一行算开方 300 万次:墙钟 0.755 秒,CPU 0.750 秒,差 5 毫秒。这就是纯粹的「在算」。 这个判断方法简单到有点土,但它是排障的第一步,而且是最有效的一步。 因为它把「慢」这个模糊的感受,变成了一个可以查的数字。
二、缓存会把瓶颈藏起来
演示第二行写读 256MB:墙钟 0.194 秒,CPU 0.188 秒,等待 3%。
看起来磁盘一点都不慢。但这 256MB 全落进了系统缓存,压根没碰到盘。
第三行加了 os.fsync(),强制把数据真写进介质:墙钟 4.016 秒,CPU 只有 0.281 秒,等待占 93%。
同一块盘、同样 256MB,差了 20 倍。区别只在「有没有等它真的落地」。 这件事在排障时特别容易踩。你在开发机上测出来「不慢」,上线量一大、缓存装不下了,立刻现原形。 所以测 IO 有一条铁律:要么把缓存绕开,要么把数据量撑到缓存装不下。否则你测的是内存,不是磁盘。
三、三种资源,三种症状
瓶颈不是「机器不行」,是某一种资源先满了。满了之后,别的资源再快也白搭。 CPU 满了看得出来:使用率长期在 90% 以上,风扇狂转。 磁盘满了没那么明显:CPU 不高,但读写很慢,监控上的 iowait 很高。 这时换 CPU 一点用没有,该换的是 SSD 或者加内存做缓存。
网络满了更隐蔽:带宽打满、RTT 变长,表现出来是「接口偶尔慢一下」,很难复现。 演示里第四行那个 socketpair 其实没测出网络——它是同一台机器里的内存管道,不走网卡。 这一段在这台机器上就测不出来,得照实说。 这也是本课的规矩:测不出来的东西不硬编。 真有网络问题的系统,看的是带宽利用率和重传率,不是这段代码。
四、修完瓶颈,还能快多少
假设你已经找到了瓶颈,把它修好了,整体能快多少?这有个公式,叫阿姆达尔定律。 S = 1 / ((1 - p) + p / s),p 是可优化部分占总时间的比例,s 是那部分快了几倍。 演示里的表:p=30%、那部分快 100 倍,整体只快 1.42 倍。因为剩下 70% 一步没动。
p=90%、那部分快 10 倍,整体快 5.26 倍。同样投十分力,差别是四倍。 这就是为什么排障要先量:优化一个占 5% 的环节,做得再好也就是 5%。 所以顺序永远是:量 → 找最大那块 → 优化 → 再量。跳过第一步,后面的活基本白干。
五、这一篇要带走的东西
三句话:墙钟减 CPU 等于在等;缓存会骗人,测 IO 要绕开它;优化之前先看它占多少。 还有一句要记牢:瓶颈会移动。你修好磁盘,瓶颈就跑到 CPU 上去了;再修 CPU,可能又跑回网络。 这是一个循环,不是一锤子买卖。 下一篇讲三种不那么明显的卡顿:内存装不下、时间花在等锁、以及「线程开了一堆却一点没变快」。
六、动手 Demo:墙钟减 CPU:四种活儿,差别在哪
跑四种负载:纯计算、写读 256MB、写读 256MB 并强制刷盘、socket 来回 2 万次。 每种都同时量墙钟和 CPU 时间。 最后算一遍阿姆达尔定律,看修瓶颈的天花板在哪。
def measure(fn):
w0 = time.perf_counter()
c0 = time.process_time()
fn()
return (time.perf_counter() - w0,
time.process_time() - c0)
def disk_task(mb=256, sync=False):
with open('big.bin', 'wb') as f:
for _ in range(mb):
f.write(b'x' * (1 << 20))
if sync:
f.flush()
os.fsync(f.fileno()) # 逼它真写进介质
for name, fn in (('纯计算', cpu_task),
('写读 不刷盘', lambda: disk_task(sync=False)),
('写读 强制刷盘', lambda: disk_task(sync=True))):
w, c = measure(fn)
print('%-12s 墙钟 %.3f CPU %.3f' % (name, w, c))
实跑输出:
--------------------------------------------------------------------
先找瓶颈:三种活儿,等的地方不一样
--------------------------------------------------------------------
① 四种活儿,分别量墙钟和 CPU
--------------------------------------------------------------------
| 活儿 | 墙钟 | CPU 时间 | 在等的比例 |
|---|---:|---:|---:|
| 算开方 300 万次 | 0.758 秒 | 0.750 秒 | 1% |
| 写读 256MB(不刷盘) | 0.219 秒 | 0.203 秒 | 7% |
| 写读 256MB(强制刷盘) | 4.513 秒 | 0.297 秒 | 93% |
| socket 来回 2 万次 | 0.324 秒 | 0.328 秒 | ≈0 |
墙钟是你手表走过的时间,CPU 时间是进程真正占用处理器的时长。两者之差就是「在等」。
② 缓存会骗你,刷盘不会
--------------------------------------------------------------------
看第二行:写读 256MB 的 CPU 时间跟墙钟几乎一样,等待比例 ≈0。难道磁盘不慢?
不是,是这 256MB 全落进了系统缓存,压根没碰到盘。
第三行加了 os.fsync(),强迫数据真写进介质——等待比例立刻跳起来。
这件事在排障时特别容易踩:你在缓存里测出来的「不慢」,上线换个机器就全变了。
测 IO 一定要把缓存绕开。
③ socket 那行也没测出网络
--------------------------------------------------------------------
第四行的 socketpair 是同一台机器、同一个进程里的内存管道,不走网卡,也就没有网络等待。
这里测出来的只是内存拷贝的速度。
真网络的一次往返要几十毫秒(同城机房)到几百毫秒(跨洲),那才是分布式系统里最贵的一段。
这一段这台机器上测不出来,第 3.3 篇会讲它怎么成为瓶颈。
④ 这就是「瓶颈」的意思
--------------------------------------------------------------------
瓶颈不是「机器不行」,是某一种资源先满了。满了之后,别的资源再快也白搭,因为活儿卡在那一处。
| 资源 | 满了的症状 | 加什么有用 |
|---|---|---|
| CPU | CPU 使用率长期 90% 以上 | 加核、换更快的主频 |
| 磁盘 | CPU 不高但读写很慢,iowait 高 | 换 SSD、加内存做缓存 |
| 网络 | 带宽打满、RTT 变长 | 加带宽、把数据挪近一点 |
排障第一步永远是量:墙钟减 CPU,看看时间花在等谁。这一步不做,换什么都是猜。
⑤ 修掉瓶颈之后还能快多少
--------------------------------------------------------------------
阿姆达尔定律:S = 1 / ((1-p) + p/s)
p 是能被优化的那部分占比,s 是那部分快了几倍。
| 可优化部分 p | 那部分快 s 倍 | 整体快 |
|---:|---:|---:|
| 30% | 2 倍 | 1.18 倍 |
| 30% | 10 倍 | 1.37 倍 |
| 30% | 100 倍 | 1.42 倍 |
| 70% | 2 倍 | 1.54 倍 |
| 70% | 10 倍 | 2.70 倍 |
| 70% | 100 倍 | 3.26 倍 |
| 90% | 2 倍 | 1.82 倍 |
| 90% | 10 倍 | 5.26 倍 |
| 90% | 100 倍 | 9.17 倍 |
看中间那几行:p 只有 0.3 的时候,那部分快 100 倍,整体也只快 1.42 倍。因为剩下 70% 一步没动。
反过来,p=0.9 时快 10 倍能拿到 5.26 倍。所以优化之前先问:这块占了多少时间?
占比不到两成的东西,优化它等于白干。
第一行最关键的不是数字,是那个方法:两个计时器一起跑,差值就是等待。任何语言、任何系统都能这么量。 第二行和第三行的差别只有一句 fsync,结果差了二十倍。 这是本篇最该记住的对照:缓存能把瓶颈藏得严严实实。 第四行 socket 那行没有等待,因为 socketpair 是同一进程内的内存管道。 测不出网络不要紧,照实说比编一个数字强。 最后那张阿姆达尔表是纯算术,但它能挡住大量无效优化。p 小于三成的时候,那部分怎么修都不值得。
30 秒实验
- 把刷盘那个 mb 从 256 改成 64,看等待比例降不降。(应该降,因为总量小了,但盘的速度没变。)
- 在纯计算那段前后各加一句
time.sleep(0.5),看等待比例变成多少。 - 把阿姆达尔那段 p 改成 0.95、s 改成 1000,看整体快几倍——这是「理论上限」, 也是为什么 p 小于 1 就永远做不到无限快。
常见误区
- 「跑得慢就加机器。」先看是哪一种资源满了。加错资源,钱花了速度不变。
- 「在开发机上测过,不慢。」开发机有缓存、数据量小、没有并发。 缓存能掩盖 20 倍的差距,上线就现原形。
- 「优化到极限了,还是慢。」用阿姆达尔算一下:你优化的那部分占多少? 占两成的东西优化到零,整体也只快 25%。
自测三问(点开对答案)
1. 一个任务墙钟 10 秒、CPU 时间 2 秒,它在等什么? —— 等外部资源:磁盘、网络、锁、下游接口。具体是哪一种,要再细分成几段分别量。 2. 为什么写 256MB 不刷盘只要 0.19 秒,刷盘要 4.02 秒? —— 不刷盘时数据只是被复制进系统缓存,函数就返回了; fsync 要求数据真正落到介质上,这才是磁盘的真实速度。 3. 阿姆达尔定律里 p=0.5、s=∞,整体最多快几倍? —— 2 倍。公式退化成 1/(1-p) = 1/0.5 = 2。这就是「一半时间不可优化」的硬上限。下一篇讲三种更隐蔽的卡顿:内存、锁、以及线程为什么加不上去。
- ☐ (本篇完,下一篇 3.2 瓶颈之外:内存与隐形卡顿)