官术网_书友最值得收藏!

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語言版本導致的問題,但卻由于錯誤信息的誤導,使得我“繞著地球走了一圈又重回到原點”,最后才恍然發現,其實我從一出發就走錯了方向。

如果變化是不可預測的,那么軟件系統也會變得不可預測。一方面我們要盡可能地控制變化,至少要將變化產生的影響限制在較小的空間范圍內;另一方面又要保證系統不會因為滿足可擴展性而變得更加復雜,最后背上過度設計的壞名聲。軟件架構的設計者們就像走在高空鋼纜上的挑戰者,驚險地調整重心以維持行動的平衡。故而,變化之難,在于如何平衡。

主站蜘蛛池模板: 乐业县| 江川县| 沽源县| 津市市| 大城县| 桐柏县| 佛坪县| 辽源市| 微博| 克什克腾旗| 株洲县| 太康县| 日照市| 陇西县| 广平县| 抚宁县| 定安县| 定日县| 张北县| 罗源县| 永康市| 祁阳县| 鄂托克前旗| 灵石县| 洛南县| 德保县| 滦平县| 汶上县| 武夷山市| 融水| 北川| 辽阳市| 时尚| 蚌埠市| 卢氏县| 宁陵县| 大埔区| 麻阳| 兴化市| 鄢陵县| 丹凤县|