非阻塞 SpringBoot 之 Kotlin 協程實現
Why?
Spring Boot 默認使用 Servlet Web服務器,Tomcat,每個請求分配一個線程。如果服務不是計算密集型,而是存在大量 I/O 等待,那麼會浪費大量CPU時間,導致CPU利用率不高。如果強行加大線程池,會耗費大量內存,且增加線程切換的損耗。
於是,我們可以考慮使用 Reactive Web 服務器,Netty,基於事件循環,對於I/O密集型服務,性能極高。
背景介紹
我們有個服務,需要封裝調用大量外部接口,然後做防腐轉換和數據聚合。隨着業務變得複雜,接口響應速度越來越慢,無法滿足業務的時延需求。於是我們開始了第一輪優化,使用CompletableFuture + 線程池進行併發調用。一番操作之後,時延降下來了,但是資源利用率不高,單個節點能承受的併發量很小。如果遇到搞活動,併發需求上升時,需要申請大量資源進行擴容,非常浪費。
此時要問:為何做了異步化改造,併發能力還是上不來?
原因在於整個服務的模型還是阻塞式I/O,異步調用的時候,雖然用了一個新線程,但調用過程還是阻塞式的,這條線程就被阻塞了。當服務併發升高時,線程池裏就會產生大量被阻塞的線程,而這些線程不是綠色線程(用户態線程),而是搶佔式的,會分走寶貴的CPU時間,那麼結果就是資源利用率低下,併發能力差了。
How?
為了解決I/O密集型服務併發能力低下的問題,可以改用響應式(Reactive)模型。實際上Spring很早就有相應的解決方案:Reactor + WebFlux,可實現非阻塞式IO。
雖然響應式編程十分強大,但也有其難點:不是過程式的,寫業務代碼很難懂,而且難以調試和測試。響應式編程不是本文的討論重點,感興趣的同學可以研究一下,從最早的 RxJava 到目前的 Project Reactor。
那有沒有更簡單的方案?不妨看看:協程。
Next:Coroutines
Java 也有協程方案,叫 Quasar(協程在裏面叫 Fiber),但是18年之後就沒有更新了,據説作者跑去寫 Project Loom 了。Loom是下一代Java協程庫,但目前還沒有成熟,上生產是不可能的了。
雖然Java沒有協程,但是JVM語言Kotlin有。下面就用 Kotlin Coroutines 結合 WebFlux 實現非阻塞式 SpringBoot 服務。
假設有個API,/slowInt,經過 1s 返回一個整數。我們要調兩次,然後計算 sum。
響應時間 1s 極端一點,不過測試的時候更容易看出區別
我們不妨使用非阻塞式(WebClient)和阻塞式(RestTemplate)的web客户端,分別做性能測試。
import kotlinx.coroutines.*
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate
import org.springframework.web.client.getForObject
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
@Service
class ExampleService {
@Autowired
lateinit var webClient: WebClient
@Autowired
lateinit var restTemplate: RestTemplate
/**
* 使用協程
*/
suspend fun sumTwo(): Int = coroutineScope {
// 分別異步調用,換成 getInt2() 再測一遍
val i1: Deferred<Int> = async { getInt() }
val i2: Deferred<Int> = async { getInt() }
// 聚合
i1.await() + i2.await()
}
/**
* None-Blocking web client
* very fast
*/
suspend fun getInt(): Int {
return webClient.get()
.uri("/slowInt")
.accept(APPLICATION_JSON)
.retrieve().awaitBody()
}
/**
* Blocking web client
* very slow
*/
fun getInt2(): Int {
val result = restTemplate.getForObject<Int>("/slowInt").toInt()
println(result)
return result
}
}
@RestController
class ExampleController {
@Autowired
lateinit var exampleService: ExampleService
@GetMapping("/sum")
suspend fun sum(): String? = "Sum: ${exampleService.sumTwo()}"
}
性能測試
然後用 JMeter 壓一壓。
對於阻塞式IO,使用 10 併發,循環10次。結果如下:
非阻塞式,使用 100 併發,循環10次。結果如下:
採用非阻塞式IO,在大併發的情況下,平均時延基本就 1s,與接口耗時吻合。
可見,響應時間大幅下降,吞吐量大幅上升,從此不再吞吞吐吐。
參考文獻
https://www.baeldung.com/kotl...