Spring MVC asenkron uçlarında timeout, istemci kopması ve güvenlik context'i aynı anda ele alınmalıdır. Bu yazı, spring boot eğitimi kapsamında DeferredResult, executor sınırları ve ölçülebilir yük testini üretim odaklı ele alır.
Spring MVC Asenkron İsteklerde Timeout, İptal ve Context Yönetimi
Spring MVC async sınırını HTTP timeout zinciriyle eşlemek
Bir spring rest api çağrısında uygulama timeout'u, ingress veya load balancer timeout'undan uzun kalırsa istemci bağlantısı kapandıktan sonra uygulama iş yapmaya devam eder. Ters durumda ise Spring, uygulama sonucunu üretmeden 503 dönebilir. Önce her katmanın süresini çıkarın: örneğin gateway 15 saniye, uygulama 12 saniye, aşağı bağımlılık 10 saniye ve veritabanı sorgusu 8 saniye. Bu bütçe, microservices mimarisi içindeki her hop'ta kalan süreyi azaltacak şekilde tasarlanmalıdır; sabit 30 saniyelik tüm servis timeout'ları kuyruk birikimini gizler.
Servlet async executor'unu sınırsız kuyrukla bırakmayın. Aşağıdaki yapı, en fazla 64 çalışan iş, 128 kuyruk elemanı ve doygunlukta doğrudan reddetme tanımlar. CallerRunsPolicy burada özellikle kullanılmıyor; servlet request thread'inin pahalı işi çalıştırması async izolasyonunu bozar. Bu yapılandırma, spring framework eğitimi veya java spring eğitimi içinde genellikle atlanan bir kapasite sınırıdır.
@Configuration
class MvcAsyncConfig implements WebMvcConfigurer {
@Bean("mvcAsyncExecutor")
AsyncTaskExecutor mvcAsyncExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(16);
delegate.setMaxPoolSize(64);
delegate.setQueueCapacity(128);
delegate.setThreadNamePrefix("mvc-async-");
delegate.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
delegate.setWaitForTasksToCompleteOnShutdown(false);
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(mvcAsyncExecutor());
configurer.setDefaultTimeout(12_000L);
}
}Doygunlukta atılan RejectedExecutionException'i genel 500 cevabına bırakmayın. Bir @ControllerAdvice içinde bu istisnayı 429 veya bağımlılık kapasitesi sorununu temsil ediyorsa 503'e çevirin ve response'a Retry-After ekleyin. Kritik ayrıntı şudur: ThreadPoolTaskExecutor'da queueCapacity pozitifse maksimum thread sayısına ancak kuyruk dolduktan sonra çıkılır. 16 core, 64 max, 10.000 kuyruk kombinasyonu pratikte çoğu zaman 16 iş parçacığı ve binlerce bekleyen istek demektir.
DeferredResult ile timeout sonrası işi gerçekten durdurmak
DeferredResult timeout'u HTTP cevabını sonlandırır, ancak Executor'a verdiğiniz Runnable'i otomatik olarak iptal etmez. Bu nedenle Future referansını saklayıp timeout ve completion olaylarında iptal etmeniz gerekir. setResult ve setErrorResult yarış koşulunda boolean döndürür; ikinci tamamlayan tarafın sonucunu sessizce yok saymak yerine bu dönüş değerini log veya metric olarak kaydedin.
@RestController
@RequiredArgsConstructor
class ReportController {
private final AsyncTaskExecutor mvcAsyncExecutor;
private final ReportService reportService;
@GetMapping("/reports/{id}")
DeferredResult<ResponseEntity<ReportDto>> report(@PathVariable UUID id) {
DeferredResult<ResponseEntity<ReportDto>> result = new DeferredResult<>(11_500L);
AtomicReference<Future<?>> futureRef = new AtomicReference<>();
result.onTimeout(() -> {
Future<?> future = futureRef.get();
if (future != null) future.cancel(true);
result.setErrorResult(ResponseEntity.status(503)
.header("Retry-After", "2").build());
});
futureRef.set(mvcAsyncExecutor.submit(() -> {
try {
ReportDto body = reportService.load(id);
result.setResult(ResponseEntity.ok(body));
} catch (Exception ex) {
result.setErrorResult(ex);
}
}));
return result;
}
}Future.cancel(true) yalnızca Java interrupt bayrağını kurar; JDBC sürücüsünün bloklu socket okumasını veya veritabanındaki sorguyu her durumda sonlandırmaz. Bu yüzden veritabanı tarafında da süre sınırı koyun. JPA sorgu hint'i milisaniye cinsinden bir üst sınır sağlar; PostgreSQL kullanılıyorsa aynı transaction içinde SET LOCAL statement_timeout daha güçlü bir ikinci savunmadır. @Transactional timeout'unun her sürücüde sorgu iptali davranışını aynı şekilde garantilediğini varsaymayın.
@Transactional(readOnly = true, timeout = 2)
@Query("select r from Report r where r.id = :id")
@QueryHints(@QueryHint(
name = "jakarta.persistence.query.timeout", value = "1800"))
Optional<Report> findWithinBudget(@Param("id") UUID id);
// PostgreSQL'e özgü, transaction kapsaminda:
jdbcTemplate.execute("SET LOCAL statement_timeout = '1800ms'");İstemci bağlantıyı kapattığında onCompletion çalışabilir, fakat bu callback normal başarılı yanıtta da çağrılır. Bu yüzden onCompletion içinde körlemesine domain side effect geri alma işlemi yapmayın. Güvenli yaklaşım, side effect'i idempotent bir komut kaydıyla yürütmek ve sadece henüz tamamlanmamış Future'i iptal etmektir. Özellikle rapor üretimi dosya yazıyorsa, interrupt alındığında geçici dosyayı finally bloğunda silin; aksi halde timeout yük testi disk alanı sızıntısını ortaya çıkarır.
Spring Security context'i ve izleme bilgisini async işte taşımak
Servlet thread'i request tamamlanınca SecurityContextHolder içeriğini temizler. Bu nedenle @Async veya ham ExecutorService ile başlatılan işte Authentication null olabilir. Örnekteki DelegatingSecurityContextAsyncTaskExecutor, görevin submit edildiği andaki spring security context'ini sarar. Ancak request-scoped bean, HttpServletRequest veya JPA EntityManager referansını async işe taşımayın; bunların yaşam döngüsü servlet thread'i ve transaction ile bağlıdır. Async metoda yalnızca userId, tenantId ve gerekli izin snapshot'ı gibi serileştirilebilir veriler verin.
MDC, Spring Security wrapper'ından ayrı bir ThreadLocal'dır. Logback MDC kullanıyorsanız TaskDecorator ile requestId kopyalayın ve finally içinde temizleyin; temizlenmeyen MDC, aynı worker thread'de sonraki kullanıcının correlation id'sini taşır.
delegate.setTaskDecorator(task -> {
Map<String, String> parentMdc = MDC.getCopyOfContextMap();
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
if (parentMdc == null) MDC.clear();
else MDC.setContextMap(parentMdc);
task.run();
} finally {
if (previous == null) MDC.clear();
else MDC.setContextMap(previous);
}
};
});spring cloud ile gateway üzerinden gelen X-Request-Id benzeri header'ları doğrudan güvenilir kimlik verisi saymayın. Gateway'de dış istemciden gelen header'ı silip yeni UUID üretin veya yalnızca izinli karakterlerle doğrulayın. Downstream çağrıda RestClient interceptor'u ile aynı correlation id'yi eklemek yeterlidir; kullanıcı yetkisini custom header'a kopyalamak yerine OAuth2 token relay veya servis kimliği kullanın. Bu ayrım, spring boot kursu projelerinde sık görülen 'X-User-Id' header'ına güvenme hatasını engeller.
Async executor kapasitesini JFR ve Micrometer ile ölçmek
Thread sayısını tahminle değiştirmeden önce executor kuyruk uzunluğu, aktif işçi sayısı, reddedilme sayısı ve HTTP timeout oranını ölçün. Micrometer'ın ExecutorServiceMetrics bağlayıcısı ThreadPoolExecutor için executor.active, executor.queued ve completed benzeri ölçümleri üretir. Bean yaratıldıktan sonra gerçek ThreadPoolExecutor'u bağlayın; yalnızca actuator http.server.requests metriği kuyruk gecikmesini göstermez.
@Bean
MeterBinder mvcExecutorMetrics(
@Qualifier("rawMvcExecutor") ThreadPoolTaskExecutor executor) {
return registry -> ExecutorServiceMetrics.monitor(
registry,
executor.getThreadPoolExecutor(),
"mvc.async.executor",
Tags.of("pool", "report"));
}Önce-sonra karşılaştırmasında aynı veri seti, aynı pod CPU limiti ve sabit istek hızını kullanın. Örneğin 120 saniye boyunca 80 RPS ile k6 çalıştırın; p95 gecikme, 503 sayısı, executor.queued maksimumu ve veritabanı aktif bağlantılarını birlikte kaydedin. Sadece p95'in düşmesi yeterli değildir: kuyruk büyürken p95 iyi görünebilir, çünkü timeout olan istekler örneklemden farklı biçimde ayrışabilir.
k6 run --vus 80 --duration 2m async-report.js
jcmd <pid> JFR.start name=mvc-async settings=profile duration=120s filename=/tmp/mvc-async.jfr
jcmd <pid> JFR.check name=mvc-asyncJFR kaydında Java Thread Park olaylarını mvc-async thread adıyla filtreleyin ve park süresini socket read, JDBC çağrısı veya lock contention ile ilişkilendirin. Değişiklik öncesinde örneğin queueCapacity=1000 iken, sonrasında 128 ve açık reddetme politikasıyla aynı testi yapın. Kabul kriterini ölçülebilir yazın: kuyruk maksimumu 128'i aşmamalı, rejected istekler 503 olarak sayılmalı, veritabanı statement timeout sayısı HTTP timeout sayısından büyük olmamalıdır. Son koşul bozuluyorsa uygulama, veritabanı iptalinden önce yanıtı kesiyor demektir.
Spring MVC async yarış koşullarını entegrasyon testiyle yakalamak
MockMvc testinde controller metodunun 200 dönmesini tek başına doğrulamak async dispatch yolunu test etmez. Önce asyncStarted bekleyin, sonra asyncDispatch ile ikinci servlet dispatch'i çalıştırın. Bu test, exception resolver ve spring rest api response mapping işlemlerinin async sonuçta da devreye girdiğini doğrular.
@Test
void reportCompletesThroughAsyncDispatch() throws Exception {
MvcResult result = mockMvc.perform(get("/reports/{id}", reportId))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(reportId.toString()));
}Timeout testinde gerçek Thread.sleep kullanmak yerine ReportService'i CountDownLatch ile bloklayın ve test profiline 50 ms DeferredResult timeout'u verin. Ardından 503 ve Retry-After doğrulayın, latch'i açtıktan sonra ikinci bir başarılı sonucun yazılmadığını test edin. Bu, timeout callback ile worker tamamlanmasının aynı anda setResult çağırdığı yarış koşulunu deterministik hale getirir. Spring MVC async tasarımında en değerli testler normal yol değil, bu iki tamamlayıcının yarıştığı testlerdir.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
spring mvc async timeout ayarı neden JDBC sorgusunu durdurmaz?
Async request timeout, DeferredResult cevabını tamamlar ancak worker Future'ini ve veritabanı sorgusunu otomatik sonlandırmaz. Future.cancel(true) çağırın, ayrıca JPA için jakarta.persistence.query.timeout veya veritabanına özgü statement_timeout tanımlayın. PostgreSQL'de SET LOCAL statement_timeout transaction içinde uygulanmalıdır.
spring security context @Async veya Spring MVC executor'unda nasıl korunur?
Ham ExecutorService yerine DelegatingSecurityContextAsyncTaskExecutor kullanın. Bu wrapper submit anındaki Authentication nesnesini worker thread'ine taşır. MDC gerekiyorsa ayrıca TaskDecorator ile MDC.getCopyOfContextMap() kopyalayın ve finally bloğunda önceki context'i geri yükleyin.
microservices mimarisi içinde spring rest api timeout bütçesi nasıl belirlenir?
İstemci, gateway, uygulama, downstream HTTP istemcisi ve SQL için ayrı süreler yazın. Dış katmandan iç katmana doğru süre azalmalıdır: gateway 15 saniyeyse uygulama 12, downstream 10, SQL 8 saniye gibi. k6 ile sabit RPS testinde executor.queued, HTTP 503 ve SQL timeout sayaçlarını aynı zaman aralığında karşılaştırın.
spring boot eğitimi projelerinde async executor queueCapacity kaç olmalı?
Sayı CPU, bloklama oranı ve kabul edilen bekleme süresinden ölçülmelidir. Başlangıç için bounded queue kullanın, 2 dakikalık sabit RPS k6 testinde executor.queued tepe değerini ve p95'i kaydedin. Queue sürekli büyüyorsa önce downstream sürelerini ve concurrency'yi inceleyin; queueCapacity değerini büyütmek yalnızca bekleyen istek sayısını artırır.
AI / LLM Discovery
Bu makale Opendart Akademi Spring Framework eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.


