软件调试与问题排查的修炼之路与实战经验

发布时间 2023-08-19 08:51:10作者: 琴水玉

久经沙场,才能练就丰富经验与实战能力。


调试调试,调整与测试。 那些机械工程师通常需要对仪器参数进行设置以便能够更好的观察。

软件调试有种类似的含义, 比如高级工程师会对一些参数进行设置以便达到更好的性能优化。 而在通常意义上,调试通常是指对不合预期的状态进行定位、调整和修复,以使之达到预期的状态。软件调试离不开问题排查。

问题排查是指当程序运行不合预期时,要找到问题出现的原因并修复它。

关于软件调试和问题排查,在学习和工作中积累了一点心得,提出来以供参考。

软件调试技能的修炼之路

初期: 重点内功修炼,训练问题分析能力

建议初学者在初学编程的时候,不要用任何调试器, 只用 print 打印语句即可。 这样做的调试效率可能有点慢, 但可以训练问题分析能力。

暂时不花精力去折腾工具, 而是分析程序状态, 推测是哪里出了问题。在可能的地方放置打印语句,然后运行,验证猜测是否正确, 逐渐排查缩小范围直至找到问题所在。

初中期:敏感捕捉线索(错误与异常)

程序员职业生涯中最不缺的就是错误和异常了。

对于较大规模的应用程序来说,因为使用了很多开源库或框架, 出错时通常会有大量的异常栈,令人不知所措。

此时, 可以查看异常栈中与业务代码相关的异常,因为框架代码通常是不会出错的,多半是业务代码本身的问题,也可能是不正确理解框架 API 导致调用不正确的问题。

如下所示。json 字符串解析出错。检查一下 CdcDetectResult 及 ModuleResult 并没有 cdcDetail 这个字段啊。 猜测可能是因为有 getCdcDetail 这个方法,导致 JSON 解析时误以为这个对象有 cdcDetail 字段。把 getCdcDetail 改成 buildCdcDetail 就 OK 了。这里要考虑库的一些约定俗称的做法(比如 bean 约定)。


中期: 学会使用调试工具

进入真正的软件开发环境,会发现很多人使用工具的效率很高,这让人很眼红。 使用工具提升开发效率可一点错都没有, 大胆使用吧!

对于调试, 首先要学会的是使用断点。 断点很类似于前面所说的打印语句, 只不过打印语句需要插入到程序中,而断点不需要插入多余的打印语句,程序运行到这里会停止。 这时,可以查看各个变量的状态值是否正确。可能会使用单步调试, 遇到可能出问题的函数时需要 SETP INTO, 而不会出问题的函数则最好直接 STEP RETURN。 基本的使用大抵是这些。

对于不熟悉的库,通过调试,可以了解接口具体分派的类是什么,每个对象的值是什么,从而更容易理解代码的实现。


调试信念

无论多么奇怪的现象, 背后总有合理的解释。需要耐心去一步步排查,分析和对比数据,发现其中的异常之处,最终找到原因。

可阅:奇怪之事总有缘由:订单状态对比不一致问题排查

热部署

很多事情需要预先做好热部署。所谓“热部署”是指做程序的修改能够很快得到反馈,而不需要重新编译和部署整个项目。

比如像 Flex 这种技术。 我初接触的一个项目,flex, java, spring 等集成, 由于偷懒而不去配置热部署,结果哪怕改一个小地方都要重新编译所有模块,虽然后来写了个自动部署脚本使得可以并发做点其他事情,但 Flex 调试的低效率耗费了不少时间。

相比而言,HTML 和 JavaScript 写的项目,只要改下,就能很快在浏览器刷新看到结果,不需要额外编译和部署。

热部署可以大幅度提升调试的效率。

详细的日志输出

详细的日志输出也是软件调试中必不可少的有力工具。 可以分为四级:

(1)错误日志: 记录程序运行出错的情况; 必不可少的。

(2)信息日志: 记录一些重要操作的情况,比如导致状态变更的操作, API 调用返回信息等, 做到有据可查; 也是很重要的。

(3)SQL日志: 记录执行的SQL语句, 方便日后查询

(4)DEBUG 日志: 通常不会用到, 在极少数情况下可能会找到一些有价值的线索。 可有可无。如果影响程序性能,可以去掉。

日志错误输出不仅要有技术性的栈信息,最好有包含业务方面的信息。在排查问题时, 不仅知道是哪个方法出错了, 而且知道是针对哪个具体的业务和所涉及的业务对象出错了,方便对比和排查问题。此外,日志中的时间信息常常会成为疑难问题排查(尤其是高并发导致的问题)的重要线索。

如有可能,从数据库入手

通常日志不能打太多,容易把磁盘打满。更多线索要从数据上找,看看涉及的每张表的数据构造是否正常,尤其是状态是否正常。

如果有访问数据库的权限, 可以从数据入手, 查看数据库中的数据是否是正确的。这样可以排除数据方面导致的问题,确定问题出在程序上。

如果数据有问题,那么要先追查导致数据不正确的原因,再定位程序问题。

单步调试

对于那些隐藏较深的问题, 单步调试的结果会让人大吃一惊: 会是想不到的原因导致的。

比如,我在调试一个调用API 的代码时, 之前都工作的好好的, 但做了些修改后,报 NullPointException 异常。 仔细阅读程序, 发现并没有明显的逻辑错误。 最后只好采用单步调试, 很快就找到了问题所在: 因为一个空字符的问题导致的。

另外一个例子是,向数据库插入数据,要先验证在数据库中已经存在。总是报“不存在”错误。仔细阅读代码,以及在数据库中直接执行SQL 都表明是存在的。百思不得其解, 后来采用单步调试,发现在应用程序通过 Spring 自动注入的 service 访问数据库时并没有查询到相应数据。

几乎没有什么问题能够逃过单步调试的“法眼”。

对比实验与分析

有时候, 同一个业务,有的报错, 有的不报错。同一种对象里的属性,有的正常,有的不正常。此时,可以做对比分析, 仔细对比数据,分析差异,从差异上找到线索,进而确定原因。

比如,排查数据库连接被 proxool 关闭的情况。经过详细的日志输出,发现所有的集群中, 集群 ABC值得关注,AB报错,而C 不报错。 A,B,C 都是集群中数据库表的记录数最多的,百万级。后来的实验表明: 执行同一个SQL, A,B需要10分钟左右,而 C 只要 9s 左右。 进一步咨询 DBA, 了解到 A,B 由于内存大小受限而导致查询速度慢, 数据库连接很可能是因为查询超时而被关闭。

比如 mongo 某个业务对象里有一个嵌套对象 detectResult,这个嵌套对象有 pid, className。 发现可以正常获取 pid 的值,而不能获取 className 的值。通过单步调试,发现 mongo 把 className 解析成了 class.name 含有系统关键字,从而报错。这种就需要通过单步调试和对比分析来排查。从代码上看很难想象会把 className 解析成 class.name。

有时,想不到是什么原因导致,可以做对比实验。改变一个地方,看看输出是什么,再做对比分析。

特定开发领域的调试

不同开发领域的调试是有一些差异的。比如前端与后台, PC、服务器和移动设备编程,系统编程与应用编程。 比如浏览器前端调试,需要开控制台,看有无报错,输出数据是什么,试着执行一下看看值是什么。手机网页调试,需要用某种工具(比如 Charles)进行抓包分析。

需要针对自己所在开发领域去专门学习一些特定的调试技巧。

反思,找出弱项

如果有些 BUG 很难找出问题之源, 最好记录下来, 过一段时间来回顾一下, 究竟是哪些类型的问题对你来说很难发现和解决? 这类BUG 有无特别的方法来排查 ?

对于我来说,涉及到框架交互的问题是比较棘手的,必须深入框架源码去查找问题。

框架交互的问题

涉及框架交互的问题往往比较棘手, 初步的方法是: 源码 + 关键断点 + 单步调试 + 大胆猜测和定位 + 验证 + 耐心。

可阅:多数据源的动态配置与加载使用兼框架交互的问题调试

做到前面这些, 基本上大部分业务问题都可以很快找到源头。

高效问题排查的实战经验

程序员经常要排查问题,找到问题所在原因。问题排查可以说是程序员日常工作中的家常便饭。

那么,如何才能高效排查问题的原因呢?

最有效的思路:二分查找,缩小范围

学过编程的人都知道二分查找,在一个有序数组里,要找到某个元素,可以总是从中间开始查起。如果比中间元素大,则继续往后查找;如果比中间元素小,则继续往前查找。直到找到元素,或者找不到元素。

这和侦探断案也有相似之处。侦探不会满地里乱找一气,而是会敏感地去发现线索,从线索上去缩小范围。本质上,都是要想办法快速缩小范围,就能够更高效查找。

比如一个恶意文件行为的检测过程,先要上传文件,再进行检测。如果没有生成告警,那么就可以从“文件上传”这个节点开始查起。如果文件上传成功,则往后找原因;如果文件上传失败,则往前找原因。

最有用的技巧: 打关键日志

前面谈到缩小范围,那么如何缩小范围呢?要找到关键路径和关键节点,在这里打日志。不要吝啬日志。我看到有的人为了减少日志量,几乎满屏代码都不怎么打日志。也许这种方便他自己排查,但别人来排查的话通常是一脸懵逼。

很多时候,不打日志,排查一个很简单的问题都会很耗时(以小时计);你猜测很可能原因是它,但无法确定,因为没有证据。尤其是客户跟你较真的时候,拿不出证据真的很尴尬。有时候,少打一行关键日志,可以让人白了头。

打关键日志,一定要注意把重要业务标识及状态、业务属性变化( 业务ID, uniqueKey, sha256, status 等)打出来,串联流程。很多人对打日志敷衍了事,打出的日志缺乏业务标识,几乎没什么用,只是用来占屏幕和填磁盘。如何打好日志,可阅: 如何使错误日志更加方便排查问题

我个人的箴言是: 打一行日志很廉价,但你的时间价值千金。

最隐藏的能力:直觉力

排查问题不仅要阅读和熟悉代码逻辑、追踪代码路径和走向, 还要依赖很多排查经验。经验就是遇到过的事情,你知道有哪些可能的原因,一个个去排除就可以了。

但有时,在一个比较复杂的语境中,几乎无从着手,需要一种直觉力。这种直觉力是经过大量问题排查的经验修炼得到的。比起一个个去费力排除可能的原因,直觉力会在第一时间告诉你,在众多可能中,最有可能的地方。

有时,绞尽脑汁百思不得其解,某个时候突然灵光一闪,啊哈,可能问题在这里!直觉力好像很难解释。可能是大脑在你做其它的事情时默默把脑回路接通了。

最可靠的技能:逻辑推导

程序里几乎全是逻辑。能够训练有素地根据代码逻辑,结合日志、监控等线索,进行严密地推导,一步步缩小范围,排除可能性,最终找到真正的原因。

大部分问题都扛不住这种训练有素的逻辑推导。

疑难问题排查

有些问题排查起来很费时或者很困难。 这些问题通常集中在:

  • 小概率,难以复现。这种通常需要大量的数据、涉及多个路径才能触发。从代码上很难看出来。比如乱序消息同步的不一致性问题。
  • 多节点并发访问共享资源没有同步互斥措施。比如多个线程同时去读写一条记录的状态并根据该状态决定下一步动作。很有可能出现读到旧状态或者不确定性覆写状态的问题。比如一段逻辑,先查找任务是否存在,如果不存在则创建任务。当多个线程同时访问时,可能会出现任务重复创建的情况。
  • 极限情况。A 发生的时间与 B 发生的时间极其接近(通常在毫秒内),导致无法按照预期正常逻辑运行。比如有一段逻辑,先去查询文件脱壳结果。如果存在脱壳结果,则用脱壳文件去做检测,如果不存在脱壳结果,则用源文件去做检测。当两个线程在同一时间都抵达这个地方时,可能会出现:一个线程因为没有查到脱壳结果而走源文件检测,生成了白样本结果;而另一个线程因为正好某个线程做完脱壳插入了脱壳结果,查询到脱壳结果,走脱壳文件检测,从而生成了告警。前一个线程的脱壳结果查询时间与脱壳结果插入时间极其接近导致没有查到脱壳结果。差距只在毫秒之间。
  • 时间差。 比如 告警生成时间在 t1 时刻, 异步的文件上传在 t2 时刻。t2 晚于 t1,导致查看告警详情时无法下载文件。
  • 浅拷贝重复问题。比如多个告警,很多字段都相同,只有少量字段不同。使用浅拷贝方式,很容易导致后面生成的告警覆盖前面生成的告警信息从而生成重复告警。
  • 环境问题。程序员排查问题往往更容易关注和局限于代码层面,而忽视环境的影响。有时,问题正出现在环境上。可阅: 记一个奇怪的数据库记录重复插入的问题排查过程
  • 被遗忘的逻辑。在独角兽公司或大厂中,你所面对的往往是很多系统之间的协作。这些系统有一些定时任务,当人们在花费大量精力在新系统上时,往往会遗忘老系统里有一些服务了很长时间的老定时任务还在勤勤恳恳效力着,而这些效力有时会起负作用。可阅: “被遗忘”的杀手
  • 临界问题。有时问题发生在边界处。边界处也是很容易触发问题而难以引起注意的地方。可阅: InnerJoin分页导致的数据重复问题排查
  • 遗留数据。有时,可能是之前遗留的数据造成的问题。这时候,需要往前查,看看之前发生了什么。