Виртуални нишки в Spring Boot, две години по-късно
Бележки от Project Loom в продукция: къде виртуалните нишки наистина опростяват един Spring сървис и кои са двата капана, които още хапят.
Когато виртуалните нишки станаха стабилни в JDK 21, обещанието беше просто: пишеш блокиращ код, получаваш почти reactive пропускливост. След като ги карам в продукционни Spring Boot сървиси от доста време, обещанието в общи линии се изпълнява — с уговорки, които си заслужава да бъдат записани.
Включването е лесната част
В Spring Boot 3.2+ това е едно-единствено property:
spring:
threads:
virtual:
enabled: true
С този флаг Tomcat обработва всяка заявка върху виртуална нишка, @Async методите и task executor-ите я следват, и класическият модел нишка-на-заявка изведнъж се мащабира като event loop. Без Mono, без Flux, без “оцветени” функции.
Къде реално помогна
Нашите сървиси са типично интеграционно лепило: приемаш заявка, викаш два-три downstream API-я, удряш MongoDB, агрегираш, отговаряш. Под натоварване платформените нишки бяха тясното място — 200 нишки, паркирани на I/O, са 200 стека пропиляна памет.
С виртуални нишки същият блокиращ код изглежда така и се мащабира добре:
@GetMapping("/items/{id}/summary")
ItemSummary summary(@PathVariable String id) {
var details = detailsClient.fetch(id); // блокира, евтино
var reviews = reviewsClient.fetch(id); // блокира, евтино
return ItemSummary.merge(details, reviews);
}
Менталният модел, който екипът ти вече има — една заявка, една нишка, четими stack trace-ове — остава непокътнат. Това е истинската печалба: изтрихме един WebFlux сървис и го заменихме с код, който и junior колеги могат да дебъгват.
Двата капана
Pinning. Виртуална нишка, която блокира вътре в synchronized блок, заковава carrier нишката си и тихо изяжда мащабируемостта. По-старите connection pool-ове и драйвери бяха пълни с това. Пускайте с включен детектор, докато не се доверите на зависимостите си:
java -Djdk.tracePinnedThreads=full -jar app.jar
Неограничена конкурентност. Виртуалните нишки правят създаването евтино, което прави лесно да насочиш пожарникарски маркуч към downstream сървис, който все още много си има лимити. Решението е същото, каквото винаги е било — експлицитен backpressure:
var semaphore = new Semaphore(50);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
for (var id : itemIds) {
scope.fork(() -> {
semaphore.acquire();
try {
return enrich(id);
} finally {
semaphore.release();
}
});
}
scope.join().throwIfFailed();
}
Виртуалните нишки премахват цената на блокирането, не цената на нещото, на което блокираш.
Бих ли ги направил default?
За нови Spring сървиси: да. Започни със spring.threads.virtual.enabled=true, дръж кода блокиращ и скучен, и посягай към reactive само когато ти трябва streaming семантика, а не пропускливост. Скучният код, който се мащабира, е най-добрият вид код.