GrowingIO 響應式編程探索和實踐

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者:林生生,GrowingIO 運營產品線研發經理,主要負責 GrowingIO 智能運營產品線研發管理工作。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"背景"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"GrowingIO 是一家提供增長平臺的公司。在 2018 年初我們推出了基於底層數據能力的智能運營平臺,結合精準的用戶分羣,數據採集以及多種運營方式,幫助企業客戶用數據驅動用戶運營,隨時驗證假設,助力產品增長。產品有以下特點:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"支持多種觸達用戶的渠道 :站內:彈窗、資源位,站外:Push、短信、Webhook。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"多平臺支持,彈窗支持:App、Web、H5和小程序。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"輕鬆建立數據運營的閉環。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下圖是運營平臺站外觸達業務流程圖。用戶可以隨時發起一次站外運營活動,通常是一個站外的觸點(推送、短信、Webhook)。後臺系統需要查詢底層的數據平臺接口,獲取此次活動對應的人羣信息,同時組裝活動數據並對外投遞任務。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/27/27cae57ec4ca5a91dc92205951b69ac8.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在這個業務場景,需要解決如下幾個問題:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"系統外界輸入是突發的,無法提前預估量級,系統需要在不斷變化的負載中保持即時響應。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"依賴底層數據服務,如果外部系統無法工作,爲了保證回彈性需要有熔斷和恢復機制。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"業務流程較長,爲保證及時響應需要對任務進行異步處理。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"綜上,爲了最大化利用服務器資源、提高服務穩定性和優化終端用戶體驗,GrowingIO 服務端團隊在異步與反應式編程上做了一些實踐。本文將介紹在優化過程中的探索與思考,希望能爲讀者帶來幫助。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"異步與響應式"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"傳統服務端程序一般採用同步阻塞模型,通過分配更多線程來支撐更多請求,這符合常人思維模式,但在突發流量的情況下,同步模型可能會導致線程池耗盡,基於一個請求一個線程的服務模式無法做到動態伸縮。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/3c/3c587c075ca798b4a5133d9636248070.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而異步編程的做法是基於一個共享的線程池,所有操作都是回調。如果遇到耗時的操作,線程並不會阻塞等待操作完成,而是會被釋放回線程池中繼續接受新的請求。等到耗時操作完成後(一般都是IO操作),通過消息機制重新向線程池申請線程恢復之前的請求代碼。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/b6/b65f748e1ae39ae02f6a018b59324571.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":" 我們可以簡單寫個程序簡單實驗一下,實現相同邏輯:1. 查詢 db 2. 查詢外部系統 3. 組裝信息返回。唯一區別是一個是同步調用的實現,另一個是採用完全異步的方式實現。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本地使用相同的 jmeter 參數模擬併發測試,得到結果如下,從左到右每列的含義分別爲:請求名稱、請求數目、失敗請求數目、錯誤率(本次測試中出現錯誤的請求的數量/請求的總數)、平均響應時間、最短響應時間、最大響應時間、90%用戶響應時間、95%用戶響應時間、99%用戶響應時間、吞吐量。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"總體測試結果如下:"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/04/04a4fa4abb53228258e3b9a7cd60614a.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/57/578322ef9871b221c27aa582ab1ac243.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":"center","origin":null},"content":[{"type":"text","marks":[{"type":"size","attrs":{"size":10}}],"text":"同步代碼測試結果"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/49/49e9f5b8076db88f9e03a4a73f61f1ab.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":"center","origin":null},"content":[{"type":"text","marks":[{"type":"size","attrs":{"size":10}}],"text":"異步代碼測試結果"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"同步代碼總共完成了 260 次請求,平均響應時間約 5 秒,因爲阻塞程序耗盡了線程池導致程序出現了拒絕服務的情況,產生了 13% 的錯誤率。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"異步代碼整體吞吐量有明顯提升,相同時間內完成了 3000 次請求。錯誤率爲 0 ,並且整體沒有出現拒絕服務的情況。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"可以看到基於消息驅動機制的異步系統能極大提高資源利用率,提高系統的吞吐量。而響應式系統則在消息驅動的基礎上增加了三個要求:及時響應性、回彈性和可擴展性。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"簡單來說具備以下四個特點的系統可以稱爲一個響應式系統:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"即時響應性,這個是響應式系統的核心目標。一個具有響應性的系統就是一個無論在什麼情況下都能快速對客戶的操作做出反饋的系統,包括事件、用戶請求、失敗場景,最終目的是保證客戶良好的體驗。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"回彈性,指的是系統從故障災難中恢復的能力。主要分兩部分,一個是系統需要考慮失敗的情況,二是系統要能從失敗中恢復回來。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"擴展性(彈性),指的是系統在不斷變化的工作負載之下依然保持即時響應性。可擴展分爲單機縱向擴展和橫向線性擴展。這裏主要指的是系統可以通過分片、複製等方式進行橫向擴展,從而避免系統產生明顯的性能瓶頸。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":4,"align":null,"origin":null},"content":[{"type":"text","text":"消息驅動的,這是響應式系統的基礎。從上面異步系統的優勢和原理分析可以看到,基於消息驅動的程序能最大化利用機器資源,同時鬆散耦合的設計創建了一個能讓業務邏輯保持清潔的環境,顯式的隔離失敗有利於系統自動恢復。"}]}]}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/91/910e210c8013f2b66a498fd2b09d4af5.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對應的響應式編程是一種程序設計思想,在 java 8 中首次引入了響應式流的規範,即 Reactive Streams 接口。Reactive Streams 非常類似於 JPA 或 JDBC,都是 API 規範,實際使用時需要採用對應的具體實現。JDK 提供的 Reactive Streams 接口:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Publisher.java","title":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Publisher.java"},"content":[{"type":"text","text":"org.reactivestreams.Publisher"}]},{"type":"text","text":": 代表一個潛在的無界數據源,根據 Subscriber 的需要發佈新的數據。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Subscriber.java","title":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Subscriber.java"},"content":[{"type":"text","text":"org.reactivestreams.Subscriber"}]},{"type":"text","text":": 數據源消費者,通過 Subscription 向數據源請求數據。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Subscription.java","title":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Subscription.java"},"content":[{"type":"text","text":"org.reactivestreams.Subscription"}]},{"type":"text","text":": 代表一次數據消費請求。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Processor.java","title":"https://github.com/reactive-streams/reactive-streams-jvm/blob/master/api/src/main/java/org/reactivestreams/Processor.java"},"content":[{"type":"text","text":"org.reactivestreams.Processor"}]},{"type":"text","text":": 處理器,代表一個既能發佈數據也能消費數據的組件。"}]}]}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/77/77e3a93604df2dc0a0b58fe96fa4b955.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Reactive Streams API 的範圍是找到一組最小的接口,這些接口將描述必要的操作和實體,從而實現具有非阻塞背壓的異步數據流。社區對於 Reactive Streams 的實現比較多,這裏做一個簡單的彙總和對比。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/df/df444a5aafde6247ea0c6c40596912b0.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"總結一下,如果是移動設備使用 rxjava 是比較合適的選擇。如果是在服務端使用 spring 框架做開發,採用基於 reactor 實現的 webflux 更合適。如果是對性能要求很高,業務相對簡單的場景,選擇 vertx 可以最大限度發揮機器性能。而 gio 的真實場景是服務端的複雜業務系統,同時使用 scala 作爲開發語言並且使用 play 作爲 web 開發框架。所以在系統構建之初很自然的選擇了 akka 作爲我們的響應式系統的實現基礎。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"使用 Actor 構建反應式系統"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在最初的時候並沒有直接採用 akka-stream,而是選擇更爲簡單,建模能力更強的 akka-actor 作爲系統實現的基礎。Akka-actor 是基於 actor 模型構建的異步工具包, 使用 akka-actor 可以很輕鬆的進行基於消息驅動的異步編程。Actor 的基礎就是消息傳遞,一個 actor 可以認爲是一個基本的計算單元,它能接收消息並執行運算,它也可以發送消息給其他 actor。Actors 之間相互隔離,它們之間並不共享內存,所以 Actor 不需要去關注鎖和內存原子性等一系列多線程常見的問題。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/2c/2c89fb57c5767845107b433d3df02b24.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Akka-actor 最核心的實現包含三個部分:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"Mailbox:可以是一個有界或者無界的消息隊列,用於存放所有收到的消息。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"Behavior:具體的消息處理邏輯。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"State:actor 包含的狀態,每個 actor 的狀態都是獨立的避免鎖競爭。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Actor 本身是不綁定線程的,相同進程的 actor 共享一個線程池,mailbox 是一個 runnable 對象,核心邏輯就是從隊列中取出消息調用 behavior 進行處理。"}]},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":" override final def run(): Unit = {\n try {\n if (!isClosed) { //Volatile read, needed here\n processAllSystemMessages() // 先處理系統級別消息\n processMailbox() // 然後處理普通消息\n }\n } finally {\n setAsIdle() //Volatile write, needed here\n dispatcher.registerForExecution(this, false, false)\n }\n }"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在同一個進程中,可以通過調整 akka-actor 線程池大小來進行縱向負載伸縮。同時,akka-actor 支持在一個系統中綁定不同類型、數量的線程池。比如在一些耗時較長的 IO 場景下可以單獨配置一個線程池起到隔離的目的。對需要橫向擴展的場景,akka 提供了基於 gossip 協議的點對點去中心化集羣解決方案 akka-cluster。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/bc/bc480b382871e121d45c5d10c206bc0a.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Akka-cluster 通過 gossip 協議進行成員之間的發現和狀態同步,同時提供了更高層的集羣工具:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"Cluster Singleton:全局唯一實例,能保證實例的全局唯一性,同時在實例出現問題的時 clsuter 能在另一個節點上重建它。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"Cluster Sharding:通過 sharding,集羣中的 actor 能跨越多個節點通過 actorRef 標識進行交互,不需要關心它們在集羣中的物理定位。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"Distributed Data"},{"type":"text","marks":[{"type":"strong"}],"text":":"},{"type":"text","text":"當需要在一個cluster的節點之間共享數據時,Distributed Data 提供了 k/v 存儲 API。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":4,"align":null,"origin":null},"content":[{"type":"text","text":"Distributed Publish Subscribe"},{"type":"text","marks":[{"type":"strong"}],"text":":"},{"type":"text","text":"集羣中的 actor 可以發佈訂閱點對點的廣播消息。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"理論上使用 akka-actor 和 akka-cluster 可以使系統具備極強的擴展性(彈性)。但是在實際使用中我們並沒有採用 akka-cluster 去擴展系統,原因也很簡單,akka-cluster 生產案例太少,功能上過於複雜,不利於大規模推廣。最終我們使用了傳統的消息中間件作爲系統橫向擴展的解決方案。在單機內使用 akka-actor,涉及到跨節點通信的場景使用消息中間件進行通信。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/e6/e6c594f564fdeff687b65ebdf2fa2bdc.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在系統回彈性方面,akka-actor 提供了基於層級的監督機制。可以把整個 actor 系統看做是一棵樹,每個 actor 實例都是樹中的一個節點。監督機制指的是每個 actor 都是其子 actor 的監督者,需要針對子 actor 制定一個錯誤處理策略。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/fb/fb806beb0b70f95f196c2664445c407a.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對應到具體的業務系統裏,我們將整個流程分割成多個 actor 實現,爲了實現監督與錯誤恢復,需要創建一個頂層 route actor 來引用所有具體的業務 actor 。如果某個業務actor 遇到問題並拋出了異常,異常會被監管者 route actor 來處理。監管者可以選擇恢復出現問題的 actor 或者重啓,也可能會將其停止掉,這依賴於問題的嚴重程度和恢復策略。Akka-actor 中有以下 4 種錯誤處理策略:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"恢復子節點,保持子節點當前積累的內部狀態。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"重啓子節點,清除子節點的內部狀態。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"永久地停止子節點。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":4,"align":null,"origin":null},"content":[{"type":"text","text":"拋出錯誤向上傳遞錯誤,由更高級的節點處理。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最終我們基於 actor 實現了整個業務流程:當一個用戶發起一次站外活動請求,主應用(基於 play)會將活動的元數據寫入數據庫中然後立馬返回結果到前端,達到及時響應的目的。同時將活動請求封裝成一個 actor 消息,異步的投遞給 route actor 進行後續的任務處理。Route actor 會根據接收到的具體消息類型進行路由分發,分別是 User Insight Actor(查詢人羣信息) - Build Push Task Actor(查詢 db 組裝 task) - Checkpoint Actor(存儲 task 信息)- Publish Task Actor(發佈 task 到 kafka)。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/2c/2c30538b5e3ebcbfeb15ab113bf243b6.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"採用消息驅動的方式設計系統取得了一些好處:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"程序之間耦合性更低,每個 actor 只需要維護好一小段邏輯。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"整個流程是異步處理的,用戶體驗良好。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"消息的生產和消費可以跨服務器,橫向擴展變得簡單。"}]}]}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"從 Actor 到 Stream"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上文提到我們創建了一個 route actor 將所有業務 actor 組織到一起,這樣既能起到一個監督的作用,也可以知道全局的邏輯視圖。但是這種實現方式也會帶來一個問題,整體編排較爲複雜。對於帶有分支與合併邏輯的處理流更是難以描述,對後續新增流程也沒有約束,只能人爲約定一個順序,比如在上面的比較靠前,可維護性比較差。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/84/84ad974f4fcf767e5bb72c651be33d60.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"又因爲整個流程中 User Insight Actor 部分依賴外部數據查詢系統,比較容易成爲整個系統的瓶頸。在負載不斷變化的情況下,外部查詢可能會失敗,從而對系統整體可用性造成影響。針對這個問題需要設計對應的限流機制和重試機制。 上面提到 Actor 的 mailbox 本身就是一個隊列,如果在負載過高的情況下消息是可以丟棄的,只需要指定 actor 的 maibox 類型爲有界隊列即可。假如消息不能被丟棄,可以採用令牌桶算法實現限流功能。對於重試機制,User Insight Actor 本身是無狀態的,這裏很自然想到在失敗時重新發送試消息到 User Insight Actor 本身進行重試。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/80/80a49ee25a7ee223d3d14bae33e59271.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個方案比較簡單,如果要滿足一些特殊場景下的需求,比如設定重試次數,延遲執行重試請求,指定重試失敗後的降級策略,只能通過定製一些邏輯實現,但是要做到代碼靈活複用需要花費大量時間進行設計。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上述方案都能滿足業務需求,總體來講通過 actor 模型可以快速實現輕量業務異步封裝,但面對相對複雜業務邏輯時還是存在一些侷限:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"難以簡單優雅實現多異步任務編排,路由方案過於複雜,不直觀。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"重試機制、限流機制等和業務無關的功能複用性不高。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這也是爲什麼後來採用了 akka-stream 來對處理流程進行重構。Akka-stream 是基於 akka-actor 的 Reactive Streams 規範實現,具備以下特點:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"具有處理無限數量的元素的能力"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"異步地按序處理元素"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"實現了非阻塞的背壓"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"並在上層提供了更加抽象靈活的 DSL 封裝,即 source、sink、flow 組件。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Source 即響應流的源頭,源頭具有一個數據出口。我們可以通過各種數據來創建一個 Source:"}]},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"val sourceFromRange = Source(1 to 10)\nval sourceFromIterable = Source(List(1, 2, 3))\nval sourceFromFuture = Source.fromFuture(Future.successful(\"hello\"))"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Sink 就是流的最終目的地,包含一個數據入口,我們可以如下來創建 Sink:"}]},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"val sinkPrintingOutElements = Sink.foreach[String](println(_))\nval sinkCalculatingASumOfElements = Sink.fold[Int, Int](0)(_ + _)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Flow 就是流的中間組件,包含一個數據入口和數據出口。我們可以這樣來創建 Flow:"}]},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"val flowDoublingElements = Flow[Int].map(_ * 2)\nval flowFilteringOutOddElements = Flow[Int].filter(_ % 2 == 0)\nval flowBatchingElements = Flow[Int].grouped(10)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而整個業務流可以通過基礎組件構成的圖和網絡來表示:"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/96/968594b61d9d7345a80f84399ac3e1ee.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"流式操作可以類比成流水線,每個算子都是一道處理工序,數據源就是加工原材料,經過多道工序處理後最後輸出一個成品。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上文提到爲了實現對系統速率的控制,引入了限流的邏輯,比如基於令牌桶算法的實現,只有程序拿到了令牌才能進入下一段處理邏輯,本質上這種實現方式是同步阻塞的,而且真實情況下下游節點可能完全能承載更多的請求。爲了解決數據源和下游節點處理速度不一致的問題,在 Reactive Streams 的規範裏引入了背壓機制,本質上是一種由處理者向數據源發起數據請求,從而進行速度調整的一種方式。Akka-Stream 提供了一套開箱即用的背壓功能,其實現方式和 Reactive Streams 一致,下游 subscriber 通過發送 subscription 到上游的 publisher 主動請求需要處理的元素數量。這樣就能從整個數據流的源頭進行速率控制,採用 pull 而不是 push 的模式能讓系統按需保持最大的處理能力,同時又不會崩潰。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/14/149310577035dfe60bab6ab7e8dc5e1b.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面是基於 akka stream 重構後的處理流,簡單對比 akka actor 的實現方式,基於操作符的組合代碼更加清晰易讀,可以輕鬆實現複雜任務編排。"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/45/45ab5438bf067adce89cb89a10fbd6ba.png","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"從底層實現來講,akka-stream 底層還是基於 akka-actor 進行工作的,只是在上層提供了更高視角的 DSL 封裝 。這種靈活的編程方式能極大提高代碼複用性和可維護性。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"總結"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本文記錄了 GrowingIO 服務團隊在針對具體業務場景進行反應式系統設計的實踐總結,從異步編程到使用 actor 模型構建基於消息驅動的系統,爲了降低系統複雜度提高可維護性又引入了 akka-stram 作爲反應式流的編排框架。最後,希望能與對反應式技術感興趣的同學多多交流,打個小廣告:我們的工程團隊持續在招聘中~ 服務端、前端、大數據各種攻城獅都缺,感興趣朋友歡迎砸簡歷 "},{"type":"link","attrs":{"href":"https://www.growingio.com/joinus","title":"https://www.growingio.com/joinus"},"content":[{"type":"text","text":"https://www.growingio.com/joinus"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"參考資料:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://info.lightbend.com/rs/558-NCX-702/images/COLL-ebook-Reactive-Microservices-Architecture.pdf","title":"https://info.lightbend.com/rs/558-NCX-702/images/COLL-ebook-Reactive-Microservices-Architecture.pdf"},"content":[{"type":"text","text":"https://info.lightbend.com/rs/558-NCX-702/images/COLL-ebook-Reactive-Microservices-Architecture.pdf"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://learning.oreilly.com/library/view/applied-akka-patterns","title":"https://learning.oreilly.com/library/view/applied-akka-patterns"},"content":[{"type":"text","text":"https://learning.oreilly.com/library/view/applied-akka-patterns"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://freecontent.manning.com/akka-in-action-why-use-clustering/","title":"https://freecontent.manning.com/akka-in-action-why-use-clustering/"},"content":[{"type":"text","text":"https://freecontent.manning.com/akka-in-action-why-use-clustering/"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://doc.akka.io/","title":"https://doc.akka.io/"},"content":[{"type":"text","text":"https://doc.akka.io/"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"關於 GrowingIO"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"GrowingIO 是國內領先的一站式數字化增長整體方案服務商。爲產品、運營、市場、數據團隊及管理者提供客戶數據平臺、廣告分析、產品分析、智能運營等產品和諮詢服務,幫助企業在數字化轉型的路上,提升數據驅動能力,實現更好的增長。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章