淘寶從2018年開始對總體架構進行反應式升級, 取得了很是好的成績。其中『猜你喜歡』應用上限 QPS 提高了 96%,同時機器數量縮減了一半;另外一核心應用『個人淘寶』實際線上響應時間降低了 40% 以上。PayPal憑藉其基於Akka構建的反應式平臺squbs,僅使用8臺2vCPU虛擬機,天天能夠處理超過10億筆交易,與基於Spring實現的老系統相比,代碼量下降了80%,而性能卻提高了10倍。可以取得如此好的成績,人們不由要問反應式究竟是什麼? 其實反應式並非一個新鮮的概念,它的靈感來源最先能夠追溯到90年代,可是直到2013年,Roland Kuhn等人發佈了《反應式宣言》後才慢慢被人熟知,繼而在2014年迎來爆發式增加,比較有意思的是,同時迎來爆發式增加的還有領域驅動設計(DDD),緣由是2014年3月25日,Martin Fowler和James Lewis向大衆介紹了微服務架構,而反應式和領域驅動是微服務架構得以落地的有力保障。緊接着各類反應式編程框架相繼進入你們視野,如RxJava、Akka、Spring Reactor/WebFlux、Play Framework和將來的Dubbo3等,阿里內部在作反應式改造時也孵化了一些反應式項目,包括AliRxObjC、RxAOP和AliRxUtil等。 從目前的趨勢看來,反應式概念將會逐漸深刻人心, 而且將引領下一代技術變革。javascript
本文將向你們介紹什麼是反應式,以及爲何要採用反應式架構,而且經過一個編程示例,深刻分析傳統的編程方式會帶來哪些問題和挑戰,以及如何作異步化改造,順利邁出反應式架構演進的第一步。java
1 什麼是反應式?
1.1 反應式介紹
爲了直觀地瞭解什麼是反應式,咱們先從一個你們都比較熟悉的類比開始。首先打開Excel,在B、C、D三列輸入以下公式:react
B、C和D三列每一個單元格的值均依賴其左側的單元格,當咱們在A列依次輸入一、2和3時,變化會自動傳遞到了B、C和D三列,並觸發相應狀態變動,以下圖:程序員
咱們能夠把A列從上到下想象成一個數據流,每個數據到達時都會觸發一個事件,該事件會被傳播到右側單元格,後者則會處理事件並改變自身的狀態。這一系列流程其實就是反應式的核心思想。算法
經過這個例子,你應該能感覺到反應式的核心是數據流(data stream), 下面咱們再來看一個例子。咱們不少人天天都會坐地鐵上下班,地鐵每兩分鐘一班,而且同一條軌道會被不少地鐵共享,你會不會由於擔憂追尾,而不敢坐首尾兩節車箱呢? 其實若是採用反應式架構構建地鐵系統,就無需擔憂追尾問題。在反應式系統中,每輛地鐵都會實時將本身的速度和位置等狀態信息通知給上下游的其餘地鐵,同時也會實時的接收其餘地鐵的狀態信息,並實時作出反饋。例如當發現下游地鐵忽然意外減速,則當即調整自身速度,並將減速事件通知到上游地鐵,如此,整條軌道上的全部地鐵造成一種回壓機制(back pressure),根據上下游狀態自動調整自身速度。 下面咱們來看下維基百科關於反應式編程的定義:sql
反應式編程 (reactive programming) 是一種基於數據流 (data stream) 和 變化傳遞 (propagation of change) 的聲明式 (declarative) 的編程範式。typescript
從上面的定義中,咱們能夠看出反應式編程的核心是數據流以及變化傳遞。維基百科給出的定義比較通用,具備普適性,沒有區分數據流的同步和異步模式, 更準確地說,異步數據流(asynchronous data stream)或者說反應式流(reactive stream)纔是反應式編程的最佳實踐。細心的讀者會發現,講了這麼多,這不就是觀察者模式(Observer Pattern)嘛! 其實這個說法並不許確,其實反應式並非指具體的技術,而是指一些架構設計原則, 觀察者模式是實現反應式的一種手段,在接下來的反應式流(Reactive Stream)一節,咱們會發現反應式流基於觀察者模式擴展了更多的功能,更強大也更易用。數據庫
1.2 反應式歷史
早在1985年,David Harel 和 Amir Pnueli 就發表了《反應式系統的開發》論文,在論文中,他們採用二分法對複雜的計算過程進行概括,提出了轉換式(transformative)與反應式(reactive)系統。其中反應式系統就是指可以持續地與環境進行交互,而且及時地進行響應。例如視頻監控系統會持續監測, 並當有陌生人闖入時馬上觸發警報。編程
表1 反應式歷史設計模式
時間 | 事件 |
---|---|
1985 | 《反應式系統的開發》by David Harel & Amir Pnueli |
1997 | Functional reactive programming (FRP) by Conal Elliott |
2009 | Rx 1.0 for .NET by Erik Meijer’s team at Microsoft |
2013 | Rx for Java by Netflix |
2013 | 反應式宣言 V1.0 |
2014 | 反應式宣言 V2.0 |
2015 | Reactive Streams |
Now | RxJava 3, Akka Streams, Reactor, Vert.x 3, Ratpack |
圖1 谷歌搜索趨勢
從Google搜索趨勢上能夠看出,從2013年6月份開始,反應式編程的搜素趨勢出現了爆發式增加,緣由是2013年6月反應式宣言發佈了第一個版本。
1.3 ReactiveX 介紹
ReactiveX是Reactive Extensions的縮寫,通常簡寫爲Rx,最初是LINQ的一個擴展,由微軟的架構師Erik Meijer領導的團隊開發,在2012年11月開源。Rx是一個編程模型,目標是提供一致的編程接口,幫助開發者更方便的處理異步數據流。Rx支持幾乎所有的流行編程語言,大部分語言庫由ReactiveX這個組織負責維護,比較流行的有RxJava/RxJS/Rx.NET/Rx.Scala/ Rx.Swift,社區網站是http://reactivex.io/。
1.4 反應式宣言
2013年6月,Roland Kuhn等人發佈了《反應式宣言》, 該宣言定義了反應式系統應該具有的一些架構設計原則。符合反應式設計原則的系統稱爲反應式系統。根據反應式宣言, 反應式系統須要具有即時響應性(Responsive)、回彈性(Resilient)、彈性(Elastic)和消息驅動(Message Driven)四個特質,如下內容摘自反應式宣言官網, 描述比較抽象,你們沒必要糾結細節,瞭解便可。
- 即時響應性(Responsive)。系統應該對用戶的請求即時作出響應。即時響應是可用性和實用性的基石, 而更加劇要的是,即時響應意味着能夠快速地檢測到問題而且有效地對其進行處理。
- 回彈性(Resilient)。 系統在出現失敗時依然能保持即時響應性, 每一個組件的恢復都被委託給了另外一個外部的組件, 此外,在必要時能夠經過複製來保證高可用性。 所以組件的客戶端再也不承擔組件失敗的處理。
- 彈性(Elastic)。 系統在不斷變化的工做負載之下依然保持即時響應性。 反應式系統能夠對輸入負載的速率變化作出反應,好比經過橫向地伸縮底層計算資源。 這意味着設計上不能有中央瓶頸, 使得各個組件能夠進行分片或者複製, 並在它們之間進行負載均衡。
- 消息驅動(Message Driven)。反應式系統依賴異步的消息傳遞,從而確保了鬆耦合、隔離、位置透明的組件之間有着明確邊界。 這一邊界還提供了將失敗做爲消息委託出去的手段。 使用顯式的消息傳遞,能夠經過在系統中塑造並監視消息流隊列, 並在必要時應用回壓, 從而實現負載管理、 彈性以及流量控制。 使用位置透明的消息傳遞做爲通訊的手段, 使得跨集羣或者在單個主機中使用相同的結構成分和語義來管理失敗成爲了可能。 非阻塞的通訊使得接收者能夠只在活動時才消耗資源, 從而減小系統開銷。
1.5 Reactive Streams
反應式宣言僅闡述了設計原則,並無給出具體的實現規範,致使每一個反應式框架都各自實現了一套本身的API規範,且相互之間沒法互通。爲了解決這個問題,Reactive Streams規範應運而生。
Reactive Streams的目標是定義一組最小化的異步流處理接口,使得在不一樣框架之間,甚至不一樣語言之間實現交互性。Reactive Streams規範包含了4個接口,7個方法,43條規則以及一套用於兼容性測試的標準套件TCK(The Technology Compatibility Kit)。該規範已經成爲了業界標準, 而且在Java 9中已經實現,對應的實現接口爲java.util.concurrent.Flow。 有一點須要提醒的是,雖然Java 9已經實現了Reactive Streams,但這並不意味着像RxJava、Reactor、Akka Streams這些流處理框架就沒有意義了,事實上偏偏相反。Reactive Streams的目的在於加強不一樣框架之間的交互性,提供的是一組最小功能集合,沒法知足咱們平常的流處理需求,例如組合、過濾、緩存、限流等功能都須要額外實現。流處理框架的目的就在於提供這些額外的功能實現,並經過Reactive Streams規範實現跨框架的交互性。
舉個例子來講,MongoDB的Java驅動實現了Reactive Streams規範, 開發者使用任何一個流處理框架,僅須要幾行代碼便可實時監聽數據庫的變化。例以下面是基於Akka Stream的實現代碼:
mongo
.collection("users") .watch() .toSource .groupedWithin(10, 1.second) .throttle(1, 1.second) .runForeach { docs => // 處理增量數據 }
上面的幾行代碼實現了以下功能:
- 將接收到的流數據進行緩衝以方便批處理,知足如下任一條件便結束緩衝並向後傳遞
- 緩衝滿10個元素
- 緩衝時間超過了1000毫秒
- 對緩衝後的元素進行流控,每秒只容許經過1個元素
1.6 小結
本章首先經過形象的例子讓你們對反應式系統有一個直觀的認知,而後帶領你們一塊兒回顧了反應式的發展歷史,最後向你們介紹了三個反應式項目,包括ReactiveX、反應式宣言和Reactive Streams。 ReactiveX是反應式擴展,旨在爲各個編程語言提供反應式編程工具。反應式宣言站在一個更高的角度,使用抽象語言向你們描述什麼是反應式系統,以及實現反應式系統應該遵循的一些設計原則。Reactive Streams規範的目的在於提升各個反應式框架之間的交互性,自己並不適合做爲開發框架直接使用,開發者應該選擇一個成熟的反應式框架,並經過Reactive Streams規範與其它框架實現交互。
2 爲何須要反應式?
2.1 命令式編程 VS 聲明式編程
實際上咱們絕大多數程序員都在使用傳統的命令式編程,這也是計算機的工做方式。命令式編程就是對硬件操做的抽象, 程序員須要經過指令,精確的告訴計算機幹什麼事情。這也是編程工做中最枯燥的地方,程序員須要耗盡腦汁,將複雜、易變的業務需求翻譯成精確的計算機指令。
聲明式編程是解決程序員的利器,聲明式編程更關注我想要什麼(What)而不是怎麼去作(How)。SQL是最典型的聲明式語言,咱們經過SQL描述想要什麼,最終由數據庫引擎執行SQL語句並將結果返回給咱們。
SELECT COUNT(*) FROM USER u WHERE u.age > 30
1.5節使用Akka Stream實現監聽MongoDB的代碼也是典型的聲明式編程,若是採用命令式方式重寫, 不只費時費力,並且還會致使代碼量暴增,最重要的是要經過更多的單元測試保證明現的正確性。
反應式架構推薦使用聲明式編程, 使用更接近天然語言的方式描述業務邏輯, 代碼清晰易懂而且富有表達力, 最重要的是大大下降了後期維護成本。
2.2 同步編程 VS 異步編程
當談到同步與異步時,就不得不提一下阻塞與非阻塞的概念,由於這兩組概念很容易混淆。致使混淆的緣由是它們在描述同一個東西,可是關注點不一樣。 阻塞與非阻塞關注方法執行時當前線程的狀態,而同步與異步則關注方法調用結果的通知機制。由於是從不一樣角度描述方法的調用過程,因此這兩組概念也能夠相互組合,即將線程狀態和通知機制進行組合。例如JDK1.3及之前的BIO是同步阻塞模式,JDK1.4發佈的NIO是同步非阻塞模式,JDK1.7發佈的NIO.2是異步非阻塞模式。
跟命令式編程同樣,同步編程也是目前被普遍採用的傳統編程方式。同步編程的優勢是代碼簡單而且容易理解,代碼按照前後順序依次執行;缺點是CPU利用率很是低,大部分時間都白白浪費在了IO等待上。
異步編程經過充分利用CPU資源並行執行任務, 在執行時間和資源利用率上遠遠高於同步方式。舉個例子來講,對於一個10核服務器,使用同步方式抓取10個網頁,每一個網頁耗時1秒,則總耗時爲10秒;若是採用異步方式,10個抓取任務分別在各自的線程上執行,總耗時只有1秒。 構建反應式系統並不是易事,尤爲是針對遺留系統進行改造,這將會是一個較爲漫長的過程。反應式架構的核心思想是異步非阻塞的反應式流,做爲過渡階段,咱們能夠選擇先對系統進行徹底異步化重構,爲進一步向反應式架構演進奠基基礎。接下來,咱們將先分析一個傳統的同步示例,而後針對該示例進行異步化重構。
2.3 同步編程示例
假設咱們要實現一個查詢手機套餐餘額的方法, 該方法接受一個手機號參數,返回該手機號的套餐餘額信息, 包括剩餘通話時間、剩餘短信數量和剩餘網絡流量。 因爲查詢套餐餘額須要連續發起三次同步阻塞的數據庫查詢請求,因此在實現中須要利用緩存提升讀取性能, 代碼以下:
private PhonePlanCache cache; public PhonePlan retrievePhonePlan(String phoneNo) { PhonePlan plan = cache.get(phoneNo); if (plan != null) { return plan; } Long leftTalk = readLeftTalk(phoneNo); Long leftText = readLeftText(phoneNo); Long leftData = readLeftData(phoneNo); return new PhonePlan(leftTalk, leftText, leftData); }
首先咱們檢查是否能夠直接從緩存中讀取套餐餘額信息,若是能夠則直接返回, 不然連續發起三次同步阻塞的遠程調用, 從數據庫中依次讀取通話餘額、短信餘額和流量餘額。代碼邏輯很是簡單,可是因爲同步阻塞代碼對線程池依賴很是嚴重,接下來咱們還須要根據SLA估算線程池和鏈接池大小。估算的過程並不容易,好在咱們有利特爾法則。
1954年, John Little基於等候理論提出了利特爾法則(Little's law): 在一個穩定的系統中,系統能夠同時處理的請求數量L, 等於請求到達的平均速度 λ 乘以請求的平均處理時間W, 即:
L = λ * W
這個法則一樣能夠用來計算線程池和鏈接池大小。 例如系統每秒接收1000個請求,每一個請求的平均處理時間是10ms, 則合適的數據庫鏈接池大小應該爲10。 也就是說系統能夠同時處理10個請求。 從長時間來看,系統平均會有10個線程在等待數據庫鏈接上的響應。 可是須要注意的是,利特爾法則只適用於一個穩定系統, 沒法處理峯值狀況, 而一般系統請求數量的峯值會比平均值高不少。假設爲了應付峯值狀況,咱們將線程池大小調整爲50, 因爲鏈接池大小仍爲10,因此會致使大量線程在等待可用鏈接, 咱們須要再次增大鏈接池大小以改善系統性能。一般通過如此反覆調整後的參數已經嚴重偏離了利特爾法則, 致使系統性能嚴重降低,在高併發場景下,若是網絡稍有抖動或數據庫稍有延遲,則會致使瞬間積壓大量請求, 若是沒有有效的應對措施,系統將面臨癱瘓風險。
2.4 同步編程面臨的挑戰
傳統應用一般基於Servlet容器進行部署,而Servlet是基於線程的請求處理模型。從上文的討論中咱們發現,一般須要設置一個較大的線程池以得到較好的性能,較大的線程池會致使如下三個問題:
- 額外的內存開銷。 在Java中,每一個線程都有本身的棧空間,默認是1MB。若是設置線程池大小爲200,則應用在啓動時至少須要200M內存,一方面形成了內存浪費,另外一方面也致使應用啓動變慢。試想一下,若是同時部署1000個節點,這些問題將會被放大1000倍。
- CPU利用率低。 有兩個方面緣由會致使極低的CPU利用率。一方面是在Oracle JDK 1.2版本以後,全部平臺的JVM實現都使用1:1線程模型(Solaris是個特例),這意味着一個Java線程會被映射到一個輕量級進程上,而有效的輕量級進程數量取決於CPU的個數以及核數。若是Java的線程數量遠大於有效的輕量級進程數量,則頻繁的線程上限文切換會浪費大量CPU時間; 另外一方面,因爲傳統的遠程操做或IO操做均爲阻塞操做,會致使執行線程被掛起從而沒法執行其餘任務,大大下降了CPU的利用率。
- 資源競爭激烈。 當增大線程池後,其餘的共享資源便會成爲性能瓶頸,如數據庫鏈接池資源。若是存在共享資源瓶頸,即便設置再大的線程池,也沒法有效地提高性能。此時會致使多個線程競爭數據庫鏈接, 使得數據庫鏈接成爲系統瓶頸。
除了上面這些問題,同步編程還會深入地影響到咱們的架構。
假設咱們準備開發一個單點登陸微服務,微服務框架使用 Dubbo 2.x,該版本還沒有支持反應式編程,微服務接口之間調用仍然是同步阻塞方式。 假設咱們須要實現以下兩個接口:
- 用戶登陸接口
- 令牌驗證接口
對於用戶登陸接口,因爲須要屢次訪問數據庫或緩存,而且須要使用Argon2等慢哈希算法進行密碼校驗,致使平均響應時間較長,約爲500毫秒。而對於令牌驗證接口,因爲只須要作簡單的簽名校驗,因此平均響應時間較短,約爲5毫秒。 假設因爲業務須要,用戶登陸接口的性能指標只須要達到1000tps便可,而令牌驗證接口的性能指標則須要達到100,000tps。
一般來講,這兩個接口會在同一個微服務類中實現,也一般會被髮布到同一個容器中對外提供服務。爲了知足業務須要,咱們先來算一下須要多少硬件成本? 爲了簡化討論,咱們認爲令牌驗證接口無需硬件成本,只關注用戶登陸接口便可。根據利特爾法則, 總線程數量(L) = TPS(λ)*平均響應時間(W)
, 即:
總線程數量(L) = (1000*0.5) = 500
假設每一個計算節點配置爲4C8G, 那麼一共須要 (500/4)=125臺計算節點。 區區的1000tps居然須要125臺計算節點!你覺得這就完了嗎? 1000tps只是平常的請求壓力,若是考慮峯值狀況呢?假設峯值請求是10, 000tps,而且會持續10秒, 那麼在這10秒內系統也能夠看作是穩定狀態, 那麼根據利特爾法則,就須要部署1250臺計算節點。 還有更壞的狀況,若是某個節點因爲數據庫延遲或網絡抖動等狀況,致使用戶登陸請求積壓,則用戶登陸請求會耗盡全部請求處理線程,致使本來能夠快速響應的令牌驗證請求沒法被及時處理,而令牌驗證接口的tps是100,000,這意味着1秒鐘就會積壓100,000個令牌驗證請求, 系統已經處在危險邊緣,隨時都會崩潰。
爲了解決令牌驗證接口的快速響應問題,咱們只能調整架構,將登錄和驗證拆分紅兩個單獨的微服務,而且各自部署到獨立的容器中。這樣是否是就萬事大吉了呢?很不幸,單點登陸迎來了一個新需求,針對員工帳戶須要遠程調用LDAP進行認證, 而遠程調用LDAP也是一個同步阻塞操做,這意味着每個LDAP遠程調用都會掛起一個線程,大量的遠程調用也會耗盡全部線程,這些被掛起的線程啥都不作,就在那傻傻的等待遠程響應。這其實就是微服務調用鏈雪崩的罪魁禍首。兩個微服務之間調用已經如此棘手了,那若是調用鏈上有10個甚至更多的微服務調用呢? 那將是一場噩夢!
其實全部問題的根源均可以歸結爲傳統的同步阻塞編程方式。尤爲是在微服務場景下,隨着調用鏈長度的不斷增加,風險也將愈來愈高, 其中任何一個節點同步阻塞操做都會致使其下游全部節點線程被阻塞,若是問題節點的請求產生積壓,則會致使全部下游節點線程被耗盡,這就是可怕的雪崩。
2.5 異步編程示例
咱們說異步編程一般是指異步非阻塞的編程方式,即要求系統中不能有任何阻塞線程的代碼。在現實狀況下,想實現徹底的異步非阻塞很是困難, 由於還有不少第三方的庫或驅動仍然採用同步阻塞的編程方式。咱們須要爲這些庫或驅動指定獨立的線程池,以避免影響到其餘服務接口。
利用Java 8提供的CompletableFuture和Lambda兩個特性,咱們對2.2節的示例進行異步化改造,改造後代碼以下:
private PhonePlanCache cache; public CompletableFuture<PhonePlan> retrievePhonePlan(String phoneNo) { PhonePlan cachedPlan = cache.get(phoneNo); if (cachedPlan != null) { return CompletableFuture.completedFuture(cachedPlan); } CompletableFuture<Long> leftTalkFuture = readLeftTalk(phoneNo); CompletableFuture<Long> leftTextFuture = readLeftText(phoneNo); CompletableFuture<Long> leftDataFuture = readLeftData(phoneNo); CompletableFuture<PhonePlan> planFuture = leftTalkFuture.thenCombine(leftTextFuture, (leftTalk, leftText) -> { PhonePlan plan = new PhonePlan(); plan.setLeftTalk(leftTalk); plan.setLeftText(leftText); return plan; }).thenCombine(leftDataFuture, www.xinyiylzc.cn (plan, leftData) -> { plan.setLeftData(leftData); return plan; }); return planFuture; }
咱們發現雖然異步編程能夠得到性能上的提高,可是編碼複雜度卻提高了不少,而且若是異步調用鏈太長,還容易致使回調地獄。
ES2017 在編程語言級別提供了async/await關鍵字用於簡化異步編程,讓開發者以同步的方式編寫異步代碼,例如:
const leftTalk = await readLeftTalkPromise(www.ping2yl.com phoneNo); const leftText = await readLeftTextPromise(www.huanhua2zhuc.cn phoneNo); const leftData = await readLeftDataPromise(www.hdptzc.cn phoneNo); const phonePlan = new PhonePlan(leftTalk, leftText, www.yunzeyle.cn leftData);
在Scala中使用 for 語句也能夠簡化異步編程,例如:
for { leftTalk <- leftTalkFuture leftText <- leftTextFuture leftData <- leftDataFuture } yield new PhonePlan(leftTalk, leftText, leftData)
看到在其它語言中異步編程如此簡單,是否是很羨慕? 別急, 在下一篇文章中,咱們將會看到如何利用反應式編程簡化異步調用問題。
3 總結
本文經過兩部份內容爲你們介紹了反應式的基本概念。第一部分介紹什麼是反應式,包括反應式的發展歷史和一些相關項目。第二部分介紹爲何要反應式,經過一個傳統的編程示例向你們闡述同步編程所面臨的問題和挑戰,尤爲在微服務場景下,面對成千上萬的微服務接口以錯綜複雜的調用鏈,爲了規避可能致使的雪崩風險,咱們不得不對已有的架構進行無心義改造,不只增長開發成本,並且致使部署和運維難度增長,同步編程方式已經深入地影響到了咱們的架構。可是無論怎麼說,反應式改造是一個長期的過程, 在這個過程當中,咱們須要不斷地完善基礎設施,同時也要注重對開發人員的培養, 由於反應式編程是對傳統方式的一次變革,編程模式和思惟都須要進行轉換,這對於開發人員來講一樣是一次挑戰。轉型雖然痛苦,可是成功蛻變以後便會迎來新生。
4 參考
- 全面異步化:淘寶反應式架構升級探索
- 反應式宣言
- 反應式設計模式 Roland Kuhn, Brian Hanafee, Jamie Allen; 何品,邱嘉和,王石衝譯; 林煒翔校
- 反應式Web應用開發 Manuel Bernhardt; 張衛濱譯
- Reactive programming vs. Reactive systems,Jonas Bonér and Viktor Klang
- PayPal Blows Past 1 www.jiuyueguojizc.cn Billion Transactions Per Day Using Just 8 VMs With Akka, Scala, Kafka and Akka Streams