Libraries & Ecosystem · کتابخانهها و اکوسیستم سنیورSenior ~38 دقیقه مطالعه~33 min read
برنامهنویسی واکنشی: Reactor و WebFluxReactive Programming: Reactor & WebFlux
راهنمای عمیق برنامهنویسی واکنشی روی JVM با Project Reactor و Spring WebFlux: از تفاوت blocking و non-blocking و backpressure تا Mono و Flux، اپراتورها، publisher سرد و گرم، Scheduler و مدیریت خطا، R2DBC، دام بلاککردن زنجیره، و اینکه با آمدن virtual thread کِی واکنشی هنوز ارزش دارد.A deep, teaching-style guide to reactive programming on the JVM with Project Reactor and Spring WebFlux: from blocking vs non-blocking and backpressure to Mono/Flux, operators, cold vs hot publishers, schedulers and error handling, R2DBC, the blocking-in-the-chain trap, and when reactive still earns its keep in the age of virtual threads.
سلام. بگذار یک حقیقت را همین اول روی میز بگذارم: مدل کلاسیک جاوا برای هر درخواست وب یک ترد (thread) اختصاص میداد و آن ترد را تا لحظهی آخر — تا وقتی دیتابیس جواب بدهد یا سرویس دیگری صدا زده شود — «معطل» نگه میداشت. این کار ساده و قابلفهم بود، اما گران. برنامهنویسی واکنشی (reactive programming) آمد تا این اسراف را تمام کند: بهجای اینکه تردی بنشیند و منتظر بماند، جریان داده را بهصورت یک لولهی رویداد مدل میکنی که هر وقت داده آماده شد بهسراغت میآید. در این فصل قرار نیست فقط فهرست اپراتورها را حفظ کنی؛ قرار است بفهمی چرا این پارادایم پدید آمد، کجا برنده است، کجا تو را زجر میدهد، و چرا با آمدن virtual thread در جاوا ۲۱ کل معادله عوض شد.
مسیری که با هم میرویم:
- مدل ذهنی بزرگ — blocking در برابر non-blocking و اینکه «معطلماندن ترد» چقدر گران است.
- Reactive Streams — چهار interface بنیادی و مفهوم backpressure.
- Mono و Flux — دو ناشر (publisher) اصلی Reactor و اینکه «تا subscribe نکنی هیچچیز اجرا نمیشود».
- اپراتورها —
map،flatMap،filter،zipو تفاوت حیاتیmapباflatMap. - سرد در برابر گرم — cold و hot publisher و
Sinks. - Scheduler و تردینگ —
publishOnوsubscribeOnو مدیریت کار بلاککننده. - مدیریت خطا —
onErrorResume،retryWhenو backoff. - WebFlux در برابر MVC، R2DBC، دامها، و در پایان virtual thread در برابر واکنشی + پرسشهای مصاحبه.
بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی
قبل از هر کد، چند اصطلاح هست که در کل فصل برمیگردند. بگذار همین حالا در ذهنت جا بیندازمشان.
- مسدودکننده (blocking): فراخوانیای که ترد را متوقف میکند تا نتیجه آماده شود. مثل ایستادن پشت صندوق و تکاننخوردن تا فروشنده کارت را بکشد.
- نامسدودکننده (non-blocking): فراخوانیای که فوراً برمیگردد و میگوید «هر وقت آماده شد خبرت میکنم». ترد آزاد میشود تا کار دیگری بکند.
- همگام / ناهمگام (synchronous / asynchronous): همگام یعنی نتیجه را همانجا و همان لحظه میگیری؛ ناهمگام یعنی نتیجه بعداً و از طریق یک callback یا رویداد میرسد.
- backpressure (فشار برگشتی): توانایی مصرفکننده برای گفتن «آهستهتر! من فقط ظرفیت ۱۰ مورد را دارم». بدون آن، تولیدکنندهی تند میتواند مصرفکنندهی کند را زیر داده دفن کند.
- ناشر / مشترک (publisher / subscriber): ناشر منبع داده است، مشترک مقصد. رابطهشان مثل روزنامه و آبونه است: تا آبونه نشوی، روزنامهای به دستت نمیرسد.
مدل ذهنی: چرا «معطلماندن ترد» گران است
تصور کن رستورانی داری و برای هر میز یک پیشخدمت میگذاری که سفارش را میگیرد و بعد کنار همان میز میایستد تا وقتی غذا از آشپزخانه بیاید — بیحرکت، فقط منتظر. با ۲۰۰ میز به ۲۰۰ پیشخدمت نیاز داری که بیشترشان فقط ایستادهاند و حقوق میگیرند. این همان مدل thread-per-request کلاسیک است: ترد گرانقیمت پشت یک فراخوانی I/O «بلاک» میشود و کاری نمیکند. مدل واکنشی مثل رستورانی است که پیشخدمت سفارش را میگیرد، به آشپزخانه میدهد و میرود سراغ میز بعدی؛ وقتی غذا آماده شد یک زنگ میخورد و هر پیشخدمت آزادی آن را میبرد. با چند پیشخدمت، صدها میز را میچرخانی.
در مدل کلاسیک (Servlet / Spring MVC)، هر درخواست یک ترد از یک استخر (thread pool) میگیرد. وقتی کد به یک فراخوانی دیتابیس یا HTTP میرسد، آن ترد بلاک میشود: در حالت WAITING میرود و تا رسیدن پاسخ هیچ کار مفیدی نمیکند. یک ترد سیستمی (platform thread) حدود ۱ مگابایت پشته (stack) میگیرد؛ پس با استخر مثلاً ۲۰۰ تایی، سقف همزمانیات عملاً ۲۰۰ درخواستِ در حال انتظار است — حتی اگر CPU بیکار باشد. این یعنی گلوگاه تو نه توان پردازش، که تعداد تردهای بلاکشده است.
اگر برنامهات بیشتر وقتش را منتظر I/O است (دیتابیس، شبکه، فایل)، مدل بلاککننده تردهای گران را هدر میدهد چون آنها فقط منتظرند. اگر برنامهات بیشتر CPU-bound است (رمزنگاری، پردازش تصویر)، مدل واکنشی هیچ معجزهای نمیکند؛ آنجا فقط تعداد هستهها مهم است. واکنشی درمانِ انتظارِ I/O است، نه درمانِ کندیِ محاسبه.
مدل non-blocking با تعداد کمی ترد (معمولاً به تعداد هستههای CPU) کار میکند. وقتی به I/O میرسد، بهجای انتظار، یک callback ثبت میکند و ترد را آزاد میکند تا به درخواست بعدی برسد. سیستمعامل با مکانیزم event loop (مثل epoll در لینوکس) وقتی داده آماده شد خبر میدهد. نتیجه: با ۴ ترد میتوانی دهها هزار اتصال همزمان را بچرخانی — بهشرطی که هیچجای زنجیره را بلاک نکنی (این «بهشرط» بعداً به بزرگترین دام فصل تبدیل میشود).
Reactive Streams: قرارداد چهارتایی
پیش از Reactor، هرکس async را جور خودش پیاده میکرد و کتابخانهها با هم جفت نمیشدند. Reactive Streams یک استاندارد کوچک (فقط چهار interface) است که در جاوا ۹ بهصورت java.util.concurrent.Flow هم وارد JDK شد. Reactor یک پیادهسازی از همین استاندارد است.
یک کارخانه را تصور کن: یک سرِ تسمهنقاله محصول تولید میکند (Publisher) و سرِ دیگر کارگری بستهبندی میکند (Subscriber). اگر تولید تندتر از بستهبندی باشد، جعبهها روی زمین تلنبار میشوند و همهچیز بههم میریزد. Reactive Streams به کارگر یک دکمه میدهد: «فعلاً فقط ۱۰ تا بفرست». تولیدکننده دقیقاً همان ۱۰ را میفرستد و منتظر درخواست بعدی میماند. این دکمه همان backpressure است — قلب استاندارد.
چهار interface اصلی:
public interface Publisher<T> {
void subscribe(Subscriber<? super T> s);
}
public interface Subscriber<T> {
void onSubscribe(Subscription s); // اتصال برقرار شد
void onNext(T item); // یک داده رسید
void onError(Throwable t); // خطا؛ پایانِ ناموفق
void onComplete(); // پایانِ موفق
}
public interface Subscription {
void request(long n); // مصرفکننده n مورد میخواهد ← backpressure
void cancel(); // دیگر چیزی نمیخواهم
}
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {}
نکتهی طلایی: مصرفکننده با request(n) تقاضا را کنترل میکند. تولیدکننده حق ندارد بیش از مجموع تقاضاها onNext بفرستد. این قرارداد است که جریان را از سیلشدن نگه میدارد. یک جریان همیشه با دقیقاً یک onError یا یک onComplete تمام میشود — نه هر دو، و بعد از آن هیچ سیگنالی مجاز نیست.
تفاوت اصلی «async ساده» با «reactive» همین backpressure است. با CompletableFuture یا callback خام، اگر منبع تندتر از مصرف باشد، یا حافظه پر میشود یا داده گم میشود. Reactive Streams تقاضا را بخشی از پروتکل میکند: مصرفکننده میتواند بگوید «کندتر»، و این پیام تا خودِ منبع بالا میرود. بدون backpressure، «واکنشی» فقط یک callback جینکرده است.
Mono و Flux: دو ناشر Reactor
Reactor دو پیادهسازی Publisher میدهد که تمام کار با آنهاست:
Mono<T>— یک جریان که صفر یا یک مقدار تولید میکند و بعد کامل میشود. مثل «یک کاربر با این id» یا «نتیجهی یک ذخیره». همارز واکنشیِOptional<T>یاCompletableFuture<T>.Flux<T>— یک جریان که صفر تا بینهایت مقدار تولید میکند. مثل «همهی سفارشهای امروز» یا یک استریم بیپایان از رویدادها.
Mono<String> one = Mono.just("Ada");
Mono<String> empty = Mono.empty();
Flux<Integer> many = Flux.just(1, 2, 3, 4);
Flux<Integer> range = Flux.range(1, 100);
Flux<Long> ticks = Flux.interval(Duration.ofSeconds(1)); // بیپایان: 0,1,2,...
مهمترین قانون: تا subscribe نکنی، هیچچیز اجرا نمیشود
یک Flux یا Mono مثل یک دستور پخت است، نه خودِ غذا. هرچقدر هم اپراتور رویش بچینی (map، filter، ...)، فقط داری دستور را کاملتر مینویسی — هنوز اجاق روشن نشده. لحظهای که subscribe() را صدا میزنی، انگار دکمهی «شروع پخت» را زدهای: تازه داده از منبع جاری میشود و از اپراتورها عبور میکند. تا آن لحظه، همهچیز فقط یک نقشه است.
Flux<Integer> pipeline = Flux.range(1, 5)
.map(i -> i * 2)
.doOnNext(i -> System.out.println("عبور: " + i));
// تا اینجا هیچ چیزی چاپ نشده! فقط دستور ساخته شده.
pipeline.subscribe(i -> System.out.println("گرفتم: " + i)); // حالا اجرا میشود
این ویژگی «تنبلی» (laziness) اسمش cold publisher است و بعداً به آن برمیگردیم. در دنیای WebFlux معمولاً تو subscribe نمیکنی؛ فریمورک وقتی میخواهد پاسخ HTTP را بنویسد این کار را میکند. قانون بزرگ: هرگز درون یک زنجیرهی واکنشی .block() نزن تا مقدار را «بیرون بکشی» — این کل هدف را نابود میکند (باز هم دام اصلی فصل).
اپراتورها: ساختنِ زنجیره
اپراتورها همان چیزهاییاند که روی یک ناشر زنجیر میکنی تا داده را تبدیل، فیلتر یا ترکیب کنی. هر اپراتور یک ناشر جدید برمیگرداند و ناشر قبلی را دستنخورده میگذارد (مثل Stream API اما ناهمگام).
map: تبدیل یکبهیک، همگام
map هر مقدار را با یک تابع همگام به مقدار دیگری تبدیل میکند — دقیقاً مثل Stream.map.
Flux.just("ada", "linus", "grace")
.map(String::toUpperCase) // "ADA", "LINUS", "GRACE"
.map(String::length); // 3, 5, 5
flatMap: تبدیلی که خودش ناشر میسازد، ناهمگام
اینجا مهمترین تمایز کل فصل است. وقتی تابع تبدیل خودش کاری ناهمگام انجام میدهد (مثلاً یک فراخوانی دیتابیس که Mono برمیگرداند)، اگر map بزنی به Flux<Mono<User>> میرسی — یک ناشرِ توی ناشر، بلااستفاده. flatMap اینها را «صاف» (flatten) میکند: هر ناشر داخلی را subscribe میکند و خروجیهایشان را در یک Flux واحد به هم میریزد.
map مثل کارگری است که خودش کار را تمام میکند و نتیجه را میدهد. flatMap مثل مدیری است که کار را به یک پیمانکار دیگر میسپارد که خودش زمان میبرد و ممکن است چند نتیجه بدهد؛ مدیر همهی نتایج پیمانکارها را جمع میکند و یککاسه تحویل میدهد. اگر کارت «تبدیل ساده» است، map؛ اگر کارت «رفتن سراغ یک منبع ناهمگام دیگر» است، flatMap.
Flux<Long> orderIds = Flux.just(1L, 2L, 3L);
// map غلط: Flux<Mono<Order>> — ناشرِ تودرتو
Flux<Mono<Order>> wrong = orderIds.map(id -> orderRepo.findById(id));
// flatMap درست: Flux<Order> — صافشده و همزمان
Flux<Order> right = orderIds.flatMap(id -> orderRepo.findById(id));
نکتهی حیاتی: flatMap ناشرهای داخلی را همزمان (concurrently) و بدون تضمین ترتیب اجرا میکند. اگر ترتیب برایت مهم است، concatMap (پشتسرهم، ترتیب حفظ میشود، اما کندتر) یا flatMapSequential (همزمان اجرا، اما خروجی مرتب) را انتخاب کن.
| اپراتور | تابع برمیگرداند | همزمانی | ترتیب خروجی | کاربرد نمونه |
|---|---|---|---|---|
map |
R (مقدار ساده) |
— | حفظ | تبدیل همگام |
flatMap |
Publisher<R> |
موازی | بدون تضمین | فراخوانیهای I/O مستقل |
concatMap |
Publisher<R> |
یکییکی | حفظ | وقتی ترتیب مهم است |
flatMapSequential |
Publisher<R> |
موازی | حفظ | موازیِ مرتب |
filter، zip، و چند اپراتور پرکاربرد دیگر
Flux.range(1, 10)
.filter(i -> i % 2 == 0) // فقط زوجها: 2,4,6,8,10
.take(3) // فقط ۳ تای اول: 2,4,6
.defaultIfEmpty(-1); // اگر خالی بود، یک مقدار پیشفرض
// zip: چند ناشر را «موازی» جفت میکند و با هم ترکیب
Mono<User> user = userRepo.findById(id);
Mono<Address> address = addressRepo.findByUser(id);
Mono<Profile> profile = Mono.zip(user, address,
(u, a) -> new Profile(u.name(), a.city()));
zip وقتی به کار میآید که چند فراخوانی مستقل داری و میخواهی هر سه همزمان اجرا شوند و بعد از آمادهشدنِ همه، نتایج را ترکیب کنی — بهجای اینکه پشتسرهم صبر کنی. این یکی از جاهایی است که واکنشی واقعاً میدرخشد: بهجای مجموعِ زمانها، بیشترین زمان را صبر میکنی.
اپراتورهای doOnNext، doOnError، doOnComplete و doOnSubscribe مقدار را تغییر نمیدهند؛ فقط به تو اجازه میدهند یک اثر جانبی (side-effect) مثل لاگکردن انجام دهی. هرگز منطق اصلی را داخل یک map که «انگار» بیضرر است پنهان نکن؛ اثر جانبی جایش doOn... است.
Cold در برابر Hot: چهکسی زمانبندی داده را تعیین میکند
یک cold publisher مثل فیلم درخواستی (VOD) است: هر بیننده که «پخش» را میزند، فیلم را از ابتدا برای خودش میبیند. هر subscribe یک اجرای مستقل و کامل میسازد. یک hot publisher مثل پخش زندهی تلویزیون است: برنامه در حال پخش است، چه تو تماشا کنی چه نکنی؛ هر وقت وصل شوی، از همان لحظه به بعد را میبینی و آنچه از دست رفته، رفته.
بیشتر ناشرهای Reactor cold اند: Flux.range، Mono.fromCallable، فراخوانی دیتابیس. هر مشترک جدید، اجرای تازهای را راه میاندازد.
Flux<Integer> cold = Flux.range(1, 3).map(i -> { System.out.println("محاسبه " + i); return i; });
cold.subscribe(i -> System.out.println("A دید: " + i));
cold.subscribe(i -> System.out.println("B دید: " + i));
// «محاسبه» دو بار کامل اجرا میشود — یکی برای A، یکی برای B
برای ساختن رفتار hot یا اشتراک یک اجرا بین چند مشترک، از share()/publish() یا Sinks استفاده میکنی:
// Sinks: پل بین کد امری (imperative) و دنیای واکنشی
Sinks.Many<String> sink = Sinks.many().multicast().onBackpressureBuffer();
Flux<String> hot = sink.asFlux();
hot.subscribe(s -> System.out.println("مشترک ۱: " + s));
sink.tryEmitNext("رویداد A"); // مشترک ۱ میبیند
hot.subscribe(s -> System.out.println("مشترک ۲: " + s));
sink.tryEmitNext("رویداد B"); // هر دو میبینند؛ «رویداد A» را مشترک ۲ از دست داد
شاید در کدهای قدیمی DirectProcessor یا EmitterProcessor ببینی. اینها منسوخ (deprecated) شدهاند. راه درستِ امروز برای «تزریق دستی» داده به یک جریان، Sinks است که thread-safe و صریح دربارهی سیاست backpressure و multicast/unicast است.
Scheduler و تردینگ: داده روی کدام ترد جاری میشود؟
یک سوءتفاهم رایج: «واکنشی یعنی چندنخی». نه! بهصورت پیشفرض، یک زنجیرهی واکنشی روی همان تردی اجرا میشود که subscribe را صدا زده — کاملاً تکنخی. Scheduler ابزاری است که به تو اجازه میدهد اجرای بخشهایی از زنجیره را به تردهای دیگر منتقل کنی.
انواع اصلی Scheduler:
Schedulers.parallel()— استخر ثابت بهاندازهی تعداد هستهها؛ برای کار CPU-محور و کوتاه.Schedulers.boundedElastic()— استخری با سقف که تردهای موقت میسازد؛ مخصوص پیچیدنِ کار بلاککنندهی I/O (مثلاً یک درایور قدیمیِ JDBC) تا بقیهی جریان را آلوده نکند.Schedulers.single()— یک ترد اشتراکی؛ برای کارهایی که باید ترتیبی و روی یک ترد بمانند.Schedulers.immediate()— همین ترد فعلی؛ یعنی «هیچ سوییچی نکن».
publishOn در برابر subscribeOn
این دو، پرتکرارترین منبع سردرگمیاند. بگذار قاطع بگویم:
subscribeOn(scheduler)تعیین میکند که کل زنجیره از منبع به بالا — یعنی جایی که subscription و تولید داده شروع میشود — روی کدام Scheduler اجرا شود. جای گذاشتنش در زنجیره مهم نیست؛ هرجا باشد، از منبع اثر میگذارد.publishOn(scheduler)از نقطهای که گذاشته میشود به بعد (پاییندست) ترد را عوض میکند. جایش کاملاً مهم است؛ فقط اپراتورهای بعد از آن روی Scheduler جدید اجرا میشوند.
تصور کن خط تولیدی داری. subscribeOn تصمیم میگیرد کل کارخانه از ابتدا در کدام شهر ساخته شود — یک انتخاب برای کلِ منبع. publishOn مثل این است که وسط خط بگویی «از این ایستگاه به بعد، تیم شب کار را ادامه دهد»؛ ایستگاههای قبل با تیم قبلی، ایستگاههای بعد با تیم جدید. برای همین جای publishOn مهم است و جای subscribeOn نه.
Flux.range(1, 3)
.map(i -> { log("map1 روی: " + Thread.currentThread()); return i; })
.publishOn(Schedulers.parallel())
.map(i -> { log("map2 روی: " + Thread.currentThread()); return i; })
.subscribeOn(Schedulers.boundedElastic())
.subscribe();
// map1 روی boundedElastic (بهخاطر subscribeOn، از منبع)
// map2 روی parallel (بهخاطر publishOn، از آن نقطه به بعد)
پیچیدنِ کار بلاککننده بهشکل درست
اگر مجبوری کد بلاککنندهای (مثلاً یک SDK قدیمی) را در دنیای واکنشی صدا بزنی، هرگز مستقیم صدایش نزن؛ در Mono.fromCallable بپیچ و روی boundedElastic بفرست تا ترد event-loop آزاد بماند:
Mono<Report> report = Mono.fromCallable(() -> legacyBlockingClient.fetch(id)) // بلاککننده
.subscribeOn(Schedulers.boundedElastic()); // روی ترد جدا، نه event-loop
مدیریت خطا: در دنیای واکنشی، خطا هم یک سیگنال است
در جاوای امری خطا را با try/catch میگیری. اما در زنجیرهی واکنشی، فراخوانیها همان لحظه اجرا نمیشوند؛ پس try/catch دورِ ساختِ زنجیره بیفایده است. خطا بهصورت سیگنال onError در جریان حرکت میکند و زنجیره را از همانجا پایان میدهد. اپراتورهای خطا برای همیناند:
userRepo.findById(id)
// اگر خطا شد، یک مقدار جایگزین بده
.onErrorReturn(User.GUEST)
// یا: بسته به نوع خطا، به یک جریانِ دیگر سوییچ کن (fallback)
.onErrorResume(TimeoutException.class, e -> cache.lookup(id))
// یا: نوع خطا را به یک خطای دامنهای ترجمه کن
.onErrorMap(SQLException.class, e -> new DataAccessException("db", e));
onErrorReturn یک مقدار ثابت میدهد؛ اما اغلب میخواهی fallback خودش ناهمگام باشد (مثلاً از cache یا سرویس دیگر بخوانی). onErrorResume دقیقاً همین است: وقتی onError رسید، به یک Publisher جدید سوییچ میکند. مثل «اگر آشپزخانهی اصلی سوخت، سفارش را از رستوران کناری بگیر» — و آن هم خودش زمانبر و ناهمگام است.
retry و backoff
برای خطاهای گذرا (مثل قطعیِ لحظهای شبکه) میخواهی دوباره تلاش کنی — اما نه بلافاصله و نه بینهایت. retryWhen با Retry.backoff تلاش مجدد با تأخیر فزاینده (exponential backoff) و jitter میدهد:
httpClient.get("/inventory")
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)) // ۳ تلاش، شروع از ۲۰۰ms
.jitter(0.5) // پراکندگی تصادفی
.filter(ex -> ex instanceof IOException) // فقط این خطاها را retry کن
.onRetryExhaustedThrow((spec, sig) -> sig.failure())); // بعد از اتمام، خطای اصلی
retry وقتی به کار میآید که جریان با خطا تمام شود و بخواهی دوباره subscribe کنی. repeat وقتی است که جریان با موفقیت (onComplete) تمام شود و بخواهی دوباره اجرایش کنی (مثلاً polling). یکی درمانِ شکست است، دیگری تکرارِ موفقیت.
Backpressure در عمل: وقتی تولید تندتر از مصرف است
اگر منبع تند تولید کند و مصرفکننده کند باشد (مثلاً Flux.interval که هر میلیثانیه رویداد میدهد اما مصرفکننده کند است)، باید تصمیم بگیری با اضافهبار چه کنی. Reactor اپراتورهای صریح میدهد:
onBackpressureBuffer()— مازاد را در صف نگه دار (خطر: پرشدن حافظه اگر بیسقف باشد).onBackpressureDrop()— مازاد را دور بینداز (برای دادهای که «جدیدبودن» مهمتر از «کاملبودن» است، مثل موقعیت GPS).onBackpressureLatest()— فقط آخرین مقدار را نگه دار و بقیه را بینداز.
Flux.interval(Duration.ofMillis(1)) // تولیدکنندهی خیلی تند
.onBackpressureDrop(dropped -> log("انداخته شد: " + dropped))
.publishOn(Schedulers.boundedElastic())
.subscribe(slowConsumer);
WebFlux در برابر Spring MVC
Spring دو استک وب کاملاً مجزا دارد که نباید در یک اپلیکیشن مخلوط شوند:
- Spring MVC — مبتنی بر Servlet، مدل thread-per-request، بلاککننده، معمولاً روی Tomcat. ساده، بالغ، و با کل اکوسیستم JDBC/JPA سازگار.
- Spring WebFlux — مبتنی بر Reactive Streams، non-blocking، معمولاً روی Reactor Netty با event loop. کنترلرها
Mono/Fluxبرمیگردانند.
// WebFlux — کنترلر واکنشی
@RestController
class OrderController {
private final OrderRepository repo; // ReactiveCrudRepository
@GetMapping("/orders/{id}")
Mono<Order> byId(@PathVariable Long id) {
return repo.findById(id); // بدون بلاک؛ فریمورک subscribe میکند
}
@GetMapping(value = "/orders/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<Order> stream() {
return repo.findAll() // استریمِ Server-Sent Events
.delayElements(Duration.ofSeconds(1));
}
}
| جنبه | Spring MVC | Spring WebFlux |
|---|---|---|
| مدل تردینگ | thread-per-request (بلاککننده) | event-loop (non-blocking) |
| سرور پیشفرض | Tomcat (Servlet) | Reactor Netty |
| نوع بازگشتی | Order, List<Order> |
Mono<Order>, Flux<Order> |
| دیتابیس | JDBC / JPA (بلاککننده) | R2DBC / درایورهای واکنشی |
| backpressure | ندارد | ذاتی |
| منحنی یادگیری | ملایم | تند |
| بهترین کاربرد | CRUD معمول، تیم آشنا با بلاک | استریمینگ، اتصالات همزمانِ بسیار زیاد |
بزرگترین اشتباه، ساختن اپ WebFlux و بعد صدازدنِ JPA/JDBC بلاککننده در کنترلر است. این کار ترد event-loop را بلاک میکند و توانِ کل سرور را زمین میزند — بدتر از MVC خالص. در WebFlux یا همهچیز non-blocking باشد (R2DBC، درایورهای واکنشی)، یا اصلاً سراغش نرو.
R2DBC: دیتابیس رابطهای، اما non-blocking
JDBC ذاتاً بلاککننده است: ResultSet منتظر میماند. برای اینکه استکِ واکنشی تا خودِ دیتابیس ادامه پیدا کند، R2DBC (Reactive Relational Database Connectivity) ساخته شد — یک SPI کاملاً non-blocking برای دیتابیسهای رابطهای (Postgres، MySQL، SQL Server، ...).
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
Flux<Order> findByStatus(String status); // Flux، نه List
Mono<Order> findByReference(String ref); // Mono، نه Optional
}
R2DBC عمداً سطحپایینتر از JPA است: نه lazy loading دارد، نه persistence context و کش سطح اول، نه مدیریت رابطههای پیچیدهی @OneToMany. تو نگاشت را صریحتر مینویسی. این قیمتی است که برای non-blocking بودن میپردازی. اگر به قدرت کاملِ ORM نیاز داری و مسیرت بلاککننده است، احتمالاً MVC + JPA انتخاب درستتری است.
بزرگترین دام: بلاککردن داخل زنجیرهی واکنشی
این خطا آنقدر رایج و مهلک است که لایقِ بخش خودش است. تمام مزیت واکنشی از این میآید که تعداد کمی ترد event-loop هرگز بلاک نمیشوند. اگر حتی یک فراخوانی بلاککننده (یک Thread.sleep، یک JDBC، یک restTemplate قدیمی، یا یک .block()) روی ترد event-loop بیفتد، آن ترد گیر میکند و چون فقط چند تای معدود از این تردها داری، توان کل سرور فرو میریزد.
// فاجعه: بلاک روی ترد event-loop
@GetMapping("/bad/{id}")
Mono<User> bad(@PathVariable Long id) {
User u = jpaRepository.findById(id).get(); // JDBC بلاککننده روی event-loop!
return Mono.just(u);
}
تشخیص این دام با چشم سخت است. ابزار BlockHound یک agent است که در زمان اجرا هر فراخوانی بلاککننده روی تردهای non-blocking را میگیرد و استثنا پرتاب میکند. در تست و توسعه فعالش کن تا این باگها را قبل از تولید شکار کنی. راهحلِ درستِ کدِ بالا یا مهاجرت به R2DBC است یا پیچیدنِ فراخوانی در Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()).
دامهای رایج دیگر:
- فراموشکردنِ subscribe. اگر جایی
subscribeنشود، هیچچیز اجرا نمیشود؛ لاگی نمیبینی و گیج میشوی. (در WebFlux فریمورک این کار را میکند، اما در کد سرویسیِ جدا نه.) - گمکردنِ context.
ThreadLocalدر واکنشی کار نمیکند چون داده بین چند ترد میچرخد؛ برای انتقال داده (مثل امنیت یا trace) باید از Reactor Context (contextWrite/deferContextual) استفاده کنی. - stack trace بیفایده. خطاها اغلب stack trace گنگی دارند؛ با
Hooks.onOperatorDebug()یاcheckpoint()مسیر واقعی را ردیابی کن. - تستنکردن با StepVerifier. برای تست جریانهای واکنشی از
StepVerifierاستفاده کن، نه assert معمولی.
StepVerifier.create(Flux.just(1, 2, 3).map(i -> i * 2))
.expectNext(2, 4, 6)
.verifyComplete();
virtual thread در برابر واکنشی: چرا معادله در ۲۰۲۶ عوض شد
اینجا مهمترین بحث «امروزی» فصل است. برنامهنویسی واکنشی عمدتاً برای حلِ یک مشکل ساخته شد: بلاکشدنِ تردهای گرانِ سیستمی روی I/O. اما جاوا ۲۱ virtual thread (رشتهی مجازی، JEP 444) را نهایی کرد که همان مشکل را با کد بسیار سادهتر حل میکند.
واکنشی و virtual thread هر دو به یک مقصد میروند: «هزاران کار همزمان بدون تردهای سیستمیِ گران». اما جادهشان فرق دارد. واکنشی میگوید «کل کد را به سبک جریان و callback بازنویسی کن تا هرگز بلاک نشوی». virtual thread میگوید «کد بلاککنندهی ساده و خطیات را همانطور بنویس؛ من زیرِ پوست، وقتی روی I/O بلاک میشوی، ترد مجازی را از ترد سیستمی جدا میکنم تا آن آزاد شود». نتیجهی کارایی نزدیک است، اما مدل ذهنیِ virtual thread همان کدِ آشنای بلاککننده است.
با virtual thread، یک فراخوانی بلاککنندهی JDBC دیگر یک ترد سیستمی را قفل نمیکند؛ JVM ترد مجازی را در حین I/O «unmount» میکند و ترد حاملِ (carrier) واقعی آزاد میشود. یعنی میتوانی مدل آشنای thread-per-request و @Transactional و کل اکوسیستم JPA را نگه داری و همزمان توان بالا بگیری.
برای اکثریتِ سرویسهای request-response و CRUD که با دیتابیس بلاککننده کار میکنند، Spring MVC + virtual thread انتخاب پیشفرضِ پراگماتیک است: کد ساده و خطی، خطاگیری با try/catch، سازگاری کامل با JPA، و توانِ نزدیک به واکنشی. برای پروژهی جدیدی که کاملاً کنترل وابستگیها را داری و استریمینگ یا backpressure جدی میخواهی، WebFlux هنوز برنده است.
پس واکنشی کِی هنوز واقعاً ارزش دارد؟
- استریمینگ و SSE/WebSocket با تعداد اتصالِ خیلی زیاد —
Flux<ServerSentEvent>روی Netty بسیار کارآمدتر ازSseEmitterروی Tomcat است. - backpressure واقعی — وقتی مصرفکننده باید به تولیدکننده سیگنال ظرفیت بدهد؛ virtual thread این را ذاتاً ندارد.
- استکِ کاملاً واکنشیِ موجود — تیمی که همین حالا روی R2DBC، MongoDB واکنشی و Redis واکنشی است و میخواهد سراسر non-blocking بماند.
- الگوهای gateway/composition — سرویسی که دهها فراخوانیِ پاییندست را ترکیب میکند؛ اپراتورهای Reactor (
zip،flatMap، timeout، retry) اینجا بسیار رسا هستند.
virtual thread نقطهی قوت واکنشی را نمیکُشد، فقط محدودهاش را کوچک میکند. واکنشی از «پیشفرض برای همزمانی بالا» به «ابزار تخصصیِ استریمینگ، backpressure و ترکیب جریان» تغییر نقش داد.
بهترین شیوهها
- در WebFlux هیچجای زنجیره را بلاک نکن؛ BlockHound را در تست فعال کن.
- برای تبدیلِ همگام
map، برای رفتن سراغ منبعِ ناهمگامflatMap(و اگر ترتیب مهم استconcatMap). - کارِ بلاککنندهی ناگزیر را در
Mono.fromCallable(...).subscribeOn(boundedElastic())بپیچ. - برای انتقال دادهی جانبی از
ThreadLocalاستفاده نکن؛ از Reactor Context بهره ببر. - جریانها را با
StepVerifierتست کن، نه assert معمولی. - استکِ واکنشی را تا انتها نگه دار: WebFlux را با JPA بلاککننده مخلوط نکن؛ R2DBC یا درایور واکنشی بردار.
- برای خطاهای گذرا
retryWhen(Retry.backoff(...))با jitter؛ برای fallbackonErrorResume. - قبل از انتخاب واکنشی بپرس: «آیا مشکل من واقعاً backpressure یا استریمینگ است، یا فقط همزمانیِ بالا؟» اگر دومی است، شاید virtual thread سادهتر و بهتر باشد.
پرسشهای مصاحبه
blocking یعنی ترد تا آمادهشدنِ نتیجه متوقف میماند و کاری نمیکند؛ non-blocking یعنی ترد فوراً برمیگردد و بعداً از طریق رویداد/callback خبردار میشود. مدل thread-per-request کلاسیک روی I/O، تردهای سیستمیِ گران (هرکدام ~۱MB پشته) را هدر میدهد و سقف همزمانی را به اندازهی استخر ترد محدود میکند. واکنشی با چند ترد event-loop و آزادکردنِ ترد در زمان انتظار، هزاران اتصالِ I/O-محور را میچرخاند. نکتهی سنیور: واکنشی درمانِ انتظارِ I/O است، نه گلوگاهِ CPU.
backpressure توانایی مصرفکننده برای کنترلِ نرخ داده است: با request(n) میگوید «فعلاً فقط n مورد بفرست». بدون آن، یک منبعِ تند میتواند مصرفکنندهی کند را با تلنبارِ داده در حافظه دفن کند (OutOfMemory) یا مجبور به دورانداختنِ داده شود. Reactive Streams تقاضا را بخشی از پروتکل میکند تا این پیام تا خودِ منبع بالا برود. تفاوت اصلیِ «async خام» با «reactive» همین است.
Mono<T> صفر یا یک مقدار میدهد (همارز واکنشیِ Optional/CompletableFuture)؛ Flux<T> صفر تا بینهایت. جداسازیِ نوع در سطح تایپ به کامپایلر و خواننده میگوید انتظارِ «یک نتیجه» را داشته باشند یا «یک جریان». مثلاً findById باید Mono بدهد و findAll باید Flux. این تمایز API را خوداظهار (self-documenting) میکند.
Flux<Integer> f = Flux.range(1, 3).doOnNext(i -> System.out.println("x" + i));
System.out.println("ساخته شد");
فقط «ساخته شد». چون تا subscribe نکنی، ناشر سرد اجرا نمیشود — Flux/Mono یک دستور پخت است نه غذا. هیچ x1/x2/x3ای چاپ نمیشود. این دقیقاً همان تنبلیِ cold publisher است و پرتکرارترین سوءتفاهمِ تازهکارهاست.
map تابعی همگام میگیرد که مقدار را به مقدار تبدیل میکند. flatMap تابعی میگیرد که خودش یک Publisher (کار ناهمگام مثل فراخوانی DB) برمیگرداند، سپس آن ناشرهای داخلی را subscribe و خروجیشان را صاف میکند. اگر داخل map یک Mono برگردانی به Flux<Mono<T>> میرسی که بلااستفاده است. قاعده: تبدیل ساده ← map؛ رفتن سراغ منبع ناهمگام ← flatMap. اگر ترتیب مهم است ← concatMap.
هر سه تابعی میگیرند که Publisher میدهد. flatMap ناشرهای داخلی را موازی و بدون تضمین ترتیب اجرا میکند (بیشترین توان). concatMap آنها را یکییکی و مرتب اجرا میکند (ترتیب حفظ، اما کندتر). flatMapSequential موازی اجرا اما خروجی را مرتب تحویل میدهد. انتخاب بین آنها یک بدهبستانِ توان در برابر ترتیب است.
cold: هر subscribe یک اجرای مستقل و از ابتدا میسازد (مثل VOD)؛ نمونه: Flux.range، فراخوانی DB. hot: داده مستقل از مشترکها جاری است و هر مشترکِ تازه فقط از لحظهی وصلشدن به بعد را میبیند (مثل پخش زنده)؛ نمونه: یک Sinks.many().multicast() که رویدادهای سیستم را broadcast میکند. برای اشتراکِ یک اجرای cold بین چند مشترک از share()/publish() استفاده میکنی.
subscribeOn تعیین میکند subscription و تولیدِ داده (کل زنجیره از منبع) روی کدام Scheduler آغاز شود؛ جایش در زنجیره اثری ندارد و فقط یک بار مؤثر است. publishOn از نقطهی قرارگیری به بعد ترد را عوض میکند؛ جایش کاملاً مهم است و اپراتورهای بالادستش دستنخورده میمانند. بهصورت پیشفرض همهچیز روی تردِ subscribe-کننده و تکنخی اجرا میشود؛ این دو ابزارِ سوییچِ ترد هستند.
هرگز مستقیم روی ترد event-loop نه. آن را در Mono.fromCallable(() -> blockingCall()) بپیچ و با subscribeOn(Schedulers.boundedElastic()) روی استخرِ مخصوصِ کار بلاککننده بفرست تا تردهای event-loop آزاد بمانند. boundedElastic سقف دارد تا از انفجارِ تعداد ترد جلوگیری کند. اگر همهی مسیرت بلاککننده است، احتمالاً اصلاً نباید WebFlux باشد.
چون ساختِ زنجیره فقط یک دستور است و اجرای واقعی بعد از subscribe و اغلب روی تردِ دیگر رخ میدهد؛ پس try/catch دورِ ساخت، خطای زمانِ اجرا را نمیگیرد. خطا بهصورت سیگنال onError در جریان حرکت میکند. اپراتورها: onErrorReturn (مقدار ثابت)، onErrorResume (سوییچ به یک Publisher جایگزین، مناسب fallbackِ ناهمگام)، onErrorMap (ترجمهی نوع خطا)، و retryWhen برای تلاش مجدد.
بلاککردنِ ترد event-loop — مثلاً صداکردنِ JDBC/JPA بلاککننده، Thread.sleep، یا .block() داخل کنترلر. چون فقط چند ترد event-loop داری، یک بلاک، توانِ کل سرور را زمین میزند (بدتر از MVC). ابزار BlockHound بهصورت agent هر فراخوانی بلاککننده روی تردِ non-blocking را میگیرد و استثنا میدهد؛ در تست فعالش کن. راهحل: R2DBC/درایور واکنشی، یا پیچیدن در boundedElastic.
چون یک زنجیرهی واکنشی ممکن است روی چند تردِ مختلف اجرا شود (بهخاطر publishOn/subscribeOn)، پس دادهای که به یک ترد چسبیده باشد گم میشود. برای انتقالِ دادهی جانبی مثل هویتِ امنیتی یا trace-id از Reactor Context استفاده کن: با contextWrite(...) مینویسی و با Mono.deferContextual/Flux.deferContextual میخوانی. Context همراهِ خودِ جریان حرکت میکند، نه ترد.
virtual thread (جاوا ۲۱) اجازه میدهد کدِ سادهی بلاککننده بنویسی اما JVM ترد مجازی را در حین I/O از ترد حامل جدا کند؛ پس توانِ نزدیک به واکنشی با JPA/@Transactional/try-catch. برای اکثریتِ سرویسهای CRUD، MVC + virtual thread سادهتر و پراگماتیکتر است. WebFlux وقتی برنده است که استریمینگ/SSE/WebSocket با اتصالِ خیلی زیاد، backpressure واقعی، الگوهای gateway، یا استکِ سراسر واکنشیِ موجود داشته باشی. virtual thread محدودهی واکنشی را کوچک کرد، اما ننِ آن نقاط قوت را نکشت.
JPA/JDBC ذاتاً بلاککنندهاند؛ ResultSet منتظر میماند و ترد event-loop را قفل میکند، که کلِ مدل non-blocking را نابود میکند (نشتِ اتصال و افتِ توان). R2DBC یک SPI کاملاً non-blocking برای دیتابیس رابطهای است که با ReactiveCrudRepository متدهایی میدهد که Mono/Flux برمیگردانند. اما R2DBC عمداً سطحپایینتر است: نه persistence context، نه lazy loading، نه کش سطح اول — نگاشت را صریحتر مینویسی. این قیمتِ non-blocking بودن است.
List<Order> orders = repo.findAll().collectList().block();
.block() کلِ زنجیره را همگام میکند و ترد فعلی را بلاک میکند تا نتیجه بیاید. اگر این روی ترد event-loop اجرا شود، فاجعه است. علاوه بر آن، هدفِ واکنشی — عدمِ بلاک — را کاملاً از بین میبرد. درست: مقدار را بهصورت Mono<List<Order>> برگردان و اجازه بده فریمورک subscribe کند (return repo.findAll().collectList();)؛ block() فقط در main یا تستهای ساده مجاز است، نه در مسیرِ درخواست.
- blocking تردِ گران را روی I/O معطل میگذارد؛ non-blocking/واکنشی با چند ترد event-loop و آزادکردنِ ترد در انتظار، هزاران اتصالِ I/O-محور را میچرخاند. واکنشی درمانِ انتظارِ I/O است، نه CPU.
- Reactive Streams چهار interface (Publisher/Subscriber/Subscription/Processor) و مفهومِ backpressure (کنترلِ تقاضا با
request(n)) است. بدون backpressure، «واکنشی» فقط callback است. Mono(۰|۱) وFlux(۰..N) ناشرهای Reactor اند و تا subscribe نکنی هیچچیز اجرا نمیشود (cold، تنبل).mapتبدیلِ همگام،flatMapرفتن سراغ منبعِ ناهمگام و صافکردن؛ برای ترتیبconcatMap.zipچند فراخوانیِ مستقل را موازی ترکیب میکند.- cold (هر subscribe اجرای تازه) در برابر hot (
Sinks، جریانِ مستقل از مشترک).publishOnجایش مهم است (پاییندست)،subscribeOnجایش مهم نیست (کلِ منبع). - خطا سیگنالِ
onErrorاست:onErrorResumeبرای fallbackِ ناهمگام،retryWhen(Retry.backoff(...))برای تلاش مجدد با تأخیر و jitter. - WebFlux را با JPA بلاککننده مخلوط نکن؛ R2DBC بردار. بزرگترین دام، بلاککردنِ ترد event-loop است؛ با BlockHound شکارش کن.
- virtual thread (جاوا ۲۱) معادله را عوض کرد: برای اکثریتِ CRUD، MVC + virtual thread سادهتر و کافی است؛ واکنشی برای استریمینگ، backpressure و ترکیب جریان هنوز برنده است.
Let me put one fact on the table up front: classic Java assigned one thread to each web request and kept that thread parked until the very end — until the database answered or the downstream service replied. Simple and easy to reason about, but expensive. Reactive programming arrived to end that waste: instead of a thread sitting and waiting, you model the data as a pipeline of events that comes to you the moment it's ready. In this chapter you won't just memorize an operator list — you'll actually understand why the paradigm exists, where it wins, where it makes you suffer, and why Java 21's virtual threads rewrote the whole equation.
The path we'll walk together:
- The big mental model — blocking vs non-blocking, and how expensive "a parked thread" really is.
- Reactive Streams — the four foundational interfaces and the idea of backpressure.
- Mono and Flux — Reactor's two publishers, and "nothing runs until you subscribe."
- Operators —
map,flatMap,filter,zip, and the crucialmapvsflatMapsplit. - Cold vs hot — cold and hot publishers, and
Sinks. - Schedulers and threading —
publishOnvssubscribeOn, and handling blocking work. - Error handling —
onErrorResume,retryWhen, and backoff. - WebFlux vs MVC, R2DBC, pitfalls, and finally virtual threads vs reactive + interview questions.
Part 0 — a few words you must feel before we start
Before any code, a few terms recur throughout this chapter. Let me plant them in your mind now.
- Blocking: a call that halts the thread until a result is ready. Like standing frozen at the checkout until the cashier swipes the card.
- Non-blocking: a call that returns immediately and says "I'll tell you when it's ready." The thread is freed to do other work.
- Synchronous / asynchronous: synchronous means you get the result right there, on the spot; asynchronous means the result arrives later, via a callback or event.
- Backpressure: a consumer's ability to say "slow down! I can only take 10 at a time." Without it, a fast producer can bury a slow consumer under data.
- Publisher / subscriber: the publisher is the source of data, the subscriber the destination. Their relationship is like a newspaper and its subscriber: until you subscribe, no paper arrives.
Mental model: why "a parked thread" is expensive
Imagine a restaurant where you assign one waiter per table who takes the order and then stands frozen beside that table until the food arrives from the kitchen — motionless, just waiting. With 200 tables you need 200 waiters, most of them just standing there drawing a salary. That's the classic thread-per-request model: an expensive thread is blocked behind an I/O call, doing nothing. The reactive model is a restaurant where the waiter takes an order, hands it to the kitchen, and moves to the next table; when food is ready a bell rings and any free waiter delivers it. With a handful of waiters, you serve hundreds of tables.
In the classic model (Servlet / Spring MVC), each request grabs a thread from a pool. When the code hits a database or HTTP call, that thread blocks: it enters WAITING and does no useful work until the response arrives. A platform thread costs roughly 1 MB of stack, so with, say, a pool of 200, your concurrency ceiling is effectively 200 in-flight requests — even if the CPU is idle. Your bottleneck isn't processing power; it's the number of blocked threads.
If your app spends most of its time waiting on I/O (database, network, files), the blocking model wastes expensive threads that are merely waiting. If your app is mostly CPU-bound (encryption, image processing), reactive works no miracle — there, only core count matters. Reactive cures I/O waiting, not slow computation.
The non-blocking model runs on a small number of threads (typically one per CPU core). When it hits I/O, instead of waiting it registers a callback and frees the thread to serve the next request. The OS event-loop mechanism (like epoll on Linux) signals when data is ready. The result: 4 threads can juggle tens of thousands of concurrent connections — provided you never block anywhere in the chain (that "provided" becomes the biggest trap in this chapter).
Reactive Streams: the four-interface contract
Before Reactor, everyone did async their own way and libraries didn't compose. Reactive Streams is a tiny standard (just four interfaces) that even entered the JDK in Java 9 as java.util.concurrent.Flow. Reactor is one implementation of it.
Picture a factory: one end of a conveyor produces goods (the Publisher) and the other end has a worker packing them (the Subscriber). If production outruns packing, boxes pile up on the floor and chaos ensues. Reactive Streams gives the worker a button: "only send me 10 for now." The producer sends exactly those 10 and waits for the next request. That button is backpressure — the heart of the standard.
The four core interfaces:
public interface Publisher<T> {
void subscribe(Subscriber<? super T> s);
}
public interface Subscriber<T> {
void onSubscribe(Subscription s); // connection established
void onNext(T item); // one item arrived
void onError(Throwable t); // error; failed termination
void onComplete(); // successful termination
}
public interface Subscription {
void request(long n); // consumer wants n items ← backpressure
void cancel(); // I want no more
}
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {}
The golden point: the consumer controls demand via request(n). The producer may not emit more onNext than the total requested. That contract keeps the stream from flooding. A stream always terminates with exactly one onError or one onComplete — never both, and no signals are allowed afterward.
The core difference between "plain async" and "reactive" is backpressure. With raw CompletableFuture or callbacks, if the source outruns the consumer you either exhaust memory or drop data. Reactive Streams makes demand part of the protocol: the consumer can say "slower," and that message propagates all the way back to the source. Without backpressure, "reactive" is just callbacks in a fancy jacket.
Mono and Flux: Reactor's two publishers
Reactor provides two Publisher implementations that everything is built on:
Mono<T>— a stream that emits zero or one value then completes. Like "the user with this id" or "the result of one save." The reactive equivalent ofOptional<T>orCompletableFuture<T>.Flux<T>— a stream that emits zero to many (unbounded) values. Like "all of today's orders" or an endless stream of events.
Mono<String> one = Mono.just("Ada");
Mono<String> empty = Mono.empty();
Flux<Integer> many = Flux.just(1, 2, 3, 4);
Flux<Integer> range = Flux.range(1, 100);
Flux<Long> ticks = Flux.interval(Duration.ofSeconds(1)); // infinite: 0,1,2,...
The most important rule: nothing runs until you subscribe
A Flux or Mono is like a recipe, not the food itself. No matter how many operators you stack on it (map, filter, ...), you're only writing a more complete recipe — the stove isn't on yet. The moment you call subscribe(), it's as if you hit "start cooking": only then does data flow from the source through the operators. Until that moment, everything is just a plan.
Flux<Integer> pipeline = Flux.range(1, 5)
.map(i -> i * 2)
.doOnNext(i -> System.out.println("passing: " + i));
// Nothing printed yet! Only the recipe was built.
pipeline.subscribe(i -> System.out.println("got: " + i)); // now it runs
This "laziness" is called being a cold publisher, which we return to shortly. In the WebFlux world you usually don't call subscribe yourself; the framework does it when it's ready to write the HTTP response. The big rule: never call .block() inside a reactive chain to "pull the value out" — that destroys the whole point (again, the chapter's central trap).
Operators: building the chain
Operators are what you chain onto a publisher to transform, filter, or combine data. Each operator returns a new publisher and leaves the previous one untouched (like the Stream API, but asynchronous).
map: one-to-one, synchronous transformation
map transforms each value into another using a synchronous function — exactly like Stream.map.
Flux.just("ada", "linus", "grace")
.map(String::toUpperCase) // "ADA", "LINUS", "GRACE"
.map(String::length); // 3, 5, 5
flatMap: a transformation that itself produces a publisher, asynchronously
Here is the most important distinction in the whole chapter. When the transforming function itself does something asynchronous (say, a database call that returns a Mono), using map gets you Flux<Mono<User>> — a publisher inside a publisher, useless. flatMap "flattens" these: it subscribes to each inner publisher and merges their outputs into a single Flux.
map is like a worker who finishes the job themselves and hands back the result. flatMap is like a manager who delegates the work to another contractor who takes time and may return several results; the manager collects all the contractors' results and hands them back as one. If your task is a "simple transform," use map; if it's "go off to another asynchronous source," use flatMap.
Flux<Long> orderIds = Flux.just(1L, 2L, 3L);
// wrong map: Flux<Mono<Order>> — a nested publisher
Flux<Mono<Order>> wrong = orderIds.map(id -> orderRepo.findById(id));
// right flatMap: Flux<Order> — flattened and concurrent
Flux<Order> right = orderIds.flatMap(id -> orderRepo.findById(id));
Crucial note: flatMap runs the inner publishers concurrently and with no ordering guarantee. If order matters, choose concatMap (sequential, order preserved, but slower) or flatMapSequential (runs concurrently but delivers ordered output).
| Operator | Function returns | Concurrency | Output order | Typical use |
|---|---|---|---|---|
map |
R (plain value) |
— | preserved | synchronous transform |
flatMap |
Publisher<R> |
parallel | none | independent I/O calls |
concatMap |
Publisher<R> |
one at a time | preserved | when order matters |
flatMapSequential |
Publisher<R> |
parallel | preserved | ordered parallelism |
filter, zip, and a few other everyday operators
Flux.range(1, 10)
.filter(i -> i % 2 == 0) // evens only: 2,4,6,8,10
.take(3) // first 3 only: 2,4,6
.defaultIfEmpty(-1); // a default value if empty
// zip: pairs multiple publishers "in parallel" and combines
Mono<User> user = userRepo.findById(id);
Mono<Address> address = addressRepo.findByUser(id);
Mono<Profile> profile = Mono.zip(user, address,
(u, a) -> new Profile(u.name(), a.city()));
zip shines when you have several independent calls and want all of them to run concurrently, then combine their results once all are ready — instead of awaiting them one after another. This is one place reactive truly excels: you wait for the maximum of the durations, not their sum.
The doOnNext, doOnError, doOnComplete, and doOnSubscribe operators don't change the value; they just let you perform a side effect like logging. Never hide core logic inside a map that "looks" harmless; side effects belong in doOn....
Cold vs Hot: who decides when data flows
A cold publisher is like video on demand (VOD): every viewer who hits "play" sees the movie from the beginning, for themselves. Each subscription creates an independent, complete run. A hot publisher is like a live TV broadcast: the show is airing whether you watch or not; whenever you tune in, you see it from that moment on and whatever you missed is gone.
Most Reactor publishers are cold: Flux.range, Mono.fromCallable, a database call. Each new subscriber triggers a fresh execution.
Flux<Integer> cold = Flux.range(1, 3).map(i -> { System.out.println("computing " + i); return i; });
cold.subscribe(i -> System.out.println("A saw: " + i));
cold.subscribe(i -> System.out.println("B saw: " + i));
// "computing" runs fully twice — once for A, once for B
To create hot behavior or share one execution across subscribers, you use share()/publish() or Sinks:
// Sinks: the bridge between imperative code and the reactive world
Sinks.Many<String> sink = Sinks.many().multicast().onBackpressureBuffer();
Flux<String> hot = sink.asFlux();
hot.subscribe(s -> System.out.println("subscriber 1: " + s));
sink.tryEmitNext("event A"); // subscriber 1 sees it
hot.subscribe(s -> System.out.println("subscriber 2: " + s));
sink.tryEmitNext("event B"); // both see it; subscriber 2 missed "event A"
In older code you may see DirectProcessor or EmitterProcessor. These are deprecated. The correct way today to "manually push" data into a stream is Sinks, which is thread-safe and explicit about its backpressure policy and multicast/unicast semantics.
Schedulers and threading: which thread does data flow on?
A common misconception: "reactive means multi-threaded." No! By default, a reactive chain runs on the same thread that called subscribe — fully single-threaded. A Scheduler is the tool that lets you move parts of the chain onto other threads.
The main Scheduler types:
Schedulers.parallel()— a fixed pool sized to the number of cores; for CPU-bound, short work.Schedulers.boundedElastic()— a capped pool that spins up temporary threads; specifically for wrapping blocking I/O work (e.g., a legacy JDBC driver) so it doesn't poison the rest of the flow.Schedulers.single()— a single shared thread; for work that must stay ordered on one thread.Schedulers.immediate()— the current thread; i.e., "don't switch at all."
publishOn versus subscribeOn
These two are the most frequent source of confusion. Let me state it firmly:
subscribeOn(scheduler)determines which Scheduler the entire chain from the source upward — where subscription and data production begin — runs on. Its position in the chain doesn't matter; wherever you put it, it affects from the source.publishOn(scheduler)switches the thread from the point it's placed onward (downstream). Its position matters entirely; only the operators after it run on the new Scheduler.
Imagine a production line. subscribeOn decides in which city the whole factory is built from the start — a single choice for the entire source. publishOn is like saying, mid-line, "from this station onward, let the night crew take over"; earlier stations use the old crew, later stations use the new one. That's why publishOn's position matters and subscribeOn's doesn't.
Flux.range(1, 3)
.map(i -> { log("map1 on: " + Thread.currentThread()); return i; })
.publishOn(Schedulers.parallel())
.map(i -> { log("map2 on: " + Thread.currentThread()); return i; })
.subscribeOn(Schedulers.boundedElastic())
.subscribe();
// map1 on boundedElastic (because of subscribeOn, from the source)
// map2 on parallel (because of publishOn, from that point on)
Wrapping blocking work the right way
If you must call blocking code (say, a legacy SDK) in the reactive world, never call it directly; wrap it in Mono.fromCallable and push it onto boundedElastic so the event-loop thread stays free:
Mono<Report> report = Mono.fromCallable(() -> legacyBlockingClient.fetch(id)) // blocking
.subscribeOn(Schedulers.boundedElastic()); // on a separate thread, not the event-loop
Error handling: in the reactive world, an error is also a signal
In imperative Java you catch errors with try/catch. But in a reactive chain, calls don't execute at that moment; so try/catch around building the chain is useless. An error travels as an onError signal within the stream and terminates the chain from that point. The error operators exist for exactly this:
userRepo.findById(id)
// if it errors, provide a fallback value
.onErrorReturn(User.GUEST)
// or: depending on error type, switch to a different stream (fallback)
.onErrorResume(TimeoutException.class, e -> cache.lookup(id))
// or: translate the error type into a domain error
.onErrorMap(SQLException.class, e -> new DataAccessException("db", e));
onErrorReturn gives a constant value; but often you want the fallback itself to be asynchronous (e.g., read from a cache or another service). onErrorResume is exactly that: when onError arrives, it switches to a new Publisher. It's "if the main kitchen burns down, get the order from the restaurant next door" — and that too takes time and is asynchronous.
retry and backoff
For transient errors (a momentary network blip), you want to retry — but not immediately and not forever. retryWhen with Retry.backoff gives retries with an increasing delay (exponential backoff) and jitter:
httpClient.get("/inventory")
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)) // 3 attempts, starting at 200ms
.jitter(0.5) // random spread
.filter(ex -> ex instanceof IOException) // only retry these errors
.onRetryExhaustedThrow((spec, sig) -> sig.failure())); // after exhaustion, original error
retry applies when a stream terminates with an error and you want to re-subscribe. repeat applies when a stream terminates successfully (onComplete) and you want to re-run it (e.g., polling). One cures failure, the other repeats success.
Backpressure in practice: when production outruns consumption
If a source produces fast and the consumer is slow (say, Flux.interval emitting every millisecond while the consumer lags), you must decide what to do with the overflow. Reactor gives explicit operators:
onBackpressureBuffer()— hold the excess in a queue (risk: memory fills up if unbounded).onBackpressureDrop()— throw the excess away (for data where "freshness" beats "completeness," like GPS position).onBackpressureLatest()— keep only the latest value and drop the rest.
Flux.interval(Duration.ofMillis(1)) // a very fast producer
.onBackpressureDrop(dropped -> log("dropped: " + dropped))
.publishOn(Schedulers.boundedElastic())
.subscribe(slowConsumer);
WebFlux versus Spring MVC
Spring has two entirely separate web stacks that must not be mixed in one application:
- Spring MVC — Servlet-based, thread-per-request model, blocking, usually on Tomcat. Simple, mature, and compatible with the whole JDBC/JPA ecosystem.
- Spring WebFlux — Reactive Streams-based, non-blocking, usually on Reactor Netty with an event loop. Controllers return
Mono/Flux.
// WebFlux — a reactive controller
@RestController
class OrderController {
private final OrderRepository repo; // ReactiveCrudRepository
@GetMapping("/orders/{id}")
Mono<Order> byId(@PathVariable Long id) {
return repo.findById(id); // no blocking; the framework subscribes
}
@GetMapping(value = "/orders/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<Order> stream() {
return repo.findAll() // a Server-Sent Events stream
.delayElements(Duration.ofSeconds(1));
}
}
| Aspect | Spring MVC | Spring WebFlux |
|---|---|---|
| Threading model | thread-per-request (blocking) | event-loop (non-blocking) |
| Default server | Tomcat (Servlet) | Reactor Netty |
| Return types | Order, List<Order> |
Mono<Order>, Flux<Order> |
| Database | JDBC / JPA (blocking) | R2DBC / reactive drivers |
| Backpressure | none | built-in |
| Learning curve | gentle | steep |
| Best fit | typical CRUD, teams used to blocking | streaming, very high concurrent connections |
The biggest mistake is building a WebFlux app and then calling blocking JPA/JDBC in a controller. This blocks the event-loop thread and collapses the whole server's throughput — worse than plain MVC. In WebFlux either everything is non-blocking (R2DBC, reactive drivers), or don't use it at all.
R2DBC: relational databases, but non-blocking
JDBC is inherently blocking: the ResultSet waits. So that the reactive stack can extend all the way down to the database, R2DBC (Reactive Relational Database Connectivity) was created — a fully non-blocking SPI for relational databases (Postgres, MySQL, SQL Server, ...).
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
Flux<Order> findByStatus(String status); // Flux, not List
Mono<Order> findByReference(String ref); // Mono, not Optional
}
R2DBC is deliberately lower-level than JPA: no lazy loading, no persistence context or first-level cache, no complex @OneToMany relationship management. You write the mapping more explicitly. That's the price you pay for being non-blocking. If you need the full power of an ORM and your path is blocking, MVC + JPA is probably the better choice.
The biggest trap: blocking inside the reactive chain
This mistake is so common and so lethal that it deserves its own section. The entire reactive advantage comes from a small number of event-loop threads that never block. If even one blocking call (a Thread.sleep, a JDBC call, a legacy restTemplate, or a .block()) lands on an event-loop thread, that thread gets stuck — and since you have only a handful of them, the whole server's throughput collapses.
// Disaster: blocking on the event-loop thread
@GetMapping("/bad/{id}")
Mono<User> bad(@PathVariable Long id) {
User u = jpaRepository.findById(id).get(); // blocking JDBC on the event-loop!
return Mono.just(u);
}
Spotting this trap by eye is hard. BlockHound is an agent that, at runtime, catches any blocking call on non-blocking threads and throws an exception. Enable it in tests and development to hunt these bugs before production. The correct fix for the code above is either migrating to R2DBC or wrapping the call in Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()).
Other common traps:
- Forgetting to subscribe. If nothing subscribes, nothing runs; you see no logs and get confused. (In WebFlux the framework subscribes, but in standalone service code it doesn't.)
- Losing context.
ThreadLocaldoesn't work in reactive because data hops across threads; to carry data (like security or trace info) you must use the Reactor Context (contextWrite/deferContextual). - Useless stack traces. Errors often have cryptic stack traces; use
Hooks.onOperatorDebug()orcheckpoint()to trace the real path. - Not testing with StepVerifier. Test reactive streams with
StepVerifier, not ordinary asserts.
StepVerifier.create(Flux.just(1, 2, 3).map(i -> i * 2))
.expectNext(2, 4, 6)
.verifyComplete();
Virtual threads versus reactive: why the equation changed in 2026
Here is the most "current" discussion of the chapter. Reactive programming was built mainly to solve one problem: expensive platform threads blocking on I/O. But Java 21 finalized virtual threads (JEP 444), which solve the same problem with far simpler code.
Reactive and virtual threads both head to the same destination: "thousands of concurrent tasks without expensive platform threads." But their roads differ. Reactive says "rewrite all your code in a streaming, callback style so you never block." Virtual threads say "write your simple, linear blocking code as usual; under the hood, when you block on I/O, I unmount the virtual thread from the platform thread so it's freed." The performance outcome is close, but the virtual-thread mental model is the same familiar blocking code.
With virtual threads, a blocking JDBC call no longer pins a platform thread; the JVM "unmounts" the virtual thread during I/O and the real carrier thread is freed. That means you can keep the familiar thread-per-request model, @Transactional, and the entire JPA ecosystem while simultaneously getting high throughput.
For the majority of request-response and CRUD services talking to blocking databases, Spring MVC + virtual threads is the pragmatic default: simple linear code, error handling with try/catch, full JPA compatibility, and throughput close to reactive. For a new project where you fully control the dependencies and genuinely need streaming or serious backpressure, WebFlux still wins.
So when is reactive still genuinely worth it?
- Streaming and SSE/WebSocket at very high connection counts —
Flux<ServerSentEvent>on Netty is far more efficient thanSseEmitteron Tomcat. - Real backpressure — when the consumer must signal capacity to the producer; virtual threads don't provide this inherently.
- An existing fully reactive stack — a team already on R2DBC, reactive MongoDB, and reactive Redis that wants to stay non-blocking end to end.
- Gateway/composition patterns — a service that composes dozens of downstream calls; Reactor's operators (
zip,flatMap, timeout, retry) are very expressive here.
Virtual threads don't kill reactive's strengths, they just shrink its scope. Reactive shifted from "the default for high concurrency" to "the specialized tool for streaming, backpressure, and stream composition."
Best practices
- In WebFlux, never block anywhere in the chain; enable BlockHound in tests.
- For synchronous transforms use
map; to go off to an asynchronous source useflatMap(andconcatMapif order matters). - Wrap unavoidable blocking work in
Mono.fromCallable(...).subscribeOn(boundedElastic()). - Don't use
ThreadLocalto carry side data; use the Reactor Context. - Test streams with
StepVerifier, not ordinary asserts. - Keep the reactive stack end to end: don't mix WebFlux with blocking JPA; use R2DBC or a reactive driver.
- For transient errors,
retryWhen(Retry.backoff(...))with jitter; for fallbacks,onErrorResume. - Before choosing reactive, ask: "Is my problem really backpressure or streaming, or just high concurrency?" If the latter, virtual threads may be simpler and better.
Interview Questions
Blocking means the thread halts until a result is ready and does nothing; non-blocking means the thread returns immediately and is later notified via event/callback. The classic thread-per-request model wastes expensive platform threads (each ~1MB stack) on I/O and caps concurrency at pool size. Reactive juggles thousands of I/O-bound connections with a few event-loop threads by freeing the thread during waits. Senior point: reactive cures I/O waiting, not a CPU bottleneck.
Backpressure is the consumer's ability to control the data rate: with request(n) it says "only send n for now." Without it, a fast source can bury a slow consumer by piling data in memory (OutOfMemory) or forcing data to be dropped. Reactive Streams makes demand part of the protocol so the message propagates back to the source. This is precisely what separates "raw async" from "reactive."
Mono<T> emits zero or one value (the reactive equivalent of Optional/CompletableFuture); Flux<T> emits zero to unbounded. Distinguishing them at the type level tells the compiler and the reader to expect "one result" versus "a stream." For example, findById should return Mono and findAll should return Flux. This distinction makes the API self-documenting.
Flux<Integer> f = Flux.range(1, 3).doOnNext(i -> System.out.println("x" + i));
System.out.println("built");
Only "built." Because until you subscribe, the cold publisher doesn't run — a Flux/Mono is a recipe, not food. No x1/x2/x3 is printed. This is exactly the laziness of a cold publisher and the most frequent beginner misconception.
map takes a synchronous function that turns a value into a value. flatMap takes a function that itself returns a Publisher (async work like a DB call), then subscribes to those inner publishers and flattens their output. If you return a Mono inside map you get Flux<Mono<T>>, which is useless. Rule: simple transform → map; going off to an async source → flatMap. If order matters → concatMap.
All three take a function returning a Publisher. flatMap runs the inner publishers concurrently with no ordering guarantee (max throughput). concatMap runs them one at a time and ordered (order preserved, but slower). flatMapSequential runs concurrently but delivers ordered output. Choosing between them is a throughput-vs-order trade-off.
Cold: each subscribe creates an independent run from the beginning (like VOD); example: Flux.range, a DB call. Hot: data flows independently of subscribers, and each new subscriber only sees from the moment it connects (like a live broadcast); example: a Sinks.many().multicast() broadcasting system events. To share one cold execution across subscribers, use share()/publish().
subscribeOn determines which Scheduler the subscription and data production (the whole chain from the source) starts on; its position in the chain has no effect and it takes effect only once. publishOn switches the thread from its placement onward; its position matters entirely and upstream operators are untouched. By default everything runs single-threaded on the subscribing thread; these two are the thread-switching tools.
Never directly on the event-loop thread. Wrap it in Mono.fromCallable(() -> blockingCall()) and push it with subscribeOn(Schedulers.boundedElastic()) onto the pool dedicated to blocking work so the event-loop threads stay free. boundedElastic is capped to prevent thread explosion. If your entire path is blocking, you probably shouldn't be on WebFlux at all.
Because building the chain is just a declaration; the actual execution happens after subscription and often on a different thread, so try/catch around the building doesn't catch the runtime error. The error moves as an onError signal in the stream. Operators: onErrorReturn (constant value), onErrorResume (switch to a fallback Publisher, ideal for async fallback), onErrorMap (translate error type), and retryWhen for retries.
Blocking the event-loop thread — e.g., calling blocking JDBC/JPA, Thread.sleep, or .block() inside a controller. Since you have only a few event-loop threads, one block collapses the whole server's throughput (worse than MVC). BlockHound as an agent catches any blocking call on a non-blocking thread and throws; enable it in tests. Fix: R2DBC/reactive driver, or wrap in boundedElastic.
Because a reactive chain may run across multiple threads (due to publishOn/subscribeOn), data pinned to a thread gets lost. To carry side data like security identity or a trace-id, use the Reactor Context: write with contextWrite(...) and read with Mono.deferContextual/Flux.deferContextual. The Context travels with the stream itself, not the thread.
Virtual threads (Java 21) let you write simple blocking code while the JVM unmounts the virtual thread from its carrier during I/O; you get throughput close to reactive with JPA/@Transactional/try-catch. For the majority of CRUD services, MVC + virtual threads is simpler and more pragmatic. WebFlux wins when you have streaming/SSE/WebSocket at very high connection counts, real backpressure, gateway patterns, or an existing fully reactive stack. Virtual threads shrank reactive's scope but didn't kill those strengths.
JPA/JDBC are inherently blocking; the ResultSet waits and pins the event-loop thread, destroying the whole non-blocking model (connection leaks and throughput collapse). R2DBC is a fully non-blocking SPI for relational databases; with ReactiveCrudRepository it gives methods that return Mono/Flux. But R2DBC is deliberately lower-level: no persistence context, no lazy loading, no first-level cache — you write the mapping more explicitly. That's the price of being non-blocking.
List<Order> orders = repo.findAll().collectList().block();
.block() makes the whole chain synchronous and blocks the current thread until the result arrives. If this runs on the event-loop thread, it's a disaster. Beyond that, it completely defeats reactive's purpose — non-blocking. Correct: return the value as Mono<List<Order>> and let the framework subscribe (return repo.findAll().collectList();); block() is only acceptable in main or simple tests, never in the request path.
- Blocking parks an expensive thread on I/O; non-blocking/reactive juggles thousands of I/O-bound connections with a few event-loop threads by freeing the thread during waits. Reactive cures I/O waiting, not CPU.
- Reactive Streams is four interfaces (Publisher/Subscriber/Subscription/Processor) plus backpressure (demand control via
request(n)). Without backpressure, "reactive" is just callbacks. Mono(0|1) andFlux(0..N) are Reactor's publishers, and nothing runs until you subscribe (cold, lazy).mapis a synchronous transform,flatMapgoes off to an async source and flattens;concatMapfor order.zipcombines several independent calls in parallel.- Cold (each subscribe re-runs) vs hot (
Sinks, flows independently of subscribers).publishOn's position matters (downstream);subscribeOn's doesn't (whole source). - An error is an
onErrorsignal:onErrorResumefor async fallback,retryWhen(Retry.backoff(...))for retries with delay and jitter. - Don't mix WebFlux with blocking JPA; use R2DBC. The biggest trap is blocking the event-loop thread; hunt it with BlockHound.
- Virtual threads (Java 21) changed the equation: for most CRUD, MVC + virtual threads is simpler and sufficient; reactive still wins for streaming, backpressure, and stream composition.