抓虫(bug)系列-概述

写这个系列的初衷

抓虫这件事要比解决bug要难的多,并且本身也需要过硬的技术基础,以及广泛的实战分析经验。如果在第一时间生产上分析到bug,或在code review中敏觉发现问题,就能最程度的降低线上损失,并且在团队中树立很🐂的名气。说不定年终升职加薪上,老板就提名你了(你看,妥妥双赢的机会嘛,还不冲一下)。

这篇blog我计划是要写一个系列的,因为身为程序员,出现问题一定是无法避免的,我们的意图是减少常见的问题以及较为严重的问题,而这些问题是可以整理总结的。在初高中的时候,面对一些经常出错的题会出一个错题集,那么这个专题就是针对类似的做法,把常见或者非常见的问题收集整理,意在提高避免、发现、解决的经验。

当然,这个系列是长期的,相关的总结会囊括所有的我真实面临过的bug、常见的问题如何避免、相关大中厂生产事故过程阐述等。

由于篇幅长度以及分类的问题,我预计用4篇BLOG,分别是:

  1. 抓虫系列-概述
  2. 抓虫系列-整体思路
  3. 抓虫系列-常见的问题
  4. 抓虫系列-案例收集

专栏传送门

Bug与它的”时空“

1947 年,哈佛大学的计算机哈佛二代(Harvard Mark II)突然停止了运行,程序员在电路板编号为 70 的中继器触点旁发现了一只飞蛾。然后把飞蛾贴在了计算机维护日志上,并写下了首个发现 Bug 的实际案例。程序错误从此被称作 Bug。

回想下,在编程路上你遇到得最多的 Bug 是?我的感受是,经常被测试和产品经理要求修改和返工的 Bug。这类的Bug其实属于对需求理解的偏差,我并不将其归纳为真正的技术Bug。

技术性的Bug可以分很多维度,我习惯从Bug出现的”时空”特征角度来分类,可划分如下两类:

  • 空间:环境过敏
  • 时间:周期规律

空间属性(环境过敏)

环境,即运行程序的空间及依赖。

运行程序的环境是很复杂的,而且一般没那么可靠,总是会出现这些哪些的问题。

我曾经部署一个分布式系统,从一开始能感知个别系统的异常逐渐演变成大面积的报警和业务异常。而原因却是个别服务器的磁盘故障,导致写日志阻塞,进程挂起,然后引发调用链路上的连锁雪崩效应。

这就是当时程序编写的时候缺乏对环境问题的考虑,引发故障。人有时候换了环境,会产生一些从生理到心理的过敏反应,程序依然。运行程序发生改变,程序就出现异常的现象,就可以称之为”程序过敏反应“。

过敏的对应之道

对应环境的过敏,自然要了解环境开始。

不同的程序部署和运行的环境千差万别,有的受控有的不受控。比如,服务器运行的环境,一般都是在数据中心(IDC)机房的内网中,相对受控;而客户端运行的环境是在用户的设备上,存在不同品牌、不同操作系统、不同浏览器等等,多种多样,不可控。

环境那么复杂。我们要了解到何种程度呢?至少要关心到程序运行直接关联的那一层环境。这样理解,以后端Java程序为例:Java是运行在JVM中,那么JVM提供的运行时配置和特性就是必须关心的一层环境了。而JVM可能是运行在Linux系统或者Docker这样的虚拟化容器中,那么Linux或者Docker者一层,理论上关心程度就没太多要求了(当然现在Spring为了支持云原生,可以直接编译Java程序为机器码,那么相对就要了解Linux和Docker这一层了)

那么前文案例中的磁盘故障,已经到了硬件的层面,这个环境层次比操作系统还更低一层。虽说故障的根源是磁盘故障,但直接连接程序运行的那一层,其实是日志库依赖的 I/O 特性,这才是我们团队应该关心、但实际却被忽略掉的部分。

同理,现今从互联网到移动互联网时代,几乎所有的程序系统都和网络有关,所以网络环境也必须是你关心的。

最后收集信息页至关重要,不仅仅局限于相关依赖环境的配置和参数,也包含用户输入的数据。真实情况下确实存在:同样的环境只针对个别用户输入才发生异常过敏反应。

  • 直接依赖的第一层环境
  • 磁盘
  • 网络
  • 收集信息

时间属性(周期与非规律)

下面我们在再来看Bug的时间维度特征。Bug有了时间特征,那么就会变成一个概率问题,他会出现如下特征:

周期特点

即一定频率出现Bug的特征

这类Bug因为会周期性地出现,相对还是容易捕获和解决的。此类问题典型特征是资源泄露问题。比如Java程序员不陌生的OutOfMemory错误,就是属于内存泄漏问题,而且一定会周期地出现。

好多年前,我才刚参加工作不久,就碰到一个周期性出现的Bug。但它的特殊之处在于,出现Bug的程序已经稳定运行了十多年,突然某天开始崩溃。而程序的原作者,早已经不知去处。

一开始组内经验老到的高工,认为这是一个意外事件,毕竟这个程序已经稳定的运行了十来之久,我们检查了编译后的二进制文件,拿到了生产的内存dump日志,最终像福尔摩斯一样发现了最终问题,因为程序使用Ehcache做缓存,这个框架允许传入一个对象直接缓存,它的内部类似一个线程安全的HashMap,需要重写HashCode和Equals方法,而新人使用的时候只重写了Equals方法,由于每次网络传输的对象都是新的对象,HashCode都是不一样的,这样长时间就导致Ehcahe爆满导致频繁FullGC最终程序崩溃。

周期性的 Bug,虽然乍一看很难解决的样子,但它总会重复出现,就像可以重新倒带的 “案发现场”,找到真凶也就简单了。

其实主要麻烦的是那种这次出现了,但不知道下次会在什么时候出现的 Bug。

非规律性

没有规律的Bug才是让人抓狂的。

曾经我接手过一个系统,是一个典型的生产者、消费者模型系统。系统接过来就发现一个比较明显的性能瓶颈问题,生产者的数据源来自数据库,生产者按规则提取数据,经过系统产生一系列的转换渲染后发送到多个外部系统。这里的瓶颈就在数据库上,生产能力不足,从而导致消费者饥饿。

问题比较明显,我们先优化 SQL,但效果不佳,遂改造设计实现,在数据库和系统之间增加一个内存缓冲区从而缓解了数据库的负载压力。缓冲区的效果,类似大河之上的堤坝,旱时积水,涝时泄洪。引入缓冲区后,生产者的生产能力得到了有效保障,生产能力高效且稳定。

本以为至此解决了该系统的瓶颈问题,但在生产环境运行了一段时间后,系统表现为速度时快时慢,这时真正的 Bug 才显形了。

这个系统有个特点,就是 I/O 密集型。于是我把目光锁定在了消费者与外部系统的 I/O 通信上。既然锁定了怀疑区域,接下来就该用证据来证明,并给出合理的解释原因了。

解决办法是增加了数据压缩功能,以牺牲 CPU 换取 I/O。增加了压缩功能重新上线后,问题却依然存在,系统性能仍然时不时地急剧降低,而且这个时不时很没有时间规律,但关联上了一个 “嫌疑犯”:它的出现和大报文数据有关,这样复现起来就容易多了。I/O 瓶颈的怀疑被证伪后,只好对程序执行路径增加了大量跟踪调试诊断代码,包含了每个步骤的时间度量。

在完整的程序执行路径中,每个步骤的代码块的执行时间独立求和结果仅有几十毫秒,最高也就在一百毫秒左右,但多线程执行该路径的汇总平均时间达到了 4.5 秒,这比我预期值整整高了两个量级。通过这两个时间度量的巨大差异,我意识到线程执行该代码路径的时间其实并不长,但花在等待 CPU 调度的时间似乎很长。那么是 CPU 达到了瓶颈么?通过观察服务器的 CPU 消耗,平均负载却不高。

只好再次分析代码实现机制,终于在数据转换渲染子程序中找到了一段可疑的代码实现。为了验证疑点,再次做了一下实验测试:用 150k 的线上数据报文作为该程序输入,单线程运行了下,发现耗时居然接近 50 毫秒,我意识到这可能是整个代码路径中最耗时的一个代码片段。

这个子程序来自上上代程序员的遗留代码,包含一些稀奇古怪且复杂的渲染逻辑判断和业务规则,很久没人动过了。仔细分析了其中实现,基本就是大量的文本匹配和替换,还包含一些加密、Hash 操作,这明显是一个 CPU 密集型的函数啊。

分析清楚后,通过简单优化该子程序的算法,从近 50 毫秒降低到 3、4 毫秒后,整个代码路径的线程平均执行时间下降到 100 毫秒左右。收益是明显的,该子程序函数性能得到了 10 倍的提高。

称得上神出鬼没的 Bug :海森堡 Bug(Heisenbug)

这个 Bug 的名字来自量子物理学的 “海森堡不确定性原理”,其认为观测者观测粒子的行为会最终影响观测结果。借用这个效应来指代那些无法进行观测的 Bug,也就是在生产环境下不经意出现,费尽心力却无法重现的 Bug。

关于海森堡 Bug,一方面很少有机会碰到,另一方面随着你编程经验的增加,掌握了很多编码的优化实践方法,也会大大降低撞上海森堡 Bug 的几率。综上所述,每一个 Bug 都是具体的,每一个具体的 Bug 都有具体的解法。但所有 Bug 的解决之道只有两类:事后和事前。

事后,就是指 Bug 出现后容易捕捉现场并定位解决的,比如第一类周期特点的 Bug。但对于没有明显重现规律,甚至神出鬼没的海森堡 Bug,靠抓现场重现的事后方法就比较困难了。针对这类 Bug,更通用和有效的方法就是在事前预防与埋伏。

Bug的反复横跳

其实除了上文说的Bug 除了时间和空间两种属性,还有一个特点是跟程序员直接相关的。在编程的路上,想必也曾犯过一些形态各异、但本质重复的错误,导致一些 Bug 总是以不同的形态反复出现。

其实不妨反思一下:为什么你总是有写出的Bug的程序,并且还会重复出现?

1. 重蹈覆辙

重蹈覆辙的错误,老实说我曾经经历过不只一次。

也许每次的形态可能有差异,但是其本质是类似的。想要写出没有Bug的程序是不可能的,程序员会收到自身水平的局限。而我所经历的重蹈覆辙的错误,总结下来有以下三类

1.1 粗心大意

人人都会粗心大意的错误,因为就是这个”人“的系统本身的固有缺陷之一。所以,作为人的程序员一定会犯一些非常低级、因为粗心大意导致的Bug。

就比如写文章、写blog都会有错别字,都是无法避免的存在。

而程序中也有类似这种错别字类型的低级错误,比如条件 if后面没有大括号导致的语义变化,======的数量差别,++--的位置,甚至; 的有无在某些变成语言中带来的语义差别。

但当我们验证自己的文章时,我们知道想表达的东西是什么。因为我们预期这些含义都存在,所以很容易忽略掉某些感官(视觉)表达上的缺失。我们眼睛看到的,在与我们脑子里的印象交战。这,便是我们对自己的错误视而不见的原因。

写程序时,我们是在进行一项高级的复杂任务:将复杂的需求或产品逻辑翻译为程序逻辑,并且还要补充上程序固有的非业务类控制逻辑。因而,一旦我们完成了程序,再来复审写好的代码,这时我们预期的逻辑含义都预先存在于脑中,同样也就容易忽略掉某些视觉感官表达上的问题。

从进化角度看,粗心写错别字,还看不出来,不是因为我们太笨,而恰恰还是进化上的权衡优化选择。

1.2 认知偏差

认知偏差,是重蹈覆辙类错误的最大来源。

曾经,我就对 Java 类库中的线程 API 产生过认知偏差,导致反复出现问题。Java 自带线程池有三个重要参数:核心线程数(core)、最大线程数(max)和队列长度(queues)。我曾想当然地以为当核心线程数(core)不够了,就会继续创建线程达到最大线程数(max),此时如果还有任务需要处理但已经没有线程了就会放进队列等待。

但实际却不是这样工作的,类库的实现是核心线程(core)满了就会进队列(queues)等待,直到队列也满了再创建新线程直至达到最大线程数(max)的限制。这类认知偏差曾带来线上系统的偶然性异常故障,然后还怎么都找不到原因。因为这进入了我的认知盲区,我以为的和真正的现象之间的差异一度让我困惑不解。

1.3 熵增问题

熵增,是借用了物理热力学的比喻,表达更复杂混乱的现象;程序规模变大,复杂度变高之后,再去修改程序或添加功能就更容易引发未知的 Bug。

我们不断进行架构升级的本质,就在于随着业务和场景功能的增加,去控制住程序系统整体 “熵” 的增加。而复杂且耦合度高(熵很高)的系统,正是容易滋生 Bug 的温床。

2. 吸取教训

为了避免重蹈覆辙,我们有什么办法来吸取曾经犯错的教训么?

2.1 优化方法

粗心大意,可以通过开发规范、代码风格、流程约束,代码评审和工具检查等工程手段来加以避免。甚至相对写错别字,代码更进一步,通过补充单元测试在运行时做一个正确性后验,反过来去发现这类我们视而不见的低级错误。

认知偏差,一般没什么太好的自我发现机制,但可以依赖团队和技术手段来纠偏。每次掉坑里爬出来后的经验教训总结和团队内部分享,另外就是像一些静态代码扫描工具也提供了内置的优化实践,通过它们的提示来发现与你的认知产生碰撞纠偏。

熵增问题,业界不断迭代更新流行的架构模式就是在解决这个问题。比如,微服务架构相对曾经的单体应用架构模式,就是通过增加开发协作,部署测试和运维上的复杂度来换取系统开发的敏捷性。

在协作方式、部署运维等方面付出的代价都可以通过提升自动化水平来降低成本,但只有编程活动是没法自动化的,依赖程序员来完成,而每个程序员对复杂度的驾驭能力是有不同上限的。

而熵增问题若不及时重构优化,最后可能会付出巨大的代价。

2.2 塑造环境

为了修正真正的错误,而不是头痛医头、脚痛医脚,我们需要更深刻地认识问题的本质,再来开出 “处方单”。

我们看过的韩国电影或电视剧中,职场上后辈对前辈、下级对上级的态度,就能感知到韩国文化相比美国所崇尚的自由精神所表现出来的权力距离是特别远的。因而造成了大韩航空未被纠正的人为小错误比例更高,最终的影响是空难率也更高,而空难就是航空界的终极系统故障,而且结果不可挽回。

那就是:需要建立和维护有利于程序员及时暴露并修正错误,挑战权威和主动改善系统的低权力距离文化氛围,这其实就是推崇扁平化管理和 “工程师文化” 的关键所在。

一旦系统出了故障非技术背景的管理者通常喜欢用流程、制度甚至价值观来应对问题,而技术背景的管理者则喜欢从技术本身的角度去解决当下的问题。我觉着两者需要结合,站在更高的维度去考虑问题:规则、流程或评价体系的制定所造成的文化氛围,对于错误是否以及何时被暴露,如何被修正有着决定性的影响。

人固有缺陷,程序固有 Bug;吸取教训避免重蹈覆辙,除了不断提升方法,也要创造环境。


抓虫(bug)系列-概述
https://liu620.github.io/2025/06/27/抓虫(bug)系列-概述/
作者
alen
发布于
2025年6月27日
许可协议