4.1 什么是复杂
Jurgen Appelo在分析复杂系统理论[16]时,将Complicated与Complex分别放在理解力与预测能力两个迥然不同的维度上。Complicated与Simple(简单)相对,意指非常难以理解;而Complex则介于Ordered(有序)与Chaotic(混沌)之间,意指在某种程度上可以预测,但会有很多出乎意料的事情发生,如图4.1所示。
大多数软件系统是难以理解的,虽然我们可以遵循一些设计原则来应对未来的变化,但由于未来是不可预测的,因而软件的演进存在不可预测的风险。如此看来,软件系统所谓的“复杂”其实覆盖了Complicated与Complex两个方面,等同于图4.1中城市所处的位置。凑巧的是,Sam Newman也认为城市的变迁与软件的演化存在很大程度的相似性[17]:
图4.1
很多人把城市比作生物,因为城市会时不时地发生变化。当居民对城市的使用方式有所变化,或者受到外力的影响时,城市就会相应地演化。
上面描述的城市和软件的对应关系应该是很明显的。当用户对软件提出变更需求时,我们需要对其进行响应并做出相应的改变。未来的变化很难预见,所以与其对所有变化的可能性进行预测,不如做一个允许变化的计划。
城市与软件的复杂度有可比之处,还在于其结构的复杂性。不同风格与不同类型的建筑,杂乱如蜘蛛网一般的城市道路,还有居民生存的复杂生态圈,展现出形态各异的风貌,甚至每一条陋巷都背负了沧桑厚重的历史。软件系统的代码行即砖瓦,通信端口即车辆行驶的道路,每个构建模块是建筑物,基础设施是排水系统,公共模块是医院、学校或者公园,软件架构就是对整个城市的规划和布局。
因而要理解软件系统的复杂度,也可以结合理解力与预测能力这两个因素来帮助我们思考。在软件系统中,是什么阻碍了开发人员对它的理解?想象一下,团队招入一位新人,这位新人就像一位游客来到了一座陌生的城市,他是否会迷失在阡陌交错的城市交通体系中不辨方向?倘若这座城市不过只有房屋数间,一条街道连通城市的两头,实则是乡野郊外的一座村落,那还会使他生出迷失之感吗?所以,影响理解力的第一要素是规模。
4.1.1 规模
软件的需求决定了系统的规模。当需求呈现线性增长的趋势时,为了实现这些功能,软件规模也会以近似的速度增长。由于需求不可能做到完全独立,这种相互影响、相互依赖的关系使得修改一处就会牵一发而动全身。就好似城市的一条道路因为施工需要临时关闭,此路不通,通行的车辆只得改道绕行,这又导致了其他原本已经饱和的道路因为涌入更多车辆,超出道路的负载从而变得更加拥堵,这种拥堵现象又会顺势向这些道路的其他分叉道路蔓延,形成一种辐射效应的拥堵现象。
以下几种情况都可能使软件开发产生拥堵现象,或许比道路堵塞更严重。
●函数存在副作用,调用时可能对函数的结果作了隐含的假设。
●类的职责繁多,不敢轻易修改,因为不知道这种变化会影响到哪些模块。
●热点代码被频繁变更,职责被包裹了一层又一层,没有清晰的边界。
●在系统的某个角落里,隐藏着伺机而动的Bug,当诱发条件具备时,就会让整条调用链瘫痪。
●在不同场景下,会产生不同的异常场景,每种异常场景的处理方式都各不相同。
●同步处理与异步处理的代码纠缠在一起,不可预知程序执行的顺序。
这是一个复杂的生态环境,新的需求变化就好似在南美洲亚马孙河流域热带雨林中的蝴蝶,轻轻扇动一下翅膀,就在美国得克萨斯州掀起了一场龙卷风。面对软件复杂度的“蝴蝶效应”,我们心存畏惧。
在我负责设计与开发的BI(Business Intelligence)产品中,我们需要展现报表(Report)下的所有视图(View)。这些视图的数据可能来自多个不同的数据集(Data Set),而视图的类型也多种多样,例如柱状图、折线图、散点图等。
在这个“逼仄”的报表问题域中,我们需要满足如下业务需求。
●在编辑状态下,支持对每个视图进行拖曳以改变视图的位置。
●在编辑状态下,允许通过拖曳边框调制视图的尺寸。
●当单击视图的图形区域时,应当使当前图形的组成部分显示高亮。
●当单击视图的图形区域时,应当获取当前值,对属于相同数据集的视图进行联动。
●如果打开钻取开关,则应当在单击视图的图形区域时获取当前值,并根据事先设定的钻取路径对视图进行钻取。
●能够创建筛选器这样的特殊视图,通过筛选器选择数据,对当前报表中所有相同数据集的视图进行筛选。
这些业务需求都是我们事先预见到的,无一例外,它们都是对视图进行操作,这就导致了多种操作之间的纠缠与冲突。例如,高亮与级联都需要响应相同的 Click 事件,钻取同样如此,与之不同的是它还要判断钻取开关是否已经打开。而在操作效果上,如果高亮与钻取仅针对当前视图本身,则联动与筛选就会因为当前视图的操作影响到同一张报表下其他属于相同数据集的视图。对于拖曳操作,虽然它监听的是 MouseDown 事件,但该事件却与Click事件冲突。显然,实现这些功能的复杂度不能仅以功能点的增加来衡量。
软件复杂度会受到需求与规模的正向影响,但它的增长趋势要比需求与规模更加陡峭。倘若需求还产生了事先未曾预料到的变化,我们又没有足够的风险应对措施,那么在时间紧迫的情况下,难免会对设计做出妥协,头疼医头,脚疼医脚,在系统的各个地方打上补丁,从而欠下技术债。当技术债越欠越多,累计到某个临界点时,量变就会引起质变,整个软件系统的复杂度达到巅峰,步入衰亡的老年期。许多遗留系统就挣扎在濒临死亡的悬崖边上。这些遗留系统符合饲养场的奶牛原则:
奶牛逐渐衰老,最终无奶可挤;然而与此同时,饲养成本却在上升。
这意味着遗留系统会逐渐随着时间的推移,不断地增加维护成本。一方面,随着需求的变化,对遗留系统的维护变得越来越捉襟见肘;另一方面,遗留系统的相关知识又逐渐被腐蚀。团队成员变动了,留存在他们大脑中的系统知识随之而去。文档呢?勤奋而尊重流程的团队或许编写了可谓圣经一般完整而翔实的文档,可惜我们却只能参考,不可尽信,因为这些文档不过是刻在船舷上的印迹,虽然刻下了当时宝剑落下的位置,然而舟船已经随着桨声欸乃滑向了彼岸。似乎只有代码才是最忠实的,然而当遭遇佶屈聱牙、晦涩难懂的代码时,当需要解开如一团乱麻般的依赖关系时,我们又该何去何从?
需求的变化,知识的流逝,正是遗留系统之殇!
我曾经参与过某大型金融机构客户系统的技术栈迁移。为了保证我们的技术栈迁移没有破坏系统的原有功能,需要为系统的核心功能编写自动化测试以形成保护网。当时,曾经参与过该系统开发的人员已经“遗失”殆尽,我们除了得到少数团队人员的有限支持,还可以参考和借鉴的只有这个系统的数百页Word 文档以及千万行级的Java代码库。Java代码库经历了大约七八年的变迁,并主要由外包团队开发,涉及的平台与框架包括EJB 2、Spring 3.0、Struts,乃至JDK 5之前的Java代码。除此之外,还有部分我们完全搞不懂的COBOL代码(COBOL语言?是在远古时代吧!)。阅读代码库时,我们常常震惊于庞大臃肿的类,许多类的代码行数超过10000行,而数千行的方法体也是屡见不鲜,并沿袭了原始时代的编程传统,常常在方法的首端定义了数十个变量,并在整个方法中被重复赋值、修改。系统通过IBM MQ实现分布式系统之间的集成。子系统之间传递的消息被定义为各式没有任何业务意义的消息编码,诸如S01、S02、P01、P02。我们需要查阅文档了解这些消息代码代表的业务含义,还需要明确消息之间传递的流程以及处理逻辑。
我们在为合并客户账户场景编写自动化测试时,发现文档中描述的异常消息S05的处理逻辑与实际的运行结果不一致。无奈之下,我们只有通过阅读源代码寻找业务的真相。这个过程仿佛福尔摩斯探案,我们不能放过代码中任何可能揭示真相的蛛丝马迹。运行已经编写好的自动化测试,结合跨进程的调试手法,通过打印控制台日志来复现消息的走向,从而通盘了解业务流程的运行轨迹。最后,真相水落石出。为了编写这个自动化测试,足足耗费了两个人日的时间。
软件规模的一个显著特征是代码行数。然而,代码行数常常具有欺骗性。如果需求与代码行数之间呈现出不成比例的关系,则说明该系统的生命体征可能出现了异常,例如代码行数的庞大其实可能是一种肥胖症,它可能包含了大量的重复代码,这或许传递了一个需要改进的信号。
我在做一个咨询项目时,曾经利用 Sonar 工具对该项目中的一个模块进行了代码静态分析,如图4.2所示。
图4.2
这个模块的代码行数有40多万行,其中重复代码竟然达到了惊人的33.9%,超过一半的代码文件混入了重复代码。显然,这里估算的代码行数并没有真实地体现软件规模,相反,因为重复代码的缘故,可能还额外增加了软件的复杂度。
Neal Ford在文章Emergent design through metrics中谈到了如何通过指标来指导设计。文中提及的 iPlasma 是一个用于面向对象设计的质量评估平台,或许我们可以通过该工具的指标(见表4.1)来找到评价软件规模的要素。
表4.1
在面向对象设计的软件项目里,除了代码行数,包、类、方法的数量,继承的层次,以及方法的调用数,还有常常提及的圈复杂度,都或多或少会影响到整个软件系统的规模。
4.1.2 结构
你去过迷宫吗?相似而回旋繁复的结构使得本来封闭狭小的空间被魔法般地扩展为一个无限的空间,变得无穷大,仿佛这空间被安置了一个循环,倘若没有找到正确的退出条件,循环就会无休无止,永远无法退出。许多规模较小却格外复杂的软件系统,就好似这样一座迷宫。此时,结构成了决定系统复杂度的关键因素。结构之所以变得复杂,多数情况下还是系统的质量属性决定的。例如,我们需要满足高性能、高并发的需求,就需要考虑在系统中引入缓存、并行处理、CDN、异步消息以及支持分区的可伸缩结构。倘若我们需要支持对海量数据的高效分析,就得考虑这些海量的数据该如何分布存储,并如何有效地利用各个节点的内存与CPU资源执行运算。
从系统结构的视角看,单体架构一定比微服务架构更简单,更便于掌控,正如单细胞生物比人体的生理结构要简单数百倍一样。那么,为何还有这么多软件组织开始清算自己的软件资产,花费大量人力物力对现有的单体架构进行重构,走向微服务化呢?究其主因,不还是系统的质量属性在作祟吗?纵观软件设计的历史,不是分久必合,合久必分,而是不断拆分、继续拆分、持续拆分的微型化过程。分解的软件元素不可能单兵作战。怎么协同,怎么通信,就成了系统分解后面临的主要问题。如果没有控制好,这些问题固有的复杂度甚至会在某些场景下超过因为分解给我们带来的收益。如图4.3所示,由于对系统进行了分解,因此各个子系统或模块之间形成了复杂的通信网结构。
图4.3
要理清这种通信网结构的脉络,就得弄清楚子系统之间的消息传递方式,明确消息格式的定义;同时,这种分布式的部署结构,在实现这些功能的同时,还必须额外考虑跨进程通信可能出现的异常场景,例如如何确保消息的可靠传递,如何保证数据结果的一致性。换言之,系统因为结构的繁复而增加了复杂度。
1.微服务的最终一致性
基于CAP理论,微服务这种分布式架构在满足A(Availability)与P(Partition Toralence)的前提下,至少要保证数据的最终一致性,即系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。
分布式架构的通信特点让我们必须要认为网络通信是不可靠的,这就导致在实现一致性上,微服务比传统的单体架构要复杂得多。假如采用补偿模式来实现数据的最终一致性,就需要引入一个额外的协调服务,它负责协调各个需要保证一致性的微服务,其职责为协调服务并按顺序调用各个微服务,如果某个微服务调用异常(包括业务异常和技术异常),就取消之前所有已经调用成功的微服务。同时,还需要考虑取消操作也可能失败的情况,即补偿过程本身也需要满足最终一致性,这就要求在服务调用出现异常后,取消服务至少要被调用一次,而取消服务操作本身则必须是幂等的。
为了实现补偿模式,我们需要记录每次业务操作,同时还要确定失败的步骤与状态,以便于定位补偿的范围。为了提高正常业务操作的成功率,还需要在设计时考虑引入重试机制。服务执行失败的原因各有不同,重试机制也需要提供与之对应的策略。例如对于系统繁忙的异常,我们应采用等待重试机制;对于一些出现概率非常小的罕见异常,可以考虑立刻重试;如果失败是由于某种业务原因导致的,那么即使重试也不可能保证操作成功,应采取终止重试策略。显然,这些机制都会因为微服务的分解而带来设计上的额外成本,它必然会导致整个系统的结构变得更加复杂。有得必有失,软件世界的自然规律其实是公平的。
在考虑微服务设计时,业界普遍认为服务分解与组织结构要保持一致,即遵循康威定律:
任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。
Sam Newman认为是“适应沟通路径”使康威原则在软件结构与组织结构中生效[18]的。他分析了一种典型的分处异地的分布式团队,整个团队共享单个服务的代码所有权。由于分布式团队的地域和时区不同,因此使得沟通成本变高,团队之间只能进行粗粒度的沟通。当协调变化的成本增加后,人们就会想方设法降低协调/沟通成本。直截了当的做法就是分解代码,分配代码所有权,使分处异地的团队各自负责一部分代码库,从而更容易地修改代码。团队之间会有更多关于如何集成两部分代码的粗粒度的沟通,最终与组织结构内的沟通路径匹配所形成的粗粒度API形成了代码库中两部分之间的接口。
注意,匹配设计方案的团队是负责开发的团队,而非使用软件产品的客户团队。在软件开发中,常常会遇见分布式的客户团队,例如不同的部门会在不同的地理位置,他们的使用场景也不尽相同,甚至用户的角色也不相同,但在对软件系统进行架构设计时,却不能想当然地按照用户角色、地理位置或部门组织来分解模块(服务),并以为这遵循了康威定律。设计人员错误地把客户的组织结构视为了系统模块(服务)的分解依据。
我曾经参与过一款通信产品的改进与维护工作。这款产品为通信运营商提供对宽带网的授权、认证与计费工作。该产品的终端用户主要分为两类:营业厅的营业员与购买宽带网服务的消费者。该产品的最初设计就自然而然地按照这两种不同的角色划分为后台管理系统与服务门户两个完全独立的子系统,在这两个子系统中都存在资费套餐管理、客户信息维护等业务。
这种不合理的软件系统结构划分,属于典型的职责分配不合理,不仅会产生大量重复代码,还会因为结构失当而带来许多不必要的通信与集成,增加软件系统的复杂度。
2.国际报税系统的架构演进
在我参与的一个国际报税系统中,就根据用户的角色进行了系统分解。针对报税人,设计了Front End模块提供报税等终端业务,而Office End模块则面向业务人员和系统管理者,如图4.4所示。
图4.4
随着需求增多,功能越来越复杂,系统各个模块的边界开始变得越来越模糊,形成了一个逻辑散乱的庞大代码库。重复代码与重复数据俯拾皆是,而Front End与Office End之间的集成也非常复杂。负责开发这两个模块的团队虽然属于同一个项目组,但团队之间存在极大的技术和业务壁垒,团队成员对整个系统缺乏整体认识,知识没有能够在团队之间传递起来。
当通过引入限界上下文(Bounded Context)来划分模块的边界,并为每个限界上下文建立公开统一的REST服务后,遵循康威定律为分解开的服务建立特性团队(Feature Team)就演变为顺其自然的结果。整个系统中各个服务的重用性和可扩展性得到了更好的保障,服务与UI之间的集成也变得更加简单。整个架构清晰可见,如图4.5所示。
图4.5
无论是优雅的设计,还是拙劣的设计,都可能因为某种设计上的权衡而导致系统结构变得复杂。唯一的区别在于前者是主动地控制结构的复杂度,而后者带来的复杂度是偶发的,是错误的滋生,是一种技术债,它可能会随着系统规模的增大而导致一种无序设计。
在Pete Goodliffe讲述的“两个系统的故事:现代软件神话”[19]中详细地罗列了无序设计系统的几种警告信号:
●代码中没有显而易见的进入系统中的路径。
●不存在一致性,不存在风格,也没有统一的概念能够将不同的部分组织在一起。
●系统中的控制流让人觉得不舒服,无法预测。
●系统中有太多的“坏味道”,整个代码库散发着腐烂的气味,是在大热天里散发着刺激气体的一个垃圾堆。
●数据很少被放在使用它的地方。
●经常引入额外的巴洛克式缓存层,试图让数据停留在更方便的地方。
看一个设计无序的软件系统,就好像隔着一层半透明的玻璃观察事物一般,系统中的软件元素都变得模糊不清,充斥着各种技术债。细节层面,代码混乱,违背了“高内聚、松耦合”的设计原则,导致许多代码要么放错了位置,要么出现重复的代码块;架构层面,缺乏清晰的边界,各种通信与调用依赖纠缠在一起,同一问题域的解决方案各式各样,让人眼花缭乱,仿佛进入了没有规则的无序社会。
3.架构与代码评审
我曾经为一个制造业客户开发的业务工具项目提供架构与代码评审的咨询服务。当时,该工具产品的代码库只有不到三万六千行代码,是一个简单的基于 ASP.NET 开发的 BS (Brower/Server)架构系统。虽然项目规模不大,但是在经历了约半年的开发周期后,项目质量与交付周期都不能得到足够的保证。在之前交付的版本中,位于欧洲的销售代表普遍对这个工具不满意,所以客户希望我们能够在技术层面上提供一些咨询建议。
该工具产品的开发存在诸多问题,例如在领域层充斥着大量的贫血对象,对框架的强依赖导致“供应商锁定”,在技术选型上也多有不当之处。但最大的问题还是系统缺乏清晰的边界,如图4.6所示。
图4.6
架构师虽然采用了经典的三层分层架构模式对关注点进行分离,却没有很明确地勾勒出各个分层的明确职责,开发人员也没有按照这种分层架构来分配职责,在本来应该是视图呈现的代码中混入了许多领域逻辑,从而导致UI层越来越臃肿。而在领域层,却又不恰当地渗入了对ASP.NET UI组件的处理逻辑。
该产品代码库存在的另一个问题是缺乏一致性。例如针对数据库的访问,产品竟然提供了如下三种不同的解决方案。
●Utils访问方式,其代码如下所示。
●DbHelper访问方式,其代码如下所示。
●ORM访问方式,其代码如下所示。
显然,选择这3种迥然不同的访问方式并非出于技术原因,又或者受到某个质量属性的约束,而是在设计时没有做到统一的规划,开发人员率性而为,内心会自然而然地选择自己最熟悉、实现成本最低的技术方案,从而导致访问数据库的解决方案不一致。
4.1.3 变化
我们之所以不能预测未来,是因为未来总会出现不可预测的变化。这种不可预测性带来的复杂度使得我们产生畏惧,因为不知道何时会发生变化,变化的方向会是哪里,所以心里会滋生一种仿若失重一般的感觉。变化让事物失去控制,受到事物牵扯的我们便会感到惶恐不安。
在设计软件系统时,变化让我们患得患失,不知道如何把握系统设计的度。若拒绝对变化做出理智的预测,那么系统的设计会变得僵化,一旦变化发生,修改的成本就会非常大;若过于看重变化产生的影响,渴望涵盖一切变化的可能,则一旦预期的变化不曾发生,我们之前为变化付出的成本就再也补偿不回来了。
从需求的角度讲,变化可能来自业务需求,也可能来自质量属性,而以对系统架构的影响而言,尤以后者为甚,因为它可能牵涉整个基础架构的变更。George Fairbanks在《恰如其分的软件架构》[20]一书中介绍了邮件托管服务公司Rackspace的日志架构变迁,虽然业务功能没有任何变化,但是邮件数量却持续增长,为了满足性能需求,架构经历了三个完全不同的系统变迁:从最初的本地日志文件到中央数据库,再到基于 HDFS(Hadoop Distributed File System,Hadoop分布式文件系统)的分布式存储,整个系统几乎发生了颠覆性的变化。这并非 Rackspace 的架构师欠缺架构设计能力,而是在公司草创之初,他们没有能够高瞻远瞩地预见到客户数量的增长,导致日志数据增多,以至于超出了已有系统支持的能力范围。俗话说“事后诸葛亮”,当对一个软件系统的架构设计进行复盘时,总会发现许多设计决策是如此愚昧。殊不知这并非愚昧,而是在设计之初,我们手中掌握的筹码不足以让自己赢下这场面对未来的战争罢了。这就是变化之殇!
1.错误的架构决策
我们的产品是一款基于大数据分析的BI产品,产品目标是能够针对大数据(TB级别)提供高性能的数据分析与可视化呈现。为了避免客户数据的源数据库带来性能上的瓶颈,产品提供数据导入服务,将业务分析人员需要用到的数据作为数据集存储到产品中。
当时,我需要做一个设计决策,就是选择什么样的存储方式作为数据集存储。由于存储的数据集需要支持高性能的数据分析(主要为聚合运算),而诸如HDFS、HBase等分布式存储都不满足我们的场景,所以我从简单性的角度出发,在架构之初选择了MySQL。
然而,随着导入数据集的数据量增加,可以通过什么手段来改进性能呢?在应用服务层,我们引入了微服务架构,并引入Nginx来做反向代理,且服务都是无状态的,可以通过建立集群的方式应对;在数据分析层,我们基于 Spark 大数据平台,它支持通过分区与并行处理来改进分布式部署;唯有在存储层,MySQL制约了整个架构的水平伸缩能力。
幸而,在产品研发前期及时地发现了这个问题,通过一些设计上的权衡,最终选择了采用列式存储结构的Parquet文件作为数据集存储。列式存储结构可以很好地支持主要针对列数据进行的组合运算,而Parquet作为文件格式可以友好地被存储到HDFS分布式文件系统中,且在同等数量级下,它占用的空间也比较小。在架构上,这种方案很好地满足了水平伸缩的需求,架构如图4.7所示。
图4.7
如果将软件系统中自己开发的部分都划归为需求的范畴,那么还有一种变化,则是由依赖的第三方库、框架或平台,甚至语言版本的变化带来的连锁反应。例如,作为Java开发人员,一定更垂涎于 Lambda 表达式的简单与抽象,然而现实是我们看到多数企业软件系统依旧在Java 6或者Java 7中裹足不前。
这还算是幸运的例子,因为我们还可以满足于这种故步自封的状态,毕竟情况并没有到必须变化的境地。然而,当依赖的第三方有让我们不得不改变的理由时,难道我们还能拒绝变化吗?
图4.8是DataBricks针对Spark 1.6与Spark 2.0做的性能测评。倘若数据分析产品依托于Spark 1.6平台,那么随着需要分析的数据量逐渐增大,在现有的计算机集群规模下,当面临严重的性能瓶颈,且用尽了所有可能的调优手段都无法满足客户需要时,我们该何去何从?我们是否将Spark版本的升级视为最后一根救命稻草呢?
图4.8
许多软件在版本变迁过程中都尽量考虑到API变化对调用者带来的影响,因而尽可能保持版本向后兼容。我亲自参与过系统从Spring 2.0到4.0的升级,Spark从1.3.1到1.5再到1.6的升级,感谢这些框架或平台设计人员对兼容性的考虑,使得我们的升级成本能够被降到最低;但是在升级之后,倘若没有对系统做全方位的回归测试,我们的内心始终是惴惴不安的。事实上,当我们的产品从Spark 1.6.1升级到Spark 2.1,同时将Scala 2.10升级到2.11时,就遭遇了各种问题与陷阱,这些问题显然不是简单修改构建文件并进行重新构建就能应付过去的。
对第三方的依赖看似简单,殊不知所依赖的库、平台或者框架还可能依赖了若干对于它们而言又分属第三方的更多库、平台和框架。每回初次构建软件系统时,我都为需要漫长等待的依赖下载过程感到烦躁不安。而多种版本共存时可能带来的所谓依赖地狱,只要亲身经历过,就没有不感到不寒而栗的。倘若你运气欠佳,那么可能还会有各种古怪的问题接踵而来,让你应接不暇,疲于奔命。
2.Scala版本带来的古怪问题
我们的产品采用编程方式运行 Spark 数据分析,并通过 Spray 框架将这些数据分析公开为REST服务。在启动spray-can时系统会实例化SparkContext,然后将其传递给真正执行任务的Akka Actor。
系统通过build.sbt设置了各种依赖:
我们希望通过Spark去访问PostgreSQL,编写的示例代码如下所示。
当我运行上面这段程序时,很不幸,它在创建SparkContext时抛出了以下错误,真可谓出师未捷身先死。
由于Spark客户端与standalone方式部署的Spark Master是通过Akka的RemoteActor通信的(这是Spark 1.6之前的版本的通信方式,1.6及之后版本的Spark不再使用Akka,而是直接基于Netty实现了远程通信),所以根据上面这段错误信息提供的字面含义,我认为是获取path为akka.tcp://sparkMaster@192.168.1.4:7077/user/Master的RemoteActor出现了问题。通过单步调试并结合对源代码的啃读,我探索到standalone模式下SparkContext的创建过程(基于Spark 1.3.1版本),即在创建SparkContext时,会创建对应的TaskSchedulerImpl与SparkDeploySchedulerBackend对象,然后执行SparkDeploySchedulerBackend的start()方法,进而跟踪到ClientActor的创建。ClientActor是一个Akka Actor,在启动前(Actor被创建后会自动以异步方式启动)会执行钩子方法preStart()。
ClientActor的实现如下所示。
注意tryRegisterAllMasters()方法的实现以及调用。当启动ClientActor时,它会根据设置的重试次数,不停地尝试注册所有的 Master。这个过程需要调用 ActorContext 的actorSelection()方法,根据传入的masterAkkaUrl获得RemoteActor。
由于错误信息提示为“All masters are unresponsive”,因此我自然认为是通信问题导致程序无法获得RemoteActor。然而,单步调试的结果却又颠覆了我的猜测,执行至如下步骤是可以获得Actor对象的:
在已启动Spark Master的前提下,我编写了如下程序验证了RemoteActor可以正常获得。
我觉得自己像是一个仓皇而逃的亡命之徒,陷入被追捕的危险境地却不知道光明的出路在哪里。无论是通过Google查找解决方案,还是通过Spark User List去咨询问题,又或者阅读Spark源代码,种种方式不一而足,费时费力,弄得我心力交瘁。待到穷途末路时,碰巧看到了Mithra在StackOverFlow上的自问自答[21],他要解决的本是spark-submit出现的问题,警告我们在使用Spark时,应注意如下要素。
●确保你的Spark版本与POM中的版本保持一致。
●Spark的Hadoop版本应该与构建Spark或使用Spark Hadoop预构建的版本保持一致。
●根据如下内容更新你的spark-env.sh:
●在将代码提交到Spark之前,确保每次编译打包jar文件都要执行clean。
“他山之石,可以攻玉”,不妨借鉴一下这里的建议。确认Spark的版本——sbt中依赖的Spark版本为1.3.1,我运行的Spark Master也是同样的版本。我最初也曾怀疑是Hadoop的问题。为了解决此问题,我专门安装了2.6版本的Hadoop,然后执行如下命令重新编译了Spark 1.3.1,保证Hadoop版本与Spark是兼容的。
我甚至在spark-env.sh中配置了与Hadoop有关的目录配置,当然也包括前面建议中提到的相关配置:
不幸的是,问题依然存在!
痛定思痛,冷静下来反思,我觉得自己似乎走入了一个误区。因为有Spark的源代码,有Google和Spark User List,所以我想当然地希望通过看到的错误信息去寻觅相似的问题,以求获得解决方案。当查找没有结果时,我又过度相信自己能够通过源代码发现一些端倪,甚至考虑通过Attach Process的方式尝试着为Remote Actor设置断点,从而进行问题跟踪。然而,我却忘了要解决问题首先要分析问题出现的缘由,并由此进行下一步分析与判断。要做到这一点,最有效的手段其实是通过日志。
我在 Spark 的 conf 目录下配置了 log4j.properties,并重新运行 start-all.sh 脚本,启动Spark Master后再运行程序。结果,我惊喜地发现在日志文件中出现了如下错误信息。
抛出的异常提示 Command 类由于兼容性问题导致序列化出错。Command 类定义在Spark中,其实是一个普通的Case Class:
Scala的Case Class自身支持对象的序列化。那么,为何会发生序列化不兼容的情况呢?由于Spark版本完全一致,因此让我想起是否是Scala版本不一致导致的。
通过阅读Spark官方文档的“Building Spark”[22]部分,我发现在通过maven构建Spark时,默认Scala版本为2.10(之后更新的Spark版本则将默认的Scala版本设置为2.11),而我本机运行的Scala版本则为2.11。通过spark-shell启动的版本是自己本地构建的,而项目中通过sbt获得依赖的Spark则为构建文档build.sbt指定了Scala 2.11.6。恰恰对于Case Class而言,Scala 2.11在实现上与2.10不同。这就是序列化不兼容的罪魁祸首。
回顾整个问题的解决过程,真的可以说是“百转千回,荡气回肠”。仅仅是Scala语言版本导致的问题,但却由于错误信息的误导,使得我“绕着地球走了一圈又重回到原点”,最后才恍然发现,其实我从一出发就走错了方向。
如果变化是不可预测的,那么软件系统也会变得不可预测。一方面我们要尽可能地控制变化,至少要将变化产生的影响限制在较小的空间范围内;另一方面又要保证系统不会因为满足可扩展性而变得更加复杂,最后背上过度设计的坏名声。软件架构的设计者们就像走在高空钢缆上的挑战者,惊险地调整重心以维持行动的平衡。故而,变化之难,在于如何平衡。