http://testerhome.com/topics/577

原文请见 Minimizing Unreproducible Bugs

不能重现的 bug 是我的灾难。我常常找到一个bug 后来又听说这不是一个 bug,因为它无法重现。但是这个 bug 仍旧在那里,等着捕食下一个受害者。这些类型的 bug 非常昂贵,因为我们需要花大量的时间去调查。它们也会对产品体验造成破坏性的影响,特别是用户发现并报告了这些被忽略的 bug。所以为了防止这类问题,我们需要做更多。在这篇文章里,我将探讨一些明显或者不是那么明显的开发或者测试的准则,这些准则能多少减少些这些 bug 发生的可能性。

如何避免或者测试竞争,死锁,内存崩溃,时间问题,访问未初始化内存,内存泄露,资源问题

在这一节里我会将许多类型的 bug 混在一起说。这些 bug 在我们如何测试它们和它们难以重现和调试这点上是相关的。
其根源及其影响可以是微秒级别,也可能持续数个小时。它们的堆栈信息可能不存在,也可能会误导人。当遇到不正常的流量高峰或者资源不够的情况下,系统可能会出现怪异的运行故障。单一的访问模式或者资源配置会导致多线/进程竞争或者死锁。当很多组件集成一起,而各组件的性能参数差异和失败/重启/超时时间延迟会让系统一团糟,这时候就会出现时间同步问题。我们在大量的函数调用里可能不会注意到内存崩溃或者访问未初始化内存的问题,但是在一些边缘用例下就很致命。一般只有在负载或者长时间运行,内存泄露才会被发现。

开发准则:

  • 简化你的同步逻辑。如果你的逻辑理解起来非常困难,那么要重现和调试复杂的并发问题也变得非常困难。
  • 使用相同的顺序取锁,来避免死锁。这是一个实践出真知的准则。但是我依然能偶尔看到不遵守这个准则的代码。定义一个顺序去拿锁,并保持这个顺序。
  • 不要通过创建多个细粒度锁来优化,除非你确信你需要这么做。额外的锁只会增加并发复杂性。
  • 避免共享内存,除非你真的需要它。访问共享内存很容易出错。但是这类 bug 却很难重现。

测试准则:

  • 有规律地对你的系统进行压力测试。不要惊讶,你的系统在高压下肯定会有出乎意料的故障。
  • 超时测试。模拟或者伪造依赖来测试超时的代码。如果你的超时代码有问题,在某种系统情况下,它可能会导致 bug。
  • 要对调试的版本和优化过的版本都进行测试。你可能会发现一个表现很好的调试版本,工作的没有任何问题,但是一旦经过优化,就出现奇怪的系统故障。
  • 在资源受限的情况下测试。试着减少数据中心,机器,进程,线程的数量,减少硬盘空间或者内存。也试试看模拟差的网络环境。
  • 耐久性测试。有些 bug 需要长时间运行才能暴露出来。比如,持久化数据迟早会崩溃。
  • 使用动态分析工具,比如内存调试器,ASan,TSan 和 MSan。他们可以帮助定位很多类型的无法重现的内存/线程问题。

强制实施先决条件

我见过很多有着高容错性的善意函数。比如,看下面的这个函数:

void ScheduleEvent(int timeDurationMilliseconds) {
if (timeDurationMilliseconds <= 0) {
timeDurationMilliseconds = 1;
}
...
}

当输入 timeDurationMilliseconds 不合理时,函数可以调整输入为可接受的值,但是它也可能掩盖了一个 bug。调用代码可能会遇到本文中描述的任一问题,而且即使传递垃圾数据给这个函数也能正常工作(只要不小于等于0)。这种带有容错的函数越多,想要找到错误根源就越难,而且很有可能,最终用户也会看见这些垃圾信息。强制实施先决条件,比如用断言,对于新系统而言,的确可能会导致很多的故障失败。但是随着系统的成熟,可以及早发现很多小的大的问题。这些检查可以帮助提高系统的长期可靠性。

开发准则:

在你的函数里强制实施先决条件,除非你有更好的理由不去做。

使用防御性编程

防御性编程是另一个靠得住的技术,它能够有效的减少无法重现的 bug。如果你的代码使用依赖去做一些事情,但是依赖的代码执行失败却没有抛出错误或者返回了垃圾,你的代码该如何处理?你可以通过模拟或者伪造来测试这种情景。但是或许你在代码对它的依赖做一些健全检查会更好。比如:

double GetMonthlyLoanPayment() {
double rate = GetTodaysInterestRateFromExternalSystem();
if (rate < 0.001 || rate > 0.5) {
throw BadInterestRate(rate);
}
...

开发准则:

尽可能的使用防御性编程,验证你所依赖部分的工作,尤其是会造成故障的已知风险,比如用户提供的数据,I/O操作和 RPC 调用。

测试准则:

使用 fuzz testing 来测试系统容错的健壮性。

不要从用户角度处理所有的错误问题

近年来有一种趋势,不惜任何代价不然用户看到故障。这在很多案例中,很有意义。但是在一些案例中,我们过头了。
如果用户遇到小故障,但是代码没有抛出异常或者直接放过了,那么无知的用户会在一个失败的状态下继续工作。到最后,软件总会到一个致命的点,所有造成这个致命故障的原因都被忽略了。如果用户不了解先前的错误,他们就不能报告这些错误,你也没办法重现它们。

开发准则:

  • 只有当你确定不会对系统状态或者对用户产生影响的时候,对用户隐藏错误。
  • 任何对用户有影响的错误都应该报告给用户,并告诉用户如何处理。向用户显示的信息,连同向工程师展示的数据,应该足够判断到底哪里出错了。

测试出错处理

错误处理的代码是最常不会被测试的部分。测试覆盖不应该略过这里的。如果不能非常好地处理致命错误,糟糕的错误处理代码会造成不能重现的 bug 并带来风险。

测试准则:

  • 测试你的错误处理代码。最好的方法是模拟或者伪造能触发错误的组件。
  • 通过日志检查所有类型的错误处理。

检查重复的键

如果唯一标识或者访问数据的键是通过随机生成的,不能保证全局唯一性的话,重复的键会导致数据损坏或者并发问题。这种问题非常难重现。

开发准则:

  • 尽力保证所有键的唯一性。
  • 如果不能保证的话,在使用前先检查键是否试用过。
  • 小心潜在的竞争,避免它们同步。

测试并发数据访问

有些 bug 只会在多个客户端读写同一块数据时候发生。一般压力测试会覆盖这些用例,但是如果没有的话,你就需要特别设计一些并发数据访问的用例。这种情况下发生的 bug 常常是无法重现的。比如,一个用户可能有两个应用实例运行在同一个账户上,它们可能没有注意到这点,直到错误发生。

测试准则:

  • 如果并发数据访问是系统的特性,那一定要测试它。事实上,就算不是系统的特性,也需要验证系统是否存在并发访问数据问题。测试并发非常有挑战。我常用的方法是创建许多工作者线程同时尝试访问,主线程则监控和验证哪些尝试是真正并发的,像预期那样阻塞或者被允许的,或者全部成功了。为了确保系统工作正常,代码层面上,对所有尝试和改变系统状态行为的后续分析也是必须的。

绕开不明确的行为和不明确的数据访问

当在某些状态下或者某些输入下,一些 API 和基本操作会对未定义的行为报警。同样,一些数据结构没有办法保证迭代顺序(比如 JAVA 的 Set)。在代码里忽略这些警告大多数时候能很好的工作。但是一旦失败,就很难重现。

开发准则:

你要了解你所使用的 API 和操作可能有未定义的行为,需要预防这些情况。不要过多依赖数据结构的迭代顺序,除非你能保证这个顺序。依赖集合和关联数组的顺序是比较普遍的错误。

记录错误日志故障日志的细节

如果日志包含足够的出错细节,那么本文描述的问题就可以很容易地重现和调试。

开发准则:

  • 遵从良好的日志实践,特别在出错处理代码里。
  • 如果日志保存在用户的机器,提供一个便捷的方法,让用户提供给你日志文件。

测试准则:

保存你的日志以便后续分析。

还有什么要添加?

我有遗漏什么重要的,可以减少这些 bug 的准则吗? 你发现和解决的难以重现的 bug 是什么?

04-25 08:40