随着这几年我对 eBPF、Prometheus 等工具的深入了解,我才逐渐意识到“可观测性”这个词背后蕴含的意义。
很早以前,我就在 Linux 上使用 /proc/
、top
、sar
等工具来排查问题,却从未意识到,“观测”竟然是一门独立的学问。
回顾我早期的编程经历,在设计模块时,我总是本能地选择时间复杂度最低的已知算法。
因为我并不知道这个模块的性能会对最终系统造成多大影响,只是凭着一种“贪心算法”的思维尽可能提升程序性能——也正是这种心态,造就了我后来的“性能强迫症”。
结果就是,代码变得不够简洁,而对整个系统性能的提升却微乎其微,甚至可能根本没有提升。我最近的重构再一次验证了这一点。
并不是我不想评估模块对系统的实际影响,而是现实中的负载情况实在太难模拟。
你测试出来的结果,往往和真实线上环境天差地别。多年来我也请教过很多人,但始终没有找到特别令人满意的答案。
在面对一个有问题的运行环境时,我们可以借助一些分析工具快速定位到热点函数,但这些工具通常都有一个弊端:它们往往会 Stop The World
—— 比如 gdb
、gprof
等。
然而线上问题通常无法容许我们用 Stop The World
的方式进行排查。
这也正是“可观测性”弥足珍贵的原因之一:当系统出问题时,我们可以通过系统本身提供的可观测能力,去追踪和理解到底发生了什么。
不得不佩服 Linux 的设计者们,/proc 文件系统的设计在多年以前就已体现出极强的可观测性理念。
我并不想讲怎么样实现可观测性
,毕竟我不是专家。
但我想谈谈观测
给了我们一个什么样的视角。
想象一下,不管在任何工厂或车间,总会有各种各样的仪表盘。
一方面,说明这些仪表盘设计的非常合理, 另一方面,说明一个复杂的系统仅在宏观上暴露一些参数指标,足够我们排查很多问题。
而会看仪表的人并不需要对整个系统的细枝末节完全理解。
这从侧面也说明了,当我们通过观测
来排查问题时,并不需要一上来就去了解整个系统的实现细节,从宏观视角就可以排查很多问题。
这一点很重要,前面铺垫了这么多,都是为了这个观点。
我也是这两年看完《性能之巅》才慢慢领会到的, 来举几个这两年真实发生的例子。
一次线上更新之后,一台机器的负载飙升到了 14.84
,整台机器几乎被卡爆。将刚更新的程序进程杀掉之后,负载才明显下降。
运维怀疑是我们最近更新的代码引入了 Bug
,要求我们自查代码。这是合理的猜测,也是最常见的原因。
巧的是,我这两年刚好研究完《性能之巅》,一直没什么机会实践,正所谓“拿着锤子到处找钉子”。
我首先用 top
查看了该进程的 CPU
和内存使用率,发现都只有 1.0%
左右,显然负载问题并不在计算上,初步判断是 IO
引起的。
于是我使用 iostat 1
查看磁盘负载,发现 %iowait
高达 75%
,但 kB_read/s
和 kB_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
内存,它就会尝试去 Mark
约 200MB
的对象。也就是说,为了 Mark
完 210MB
的初始内存,理论上需要实际分配 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)来分析问题究竟出在哪一层。
一旦我们确定了问题所在的抽象层,再去深入分析其具体实现细节,这将大大降低问题分析的复杂度,也能帮助我们更系统、更高效地定位和解决问题。