第 2 章 案例研究:让航空公司停飞的代码异常

第 2 章 案例研究:让航空公司停飞的代码异常

如果留心就能发现,生活中有很多“千里之堤,毁于蚁穴”的例子。一个不起眼的编程差错,很快就能变成“滚下山坡的雪球”。随着它越滚越大,问题也越来越严重。一家大型航空公司就经历过这样的事件,最终让数千名乘客滞留机场,并使公司损失数十万美元。事件是如何发生的呢?

案例中的所有名称、地点和日期都已做了更改,以保护相关员工和公司的隐私。

事件始于针对数据库集群的一次计划内的故障切换。该数据库集群为CF1系统提供服务。为了提高重用性,缩短开发时间并降低运维成本,这家航空公司正朝着SOA2的方向迈进。当时的CF尚处于第一代。团队计划以产品特性为导向,分阶段地发布CF系统。这是一个合理的计划,可能听起来很熟悉——大多数大公司的项目与此有异曲同工之处。

1core facility,核心设施。——译者注

2service-oriented architecture,面向服务的架构。——译者注

CF系统会处理航班搜索的请求,这是任何航空公司的应用程序都会提供的通用服务。如果给定日期、时间、城市、机场代码、航班号或上述各项的任意组合,CF系统就可以搜索然后为用户提供航班详细信息列表。当事件发生时,自助登机系统、电话菜单和“渠道合作伙伴”应用程序都为使用CF系统做了升级。“渠道合作伙伴”应用程序,能为大型机票预订网站生成并提供数据。IVR3系统和自助登机系统提供机票选座服务,帮助乘客登机。为了使用CF系统查询航班信息,该公司已在开发进度表中对新版登机检票和呼叫中心应用程序做出安排,但在事件发生时新版软件尚未发布。下面就会看到,这其实是一件好事。

3interactive voice response,互动式语音应答。——译者注

CF系统的架构师非常清楚该系统对业务的重要性,因此他们按照高可用性的标准来构建该系统。该系统在J2EE应用服务器集群上运行,配备Oracle 9i数据库冗余。所有的数据都存储在一个大型的外部RAID4中,每天在磁带和第二个硬盘底座的磁盘副本上进行两次脱机备份,确保系统最多丢失5分钟的数据。这一切都在硬件上运行,硬件只配备CPU、飞速运转的磁盘以及操作系统,没有任何虚拟化技术。

4redundant array of independent disks,独立冗余磁盘阵列。——译者注

Oracle数据库服务器每次会在集群中的一个节点上运行。Veritas Cluster Server会控制数据库服务器,分配虚拟IP地址,并从RAID中挂载或卸载文件系统。在系统的前端,一对冗余的硬件负载均衡器会将传入的流量导入其中一台应用程序服务器。客户端应用程序,如自助登机系统和IVR系统的服务器,会访问前端虚拟IP地址。截至事发,一切良好。

图2-1可能看起来很熟悉,因为这是常用于物理基础设施的高可用性架构。这是一个很好的架构,因为CF系统不会遇到任何常见的单点失效问题。硬件的每一部分都有冗余,如CPU、驱动器、网卡、电源、网络交换机,甚至风扇。为了防止某个机架受到损坏或破坏,服务器甚至被分散安装到不同的机架上。事实上,如果发生火灾、洪水、炸弹袭击或者哥斯拉怪兽袭击,位于48千米外的第2个机房可以随时把系统接管过去。

图 2-1 高可用性架构示例

2.1 进行变更

与我大多数大客户的情况一样,这家航空公司的基础设施由当地的专属团队负责运维。实际上,当事件发生时,该团队已经负责CF系统的运维工作已有3年之久。在出事的那个晚上,当地的工程师已经手动执行了从CF数据库1到CF数据库2的故障切换(请见图2-1)。他们使用Veritas Cluster Server将活动数据库从一台主机迁移到了另一台主机,从而对第一台主机进行一些日常维护工作。这完全是例行维护,他们过去已经做过很多次这个工作了。

要先说明一下,在事件发生的那个年代,“计划内的停机”很正常,现在则不被接受。

Veritas Cluster Server正在处理故障切换。在一分钟内,它会关闭CF数据库1所在的Oracle服务器,卸载RAID上的文件系统并将其重新挂载到CF数据库2,在那里启动Oracle,然后将虚拟IP地址重新分配给CF数据库2。因为在配置时仅让服务器连接虚拟IP地址,所以图2-1中的那些应用程序服务器甚至都不会觉察到这些变更。

客户把这次特殊的变更安排在星期四晚上11点左右(太平洋标准时间)。一位来自当地团队的工程师与运维中心的工作人员合作执行了这次变更。一切都按计划完成。他们将活动数据库从CF数据库1改为CF数据库2,然后更新了CF数据库1。双方检查并确认CF数据库1更新正确之后,他们将活动数据库改回CF数据库1,并在CF数据库2上执行了相同的变更。在整个过程中,现场例行监控显示,那些应用程序一直处于可用状态。这次变更没有安排计划停机时间,整个过程也确实没有发生停机。到了凌晨0:30左右,工程师将变更标记为“已完成,成功”并签字。在工作了22小时后,当地的工程师倒头便睡。毕竟,要不是有双份意式浓缩咖啡顶着,谁也不能坚持这么长时间。

接下来的两个小时,一切正常。之后,异常发生了。

2.2 遭遇停机

凌晨2:30左右,自助登机系统在监控台中发出了红色故障警报。分布在全美各地的每一个自助登机终端,都在同一时间停止了服务。几分钟后,IVR服务器也变成了红色。故障发生的这个时间点着实令人抓狂,因为太平洋标准时间凌晨2:30,就是美国东部时间凌晨5:30,正是东海岸的乘客登上通勤航班的高峰时间。运维中心立即发出了严重级别高达1级的故障报告,并把当地团队拉到电话会议上。

对于任何事故,我认为首要的任务都是恢复服务。要先恢复服务,之后才是调查原因,最好能够在不延长停机时间的情况下收集一些数据,以撰写事后分析报告5。当团队内部意见产生分歧时,不能意气用事。幸运的是,团队很早以前就创建了一些脚本,对所有Java应用程序进行线程转储,保存数据库的快照。这种自动数据收集方式就是一种完美的补救措施。它既不是临时拼凑的,也不会延长停机时间,而且还有助于事后分析。根据流程,运维中心立即运行了这些脚本,还尝试重新启动其中一台应用程序服务器。

5即事故根因分析和复盘报告。——译者注

恢复服务的诀窍是弄清楚“病根”是什么。当然可以通过重新逐层启动每台服务器这种方法来“重启一切”。这几乎总是有效的,但需要漫长的时间。大多数情况下可以找到造成程序死锁的原因。在某种程度上,这就像是医生诊断疾病。可以逐一使用目前掌握的所有疾病的治疗方法来医治病人,但这个过程令病人过于痛苦,而且治疗费昂贵、进展缓慢。相反,应该看看患者所表现的症状,确定到底要治疗哪种疾病。麻烦的是,那些孤立的症状都不够具体。当然,偶尔有一些症状会直接帮助医生发现基本问题,但通常情况下都不会这么走运。大多数时候,像发烧那样的症状,其本身并不能说明病根是什么。

数百种疾病都能引起发烧。为了判断可能的病因,需要进行化验或观察,了解更多信息。

此时团队所面临的情况是,两套彼此分离的应用程序几乎在同一时间完全停止响应,而较小的时间差仅仅是由于自助登机系统和IVR应用程序使用不同的监测工具。最可能的原因是,这两套应用程序都依赖于某个出现问题的第三方系统。正如展示依赖关系的图2-2所示,问题的焦点就是CF系统。它是自助登机系统和IVR系统唯一共同依赖的系统。CF系统在问题发生前3小时进行了数据库故障切换,这让它变得很可疑。不过,监控系统并没有报告CF系统发生了任何问题,抓取的日志文件也没有显示任何问题,URL探测也显示一切正常。其实,监控应用程序所做的只是显示了一个状态页面,它并没有真正说明CF应用程序服务器的真实健康状况。暂且把此事记下来,稍后会通过正常渠道修复这个问题。

图 2-2 系统依赖关系示意图

请记住,恢复服务是当务之急。本例中的停机时间已接近SLA6的一小时上限,因此团队决定重启每台CF应用程序服务器。在重启第一台之后,IVR系统的服务就开始恢复了。当所有的CF应用程序服务器都重启之后,IVR系统变为绿色,但自助登机系统仍然是红色。凭直觉,主任工程师决定重启自助登机系统自己的应用程序服务器。果然,自助登机系统和IVR系统在显示面板上全部变为绿色。

6service level agreement,服务等级协定。——译者注

整个事故持续了约3小时。

2.3 严重后果

较之于史上的一些严重停机事故,例如2017年6月由于电源系统失效而导致的英国航空公司计算机系统全球性停机事故,3小时听起来可能并不太长。但是,航空公司处理善后事宜的时间要远远超过3小时。当在故障尚未排除而不得不使用旧系统时,航空公司没有足够的登机口检票员让乘客登机。当自助登机系统停机时,航空公司不得不把已经下班的检票员请回来。他们当中一些人在那个星期已经工作了40多个小时,是工会合同中规定加班时长的1.5倍。尽管如此,那些请回来的已下班的检票员也只是人而不是机器。当航空公司把更多的人手请回工作岗位后,他们只能处理以前积压的登机手续。这些积压工作直到当天下午3点左右才处理完。

这次事故严重延长了早班航班的登机时间,而飞机必须等所有乘客检票登机才可起飞,因此不能推出登机口。这令许多当天出发和到达的乘客晚点了。星期四恰好有很多往返于高科技城市的航班,这些通勤航班把研究员们送回所在城市。因为登机口仍然被早班航班占着,所以这些陆续抵港的航班不得不停降到其他空置的登机口。因此,即使已经办了登机手续,乘客也仍然不能安生,他们不得不从原来的登机口狂奔到重新分配的登机口。

航班延误的消息很快上了“早安美国”节目和气象频道的旅游栏目,“早安美国”还播出了滞留在机场的无奈的单亲妈妈与她们婴儿的视频。

美国联邦航空管理局会度量航班抵港和离港的准时程度,并写入航空公司年度报告卡。他们还记录收到的顾客对相关航班的投诉。

航空公司首席执行官的薪酬标准会部分参考联邦航空管理局的年度报告卡。

当看到航空公司首席执行官怒气冲冲地绕着运维中心踱着步子,寻思着究竟是哪个家伙让他在圣托马斯家中度假的计划泡汤的样子,就会知道那一天一定很难熬。

2.4 事后分析

太平洋标准时间上午10:30,也就是停机事故发生后的第8个小时,客户代表汤姆(当然不是他的真名)打电话请我去做事后分析。由于在数据库故障切换和维护之后,系统很快就失效了,因此疑点自然聚焦在故障切换操作上。在运维圈子里常被说起的“post hoc, ergo propter hoc”这句拉丁语,就表示了“谁最后动的就赖谁”。虽然这句话并不总是对的,但它确实提供了一个很好的着手点。实际上,汤姆想让我解释为什么数据库故障切换会导致服务中断。

在飞往事故发生地的飞机上,我开始在笔记本计算机上查看故障单和初步事故报告。

接下来的议程很简单,就是进行事后分析调查,并找到下面这些问题的答案。

  • 停机事故是否由数据库故障切换所导致?如果不是,那是由什么导致的?
  • 集群配置是否正确?
  • 运维团队是否正确地进行了维护?
  • 在发展到停机事故之前,系统失效是如何被一点点检测到的?
  • 最重要的是,如何确保这次停机事故永远不会再发生?

当然,我这次亲赴现场也是向客户表明,我们非常重视这一停机事故。另外,我的调查也有助于缓解所有人们对当地团队粉饰事件的担忧。当然,团队是不会做这种事的。但是在重大事件发生后,管理人们的感知与管理这个事件本身同样重要。

事后分析犹如破解谋杀案,手上都会有不少线索。一些是可靠的,如在停机事故发生时复制下来的服务器日志;有些则是不可靠的,如人们对所见之事的陈述。与真实的证人一样,人们会将观察与猜测混在一起。他们会把假设当成事实。相比破解谋杀案,软件事故的事后分析实际上更难完成,因为事件结束了,没有留下可供分析的实物——服务器已备份并继续运行。其中任何引发系统失效的状态都已不复存在。系统失效可能会在那时所采集的日志文件或监控数据中留下痕迹,也可能不会。线索很难看得分明。

我一边阅读材料,一边记录将要收集的数据。从应用程序服务器中,需要收集日志文件、线程转储和配置文件。在数据库服务器上,需要收集数据库和集群服务器的配置文件。我还制作了一个便签,对比现在的配置文件与事故之前每晚例行备份的配置文件。停机前的备份数据可以显示当时的配置与现在配置的差异。换句话说,这会验证是否有人试图掩盖所犯的错误。

当我到达酒店时,已经是后半夜了。此时满脑子只想冲个澡睡个觉,可偏偏要参加一个与客户经理的会议,并了解在我乘飞机过来这段时间里事态的发展。这一天终于在凌晨1点左右结束了。

2.5 寻找线索

次日上午,在一杯杯咖啡的支撑下,我开始深入探究数据库集群和RAID的配置信息。首先要寻找集群配置的常见问题:没有足够的心跳7,心跳数据和生产数据由相同的交换机传输,服务器设置为使用物理IP地址而不是虚拟IP地址,设计的软件包之间混乱的依赖关系,等等。当时,我并没有拿着一张清单逐项检查。上面那些都只是我以前多次见过,或者通过和人聊天打听到的问题。配置信息没有发现纰漏。工程团队在数据库集群方面做得很好,都遵循了验证过的和教科书上的实践。实际上,一些脚本看起来直接照搬了Veritas的内部培训资料。

7指的是定时发送的信号,用以表明系统运行正常。——编者注

接下来,该查看应用程序服务器的配置了。在停机事故期间,当地的工程师已经从自助登机系统应用程序服务器复制了所有日志文件。我也获得了CF应用程序服务器的日志文件。因为停机事故也就是前两天的事情,所以他们仍然能找到日志文件。更好的是,两组日志文件中都带有线程转储文件。作为一名老Java程序员,我很喜欢能方便调试应用程序停止响应问题的Java线程转储机制。

如果知道如何阅读Java的线程转储文件,那么在它的帮助下,应用程序就活像一本打开的书。可以凭借它来推断出应用程序的以下方面,哪怕从未阅读过其源代码。

  • 应用程序使用了哪些第三方库?
  • 应用程序拥有哪些种类的线程池?
  • 每个线程池中有多少个线程?
  • 应用程序使用了什么后台处理系统?
  • 应用程序使用了什么协议(通过在每个线程的栈跟踪信息中查看类和方法)?

获取线程转储

当在UNIX系统中发送信号SIGQUIT或在Windows系统中按Ctrl+Break键时,任何Java应用程序都会转储JVM8中每个线程的状态。

要在Windows中获取线程转储,就必须在命令提示窗口里运行Java应用程序。很明显,如果使用远程登录的方式,就需要在VNC或远程桌面上进行操作。

在UNIX中,如果JVM直接在tmux或屏幕会话中运行,则可以键入Ctrl-\。在大多数情况下,该进程将从终端会话中分离出来。此时就可以使用kill命令来发送信号:

kill -3 18835

在控制台触发线程转储有一个问题:它们总是以“标准输出”的形式出现。许多现有的启动脚本不会捕获标准输出,或者将其发送到/dev/null。使用Log4j或java.util.logging所生成的日志文件并不能用于显示线程转储。所以可能需要试一试应用程序服务器的启动脚本,看看能否获取线程转储。

如果能直接连接到JVM,就可以使用jcmd来将JVM的线程转储到终端应用程序:

jcmd 18835 Thread.print

如果能这样做,那么可以将jconsole指向JVM,并在图形用户界面中浏览那些线程!

这是线程转储的一小部分内容。

"http-0.0.0.0-8080-Processor25" daemon prio=1 tid=0x08a593f0 \
 nid=0x57ac runnable [a88f1000..a88f1ccc]
 at java.net.PlainSocketImpl.socketAccept(Native Method)
 at java.net.PlainSocketImpl.accept(PlainSocketImpl.java:353)
 - locked <0xac5d3640> (a java.net.PlainSocketImpl)
 at java.net.ServerSocket.implAccept(ServerSocket.java:448)
 at java.net.ServerSocket.accept(ServerSocket.java:419)
 at org.apache.tomcat.util.net.DefaultServerSocketFactory.\
acceptSocket(DefaultServerSocketFactory.java:60)
 at org.apache.tomcat.util.net.PoolTcpEndpoint.\
acceptSocket(PoolTcpEndpoint.java:368)
 at org.apache.tomcat.util.net.TcpWorkerThread.runIt(PoolTcpEndpoint.java:549)
 at org.apache.tomcat.util.threads.ThreadPool$ControlRunnable.\
run(ThreadPool.java:683)
 at java.lang.Thread.run(Thread.java:534)

"http-0.0.0.0-8080-Processor24" daemon prio=1 tid=0x08a57c30 \
nid=0x57ab in Object.wait() [a8972000..a8972ccc]
 at java.lang.Object.wait(Native Method)
- waiting on <0xacede700> (a \
org.apache.tomcat.util.threads.ThreadPool$ControlRunnable)
 at java.lang.Object.wait(Object.java:429)
 at org.apache.tomcat.util.threads.ThreadPool$ControlRunnable.\
run(ThreadPool.java:655)
- locked <0xacede700> (a org.apache.tomcat.util.threads.ThreadPool$ControlRunnable)
 at java.lang.Thread.run(Thread.java:534)

线程转储确实很冗长。

这个片段显示了两个线程,每个线程都被命名为http-0.0.0.0-8080-ProcessorN。其中编号为25的线程处于可运行状态,而编号为24的线程被阻塞在Object.wait()方法上。此栈跟踪清楚地表明这两个线程都来自同一个线程池。栈中的名为ThreadPool$ControlRunnable()的一些类也可能是一个线索。

8Java virtual machine,Java虚拟机。——译者注

没用多长时间,我就确定问题必定出在CF系统内。在对事故期间观察到的异常行为做出推断后,我发现自助登机系统应用程序服务器的线程转储显示的信息正好与我预期看到的相吻合。分配用于处理所有来自自助登机系统请求的那40个线程,被齐刷刷地阻塞在SocketInputStream.socketRead0()方法上。这是Java的套接字库的内部实现中的一个原生方法,它们当时正在试图读取永远不会到来的响应。

自助登机系统应用程序服务器的线程转储,也正好显示了那40个线程所调用的类和方法的名字:FlightSearch.lookupByCity()。我惊讶地发现,栈较高的地址中引用了RMI9方法和EJB10方法。CF系统一直被描述为“Web服务”。不得不承认,当时对Web服务的定义还非常模糊,但将无状态的会话组件称为“Web服务”似乎有些过头了。

9remote method invocation,远程方法调用。——译者注

10enterprise JavaBean,企业级JavaBean。——译者注

RMI为EJB实现了后者所需的RPC11。EJB的调用可以从下面两种传输方式中选择一种来实现:CORBA(已经像迪斯科舞一样无人问津了)或RMI。尽管RMI使得跨机器之间的通信方式就像在本机内部通信,但这种方式无法为通信调用设置超时时间,所以有一定的风险。因此,调用方很容易被其调用的远程服务器中的问题拖垮。

11remote procedure call,远程过程调用。——译者注

2.6 证据确凿

事后分析的结果与停机事故本身的症状相吻合:CF系统导致了IVR系统和自助登机系统同时停止响应。剩下的最大问题仍然是:“CF系统发生了什么?”

研究CF系统的线程转储之后,事情就变得更加清晰了。CF系统的应用程序服务器为处理EJB调用和HTTP请求而分别准备了两个线程池。这就是CF系统即使在停机事故期间,也能始终对监控应用程序做出响应的原因。HTTP线程池中的线程几乎完全空闲,这对于这个EJB服务器来说是正常的。另一方面,EJB线程池中的线程全都忙于处理对Flight-Search.lookupByCity()方法的调用。实际上,所有应用程序服务器上的线程都被阻塞在完全相同的代码行上:尝试从一个资源池中获取一个数据库连接。

然而,这只是间接证据,而不是确凿证据。但考虑到停机事故发生之前进行过数据库故障切换,似乎调查已经走上了正轨。

接下来就要做冒险的事情了:需要查看那一处的源代码。但运维中心无法访问源代码管理系统,被部署到生产环境中的只有二进制文件。这通常是一种很好的安全防范措施,但不利于复盘。当被问到如何才能访问源代码时,客户经理开始变得迟疑起来。考虑到这次停机事故的规模,可以想见有多少口“黑锅”在空中飞舞,寻找那些“接锅侠”。平时运维部门与开发部门之间的关系本就很难搞好,在此时显得更加紧张。每个人都处于防御状态,防范着任何责备他们的企图。

由于没有合法的源代码访问权限,此时只能做唯一能做的事情了——从生产环境中获取二进制文件并将其反编译。当一眼看到那个可疑的EJB的代码时,我就知道有力证据到手了。这是实际的代码:

package com.example.cf.flightsearch;
. . .
public class FlightSearch implements SessionBean {

  private MonitoredDataSource connectionPool;

  public List lookupByCity(. . .) throws SQLException, RemoteException {
    Connection conn = null;
    Statement stmt = null;

    try {
      conn = connectionPool.getConnection();
      stmt = conn.createStatement();

      // 执行查询逻辑
      // 返回结果列表
    } finally {
      if (stmt != null) {
        stmt.close();
      }

      if (conn != null) {
        conn.close();
      }
    }
  }
}

这个方法乍一看写得不错。对try-finally块的使用,表明代码作者希望结束后能清理资源。事实上,这一段资源清理块中的代码就出现在市场上的一些Java书中。可糟糕的是,它包含一个致命的缺陷。

java.sql.Statement.close()语句可以抛出一个SQLException异常。这个异常在正常情况下几乎是不会被抛出来的。但是,当Oracle驱动程序在遇到IOException的情况下尝试关闭数据库连接时,例如执行数据库故障切换,SQLException异常就会抛出。

假设JDBC12连接是在故障切换之前创建的。当数据库服务器执行故障切换时,用于创建连接的数据库服务器IP地址将从一台主机变成另一台主机,但TCP13连接的当前状态不会将数据库主机地址转变为第二个主机地址。任何对套接字的写入操作,最终都会抛出一个IOException异常(这在操作系统和网络驱动程序最终确定TCP连接已经断掉之后发生)。这意味着资源池中的每个JDBC连接都是能引发事故的“地雷”。

12Java database connectivity,Java数据库互连。——译者注

13transmission control protocol,传输控制协议。——译者注

令人惊讶的是,JDBC连接即使在这种情况下也会创建statement语句。要创建statement语句,驱动程序的连接对象只检查自己的内部状态(这可能是特定版本的Oracle JDBC驱动程序所特有的怪问题)。如果JDBC连接认为它自己仍然处于连接状态,那么它将创建statement语句。在执行该statement语句进行一些网络I/O操作时,会抛出SQLException异常。而在关闭该statement语句时也会抛出SQLException异常,因为驱动程序会尝试告诉数据库服务器释放与该语句相关的资源。

简而言之,驱动程序会创建一个无法使用的Statement Object。有人可能会认为这是一个软件缺陷。这家航空公司的许多开发工程师当然都会发出这样的指责。然而,我们从本例中能学到的关键教训是,JDBC规范允许java.sql.Statement.close()抛出一个SQLException异常,所以代码必须处理该异常。

在上述出问题的代码中,如果在关闭statement时抛出异常,则数据库连接不会被关闭,从而导致资源泄漏。当这段代码被调用40次后,资源池就被耗尽。此后的调用都会阻塞在connectionPool.getConnection()方法处。这正是我在CF系统线程转储中所看到的。

拥有数百架飞机和几万名员工的这家价值数十亿美元的全球性航空公司,被迫停飞的原因只是一个未被捕获的SQLException异常。

2.7 预防管用吗

当如此之小的差错产生如此惊人的后果时,人们最自然的反应就是说:“绝不让此事再发生。”(我曾看到运维部门经理一边用鞋子敲桌,一边宣称:“绝不让此事再发生!”)但是如何预防呢?代码评审是否会发现这样的缺陷?除非其中一位代码评审员知道Oracle JDBC驱动程序的内部实现细节,或者代码评审团队对每个Java方法都花费几个小时来评审,否则是不会发现的。执行更多的测试能发现这个缺陷吗?也许吧。在定位了上述问题之后,该团队在压力测试环境中进行了测试,确实重现了同样的差错。常规的测试用例并没有全方位地测试这个Java方法,发现软件缺陷。换句话说,只有在知道问题的情况下,编写发现问题的测试才会变得简单。

指望着每一个像这样的软件缺陷最终都能被揪出来,就是天方夜谭。软件缺陷肯定会产生,无法被消灭,那就根据这些缺陷不断优化软件吧。

最糟糕的问题是,系统中的一个软件缺陷可能会传播到所有其他受影响的系统中。可以换一个更好的问法:“如何防止系统中的缺陷殃及其他的系统?”如今,每个企业内部都有相互关联和相互依赖的系统。他们不能(也绝不可能)允许软件缺陷导致一连串的系统失效。下文将研究防止这类问题蔓延的设计模式。

目录