Scala在挖財的應用實踐
編者按:本文 是根據 ArchSummit大會上挖財資深架構師王宏江的演講《Scala在挖財的應用實踐》整理而成。
這次分享有三個方面,1是介紹一下挖財當年的開發情況和后端的架構, 2是挖財選擇Scala的原因,3是挖財使用Scala相關的技術時碰到的問題以及經驗。
第一部分是團隊的情況和后端技術的架構。近一年我們的開發團隊從50人增長到了現在兩百人,公司總人數擴張到600左右,技術人員占的比例跟國內大多互聯網創業公司的比例差不多,1/3左右的樣子,昨天大會上王天提到推ter的工程師44%左右在硅谷差不多是平均水平,國內公司比例要低一些。我們是一家典型的業務驅動公司,并不是技術驅動的,業務驅動的一個特點是需要快速嘗試各種可能性,需要技術人員能盡快跟上,好的程序員并不是那么好招的,某些階段在一定程度上先靠人數來堆。所以挖財技術團隊中少部分人是有一定經驗的,核心的一些架構師主要是從阿里出來的,大部分的程序員相對普通。
而后端技術用的主要是比較大眾的東西,Web容器用Tomcat,框架主要是Spring MVC,也有少量的Play,中間服務層是Dubbo,微容器用Spring Boot,服務注冊這一塊是用ZooKeeper,核心業務開發方式還是圍繞著Spring和Mybatis等;數據的存儲這塊是MySQL和Hbase,分布存儲這塊是用阿里巴巴之前開源的一個中間件Cobar。消息和實時計算這塊主要是Kafka, Storm,日志以及監控系統則是用典型的ELK和Zabbix。另外我們將要放棄MongoDB和Memcached,并不是這些產品自身的問題,而是人的因素,最早的架構師對MongoDB很熟,后來因為他離開,這塊的一些問題缺乏強有力的人把控,就放棄了。其他人對MySQL和Hbase更熟悉,并且這兩種組合基本滿足我們的需求了。Memcached則是被Redis替代。總的來說,我們裁剪掉了一些之前使用的相似產品,減少為一種,主要是基于自身運維能力考慮的,不在于這些技術是否足夠好或者是否符合團隊的特性和遇到的問題,而是你能不能把它維護的好,或者說風險控制好。
業務上面我們大概也是比較典型的。基礎設施是一塊,然后中間是服務,上面是日常的應用。基礎設施就包括典型的存儲、消息、框架,還有發布、運維、監控等等。中間就是現在典型的SOA服務,用戶的資產、服務、流水、交易等等,所有的服務采用Spring Boot打包部署,主要便于運維上的統一和方便做一些切面層的事情。最上面是應用,包括社區、記帳、錢管家、理財。而安全和風控會切入在每一個環節。
其實作為一個互聯網的金融公司,很多時候我們對金融會有一點點陌生,在我們看來做一個理財產品或者一個理財業務其實和電商業務有很大的相似之處。主要就是理財產品,他也是一個商品,這個商品可能有高的回報,最初形成這個商品的時候背后有一套綜合系統或者說有一套邏輯。并且購買這個商品之后,這個商品是有歷史的,他的收益是在變化的,所以簡化之后,我們發現跟電商系統是相似的。
這就是典型的架構,前面是Web或Gateway,中間是Service,中間層協議是Dubbo默認方式或者是HTTP。
數據存儲這一塊主要是MySQL和Hbase,當前還有小部分數據使用的Mongo,ORMapping這塊我們用了MyBatis。數據的水平擴展主要是基于剛才提到的Cobar,它是阿里比較成功的開源產品之一。Cobar支持數據的二維擴展,能滿足我們的場景,比如說幾千萬用戶的流水記錄可以按用戶加時間的維度來劃分。如果單純按時間的維度分區,在查詢用戶的流水時要散落到各個分區上,對各個分區的壓力都較大。而按用戶的維度則可能導致這個分區在一定時間后達到我們預設的容量上線,因為一天有幾百萬甚至上千萬的流水進入。二維的方式可以解決這些問題。盡管我們后來發現用戶的數據大多在最近一段時間內較熱,時間比較早的數據相對較冷,只用了Cobar的一維擴展方式,將冷數據歸檔到Hbase集群來解決。在數據的在線處理這塊,有很多用Akka框架的程序,比如計息系統主要是運算的,所以Akka很適合干這個事情。
自動記帳這塊主要是錢管家這款產品,他會根據銀行的帳單把這些數據匯總到這個產品里面,主要是從其他系統匯聚出原始的數據,根據這些數據我們會進行分類,進行加工和抽取,最后匯總到database,這個過程的思路就是command+pipeline下面這套組合,只不過是分布式命令和管道,分布式里面命令是Akka所承擔的角色。
數據分析這塊,Canal會把binlog數據丟到Kafka然后同步到其他系統。在線分析主要通過Storm進行,數據分析過程中緩存用了豌豆莢的Codis。離線分析目前用Hadoop/Spark的方式,數據分析之后,一部分存到Hbase,另一部分在通過Cober存儲到MySQL里面。
前面介紹了挖財的架構,第二部分說說我們選擇Scala的原因。首先Scala的語言還是有點爭議的,不管是大師級的人物還是編程語言愛好者,對編程語言的爭議從來沒停止過的。但不管別人說Scala怎么樣,從工程師的角度,從實用的角度來說,我是比較認可這個語言的。我覺得松本行弘批判保羅·格雷厄姆的話非常代表我的觀點,他說的語言會朝著“小而干凈”的方向進化,他自己也創造過一個Lisp的方言叫ARC,他崇尚保持簡潔和純粹的設計思路,但這門語言并不算成功。從總體或者說未來的趨勢來看,語言已經不可能保持那么小而精的狀態。能夠真正做到解放生產力這么一個目標才是演化的方向,所以從解放生產力這個角度來看的話,Scala實際上是達到了這個效果的。不管怎么罵,Scala確實為我們解決了很多實際問題,加速了我們的效率。
我們為什么會選擇Scala?可以說有一些巧合,在挖財快速發展的過程中,幾個核心的架構師對這種語言都是比較喜愛的,并且之前有些經驗。如果是一個人,或者說你的公司想做這個事情,但是沒有人能夠支持你去做這件事情的話,那其實是相當麻煩的。因為你至少要有好幾個能力相當不錯的人能夠一起來做這個事情,才可能在公司把這個事情推廣,才能夠影響更多的人。這樣形成核心的小的影響之后,會吸引來更多的愛好者的加入,這樣才能夠使這個語言被大家所接受。所以這就必須是一個團隊,既使個人能力再強也沒用,團隊的意愿非常重要,假如他不愿意用這個語言,或者他不接受這個語言,那么用主流的Java也是OK的,培養而不是強迫,如果用這個語言當然更好,如果不使用的話也沒有關系。但是我們可能在口味上會偏好這些人,因為我發現相對來講這些人他會更愿意去思考,或者說在處理一些問題的時候會比普通的程序員想的更多。在這個過程中還有個非常重要的事,就是慎重的選擇開發棧。就拿選擇Scala來講,Scala有他自己的生態系統,跟Java還不太一樣。比如說Java在Web層有Struts/Spring MVC框架,Scala有Play/Lift,構建工具Java里面是Maven,Scala則以sbt為主。Scala有一套自己的玩法或者說技術棧,你是不是選擇整套技術棧是需要非常慎重的。挖財處于一個中等創業公司的規模,對我們來說,Java還是更大眾化的。在業務發展過程中,人員加入還是比較快的。而且很多人還是以Java為主,所以我們的做法是不改變這些人員的習慣,還是以Java生態為主,沒有強制的用Scala的開發棧。而且Scala的技術棧里有不錯的產品,也有維護的很差的產品。你選擇一個產品時,先看它的郵件列表,如果郵件列表里都沒有什么來往郵件,這個產品的維護性和持續性肯定不夠。
Scala的優點體現在,原來Java里的設計模式,現在在Scala的語言層面就提供了。比如說Java里的單例,Scala直接對應的就是object;訪問者模式我們可以用更優雅直觀的模式去匹配;還有構造器模式;依賴注入在Scala里面有蛋糕模式。
不可變的模式主要是在并發編程里面的,并發編程我們主要面臨的就是狀態,怎么同步或維護這個狀態,保證這個狀態不被污染掉。如果這個狀態本身就是不可變的,那么就不存在競爭性。Scala本身推崇不可變的思路,當你想要去改變這個狀態時,實際是new一個新的狀態,原來的數據對象并不會改變本身,但Scala也并不是說完全像一些純粹的函數式語言偏執于只用一種純粹的不可變對象,所以Scala也提供了一種像Java的可變的數據變量(用var聲明)。還有就是Java里面我們用來在不同的領域(尤其是在網絡)之間傳輸的都是值對象,值對象的描述跟一般的對象一樣,表達上略微有點啰嗦。而Scala里使用的case class在表達上則簡潔一些,并且這些case class配合模式匹配非常好用。這些都是表達方式的簡潔化,或者提升生產力的一種體現。
其他的優點比如動態類型以靜態類型的方式去實現動態的效果,我們稱之為duck typing,他看起來像duck聽起來也像duck那么它就是duck。Scala里面以隱式參數或者隱式轉換的方式實現動態語言的等價效果,只不過這種方式在編譯器能夠更好的檢測,所以在安全里更有保障。還有利用函數式特性去自定義流控,比如說C#里的using是一個很好的特性,我們在using的括號里面定義一個resource,不管是一個文件流或者是一個disposable資源,程序處理完會把這個資源自動釋放,我們可以在Scala里面通過柯里化和函數特性也可以靈活的定義類似的流控,實現等價效果。另外還有它強大的集合操作,就舉兩個例子,我們想要從同一組流中進行下圖的兩個查找,同樣的效果的寫Java很麻煩,而Scala通過高效的運算以很簡潔的方式實現出來。
當然他有不好的地方就是門檻相對高一點,這個門檻就是函數式背景,舉一個例子,比如說eta規約,比如說我這個函數里面的參數是接收另外一個函數,簡單的說就是高級函數,這個高級函數里面傳給他的參數比如說是 arg => method(arg) ,就是一個參數傳給另外一個方法,我們可以直接簡化一下把這個表達式直接簡化成 method,(在Java里這種Lambda的簡化表達為class::method還相對好辨識一些) 這些可能會導致很多人在看這些函數式的編程語法的時候會產生一些疑惑和不熟悉,這些疑惑表面上覺得是編程語言的俗語,實際上背后是函數式的理論或者它的一些特性,不是大部分人都熟悉的。另外一個不好的地方就是在類型上確實做的有點復雜,有點過頭了,他的類型系統是從ML系語言以及Hashkell過來的,在類型方面有很多高階特性,遠比Java要復雜多了,可以這么對比,Java的類型系統是一維世界,那么Scala的類型系統是二維世界。甚至他的類型系統本身也是圖靈完備的,可以用類型系統解決SKI組合子問題,我推薦大家去搜一下用Scala的類型系統來解決漢諾塔的程序,依賴類型來解決問題思路是這樣的:編譯即運算。如果編譯器編譯通過,就說明類型演算證明了這些問題。這樣帶來的好處就是我只要編譯成功,類型演算就完全被編譯器執行,就是這些事情完全交給編譯器去做。不好的地方還在于他的靈活性必須有一些克制和一些妥協,減少高階特性,并不是每個人都喜歡高階特性。我們還要在編碼風格上有一些約定。當然在編譯過程中也要利用好編譯器的參數,這些參數會幫你提前發現一些問題。所以在挖財,我們主要引入Scala語言,但沒有用Scala這個生態系統里面的一些框架(除了Akka),沒有全面用它提倡的全面異步化或基于事件的處理方式也沒有用它Web或RPC之類的框架,我們沒有改變傳統模式,還是基于Servlet模型,還是以Spring為中心。這種保守的做法使得大家對整個架構接受起來是比較容易的,并且較好把控。
接著講講Scala在挖財的使用方式。我們用一種很自然很透明的方式就能將Spring這些框架與Scala集成的很好, Scala與Java只稍微有一些差異,所以Scala與Java的生態系統集成這塊是非常順當自然的。但在生態系統集成這塊也會遇到一些問題,就是Akka和Spring都是比較“強勢”的托管者。Spring是bean的托管者,bean的創建和生命周期是由context來掌管的,而Akka系統中所有的actor創建都由其父actor掌管,當把actor也當作spring中的一個bean時,Akka需要做一點”妥協”,它提供了Indirect的Producer,可以包裝到一個Akka的Extension里來簡化actor在spring中的構造。我們選擇了Spring,最大的原因就是希望所有的開發人員在接受這些程序時不會感覺到陌生,他所有的編程習慣沒有改變,只是語法上的不同而已。Spring與Dubbo集成沒有什么問題,只要注意到一點,就是多個系統之間,有的系統不是用Scala的時候,你必須用純的Java來返回API里定義的模型,否則可能一個底層是Scala實現的對象在對方那里反序列化會找不到類。在不同的系統間,你在API這個層面暴露的只能是Java定義API。
然后要用好REPL,它是交互式的工具,它對于大家快速地驗證簡短的幾行程序是非常有幫助的。編譯我們還是沒有選擇sbt,sbt是基于apache ivy的包管理方式,所以出于統一的考慮我們大多數scala也是采用maven管理。每個人發布他的項目必須保證他的項目是自測的或者他自己用mvn spring-boot:run或mvn tomcat7:run的方式能夠跑起來。一些參數的話,編譯系統的參數可能也有一些對比,在提前編譯的時候,我發現你的問題是有好處的,其中比如第二個"-Yno-adapted-args"這個參數我舉一個例子,就是Scala里面有一些類型推導會自動適配,假設有一個Unit類型,就是類似于Java里面Void,println(a:Any)這個方法在Scala的里面定義的其實就是接受Any任何類型的參數,我執行print的時候我可以傳給他任何參數,比如 print(1,2,3),這個時候你會發現與你預期不符,明明我傳遞了三個參數,怎么它最后也成功打印出來了,你就會覺得很詭異,這個到底是編譯器搞了什么鬼還是bug,這個叫做類型適配,這個里面就會涉及到Scala的Tuple類型,叫做元組,元組這種類型就是當我用小括號,一般是用小括號去描述它的,比如說(1、2、3)這樣的元組,他是三個元素的元組,用小括號來描述一個元組時,你如果沒有小括號編譯器發現你這個參數與他期望的參數不匹配的時候,他會把這個東西適配成一個元組,然后這里面變成一個Tuple3,就是一個Any具體的實現,導致這個方法可以通過,所以no-adapted-args這個參數告訴編譯器我們不要去做適配的事情。
第三部分我會介紹一下我們在使用的一些中間件產品如Cobar、Kafka、Akka的問題和經驗。挖財使用Cobar主要是因為之前我們的首席架構師王福強曾參與過Cobar的開發。相對來講在我們的團隊里面,它還是比較穩定的。對于個別的小問題我們是可以規避的,或者我們知道了以后是可以改的。我們現在采用的分區策略有兩種,一種是by range的方式還有一種是by hash。第一種比如說每天你記帳進來,按照這個時間的緯度當一個庫承載不了時,他會按照時間的緯度自動的到下一個庫,所以是比較簡單的方式。第二種就是你開始就明確的建好,比如說我們分成十個,當后面發現不夠的時候,可以再把它變成20個。所以你只要開始預留好,當你翻倍的時候你再做一些事情就可以了。Cobar本身也是支持二維的,但是這個方式稍微有點高階,盡管我們有些場景需要這個。就是說你按照時間維度去劃分時,很多時候用戶的請求,也就是人的流水會分布在所有的時間,這個會造成一些麻煩,他可能會碰到多個區間。但是我們可以用一些方法來解決這些問題。Kafka在挖財是比較重要的中間件,我們現在的規模并不大,業務消息是幾千萬級的情況,日志相對多一些有幾個億,Kafka在我們這就是萬金油,它不僅僅是消息,并且當數據在處理時,把它當成分布式管道來運用的情況是很多的,還有一些數據我現在先把它放在Kafka里,然后又交給其他的程序做后續處理。所以它是被我們廣泛使用的中間件。我們了解到一些金融公司,他們是采用那種EventSourcing的設計思路,也就是事件驅動。事件驅動這塊他會面臨一些問題,就是event要怎么樣去journal好,不管是審計還是其他的一些需要。也看到有一些系統利用它做了應用級的journal。
在我們這邊的話,Kafka的一種方式,就是基于JVM的業務系統直接去連接這個集群,而另外一些異構的系統,通過HTTP-proxy的方式可以簡單地處理。我們稍微有一點不同的是,我們是把offset全部存在Redis里,而ZooKeeper里沒有存offset。
沒有選擇ZooKeeper存儲是因為剛搭建的時候,當時對ZooKeeper的運維能力相對弱一些,所以我們會更偏好使用相對有把握的一些東西,所以我們基于SimpleConsumer做了一些封裝,把offset的存儲為本地文件或Redis。我們在運維上做了一個簡單的約束,當前業務量不是很大的情況下我們盡量減少分區數,大多情況一個分區就夠了,必要的話可以多個topic。
有一個案例就是Kafka,不管同步還是異步發送都會出現消息重復的情況。還有一個我們當時的測試環境, 某個topic發現消息較大發送不成功,調整了一下msgeSize和客戶端的fetchSize,當時leader這邊可以接收這個大的消息,但follow出了問題,副本是使用的replicaSize的,結果發現它是不可修改(至少在我們遇到的例子時的版本里并沒有地方可以修改這個參數)而導致了兩個follow從leader去取數據的時候就取不了這個大的消息,因為這個msgSize大于它的replicaSize,所以造成follow這邊不斷的去連,但是又消費不了,整個網絡流量都異常了。
Akka在我們這邊也是廣泛使用的系統,在業務里面在用它做運算,其他的場景主要是在數據處理,它天生具有擴展性,所以我們開始做一個事情的時候,可能一個Akka就可以處理這個東西,等到將來它量變大時,因為一個Akaa和多個Akka之間的擴展性是有天然的,所以擴展起來還是非常方便的。我們在使用它的時候也因為人員的情況,并沒有使用一些很復雜的情況,比如說持久化那塊曾經去嘗試過,但是發現還是有一些問題,所以后來就沒有再用持久化,還有它的集群也還沒有用。下面我介紹一個簡單的場景。假設你在類似于BAT這樣的流量入口做一些廣告或者做一些引流的時候,可能它會瞬間給你帶來巨大的流量,而如果你自己的私有云或者你自己的機房沒有立刻準備好幾百個機器應對時,可能會宕機。這時就有了公有云和私有云結合的方案,我們就在公有云上面申請上百個甚至幾百個實例,公有云是活動的入口,你這個廣告可能會面向幾千萬的用戶,但是假設這幾千萬的用戶就像一個漏斗層層在損失,最后能用的時候就變成了幾十萬,取決于你的業務特性,在最前頭也就是入口這塊量是最大的,你怎么解決入口這塊呢?可以簡單地在公有云上購買一大堆服務器,然后把流量引入過來之后,把這些請求全部丟到Kafka里,然后用Akka異步做一些處理,這些處理需要你去調你做的這個活動的接口,調用完之后再把這些東西存到Akka,由你自己的集團去搭那些處理過的東西,這樣就極大的利用公有云構建了這些設備,解決了你活動的需求。
還是剛才這個例子里面我們看一下在Akka里的幾個actor角色,這前面我們通過Netty把這些請求丟掉Kafka之后,后面每個Akka都會處理這些請求,第一個就是KafkaReceiver,他負責從Kafka去拉這些消息,這塊采用pull而不是采用push,一個原因是我們的Kafka-client是有自己獨立的線程,并非Akka里的dispatch線程,因為最早實現Kafka-client的時候,它并非為Akka所設計和使用。KafkaReceiver角色里通過一個BlockingQueue來平衡receiver與后續actor之間處理的能力的不匹配問題。
最重要的一點是在actor的世界里面千萬避免同步阻塞,所有的actor底層的線程池是復用的,如果你的HTTP調用在里面堵塞住了,那么可能整個actor都可能被堵塞了,mailbox里后續的消息無法被處理,甚至整個系統都會被阻塞,所以同步在這里面是一定要避免的,當這些調用成功的時候我們交給后面一組actor,異常的時候我們會交給另外一個組,他可能會丟到另外一邊,由另外一邊來處理,這樣就避免了你自己在一套邏輯里容錯的時候要考慮一下這個問題。Akka的感覺比Scala原本自己的actor是要做的更好一些,要不然TypeSafe也不會收購Akka這個團隊,在actor的模型里面,他有很天然的擴展能力,但是在JVM里,他的底層實現還是要通過線程調度,他的線程實現這塊是利用非常高效的fork/join,最大的特點就是雙向隊列,當一個隊列里面被處理完以后,線程會從其他隊列里偷取別人的任務,這樣會解決不均的情況,有的處理慢有的處理快,處理快的就幫助處理慢的這些,所以他在實際執行過程中Akka還是很容易把CPU跑滿,所以利用率還是非常高效。
我們大部分的數據處理,是用Akka和Kafka的簡單模式,就是Unix上的管道模式,現在是用分布式的方式,我們用它也還是要和業務結合,就比如說你要聚焦這個服務,而這些現有的服務都是Dubbo的一種,所以Akka與Spring整合起來還是很方便的。最后我們總結一下對Akka使用的一些小結。第一是盡可能保證Actor職責盡可能單一。第二避免阻塞,阻塞在Akka的模式下一定要避免這個方式。第三是Supervisor和錯誤處理也是要有自己的策略,到底是收到以后重啟它還是立刻把這個上報,這個都要根據自己的業務情況確定,在消息處理的時候要保持平衡,這邊生產出的那邊要消費了,你的能力強和弱的時候你要自己去考慮。有些地方我們可以通過加一些簡單的監控,比如對mailbox的計數,或者訪問統計,將待處理的任務用WaterMark標記出來,就能夠知道當前Actor的處理能力,讓我們更好的了解各個Actor狀況。還有常用的模式,類似于替身模式,或者短路模式等等。因為時間的關系我們就不去展開的講。
最后分享一個小技巧,就是關于Actor的優雅關閉。其實優雅關閉的話題在每個系統里都要有的,比如你發布后,你怎么樣讓這個系統優雅地下線,還有你后面關閉服務的時候,Actor或Akka如何優雅關閉。你應該先關閉什么后關閉什么從而讓這個順序有所保障,這就是讓你盡可能的做到優雅。
有一種模式就是我們根據Terminator模式設計的一個變種Governor模式,所有干活的Actor都由Governor來掌管,Governor是他們的父Actor,他們在消亡中的時候所有都是由Governor來掌控他們的生殺大權的。另外一個角色是Master,他不是真正干活的,他只是負責這個任務要開始真正執行了,Master跟Governor之間是協作,Master把自己注冊到Governor,由Governor去watch它,當Master死亡的時候會發送消息給Governor。實現上先提供一個Governor的模板,在這里負責創建那些干活的Actor,以及順序停止的時候先停止誰后停止誰這么一個過程。把他的順序制訂好,比如說KafkaReceiver,他相比Processor的順序是1,然后Processor的順序是2,大家在關閉的時候都會按照這個順序先把KafkaReceiver給關閉了,然后再關閉Processor,最后在關閉Storer。當Master發給自己一個毒藥丸說我要退出了時,因為Governor會關注Master,所以他會按順序殺死子Actor。
最后推薦一些Scala相關的書。第一本是《Programming in Scala》,是Scala比較重量級的一本書,Scala之父他們幾個人聯合寫的一本書,里面寫的很含蓄但是信息量是非常大的,所以Scala真的要去消化很多遍。第二本是《Scala for the impatient》,第三本是《Scala in depth》這個是相對高級一點的書,最后一本是《Functional Programming In Scala》,這本書里面講到了很多類型性的和函數式的比較高階的比如 monad等一些特性。
來自: http://www.infoq.com/cn/articles/scala-architecture-wacai