随着这几年我对 eBPFPrometheus 等工具的深入了解,我才逐渐意识到“可观测性”这个词背后蕴含的意义。

很早以前,我就在 Linux 上使用 /proc/topsar 等工具来排查问题,却从未意识到,“观测”竟然是一门独立的学问。

回顾我早期的编程经历,在设计模块时,我总是本能地选择时间复杂度最低的已知算法。

因为我并不知道这个模块的性能会对最终系统造成多大影响,只是凭着一种“贪心算法”的思维尽可能提升程序性能——也正是这种心态,造就了我后来的“性能强迫症”。

结果就是,代码变得不够简洁,而对整个系统性能的提升却微乎其微,甚至可能根本没有提升。我最近的重构再一次验证了这一点。

并不是我不想评估模块对系统的实际影响,而是现实中的负载情况实在太难模拟。

你测试出来的结果,往往和真实线上环境天差地别。多年来我也请教过很多人,但始终没有找到特别令人满意的答案。

在面对一个有问题的运行环境时,我们可以借助一些分析工具快速定位到热点函数,但这些工具通常都有一个弊端:它们往往会 Stop The World —— 比如 gdbgprof 等。

然而线上问题通常无法容许我们用 Stop The World 的方式进行排查。

这也正是“可观测性”弥足珍贵的原因之一:当系统出问题时,我们可以通过系统本身提供的可观测能力,去追踪和理解到底发生了什么。

不得不佩服 Linux 的设计者们,/proc 文件系统的设计在多年以前就已体现出极强的可观测性理念。


我并不想讲怎么样实现可观测性,毕竟我不是专家。

但我想谈谈观测给了我们一个什么样的视角。

想象一下,不管在任何工厂或车间,总会有各种各样的仪表盘。

一方面,说明这些仪表盘设计的非常合理, 另一方面,说明一个复杂的系统仅在宏观上暴露一些参数指标,足够我们排查很多问题。

而会看仪表的人并不需要对整个系统的细枝末节完全理解。

这从侧面也说明了,当我们通过观测来排查问题时,并不需要一上来就去了解整个系统的实现细节,从宏观视角就可以排查很多问题。

这一点很重要,前面铺垫了这么多,都是为了这个观点。

我也是这两年看完《性能之巅》才慢慢领会到的, 来举几个这两年真实发生的例子。


一次线上更新之后,一台机器的负载飙升到了 14.84,整台机器几乎被卡爆。将刚更新的程序进程杀掉之后,负载才明显下降。

运维怀疑是我们最近更新的代码引入了 Bug,要求我们自查代码。这是合理的猜测,也是最常见的原因。

巧的是,我这两年刚好研究完《性能之巅》,一直没什么机会实践,正所谓“拿着锤子到处找钉子”。

我首先用 top 查看了该进程的 CPU 和内存使用率,发现都只有 1.0% 左右,显然负载问题并不在计算上,初步判断是 IO 引起的。

于是我使用 iostat 1 查看磁盘负载,发现 %iowait 高达 75%,但 kB_read/skB_wrtn/s 加起来也才 200 左右。

结合这些数据,我怀疑是硬盘出现了问题。果然,负责该程序的同事自查代码后并未发现明显异常。最终联系云服务商确认,确实是他们的硬盘出了问题。

另一次是在某个凌晨,游戏服务器访问数据库时出现了 i/o timeout 错误。这里的 i/o timeout 是网络连接层的报错。

但如果网络连接迟迟没有返回,理论上也可能是数据库处理被卡住了。

我通过 Prometheus 查找出故障时段的监控数据,发现读取速度仅有 500KB/s 左右,而 IOPS 并无明显变化,但单次 IO 读取的耗时却突然飙升到了 200ms

综合来看,这很可能又是一场硬盘故障。与云服务商确认后,他们也承认是由于存储集群的某种原因触发了自动切换,导致了 IO 抖动。


上面两个案例中,我在分析问题时并没有去看程序的代码,甚至第一个程序的源码我根本没有阅读过。

这正说明,通过一些宏观指标,其实就可以定位出许多问题。不过,这两个案例还没有很好地凸显“视角”这个关键概念。

最近,我尝试对一个 Lua 程序的内存使用进行调优。同样地,我也没有看过这个 Lua 程序的源码。

我们可以假设这个程序只有一个接口 function foo(xx)foo 接收参数并返回计算结果,过程中会产生一堆垃圾对象。

也就是说,在调用 foo 前后各执行一次全量 GC,程序的内存使用应该没有任何变化。

在调优过程中,我发现每次调用 foo 函数之后,手动执行一次 lua_gc(LUA_GCSTEP, 10*1024),可以将程序的内存峰值从 560MB 降到 260MB

根据 Lua 手册中对 LUA_GCSTEP 的说明,10*1024 相当于让 GC 系统分配约 10MB 的对象。

不过我一开始测出的是一个错误的数据:我认为 foo 每次调用都会产生 50MB 的垃圾,其实只有首次调用才会产生这么多。

我原以为程序在每次调用 foo 后都会生成 50MB 的垃圾对象。但根据手册和我之前阅读 lgc.c 的经验,我始终无法理解:为什么在程序初始内存为 210MB(即执行 dofile("main.lua") 后)时,手动调用 lua_gc(LUA_GCSTEP, 10*1024) 居然能把内存峰值降低超过 300MB

根据默认 GC 参数,只要你分配了 100MB 内存,它就会尝试去 Mark200MB 的对象。也就是说,为了 Mark210MB 的初始内存,理论上需要实际分配 105MB

我虚假分配了 10MB,加上 foo 每次调用产生的 50MB,就需要实际再分配大约:105 / (50 + 10) * 50 = 87.5MB的内存,才能完成对所有存活对象的标记。

而根据 Lua增量 GC 实现,只有当对象被全部 Mark 完后,才会进入清理阶段。

因此,在进入清理阶段前,内存峰值至少已经达到了 297.5MB。而我还没有考虑,LUA_GCSTEP 实际上是要在 foo 分配完 50MB 之后才会执行,另外还有 SWEEPCOST 带来的额外内存负担。

按默认 GC 策略,GC 会等到内存分配到 297.5 * 2 = 595MB 时才会启动下一轮 GC,这个计算远远高于我的观查到的数据。

我和两位同学讨论了一下,他们都认为是 Lua 业务逻辑(非 VM 实现)干扰了 GC 行为,让我去分析业务代码。

但我并不这么认为——不管业务逻辑怎么写,在 GC 系统的视角下,它的行为是稳定的,它始终以内存分配量的 2 倍作为标记对象的阈值。

于是我逐步打印了每次调用 foo 后的内存变化,最终发现我原来的测量是错误的。实际上,只有第一次调用 foo 才会分配 50MB,后续每次调用只产生约 0.8MB 的垃圾。

这就合理了:调用 foo 十次产生约 8MB 的垃圾,加上我虚假分配的 10MB,共计约 108MB,就足够触发 GC 标记完 210MB 的内存。此时内存峰值大约为:210 + 0.8 * 10 ≈ 218MB

考虑到 GC 步长、首次 dofile 后仍残留的垃圾、以及 foo 首次调用产生的内存并非全是垃圾等因素,这个数字已经非常合理了,至少他是可解释的。


之所以举这三个例子,是希望引出这样一个宏观的抽象视角:

在一台计算机硬件上,运行着操作系统;在操作系统之上,运行着我们的应用程序;而我们的应用程序在设计上,往往又被进一步划分为多个抽象层。

当我们在排查问题时,可以尝试忽略其他抽象层与具体实现的干扰,只集中在某一个切面(Aspect)来分析问题究竟出在哪一层。

一旦我们确定了问题所在的抽象层,再去深入分析其具体实现细节,这将大大降低问题分析的复杂度,也能帮助我们更系统、更高效地定位和解决问题。