經驗分享:將微服務遷移到Spring WebFlux - allegro.tech

banq發表於2019-07-16

反應式程式設計在這幾個月內一直是許多會議演講的熱門話題。找到簡單的程式碼示例和教程並將它們應用於綠地新專案是毫不費力的。當需要從現有解決方案遷移時,特別是它是具有數百萬使用者和每秒數千個請求的生產服務時,事情變得有點複雜。在本文中,我想 通過一個Allegro微服務的例子討論從Spring Web MVC到 Spring WebFlux的遷移策略 。我將展示一些常見的陷阱,以及生產中的效能指標如何受遷移的影響。

改變的動機

在詳細探討遷移策略之前,讓我們先討論其變更的動機。其中一個由我的團隊開發和維護的微服務,參與了2018年7月18日的重大Allegro停運(詳見屍檢))。雖然我們的微服務不是問題的根本原因,但由於執行緒池飽和,一些例項也崩潰了。臨時修復是增加執行緒池大小並減少外部服務呼叫的超時; 然而,這還不夠。

臨時解決方案僅略微提高了外部服務延遲的吞吐量和彈性。我們決定轉而使用非阻塞方法來徹底擺脫執行緒池作為併發的基礎。

使用WebFlux的另一個動機是新專案,它使我們的微服務中的外部服務呼叫流程變得複雜。無論複雜程度如何增加,我們都面臨著保持程式碼庫可維護性和可讀性的挑戰。我們看到WebFlux比我們之前基於Java 8的解決方案(CompletableFuture可以模擬複雜的流程)更加友好。

什麼是Spring WebFlux?

讓我們從瞭解Spring WebFlux的內容和內容開始。Spring WebFlux是反應堆疊Web框架,定位為眾所周知且廣泛使用的Spring Web MVC的後續版本。建立新框架的主要目的是支援:

  • 一種非阻塞方法,可以用少量執行緒處理併發並有效擴充套件,
  • 函數語言程式設計,它有助於使用流暢的API編寫更多的宣告性程式碼。

最重要的新功能是功能端點,事件迴圈併發模型和 反應式Netty伺服器。您可能會認為通過引入一個全新的堆疊和範例,WebFlux和Web MVC之間的相容性已被打破。實際上,Pivotal致力於使共存儘可能輕鬆。

經驗分享:將微服務遷移到Spring WebFlux  - allegro.tech

我們不會被迫將程式碼的每個方面都遷移到新方法。我們可以輕鬆地選擇一些需要Reactive的東西(比如 反應式WebClient)並前進一小步。如果我們不認為它們提供真正的價值但是具有顯著的變化成本,我們甚至可以省略一些功能的改進。此外,如果您熟悉UndertowTomcat配置 ,您不必使用Netty伺服器。

什麼時候(不)遷移?

每一種新興技術都傾向於其炒作週期。玩一種新的解決方案,特別是在生產環境中,僅僅因為它新鮮,有光澤和嗡嗡聲 - 可能導致沮喪,有時甚至是災難性的後果。每個軟體供應商都想宣傳他的產品,並說服客戶使用它。但是,Pivotal表現得非常負責任,密切關注遷移到WebFlux不是最好的想法。官方檔案的第1.1.4部分詳細介紹了這一點。最重要的一點是:

  • 不要改變工作正常的東西。如果您的服務中沒有效能或擴充套件問題 - 找一個更好的地方來嘗試WebFlux。
  • 堵塞API和WebFlux不是最好的朋友。他們可以合作,但從遷移到反應堆疊沒有效率提升。你應該帶著一點點的意見來接受這個建議。當程式碼中只有一些依賴項被阻塞時 - 有一些優雅的方法來處理它。當它們佔多數時 - 您的程式碼變得更加複雜且容易出錯 - 一個阻塞呼叫可以鎖定整個應用程式。
  • 團隊的學習曲線,特別是如果沒有反應性東西的經驗,可能會很陡峭。你應該在遷移過程中非常注意人為因素。

我們來談談效能。對此有很多誤解。反應Reactive並不意味著自動提升效能。此外,WebFlux文件警告我們,以非阻塞方式執行操作需要做更多工作。但是,每次呼叫的延遲或呼叫之間的相互依賴性越高,其益處就越大。反應性閃耀點:等待其他服務響應不會阻塞執行緒。因此,獲得相同吞吐量所需的執行緒更少,執行緒越少意味著使用的記憶體越少。

始終建議檢查獨立來源以避免框架作者的偏見。在選擇新技術時,一個很好的意見來源是 ThoughtWorks的技術雷達。他們報告了遷移到WebFlux後系統吞吐量和程式碼可讀性的改進。另一方面,他們指出,思維的重大轉變是成功採用WebFlux的必要條件。

總而言之,遷移到WebFlux有四個指標如果符合則可行:

  1. 當前的技術堆疊沒有解決具有足夠效能和可擴充套件性的問題。
  2. 對外部服務或資料庫的呼叫很多,響應速度可能很慢。
  3. 現有的阻塞依賴項可以很容易地被替換為依賴。
  4. 開發團隊面臨新的挑戰並願意學習。

遷移戰略

根據我們的遷移經驗,我想介紹三階段遷移策略。為什麼3個階段?

如果我們談論具有大型程式碼庫的實時服務,每秒數千個請求和數百萬使用者, 從頭開始​​重寫是一個相當大的風險。讓我們看看如何在後續的小步驟中將應用程式從Spring Web MVC遷移到Spring WebFlux,從而實現從阻塞到非阻塞世界的平滑過渡。

第1階段,入門 - 遷移一小段程式碼

通常,首先在系統的非關鍵部分嘗試新技術是一種很好的做法。反應性技術也不例外。這個階段的想法是隻要找到一個非關鍵特性功能,它又是被封裝在一個阻塞方法呼叫中,那就將其重寫為非阻塞風格。讓我們看看執行此阻塞方法的示例,該方法用於RestTemplate從外部服務檢索結果。

Pizza getPizzaBlocking(int id) {
    try {
        return restTemplate.getForObject("http://localhost:8080/pizza/" + id, Pizza.class);
    } catch (RestClientException ex) {
        throw new PizzaException(ex);
    }
}

我們從豐富的WebFlux功能集中選擇一件事 - 反應式WebClient - 並使用它以非阻塞方式重寫此方法:

Mono<Pizza> getPizzaReactive(int id) {
    return webClient
        .get()
        .uri("http://localhost:8080/pizza/" + id)
        .retrieve()
        .bodyToMono(Pizza.class)
        .onErrorMap(PizzaException::new);
}

現在是時候將我們的新方法與應用程式的其餘部分連線起來了。非阻塞方法返回Mono,但我們需要一個普通型別。我們可以使用Mono.block()方法從中檢索值。

Pizza getPizzaBlocking(int id) {
    return getPizzaReactive(id).block();
}

最終,我們的方法仍在都塞等待。但是,它內部使用了非阻塞庫。此階段的主要目標是熟悉非阻塞API。這種更改對應用程式的其餘部分是透明的,易於測試並可部署到生產環境中。

第二階段,主菜 - 將關鍵路徑轉換為非阻塞方法

在使用WebClient轉換一小段程式碼後,我們準備更進一步。第二階段的目標是將應用程式的關鍵路徑轉換為所有層中的非阻塞 - 從HTTP客戶端到處理外部服務響應的類,再到控制器。在這個階段,重要的是避免重寫所有程式碼。應用程式中較不重要的部分,例如沒有外部呼叫或很少使用的部分,應該保持不變。我們需要關注非阻塞方法揭示其優勢的領域。

//parallel call to two services using Java8 CompletableFuture
Food orderFoodBlocking(int id) {
    try {
        return CompletableFuture.completedFuture(new FoodBuilder())
            .thenCombine(CompletableFuture.supplyAsync(() -> pizzaService.getPizzaBlocking(id), executorService), FoodBuilder::withPizza)
            .thenCombine(CompletableFuture.supplyAsync(() -> hamburgerService.getHamburgerBlocking(id), executorService), FoodBuilder::withHamburger)
            .get()
            .build();
    } catch (ExecutionException | InterruptedException ex) {
        throw new FoodException(ex);
    }
}

//parallel call to two services using Reactor
Mono<Food> orderFoodReactive(int id) {
    return Mono.just(new FoodBuilder())
        .zipWith(pizzaService.getPizzaReactive(id), FoodBuilder::withPizza)
        .zipWith(hamburgerService.getHamburgerReactive(id), FoodBuilder::withHamburger)
        .map(FoodBuilder::build)
        .onErrorMap(FoodException::new);
}

使用.subscribeOn()方法可以輕鬆地將阻塞部分系統與非阻塞程式碼合併。我們可以使用預設的Reactor排程程式之一以及我們自己建立並提供的執行緒池ExecutorService。

Mono<Pizza> getPizzaReactive(int id) {
    return Mono.fromSupplier(() -> getPizzaBlocking(id))
        .subscribeOn(Schedulers.fromExecutorService(executorService));
}

此外,只需對控制器進行少量更改即可 - 將返回型別更改Foo為Mono<Foo>或Flux<Foo>。它甚至可以在Spring Web MVC中執行 - 您不需要將整個應用程式的堆疊更改為被動。第2階段的成功實施為我們提供了非阻塞方法的所有主要優點。是時候測量並檢查我們的問題是否已解決。

第3階段,甜點 - 讓我們改變WebFlux的一切!

我們可以在第2階段之後做更多的事情。我們可以重寫程式碼中不太關鍵的部分並使用Netty伺服器而不是servlet。我們也可以刪除@Controller註釋並將端點重寫為函式風格,儘管這是風格和個人偏好而非效能的問題。

這裡的關鍵問題是:這些優勢的成本是多少?程式碼可以一直重構,並且通常定義“足夠好”的點是很有挑戰性的。在我們的案例中,我們沒有決定更進一步。

重寫整個程式碼庫需要很多工作。帕累託原則 結果證明是有效的一次。我們認為我們已經取得了顯著的收益,而後續的收益也相對較高。作為一般規則 - 當我們從頭開始編寫新服務時,獲得WebFlux的所有特權是很好的。另一方面,當我們重構現有(微)服務時,通常最好儘可能少地完成工作。

遷移陷阱 - 經驗教訓

正如我之前所說,將程式碼遷移到非阻塞需要思想發生重大轉變。我的團隊也不例外 - 我們陷入了一些陷阱,主要是因為根植於阻塞和命令式編碼實踐。如果您打算將一些程式碼重寫為WebFlux - 這裡有一些準備好的具體內容供您使用!

問題1 - 在構建伺服器中掛起整合測試

優秀的程式碼測試覆蓋率是安全重構的最佳朋友。特別是整合測試可以確認我們在重寫應用程式的大部分內容後感覺一切正常。在我們的例子中,大多數是框架甚至程式語言不可知 - 他們使用HTTP請求查詢測試中的服務。不幸的是,我們注意到我們的整合測試有時會開始掛起。

這是一個令人震驚的訊號 - 在遷移到WebFlux之後,從客戶端的角度來看,服務應該表現相同。經過幾天的研究,我們終於發現Wiremock(我們的測試中使用的模擬庫)與WebFlux啟動器不完全相容。經過進一步調查,我們瞭解到webmvc啟動器的測試工作正常。 GitHub問題#914 詳細介紹了這一點。

經驗教訓:

  • 仔細檢查您的測試庫是否完全支援WebFlux。
  • 在重構的早期階段,不要將spring-boot-starter依賴從webmvc更改為webflux。嘗試將程式碼重寫為非阻塞,並且只有在servlet應用程式型別的一切正常工作時才將應用程式型別更改為響應。

問題2 - 掛起了單元測試

我們使用Groovy + Spock作為單元測試的基礎。雖然WebFlux提供了新的令人興奮的測試可能性,但我們嘗試以儘可能少的努力使現有的單元測試適應非阻塞現實。當某些方法轉換為return Mono<Foo>而不是Foo,通常在測試中跟隨此方法.block()呼叫就足夠了。否則,存根和模擬配置為返回foo,現在應該用反應型別包裝它,通常返回Mono.just(foo)。

理論似乎很簡單,但我們的測試開始掛起。幸運的是,以可重現的方式。出了什麼問題?

在經典的阻塞方法中,當我們忘記(或故意省略)在存根或模擬中配置一些方法呼叫時,它只返回null。在許多情況下,它不會影響測試。但是,當我們的stubbed方法返回一個被動型別時,錯誤配置可能會導致它掛起,因為預期Mono或Flux永遠不會解析。

學到的經驗教訓: 返回反應性型別的方法的存根或Mock,在測試執行期間呼叫,之前隱式返回 null,現在必須顯式配置為至少返回Mono.empty()或Mono.just(some_empty_object)。

問題3 - 缺乏訂閱

WebFlux初學者有時會忘記反應流往往儘可能地會惰載入。由於缺少訂閱,以下功能永遠不會向控制檯列印任何內容:

Food orderFood(int id) {
    FoodBuilder builder = new FoodBuilder().withPizza(new Pizza("margherita"));

    hamburgerService.getHamburgerReactive(id).doOnNext(builder::withHamburger);
    //hamburger will never be set, because Mono returned from getHamburgerReactive() is not subscribed to

    return builder.build();
}

教訓: 每一個Mono和Flux應訂閱。在控制器中返回反應式型別就是這種隱式訂閱。

問題4 - .block()在Reactor執行緒中

正如我之前所展示的(在第1階段),.block()有時用於將反應函式加入到阻塞程式碼中。

Food getFoodBlocking(int id) {
    return foodService.orderFoodReactive(id).block();
}

在Reactor執行緒中無法呼叫此函式。這種嘗試會導致以下錯誤:

block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-2

.block()只允許在其他執行緒中使用顯式用法(請參閱參考資料.subscribeOn())。Reactor丟擲一個異常並告知我們這個問題是有幫助的。不幸的是,許多其他方案允許將阻塞程式碼插入到Reactor執行緒中,這不會自動檢測到。

學到的經驗教訓:.block()只能在scheduler中執行的程式碼中使用。更好的是避免使用.block()。

問題5 - 阻塞Reactor執行緒中的程式碼

沒有什麼能阻止我們將阻塞程式碼新增到被動流中。而且,我們不需要使用.block()- 我們可以通過使用可以阻止當前執行緒的庫無意識地引入阻塞。請考慮以下程式碼示例。第一個類似於正確的“反應性”延遲。

Mono<Food> getFood(int id) {
    return foodService.orderFood(id)
        .delayElement(Duration.ofMillis(1000));
}

另一個示例模擬了一個危險的延遲,它阻塞了訂戶執行緒。

Mono<Food> getFood(int id) throws InterruptedException {
    return foodService
      .orderFood(id)
      .doOnNext(food -> Thread.sleep(1000));
}

一目瞭然,這兩個版本似乎都有效。當我們在localhost上執行此應用程式並嘗試請求服務時,我們可以看到類似的行為。“Hello,world!”在延遲1秒後返回。然而,這種觀察極具誤導性。在更高的流量下,我們的服務響應會發生巨大變化 讓我們使用JMeter 來獲得一些效能特徵。

使用100個執行緒查詢了兩個版本。我們可以看到,具有反應式延遲(上一段程式碼)的版本在重負載下執行良好,另一方面,具有阻塞延遲(下一段程式碼)的版本不能提供任何可觀的流量。

為什麼這麼危險?如果延遲與外部服務呼叫相關聯,只要其他服務快速響應,一切正常。這是一顆滴答作響的定時炸彈。這樣的程式碼甚至可以在生產環境中生存幾天,並在您最不期望的時候導致突然中斷。

經驗教訓:

  • 始終仔細檢查在反應式環境中使用的庫。
  • 對應用程式進行效能測試,尤其是考慮外部呼叫的延遲。
  • 使用BlockHound等特殊庫,可以檢測隱藏的阻塞呼叫,提供寶貴的幫助。

問題6 - WebClient未消費響應

WebClient .exchange()方法的文件明確指出: 您必須始終使用響應的主體或實體方法之一來確保釋放資源。 官方WebFlux文件的第2.3章 給出了類似的資訊。這個要求很容易被遺漏,主要是當我們使用.retrieve()方法時,是.exchange()的一個快捷方式。我們偶然發現了這樣一個問題。我們正確地將有效響應對映到物件,並在出現錯誤時完全忽略響應。

Mono<Pizza> getPizzaReactive(int id) {
    return webClient
        .get()
        .uri("http://localhost:8080/pizza/" + id)
        .retrieve()
        .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new Pizza5xxException()))
        .bodyToMono(Pizza.class)
        .onErrorMap(PizzaException::new);
}

只要外部服務返回有效響應,上面的程式碼就能很好地工作。在前幾個錯誤響應後不久,我們可以在日誌中看到令人擔憂的訊息:

ERROR 3042 --- [ctor-http-nio-5] io.netty.util.ResourceLeakDetector       : LEAK: ByteBuf.release() was not called before it's garbage-collected. See http://netty.io/wiki/reference-counted-objects.html for more information.

資源洩漏意味著我們的服務將崩潰。在幾分鐘,幾小時或幾天內 - 它取決於其他服務錯誤計數。此問題的解決方案很簡單:使用錯誤響應生成錯誤訊息。現在它被正確消費了。

經驗教訓: 始終在考慮外部服務錯誤的情況下測試您的應用程式,尤其是在高流量時。

問題7 - 意外的程式碼執行

Reactor有許多有用的方法,有助於編寫富有表現力和宣告性的程式碼。但是,其中一些可能有點棘手。請考慮以下程式碼:

String valueFromCache = "some non-empty value";
return Mono.justOrEmpty(valueFromCache)
    .switchIfEmpty(Mono.just(getValueFromService()));
   

我們使用類似的程式碼檢查特定值的快取,然後在缺少值時呼叫外部服務。作者的意圖似乎很明確:getValueFromService() 僅在缺少快取值的情況下執行。但是,此程式碼每次都會執行,即使是快取命中也是如此。賦給.switchIfEmpty()的引數不是lambda,而是Mono.just()直接執行作為引數傳遞的程式碼。

顯而易見的解決方案是使用Mono.fromSupplier()並將條件程式碼作為lambda傳遞,如下例所示:

String valueFromCache = "some non-empty value";
return Mono.justOrEmpty(valueFromCache)
    .switchIfEmpty(Mono.fromSupplier(() -> getValueFromService()));

經驗教訓: Reactor API有許多不同的方法。始終考慮引數是應該按原樣傳遞還是用lambda包裝。

遷移帶來的好處

總結一下,在遷移到WebFlux之後檢查我們服務的生產指標。明顯而直接的影響是應用程式使用的執行緒數量減少。有趣的是,我們沒有將應用程式型別更改為Reactive(我們仍然使用servlet,有關詳細資訊,請參閱第3階段),但Undertow工作執行緒的使用也變小了一個數量級。

低階指標如何受到影響?我們觀察到更少的垃圾收集,並且他們花費的時間更少。

此外,響應時間略有下降,但我們沒有預料到這樣的效果。其他指標(如CPU負載,檔案描述符使用情況和消耗的總記憶體)未發生變化。我們的服務也做了很多工作,這與呼叫無關。將流量遷移到HTTP客戶端和控制器周圍的響應是至關重要的,但在資源使用方面並不重要。正如我在開始時所說的那樣,遷移的預期收益是延遲的可擴充套件性和彈性。我們確信我們已經實現了這一目標。

結論

你在綠地新專案上工作嗎?這是一個熟悉WebFlux或其他反應框架的好機會。

您是否正在遷移現有的微服務?考慮到文章中涉及的因素,不僅僅是技術因素 - 檢查時間和人員使用新解決方案的能力。有意識地決定不盲目信任技術炒作。

始終測試您的應用程式 覆蓋外部呼叫延遲和錯誤的整合和效能測試在遷移過程中至關重要。請記住,反應性思維不同於眾所周知的阻礙,命令式方法。

玩得開心,構建彈性微服務!

相關文章