Gerçek mülakatta sorulanlar: soru soru cevaplar ve dead letter queue
Bir backend live coding mülakatında sorulan sekiz soruya mülakatta sesli söylenecek senior cevapları verir ve dead letter queue kavramını Kafka, RabbitMQ ve SQS üzerinden baştan sona anlatır.
Bu bölümde
Bu bölüm kitabın "Gerçek Mülakattan" kısmını açar. Buradaki sekiz soru varsayımsal değil: gerçek bir backend live coding mülakatında soruldu ve bir kısmı zorlayıcıydı. Amaç, aynı sorular (ya da komşuları) tekrar geldiğinde cevabın hazır, yapılandırılmış ve senior seviyede olması.
Her soru için önce mülakatta sesli söylenebilecek bir "60 saniyelik cevap", sonra mülakatçının bu soruyla neyi ölçtüğü, sonra derin anlatımın nerede olduğu var. Dead letter queue sorusunun derin anlatımı bu bölümde; vaka sorusu (deposit/withdraw/exchange) Bölüm 22'de, dağıtık loglama ve hata ayıklama Bölüm 23'te, Spring'in iç işleyişi (anotasyonlar, reflection, context) Bölüm 24'te.
Bölümün sonunda bu mülakattan çıkan dersler, aynı mülakatçının sorabileceği komşu sorular ve bilmediğin bir soruya nasıl cevap vereceğin var.
Mülakattaki sorular: hızlı cevaplar
Bu cevaplar ezber metni değil, iskelettir; kendi cümlelerinle ve kendi deneyiminden bir örnekle söylendiğinde değer kazanır. Her biri bir dakikada söylenebilir; mülakatçı derine inmek isterse takip sorusunu sorar.
Soru 1: Dead letter queue nedir?
Soru: "Dead letter queue nedir?"
Mülakatta beklenen: Tanımın ötesinde neden gerektiği (poison message, kuyruğun tıkanması), hangi hataların oraya gittiği ve oraya düşen mesajla ne yapıldığı.
60 saniyelik cevap
"Dead letter queue, işlenemeyen mesajların ana akışı tıkamadan park edildiği ayrı bir kuyruk ya da topic. Bir consumer bir mesajı her denemede işleyemiyorsa, mesela JSON bozuksa ya da iş kuralı ihlali varsa, o mesajı sonsuza kadar tekrar denemek arkasındaki tüm mesajları bekletir; atlayıp commit etmek de veriyi kaybettirir. DLQ bu ikisinin arasındaki yol: mesajı kaybetmeden kenara alıp akışa devam etmek. Geçici hataları (timeout, 503) önce backoff ile birkaç kez denerim, deserialization ve validation gibi kalıcı hataları retry etmeden doğrudan DLQ'ya gönderirim. Mesajın yanına orijinal topic, partition, offset, exception ve traceId gibi bilgileri eklerim ki sonradan incelenebilsin. Kafka'da DLQ yerleşik değil, Spring Kafka'nın DeadLetterPublishingRecoverer'ı gibi uygulama tarafı yazar; RabbitMQ'da broker'ın dead letter exchange'i, SQS'te redrive policy var. DLQ'ya düşen her mesaj için alarm, bir sahip ve düzeltme sonrası replay süreci olmalı; replay edilen mesaj çift etki yaratmasın diye consumer idempotent olmalı. Alarmı ve sahibi olmayan bir DLQ, mesajların sessizce gömüldüğü bir mezarlıktır."
Bu soruyla neyi ölçüyorlar? Mesajlaşma sistemlerini production'da işletip işletmediğini: hata sınıflandırması, veri kaybı bilinci ve DLQ'nun operasyonel tarafı (alarm, replay, idempotency).
Derinlemesine: Bu bölümün "Konu anlatımı: dead letter queue" kısmı. Spring Kafka'nın retry ve DLT ayrıntıları için bkz. Bölüm 12.
Soru 2: Deposit, withdraw ve exchange servisini baştan kur
Soru: "Bana deposit, withdraw ve 3rd party bir servise giden exchange servisi yaz. App tek bir DB'de, diğerleri mikroservis gibi. DB tek instance, her gün 1 milyon istek geliyor. Nasıl oluşturursun, neleri göze alırsın, race condition'ı nasıl engellersin? Sistemi baştan kur."
Mülakatta beklenen: Gereksinimleri netleştirmek, para tutarlılığını eşzamanlılık altında korumak, 3rd party çağrısını transaction dışında ve hataya dayanıklı yapmak.
60 saniyelik cevap
"Önce sayıları netleştiririm: günde 1 milyon istek saniyede ortalama 12 istek, tepe saatte on katı 120 civarı; tek bir PostgreSQL bunu rahat taşır, asıl risk aynı hesaba eşzamanlı yazmalar ve 3rd party servisin gecikmesi. Parayı BigDecimal ve NUMERIC ile tutar, her hareketi append-only bir ledger tablosuna yazarım. Race condition'ı uygulamada okuyup karar vererek değil, veritabanında atomik koşullu güncellemeyle engellerim: UPDATE account SET balance = balance - :amount WHERE id = :id AND balance >= :amount; etkilenen satır 0 ise bakiye yetersizdir. Birden fazla hesabı kilitlemem gerekirse SELECT ... FOR UPDATE kullanır, deadlock olmasın diye hep id sırasıyla kilitlerim; son savunma hattı CHECK (balance >= 0). Her istekte bir idempotency key alır, unique constraint ile tekrar gelen isteğe aynı cevabı dönerim. Exchange'de kuru TTL'li bir quote olarak alır, kaynak hesabı düşüp işlemi PENDING yazarım; 3rd party çağrıyı transaction ve DB connection'ı dışında, timeout, circuit breaker ve sağlayıcının idempotency key'iyle yaparım. Timeout 'sonucu bilmiyorum' demektir: işlem PENDING kalır, sağlayıcıdan durum sorgulayan bir reconciliation job'ı onu tamamlar ya da telafi eder. Tek DB instance'ını da risk olarak adlandırırım: yedekleme, replika, connection pool boyutu."
Bu soruyla neyi ölçüyorlar? Para gibi doğruluğun kritik olduğu bir alanda eşzamanlılık, idempotency ve dış bağımlılık hatalarını birlikte düşünebilmeni; ve "baştan kur" denince doğrudan koda atlamak yerine gereksinim ve sayılarla başlayıp başlamadığını.
Derinlemesine: (bkz. Bölüm 22). Kilitleme ve izolasyon için Bölüm 10, idempotency key ve saga için Bölüm 13, timeout ve circuit breaker için Bölüm 14.
Soru 3: Distributed logging nasıl olur, loglar neyle takip edilir?
Soru: "Distributed logging nasıl olur, loglar neyle takip edilir?"
Mülakatta beklenen: traceId (correlation id) ve onun servisler arasında nasıl taşındığı; merkezi log toplama.
60 saniyelik cevap
"Bir istek birden fazla servisten geçtiğinde loglarını birleştirmenin yolu, isteğe girişte bir trace id atamak ve bu id'yi tüm çağrılarda taşımak. Standart format W3C Trace Context'in traceparent header'ı: HTTP'de header, Kafka'da record header olarak gider. Spring Boot 3 ve sonrasında Micrometer Tracing, OpenTelemetry ya da Brave köprüsüyle bunu otomatik yapar ve traceId ile spanId'yi MDC'ye koyar; böylece her log satırına girer. Logları JSON formatında yazarım (Boot 3.4+ structured logging) ve merkezi bir sisteme toplarım: ELK/OpenSearch, Loki ya da Datadog. Bir hatada traceId ile tek sorguda tüm servislerin loglarını, tracing backend'inde (Tempo, Jaeger, Zipkin) de hangi serviste ne kadar zaman geçtiğini görürüm. Tuzak, async sınırlar: @Async ya da kendi executor'ımda context kaybolur, propagation'ı ayrıca kurmak gerekir. paymentId gibi iş anahtarlarını da MDC'ye koyarım ki müşteri şikayetinden traceId'ye ulaşabileyim."
Bu soruyla neyi ölçüyorlar? Mikroservis ortamında hata ayıklamayı gerçekten yapıp yapmadığını. Beklenen anahtar kelime traceId'dir; senior cevap onu nasıl taşıdığını (header, MDC, async sınırlar) da söyler.
Derinlemesine: (bkz. Bölüm 23). Metrik, log ve trace üçlüsü ve Kubernetes tarafı için Bölüm 15.
Soru 4: Serviste bir hata aldın; ilk nerelere bakarsın?
Soru: "Bir yerde bir hata aldın serviste. Nasıl kontrol edersin, kontrol adımların nasıl olmalı, servisinde ilk nerelere bakarsın?"
Mülakatta beklenen: Rastgele log okumak yerine sistematik bir sıra: etki, değişiklik, kanıt, kök neden.
60 saniyelik cevap
"Önce etkiyi ve kapsamı anlarım: hata oranı, latency, hangi endpoint, hangi pod'lar, ne zamandan beri ve kaç kullanıcı etkileniyor. İkinci soru 'ne değişti?': son deploy, config, feature flag, trafik deseni ya da bir bağımlılık. Etki büyükse önce kanamayı durdururum, yani rollback, feature flag kapatma ya da ölçekleme; kök nedeni sonra ararım. Sonra hatalı bir örnek istek bulur, traceId ile o isteğin tüm servislerdeki loglarına ve trace'ine giderim; stack trace'te en alttaki Caused by satırına bakarım. Servisin kendi sağlığına bakarım: pod restart ve OOMKilled, CPU throttling, heap ve GC, thread pool ve HikariCP connection pool doluluğu, Kafka consumer lag. Bağımlılıklara bakarım: veritabanında yavaş sorgu ve bekleyen kilitler, downstream servislerin hata oranı ve circuit breaker durumu. Hipotezimi veriyle doğrular, mümkünse yeniden üretir, düzeltmeye test eklerim; son adım blameless postmortem ve eksik alarm."
Bu soruyla neyi ölçüyorlar? On-call deneyimini ve yapılandırılmış düşünmeyi. Mülakatçı "loglara bakarım" cevabının arkasında bir sıra, bir öncelik (önce etkiyi azalt) ve gözlemlenebilirlik araçlarına hakimiyet arıyor.
Derinlemesine: (bkz. Bölüm 23). Probe'lar, metrikler ve alarmlar için Bölüm 15; connection pool ve timeout kaynaklı arızalar için Bölüm 14.
Soru 5: @SpringBootApplication anotasyonu nasıl çalışır?
Soru: "@SpringBootApplication anotasyonu nasıl çalışır?"
Mülakatta beklenen: Üç anotasyonun birleşimi olduğu ve auto-configuration'ın koşullu bean tanımlarıyla nasıl çalıştığı.
60 saniyelik cevap
"@SpringBootApplication üç anotasyonun birleşimi: @SpringBootConfiguration, @EnableAutoConfiguration ve @ComponentScan. @SpringBootConfiguration sınıfı bir @Configuration yapar; testlerde Boot'un ana konfigürasyonu bulmasını da sağlar. @ComponentScan ana sınıfın paketinden başlayarak alt paketleri tarar; bu yüzden ana sınıf kök pakette durur. @EnableAutoConfiguration, AutoConfigurationImportSelector'ı import eder; bu bir DeferredImportSelector olduğu için benim bean tanımlarım işlendikten sonra çalışır. Classpath'teki jar'lardaki META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports dosyalarından aday auto-config sınıflarını okur, exclude edilenleri çıkarır ve @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty gibi koşullarla eler. Bu yüzden ben bir DataSource tanımlarsam Boot kendi DataSource'unu tanımlamaz, geri çekilir. Hangi auto-config'in neden açılıp kapandığını --debug ile condition evaluation report'ta görürüm. Yani Boot'ta sihir yok, koşullu bean tanımları var."
Bu soruyla neyi ölçüyorlar? Framework'ü sadece kullanıp kullanmadığını, yoksa bir şey ters gittiğinde (bean bulunamadı, yanlış bean oluştu) içini açıp bakabilip bakamadığını.
Derinlemesine: (bkz. Bölüm 24). Auto-configuration ve koşullar için Bölüm 7.
Soru 6: Reflection nedir?
Soru: "Reflection nedir?"
Mülakatta beklenen: Tanım, framework'lerin onu neden ve nerede kullandığı, bedelleri.
60 saniyelik cevap
"Reflection, çalışma zamanında bir sınıfın yapısını, yani alanlarını, metotlarını, constructor'larını ve anotasyonlarını Class nesnesi üzerinden incelemeyi ve derleme zamanında bilinmeyen üyeleri çağırmayı sağlayan java.lang.reflect API'si. getDeclaredMethods, Method.invoke, Field.set, getAnnotation gibi. Spring, Hibernate, Jackson ve JUnit bunun üzerine kurulu: Spring constructor'ı reflection ile bulup çağırır, field injection'da alanı setAccessible(true) ile set eder; Jackson alanları okur, JUnit @Test metotlarını bulur. Bedelleri var: tip güvenliği kaybolur ve refactoring hataları runtime'a kayar; doğrudan çağrıdan yavaştır (JDK 18'de JEP 416 ile core reflection method handle'lar üzerine yeniden yazıldı); kapsüllemeyi deler. JDK 16-17'deki güçlü kapsülleme (JEP 396, 403) ile JDK iç sınıflarına reflection erişimi --add-opens olmadan engellenir; GraalVM native image'da da Spring AOT reflection hint'leri üretmek zorundadır. Bir detay: Spring component scan'de sınıfları yüklemez; class dosyalarının metadata'sını ASM ile, Spring Framework 7'de Java 24+ üzerinde ClassFile API ile okur."
Bu soruyla neyi ölçüyorlar? Java temeli ile framework bilgisini birleştirebilmeni. Soru genelde @SpringBootApplication ve @ComponentScan sorularıyla bir zincir kurar: "Spring anotasyonları nasıl okuyor?"
Derinlemesine: (bkz. Bölüm 24). Proxy'ler ve bean yaşam döngüsü için Bölüm 6.
Soru 7: @ComponentScan nasıl çalışır, ne işe yarar?
Soru: "@ComponentScan nasıl çalışır, ne işe yarar?"
Mülakatta beklenen: Hangi sınıfların bean olduğunu nasıl bulduğu, varsayılan paket kuralı, bean'in bu aşamada oluşmadığı.
60 saniyelik cevap
"@ComponentScan verilen base package'lardan başlayarak classpath'i tarar, @Component ya da onu meta-anotasyon olarak taşıyan sınıfları (@Service, @Repository, @Controller, @RestController, @Configuration) bulur ve her biri için bir BeanDefinition kaydeder. Base package vermezsem anotasyonun bulunduğu sınıfın paketi ve alt paketleri taranır. Mekanizma: bir BeanDefinitionRegistryPostProcessor olan ConfigurationClassPostProcessor @ComponentScan'i görür ve işi ClassPathBeanDefinitionScanner'a verir; scanner .class kaynaklarını bulur, metadata'yı sınıfı yüklemeden okur, filtrelerden geçirir, bean adını ve scope'unu belirleyip tanımı kaydeder. Bu aşamada nesne oluşmaz; nesneler context refresh'in sonunda singleton'lar yaratılırken oluşur. Boot, @SpringBootApplication içindeki taramaya TypeExcludeFilter ve AutoConfigurationExcludeFilter ekler; auto-config sınıfları taramayla değil imports dosyasıyla gelmelidir. Pratikte en sık sorun, ana sınıfın paketinin dışındaki bir bean'in bulunmaması ve NoSuchBeanDefinitionException."
Bu soruyla neyi ölçüyorlar? Spring'in açılış sürecini (tanımlar, post-processor'lar, nesne oluşturma) ayırt edebilmeni ve "bean bulunamadı" hatasını mekanizmadan açıklayabilmeni.
Derinlemesine: (bkz. Bölüm 24). Component scan ve bean tanımları için Bölüm 6.
Soru 8: Spring context nedir?
Soru: "Spring context nedir?"
Mülakatta beklenen: ApplicationContext'in IoC container olduğu, BeanFactory'den farkı ve refresh sürecinin ana adımları.
60 saniyelik cevap
"Spring context, yani ApplicationContext, uygulamadaki bean tanımlarını tutan, nesneleri oluşturan, bağımlılıklarını enjekte eden ve yaşam döngülerini yöneten IoC container. Temeli BeanFactory; ApplicationContext buna event yayınlama, Environment ve profiller, property çözümleme, i18n için MessageSource, resource yükleme ve post-processor'ların otomatik kaydını ekler. Kalbi refresh() metodu: önce bean tanımları toplanır, sonra BeanFactoryPostProcessor'lar çalışır (configuration class'lar ve component scan burada işlenir), BeanPostProcessor'lar kaydedilir, non-lazy singleton'lar oluşturulur ve @Transactional, @Async gibi davranışlar bu sırada post-processor'ların ürettiği proxy'lerle eklenir, en sonda ContextRefreshedEvent yayınlanır. Spring Boot'ta SpringApplication.run uygulama tipine göre doğru context'i seçer ve web uygulamasında gömülü sunucuyu refresh sırasında başlatır. Testlerde TestContext framework'ü context'i cache'ler; her test sınıfında farklı bir @MockitoBean kombinasyonu yeni bir context kurdurur ve testleri yavaşlatır."
Bu soruyla neyi ölçüyorlar? Spring'i bir kütüphane yığını olarak değil, yaşam döngüsü olan bir container olarak görüp görmediğini. Bu cevap, proxy, transaction ve bean scope sorularına açılan kapıdır.
Derinlemesine: (bkz. Bölüm 24). IoC, bean yaşam döngüsü ve post-processor'lar için Bölüm 6.
Konu anlatımı: dead letter queue
Tanım
Dead letter queue (DLQ), işlenemeyen mesajların ana akışı tıkamadan park edildiği ayrı bir kuyruk ya da topic'tir. Adı, teslim edilemeyen mektupların gittiği "dead letter office"ten gelir; Enterprise Integration Patterns'taki adı Dead Letter Channel'dır. Kafka'da çoğunlukla dead letter topic (DLT) denir: kavram aynı, ama broker'ın özelliği değil, consumer tarafının yazdığı sıradan bir topic'tir.
DLQ üç şeyi aynı anda sağlar:
- Akış devam eder. Hatalı mesaj arkasındakileri bekletmez.
- Veri kaybolmaz. Mesaj atlanmaz, sonradan incelenebilecek bir yere yazılır.
- Hata görünür olur. DLQ'ya düşen her mesaj bir alarm, bir inceleme ve bir karar demektir.
Neden gerekir
Poison message (poison pill). Her denemede aynı şekilde fail eden mesajdır: bozuk JSON, beklenmeyen şema sürümü, artık var olmayan bir hesaba ait işlem. Kaç kez denersen dene sonuç değişmez.
Head-of-line blocking. Sıralı tüketilen bir kuyrukta (Kafka partition'ı, tek consumer'lı RabbitMQ kuyruğu) baştaki mesaj işlenemezse arkasındakiler de bekler. Spring Kafka'nın DefaultErrorHandler'ı retry'ı hatalı kayda seek ederek yaptığı için bir poison pill partition'daki binlerce sağlam mesajı bekletir; consumer lag sürekli büyür.
Sonsuz retry döngüsü. Spring Boot dokümanına göre RabbitMQ'da retry kapalıyken exception fırlatan listener'ın mesajı sonsuza kadar yeniden teslim edilir: CPU'yu yakan, logları dolduran ve ilerlemeyen bir döngü.
Veri kaybı. Döngüden kurtulmanın kolay ama yanlış yolu exception'ı yakalayıp loglamaktır. Kafka'da bu offset'in commit edilmesi, yani mesajın kalıcı olarak kaybolması demektir; kaybolan bir ödeme event'ini sonradan mutabakatla bulmak günler sürer. DLQ, "takılmak" ile "kaybetmek" arasındaki üçüncü yoldur.
Hangi hata nereye: geçici mi, kalıcı mı
Bir mesajı DLQ'ya göndermeden önce sorulacak tek soru: Aynı mesajı biraz sonra tekrar denesem sonuç değişir mi?
| Hata | Tür | Doğru davranış |
|---|---|---|
| Deserialization hatası, bozuk JSON, bilinmeyen şema | Kalıcı | Retry yok, doğrudan DLQ |
| Validation hatası (zorunlu alan yok, negatif tutar) | Kalıcı | Retry yok, doğrudan DLQ |
| İş kuralı ihlali (hesap kapalı, para birimi desteklenmiyor) | Kalıcı | Retry yok; DLQ ya da bir "rejected" event'i |
| Downstream timeout, 503, bağlantı reddi | Geçici | Backoff ile retry; tükenirse DLQ |
| Optimistic lock çakışması, deadlock | Geçici | Kısa retry; neredeyse her zaman ikinci denemede geçer |
| Bağımlı veri henüz yok (sipariş event'i ödeme event'inden önce geldi) | Geçici ama süresi belirsiz | Gecikmeli retry (retry topic); tükenirse DLQ |
| Downstream tamamen çökmüş (tüm mesajlar fail) | Sistem geneli | DLQ değil: tüketimi durdur, circuit breaker ile bekle |
Son satır en sık atlanan noktadır. DLQ mesaja özgü hatalar içindir. Kur sağlayıcısı 20 dakikalığına çöktüyse o sürede gelen her mesajı DLQ'ya atmak, sonra hepsini replay etmek demektir. Doğrusu back-pressure'dır: Kafka'da listener container'ı pause edip lag'in birikmesine izin vermek, RabbitMQ'da consumer'ı durdurmak. Kuyruk zaten bir tampondur (bkz. Bölüm 12 ve 14).
İş kuralı ihlali ("hesap kapalı") çoğu zaman bir hata değil, bir sonuçtur; DLQ yerine DepositRejected event'i yayınlayıp saga'nın telafi adımını tetiklemek daha doğru olabilir (bkz. Bölüm 13). DLQ, sistemin "bununla ne yapacağımı bilmiyorum" dediği mesajlar içindir.
Akışın tamamı:
flowchart TD
A[Mesaj geldi] --> B{İşlendi mi?}
B -- Evet --> C[Ack / offset commit]
B -- Hayır --> D{Hata türü}
D -- Kalıcı: parse, validation --> G[DLQ'ya yaz + metadata]
D -- Geçici: timeout, 503 --> E{Deneme hakkı kaldı mı?}
E -- Evet --> F[Backoff ile bekle, tekrar dene] --> B
E -- Hayır --> G
D -- Sistem geneli: downstream çökmüş --> P[Tüketimi pause et, circuit breaker]
G --> H[Alarm + sahip ekip]
H --> I{İnceleme sonucu}
I -- Kod veya veri düzeltildi --> J[Replay: ana kuyruğa geri gönder]
I -- Geçersiz mesaj --> K[Kayıt altına alıp at]
J --> BRetry stratejileri
DLQ, retry'ın bittiği yerdir; ayrıntılar Bölüm 12 (Spring Kafka) ve Bölüm 14'te (backoff, jitter, retry amplification), burada sadece DLQ tasarımını etkileyen kısım var.
In-place (blocking) retry. Consumer aynı mesajı, aynı yerde, backoff ile birkaç kez dener (Spring Kafka DefaultErrorHandler, Spring AMQP retry interceptor). Sıra korunur, bedeli partition'ın beklemesidir. Kafka'da bir backoff aralığı ile işleme süresinin toplamı max.poll.interval.ms'i aşarsa rebalance olur; bu yüzden kısa tutulur.
Non-blocking retry (retry topic'leri). Hatalı mesaj gecikmeli retry topic'lerine taşınır (payment-events-retry-1000, payment-events-retry-4000, sonra DLT), ana topic ilerler. Uber'in yazısındaki "merdiven" yaklaşımıdır; Spring Kafka karşılığı @RetryableTopic. Bedeli sıra garantisinin kaybolmasıdır.
Retry penceresi. Geçici hatanın tipik süresine göre seçilir: pod restart'ı ya da kısa ağ kesintisi 5-30 saniye sürer, toplam retry penceresi bunu kapsamalı. Saatler süren bir kesinti retry ile değil, pause ile karşılanır.
Kafka'da DLQ
Kafka'nın broker tarafında klasik consumer group'lar için yerleşik bir DLQ yoktur. Kafka mesajı silmez, "teslim edilemedi" diye bir durum tutmaz; consumer bir offset'ten okur ve kendi offset'ini commit eder. Dolayısıyla "bu mesajı başka bir yere koy" kararını ve işlemini consumer uygulaması ya da kullandığı framework yapar.
Spring Kafka. DefaultErrorHandler retry'ı yönetir; denemeler bitince ya da exception retry edilmeyecek türdense kaydı bir ConsumerRecordRecoverer'a verir. DeadLetterPublishingRecoverer bu recoverer'ın DLT'ye yazan halidir. Bilinmesi gerekenler (ayrıntılar Bölüm 12'de):
- Varsayılan hedef orijinal kaydın partition numarasıyla aynı partition'dır; DLT'nin en az kaynak topic kadar partition'ı olmalıdır.
- Varsayılan DLT adı Spring Kafka 3.2 ve öncesinde
<topic>.DLT, 3.3+ ile<topic>-dlt; adı resolver ile açıkça vermek belirsizliği kaldırır. - Varsayılan backoff
FixedBackOff(0L, 9)(beklemeden toplam 10 deneme);DeserializationException,MessageConversionException,ClassCastExceptiongibi exception'lar varsayılan olarak retry edilmez. - Deserialization hataları için
ErrorHandlingDeserializerşarttır; yoksa consumer aynı offset'te takılabilir. Bu deserializer ile recoverer DLT'ye orijinal byte'ları yazar.
Kafka Connect. Connect'te DLQ bir konfigürasyon meselesidir ve sink connector'lar içindir. errors.tolerance varsayılan olarak none'dır: herhangi bir hata connector task'ını hemen durdurur. all yapıldığında problemli kayıtlar atlanır; errors.deadletterqueue.topic.name verilirse bu kayıtlar o topic'e yazılır (varsayılan boş, yani DLQ yok). errors.deadletterqueue.context.headers.enable=true hata bağlamını header olarak ekler (varsayılan false). errors.retry.timeout varsayılan 0'dır, yani retry yapılmaz. Tuzak: errors.tolerance=all verip DLQ topic'ini vermemek, hatalı kayıtları sessizce atlamak demektir.
Share groups. Kafka'nın share group'ları (KIP-932) mesaj başına teslim sayısı tuttuğu için broker tarafı DLQ'ya zemin sağlar; bunu tanımlayan KIP-1191 kabul edildi. Hangi sürümde geldiğini kullandığın sürümün release notlarından doğrula; klasik consumer group'larda DLQ hâlâ uygulama tarafındadır.
RabbitMQ'da DLQ: dead letter exchange
RabbitMQ'da dead lettering broker'ın özelliğidir. Bir kuyruğa dead letter exchange (DLX) tanımlanır; belirli olaylarda mesaj o exchange'e yeniden yayınlanır ve oradan bağlı bir kuyruğa (DLQ) yönlenir.
Bir mesaj şu durumlarda dead-letter olur (RabbitMQ 4.x dokümanı):
- AMQP 0.9.1 consumer mesajı
basic.rejectya dabasic.nackilerequeue=falseolarak reddederse (AMQP 1.0'darejectedoutcome), - Mesajın TTL'i dolarsa (per-message ya da kuyruk seviyesinde mesaj TTL'i),
- Kuyruk uzunluk limiti (
max-length,max-length-bytes) aşıldığı için mesaj düşürülürse, - Quorum queue'da mesaj
delivery-limit'ten fazla kez geri dönerse.
Kuyruğun kendisinin süresi dolup silinmesi (queue expiry) ise mesajları dead-letter yapmaz.
Konfigürasyon. İki yol vardır: policy (dead-letter-exchange, dead-letter-routing-key) ya da kuyruk tanımındaki argümanlar (x-dead-letter-exchange, x-dead-letter-routing-key). İkisi birden varsa argüman kazanır. RabbitMQ dokümanı policy'yi önerir, çünkü argümanlar uygulamayı yeniden deploy etmeden değiştirilemez. Routing key verilmezse mesajın kendi routing key'i kullanılır.
Header'lar. Dead-letter olan mesaja AMQP 0.9.1'de x-death header'ı eklenir: kuyruk, sebep (rejected, expired, maxlen, delivery_limit), sayı, zaman ve exchange. Ayrıca x-first-death-reason, x-last-death-reason gibi alanlar vardır. RabbitMQ 4.0 ile broker, istemcinin (yeniden) yayınladığı mesajdaki x-death header'ını artık yorumlamaz (dead lettering sırasında kendisi set etmeye devam eder); mesajı x-death ile yeniden yayınlayıp bu sayaçla retry sayısı tutan eski kodlar bu yüzden bozulabilir, sayaç için kendi x- header'ını kullanmak gerekir.
Quorum queue'larda iki kritik detay:
- Delivery limit varsayılanı. RabbitMQ 4.0'dan itibaren quorum queue'larda
delivery-limitvarsayılan olarak 20'dir; bu sayıyı aşan mesaj düşürülür ya da (DLX tanımlıysa) dead-letter olur. Yani DLX'siz bir quorum queue'da poison message 20 denemeden sonra sessizce silinir. - Dead lettering varsayılan olarak at-most-once'tır. Broker mesajı DLX'e iç publisher confirm olmadan yayınlar ve kaynaktan hemen siler; hedef o an erişilemezse mesaj kaybolabilir. Quorum queue'lar at-least-once dead lettering destekler:
dead-letter-strategy=at-least-onceveoverflow=reject-publish(varsayılandrop-headile at-most-once'a döner). Bedeli daha fazla bellek ve CPU ile DLQ'da duplicate olasılığıdır; para taşıyan bir kuyrukta bu bedel ödenir.
DLX ile retry döngüsü. Yaygın bir desen, TTL'li bir bekleme kuyruğu ile DLX'i birleştirip gecikmeli retry yapmaktır. RabbitMQ'nun güncel (4.4) dokümanı geçici hatalar için bu döngü yerine quorum queue'ların yerleşik delayed retry özelliğini (RabbitMQ 4.3+) önerir (mesajı yeniden yazmaz, at-most-once dead lettering'deki kayıp riski yoktur) ve dead lettering'i hiç işlenemeyecek mesajlara ayırır.
Amazon SQS'te DLQ: redrive policy
SQS'te DLQ, kaynak kuyruğa tanımlanan bir redrive policy ile kurulur. Policy'deki maxReceiveCount, bir mesajın kaynak kuyruktan kaç kez alınabileceğini belirtir; consumer mesajı alır ama silmezse (yani işleyemezse) visibility timeout dolunca mesaj tekrar görünür olur ve alınma sayısı artar. Sayı aşılınca SQS mesajı DLQ'ya taşır. Konsoldaki karşılığı "Maximum receives"tir, geçerli aralık 1-1.000'dir. AWS dokümanı maxReceiveCount=1'in tek bir başarısız alımda mesajı DLQ'ya taşıyacağını, değeri yeterli retry'a izin verecek kadar yüksek seçmek gerektiğini belirtir.
Kurallar ve tuzaklar:
- DLQ, kaynakla aynı hesap, bölge ve tipte olmalıdır: FIFO kuyruk FIFO DLQ, standard kuyruk standard DLQ.
- Retention. Standard kuyrukta süre orijinal giriş zamanına göre işler: kaynakta 1 gün bekleyip 4 günlük retention'lı DLQ'ya düşen mesaj 3 gün sonra silinir (FIFO'da zaman damgası sıfırlanır). AWS'nin önerisi: DLQ retention'ı kaynağınkinden uzun olsun.
- Sıra. AWS dokümanı, işlem sırasının bozulmasını istemiyorsan FIFO kuyrukla DLQ kullanmamanı söyler.
- Redrive. Mesajlar "DLQ redrive" (
StartMessageMoveTask) ile varsayılan olarak kaynak kuyruğa geri taşınır; hız en fazla saniyede 500 mesaja ayarlanabilir, kuyruk başına tek aktif görev olur. Taşınan mesajlar yeni mesaj sayılır ve taşıma sırasında değiştirilemez.
Üç teknolojinin karşılaştırması
| Kafka (Spring Kafka) | RabbitMQ | Amazon SQS | |
|---|---|---|---|
| DLQ'yu kim yapar | Uygulama/framework (DeadLetterPublishingRecoverer) | Broker (DLX) | Servis (redrive policy) |
| Tetikleyici | Retry'ların bitmesi veya retry edilmeyen exception | reject/nack requeue=false, TTL, uzunluk limiti, delivery-limit | maxReceiveCount aşımı |
| Deneme sayacı | Error handler'ın belleğinde; header ile görünür yapılabilir | x-delivery-count (quorum), x-death | ApproximateReceiveCount |
| Hata bilgisi | DLT header'ları (exception sınıfı, mesajı, stack trace, orijinal topic/partition/offset) | x-death (sebep, kuyruk, sayı); exception bilgisini uygulama ekler | Yok; uygulama loglamalı |
| Replay | Uygulama yazar (DLT'den okuyup ana topic'e yayınlayan araç) | Uygulama/shovel ile DLQ'dan ana exchange'e | Yerleşik DLQ redrive |
| Sıra | Blocking retry'da korunur; DLT'ye giden kayıt sıradan çıkar | Requeue ve DLX sırayı bozar | FIFO + DLQ sırayı bozar |
Mülakatta bu tablonun özeti: "Kafka'da DLQ'yu ben yazarım, RabbitMQ ve SQS'te broker yapar; ama üçünde de alarm, inceleme ve replay benim işimdir."
DLQ mesajına eklenecek metadata
DLQ'daki bir mesaj, yanında "neden burada" bilgisi yoksa incelenemez. Her DLQ kaydında şunlar olmalı:
- Kaynak: orijinal topic/kuyruk, partition, offset (ya da message id), consumer group, orijinal zaman damgası.
- Hata: exception sınıfı, mesajı, root cause sınıfı; stack trace (boyut ve hassas veri açısından dikkatli).
- Denemeler: kaç kez denendiği, ilk ve son hata zamanı.
- Bağlam:
traceId(ya da tümtraceparentheader'ı), işleyen servis adı ve sürümü, iş anahtarı (paymentId,orderId). - Orijinal payload: değiştirilmemiş haliyle. Deserialization hatasında parse edilmiş nesne değil, ham byte'lar.
Spring Kafka'nın DeadLetterPublishingRecoverer'ı kaynak ve hata bilgisini KafkaHeaders.DLT_ORIGINAL_* ve DLT_EXCEPTION_* header'larıyla (topic, partition, offset, timestamp, consumer group, exception sınıfı, cause, mesaj, stack trace) kendisi ekler; geri kalanını setHeadersFunction ile sen eklersin. Deneme sayısı için container'ın deliveryAttemptHeader özelliği açılabilir (varsayılan kapalı; KafkaHeaders.DELIVERY_ATTEMPT, sadece record listener'lar).
traceId, DLQ alarmı geldiğinde mesajı üreten isteğin hikayesine giden yoldur: trace context Kafka header'ında taşınıyorsa (bkz. Bölüm 15) ve DLQ kaydında korunuyorsa, tek sorguyla o isteğin tüm loglarına ulaşırsın.
İzleme ve alarm: "sessiz çöplük" anti-pattern'i
En yaygın DLQ hatası, DLQ'yu kurup unutmaktır. Altı ay sonra biri fark eder: DLQ'da 40.000 mesaj var, bir kısmı müşterinin hesabına hiç geçmemiş para yatırma işlemleri, retention'ı dolan yüzlercesi silinmiş. Bu, DLQ olmamasından bile kötüdür; çünkü herkes "hatalı mesajlar DLQ'ya gidiyor" diye güvende hisseder.
Kurallar:
- DLQ'ya mesaj girmesi bir alarmdır. Kritik akışlarda (ödeme, bakiye) tek mesaj bile; yüksek hacimli ve kayba toleranslı akışlarda bir eşik (ör. 5 dakikada 10 mesaj). DLQ derinliği büyümeye devam ediyorsa ayrı bir alarm.
- Metrikler: DLQ'ya giriş oranı (exception türüne göre etiketli), DLQ derinliği, en eski mesajın yaşı. SQS'te
ApproximateNumberOfMessagesVisibleveApproximateAgeOfOldestMessageCloudWatch metrikleri; RabbitMQ'da DLQ kuyruğunun hazır mesaj sayısı; Kafka'da recoverer'da artırılan bir Micrometer sayacı ya da DLT'nin end offset'i ile bir izleme grubunun offset'i arasındaki fark. - Sahiplik: Her DLQ'nun bir sahibi ekip ve bir runbook'u olur: kim bakar, hangi süre içinde, hangi kararları verebilir.
- Retention'dan önce aksiyon: DLQ retention süresi, alarmdan aksiyona kadar geçebilecek en uzun süreden (hafta sonu, bayram tatili dahil) uzun olmalı.
DLQ'ya giriş oranı aniden artıyorsa sorun büyük ihtimalle tek tek mesajlarda değil, bir deploy'da ya da şema değişikliğindedir; önce o değişikliği geri alırsın.
Replay (redrive) ve idempotency
DLQ'nun amacı mesajları saklamak değil, düzeltilip tekrar işlenmelerini sağlamaktır. Tipik süreç:
- Sınıflandır. Mesajları exception türüne, zamana ve kaynağa göre grupla; genelde birkaç kök neden binlerce mesajı açıklar.
- Kök nedeni düzelt. Kod hatasıysa deploy, eksik referans verisiyse veri düzeltmesi, bozuk mesajsa üreten ekiple konuşma.
- Karar ver. Her grup için replay, kayıt altına alarak atma ya da elle düzeltme.
- Replay et ve doğrula. Hızı sınırlayarak ana kuyruğa geri gönder; mesajların işlendiğini ve DLQ'ya geri düşmediğini gör.
Idempotency zorunludur. Replay edilen mesajın bir kısmı aslında işlenmiş olabilir: consumer işi yaptı, ack'ten ya da commit'ten önce öldü; ya da at-least-once dead lettering duplicate üretti. Idempotent olmayan bir consumer'da bu çift para yatırma demektir. Event id'yi unique constraint'li bir processed_event tablosuna aynı transaction'da yazmak ya da doğal idempotency (mutlak değer atama, versiyon kontrolü) gerekir (bkz. Bölüm 13).
Sonsuz replay döngüsü. Kök neden düzelmeden replay edilen mesaj tekrar DLQ'ya düşer. Replay sayısını bir header'da taşı; üst sınırdan sonra mesajı otomatik replay edilmeyen bir "parking lot"a al.
Kafka'ya özgü not. DLT'den okumak mesajı silmez. Replay aracı nerede kaldığını kendi consumer group offset'iyle bilmeli; yoksa ikinci çalıştırma her şeyi tekrar gönderir.
Sıralama sorunu
Bir mesaj DLQ'ya gittiğinde aynı key'in sonraki mesajları ana akışta işlenmeye devam eder. Hesap 42 için sırayla Deposited(100), Withdrawn(80) event'leri geldiğini düşün. Deposited bir hata yüzünden DLQ'ya giderse Withdrawn yetersiz bakiyeyle karşılaşır ya da, kontrol yoksa, bakiye eksiye düşer. Sonra Deposited replay edildiğinde sıra tersine işlenmiş olur.
Çözümler, artan karmaşıklık sırasıyla:
- Sırayı bozmayan retry. Sıranın kritik olduğu akışta non-blocking retry kullanma; blocking retry ile geçici hataları yerinde atlat. Sadece gerçekten kalıcı hatalar DLQ'ya gitsin.
- Stop on error. Bazı akışlarda (CDC, muhasebe defteri) bir mesaj işlenemiyorsa o partition'ı durdurup insan müdahalesi beklemek, sırayı bozmaktan iyidir. Confluent'in "Error Handling Patterns for Apache Kafka Applications" yazısı bunu ayrı bir desen olarak anlatır.
- Key bazında park etme. DLQ'ya bir mesaj gittiğinde key'ini "beklemede" olarak işaretle (ör. bir tabloda ya da compacted bir topic'te); aynı key'in sonraki mesajları da doğrudan DLQ'ya ya da bir bekleme kuyruğuna gitsin. Replay sırasında o key'in mesajları orijinal sırayla işlenir, sonra işaret kaldırılır. Confluent yazısındaki "maintain order of redirected events" deseni budur. Diğer key'ler etkilenmez.
- Sıraya duyarsız event tasarımı. Event'e bir versiyon ya da sequence numarası koy; consumer kendi tuttuğu versiyondan eski bir event'i atlar ya da bekletir. Ya da "şu kadar düş" yerine "son durum şu" taşıyan, versiyonlu durum event'leri kullan; böylece en yüksek versiyon kazanır.
Mülakatta söylenecek cümle: "DLQ sıra garantisini bozar; sıranın önemli olduğu akışta ya blocking retry ve stop-on-error kullanırım ya da aynı key'in sonraki mesajlarını da park ederim."
Retention, kişisel veri ve güvenlik
DLQ, sistemdeki en hassas verinin en az korunan kopyası olmaya adaydır: ödeme event'leri hesap numarası ve isim, stack trace header'ları SQL parametreleri ya da istek gövdeleri taşıyabilir.
- Retention'ı bilinçli seç: aksiyon süresinden uzun, KVKK/GDPR açısından gerekenden uzun değil (Kafka'da DLT'ye ayrı
retention.ms, SQS'teMessageRetentionPeriod). - Erişimi kısıtla ve auditle: replay bir yazma işlemidir.
- Maskele: hata mesajlarına kart numarası, TCKN gibi alanlar sızmasın. Spring AMQP'nin
RepublishMessageRecoverer'ı güncel sürümlerde (4.0.5+) stack trace'i bu yüzden varsayılan olarak eklemez (includeStackTrace(true)ile açılır); daha eski sürümler (3.x dahil)x-exception-stacktraceheader'ını her zaman ekler.
Spring Kafka örneği: DLT, metrik ve replay
Bölüm 12'deki temel kurulumun üzerine, production'da gereken üç parçayı ekleyen bir örnek: açık DLT tanımı, DLT metriği ve ek header'lar, ve kontrollü bir replay aracı.
@Configuration
public class PaymentKafkaErrorConfig {
private static final Logger log = LoggerFactory.getLogger(PaymentKafkaErrorConfig.class);
@Bean
NewTopic paymentEventsDlt() {
return TopicBuilder.name("payment-events-dlt")
.partitions(12) // kaynak topic ile en az aynı: recoverer aynı partition numarasına yazar
.replicas(3)
.config(TopicConfig.RETENTION_MS_CONFIG, String.valueOf(Duration.ofDays(14).toMillis()))
.build();
}
@Bean
DefaultErrorHandler kafkaErrorHandler(KafkaTemplate<Object, Object> template,
MeterRegistry meterRegistry) {
var dltPublisher = new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(record.topic() + "-dlt", record.partition()));
// DLT'ye yazılamazsa exception fırlat: kayıt tekrar denenir, sessizce kaybolmaz.
dltPublisher.setFailIfSendResultIsError(true);
// Standart DLT header'larına ek bağlam.
dltPublisher.setHeadersFunction((record, ex) -> new RecordHeaders(new Header[] {
new RecordHeader("x-dlt-service", "payment-service".getBytes(StandardCharsets.UTF_8)),
new RecordHeader("x-dlt-failed-at", Instant.now().toString().getBytes(StandardCharsets.UTF_8))
}));
// Metrik, publish başarılı olduktan sonra artar.
ConsumerRecordRecoverer recoverer = (record, ex) -> {
dltPublisher.accept(record, ex);
Throwable root = NestedExceptionUtils.getMostSpecificCause(ex);
meterRegistry.counter("kafka.dlt.published",
"topic", record.topic(),
"exception", root.getClass().getSimpleName()).increment();
};
// 500 ms arayla 3 retry = toplam 4 deneme. Üssel bekleme için
// ExponentialBackOffWithMaxRetries kullanılabilir (bkz. Bölüm 12).
var handler = new DefaultErrorHandler(recoverer, new FixedBackOff(500L, 3L));
// Kalıcı hatalar: tekrar denemek sonucu değiştirmez, doğrudan DLT.
handler.addNotRetryableExceptions(
InvalidPaymentEventException.class,
AccountClosedException.class);
handler.setRetryListeners((record, ex, attempt) ->
log.warn("Payment event failed, topic={} partition={} offset={} attempt={}",
record.topic(), record.partition(), record.offset(), attempt, ex));
return handler;
}
}
Spring Boot, context'teki tek CommonErrorHandler bean'ini listener container factory'ye otomatik bağlar. addNotRetryableExceptions'a IllegalArgumentException gibi geniş bir tür eklemek, kodun derinliklerinden gelen ilgisiz bir hatayı da doğrudan DLT'ye yollar; kendi exception türlerini kullan.
Replay aracı: DLT'yi varsayılan olarak tüketmeyen, bir admin endpoint'iyle başlatılıp durdurulan bir listener.
@Component
public class PaymentDltReplayer {
private static final String REPLAY_COUNT = "x-replay-count";
private static final String DLT_HEADER_PREFIX = KafkaHeaders.PREFIX + "dlt-";
private static final int MAX_REPLAY = 3;
private final KafkaTemplate<String, byte[]> bytesTemplate; // ByteArraySerializer ile ayrı tanımlı
public PaymentDltReplayer(KafkaTemplate<String, byte[]> bytesTemplate) {
this.bytesTemplate = bytesTemplate;
}
@KafkaListener(id = "payment-dlt-replayer", topics = "payment-events-dlt",
autoStartup = "false",
properties = {
"value.deserializer=org.apache.kafka.common.serialization.ByteArrayDeserializer",
"max.poll.records=50"
})
public void replay(ConsumerRecord<String, byte[]> record) {
String originalTopic = header(record, KafkaHeaders.DLT_ORIGINAL_TOPIC);
int replayCount = Optional.ofNullable(header(record, REPLAY_COUNT))
.map(Integer::parseInt)
.orElse(0);
if (originalTopic == null || replayCount >= MAX_REPLAY) {
return; // otomatik replay yok: parking lot'a / insan incelemesine (gerçekte burada loglanır)
}
var headers = new RecordHeaders();
for (Header h : record.headers()) {
// Eski hata header'larını taşıma; traceparent gibi iş header'ları kalsın.
if (!h.key().startsWith(DLT_HEADER_PREFIX) && !h.key().equals(REPLAY_COUNT)) {
headers.add(h);
}
}
headers.add(REPLAY_COUNT, String.valueOf(replayCount + 1).getBytes(StandardCharsets.UTF_8));
// Partition null: key'e göre seçilir, aynı key yine aynı partition'a gider.
bytesTemplate.send(new ProducerRecord<>(originalTopic, null, record.key(), record.value(), headers))
.join(); // gönderilemezse exception: offset commit edilmez
}
private static String header(ConsumerRecord<?, ?> record, String key) {
Header h = record.headers().lastHeader(key);
return h == null ? null : new String(h.value(), StandardCharsets.UTF_8);
}
}
Bu aracı KafkaListenerEndpointRegistry.getListenerContainer("payment-dlt-replayer").start() ile bir admin endpoint'inden başlatırsın; kendi consumer group'u olduğu için nerede kaldığını hatırlar ve ikinci çalıştırmada aynı mesajları tekrar göndermez. Gerçek bir sistemde buna exception türüne ya da zaman aralığına göre filtreleme ve hız sınırı eklenir. Ana consumer idempotent değilse bu araç kullanılmamalıdır.
Spring AMQP örneği: quorum queue, DLX ve retry
RabbitMQ'da DLX'i broker yapar; uygulamanın işi kuyruğu doğru tanımlamak ve kalıcı hatada mesajı requeue etmeden reddetmektir.
@Configuration
public class PaymentRabbitConfig {
@Bean
DirectExchange paymentDlx() {
return new DirectExchange("payment.dlx");
}
@Bean
Queue paymentDlq() {
return QueueBuilder.durable("payment.process.dlq").quorum().build();
}
@Bean
Binding paymentDlqBinding(Queue paymentDlq, DirectExchange paymentDlx) {
return BindingBuilder.bind(paymentDlq).to(paymentDlx).with("payment.process");
}
@Bean
Queue paymentQueue() {
return QueueBuilder.durable("payment.process")
.quorum()
.deadLetterExchange("payment.dlx")
.deadLetterRoutingKey("payment.process")
.deliveryLimit(10) // varsayılan 20 (RabbitMQ 4.0+)
.withArgument("x-dead-letter-strategy", "at-least-once")
.withArgument("x-overflow", "reject-publish") // at-least-once için şart
.build();
}
}
@Component
public class PaymentCommandListener {
private final PaymentService paymentService;
public PaymentCommandListener(PaymentService paymentService) {
this.paymentService = paymentService;
}
@RabbitListener(queues = "payment.process")
public void onMessage(PaymentCommand command) {
try {
paymentService.process(command); // idempotent: paymentId unique
} catch (InvalidPaymentException e) {
// Kalıcı hata: requeue etme, broker DLX'e göndersin.
throw new AmqpRejectAndDontRequeueException("Invalid payment " + command.paymentId(), e);
}
}
}
spring:
rabbitmq:
listener:
simple:
default-requeue-rejected: false # reddedilen mesaj kuyruğa dönmesin, DLX'e gitsin
retry:
enabled: true
max-attempts: 4 # Boot 3.x: toplam deneme sayısı
# max-retries: 3 # Boot 4.x: ilk denemeden sonraki tekrar sayısı
initial-interval: 500ms
multiplier: 2.0
max-interval: 5s
Davranış: Boot'un listener retry'ı açıkken denemeler tükenince varsayılan recoverer RejectAndDontRequeueRecoverer'dır; mesaj reddedilir ve broker'da DLX tanımlıysa oraya gider, değilse düşer. Retry kapalıyken exception fırlatan listener'ın mesajı sonsuza kadar yeniden teslim edilir; default-requeue-rejected: false ya da AmqpRejectAndDontRequeueException bunu keser. Retry özellik adı Spring Boot 4 ile değişti: Boot 3.x'te max-attempts (varsayılan 3, toplam deneme), Boot 4.x'te max-retries (varsayılan 3, tekrar sayısı).
İki uyarı. Birincisi, kuyruğu argümanlarla tanımlamak örnekte okunabilirlik içindir; RabbitMQ dokümanı DLX ayarlarını policy ile vermeyi önerir, çünkü argümanlar deploy olmadan değiştirilemez:
rabbitmqctl set_policy payment-dlx "^payment\.process$" \
'{"dead-letter-exchange":"payment.dlx","dead-letter-routing-key":"payment.process","dead-letter-strategy":"at-least-once","overflow":"reject-publish","delivery-limit":10}' \
--apply-to queues
İkincisi, retry katmanı açıkken kalıcı bir exception'ın yine de tekrar denenip denenmeyeceği retry politikasının exception sınıflandırmasına bağlıdır; kalıcı hataları retry dışı bırakmak için politikayı exception türüne göre yapılandır (Boot 4'te RabbitListenerRetrySettingsCustomizer) ve davranışı bir entegrasyon testiyle doğrula. Recoverer olarak RepublishMessageRecoverer kullanırsan mesaj hata bilgisiyle kendi seçtiğin exchange'e yayınlanıp ack edilir; broker'ın DLX'i devreye girmez.
Bu mülakattan çıkan dersler
Soruların ortak teması
Sekiz soru dört başlıkta toplanıyor:
- Production deneyimi. DLQ ve "serviste hata aldın" soruları "bir sistemi işlettin mi?" sorusudur. Tanım yetmez; mülakatçı "DLQ'ya düşen mesajla ne yaptın, kim baktı, nasıl replay ettin" gibi bir hikaye bekler.
- Gözlemlenebilirlik. traceId, merkezi log, metrik ve alarm. Bunlar mikroservis mimarisinde hata ayıklamanın ön koşuludur ve büyük ölçekli bir şirket için temel beklentidir.
- Framework'ün iç işleyişi. @SpringBootApplication, @ComponentScan, reflection, Spring context. Dört soru aslında tek bir zincir: "Spring Boot uygulaması açılırken ne oluyor?" Bu zinciri baştan sona anlatabilen biri, tekil soruların hepsini cevaplar.
- Doğruluk ve eşzamanlılık. Para hareketinde race condition, idempotency, dış servis hataları. Bankacılık geçmişi olan bir aday için bu sorunun cevabı en güçlü yer olmalı: ledger, koşullu güncelleme, idempotency key, mutabakat bu alanın günlük dilidir.
Hiçbir soru egzotik değil; hepsi her gün yapılan işin "neden ve nasıl"ı. Zorlanmanın sebebi bilgi eksikliğinden çok, bilginin mülakatta söylenecek bir yapıya dökülmemiş olması.
Bir sonraki mülakat için hazırlık listesi
- Spring Boot açılışını tek seferde anlatabilmek:
SpringApplication.run→ context → bean tanımları (component scan, configuration class'lar, auto-configuration) → post-processor'lar → singleton'lar ve proxy'ler → web sunucusu →ApplicationReadyEvent. (Bölüm 6, 7, 24) - Reflection'ı Spring'in constructor çağırması örneğiyle anlatabilmek. (Bölüm 24)
- Gerçek bir production olayını hikaye olarak hazırlamak: nasıl fark edildi, ilk ne yapıldı, kök neden, sonra ne değişti. (Bölüm 23)
- traceId akışını çizebilmek: gateway → servis A → Kafka → servis B; her adımda header ve MDC. (Bölüm 15, 23)
- DLQ'yu üç teknolojide karşılaştırabilmek, replay sürecini anlatabilmek. (bu bölüm, Bölüm 12)
- Para transferi vakasını 15 dakikada tahtada kurabilmek. (Bölüm 22)
- Lock türlerini, outbox'ı ve idempotent consumer'ı tek nefeste anlatabilmek. (Bölüm 8, 10, 13)
- Her konuda "bunu ne zaman kullanmam" cümlesi hazırlamak.
Bilmediğinde nasıl cevap verirsin
Mülakatta bir terimin aklına gelmemesi normaldir. Fark yaratan, ondan sonra ne yaptığındır. Üç yanlış tepki var: susmak, "bilmiyorum" deyip durmak ve emin olmadığın bir şeyi kendinden emin bir tonla uydurmak. Doğru tepki problemi yüksek sesle çözmektir: mülakatçı terimi değil, akıl yürütmeni ölçüyor.
Örnek: "Distributed logging'de loglar neyle takip edilir?" sorusunda "traceId" kelimesi aklına gelmedi. Şöyle bir cevap, kelimeyi bilen ama ezberden söyleyen bir adaydan daha iyi izlenim bırakabilir:
"Problemi şöyle düşünüyorum: bir istek beş servisten geçiyor ve her servis kendi logunu ayrı bir yere yazıyor. Bunları birleştirmem için hepsinde ortak bir anahtar olması lazım. O yüzden isteğe sisteme girdiği ilk noktada benzersiz bir kimlik verirdim, bu kimliği her servis çağrısında bir HTTP header'ı olarak, Kafka'da mesaj header'ı olarak taşırdım ve her servis bunu log satırlarına yazardı; Java'da bunu MDC ile her log satırına otomatik ekletirdim. Sonra loglar merkezi bir yerde toplandığında bu kimlikle tek sorguda isteğin tüm yolculuğunu görürdüm. Sanırım sektördeki adı trace id ya da correlation id; OpenTelemetry ve Micrometer Tracing bunu standart bir header ile otomatik yapıyor."
Bu cevapta aday doğru mekanizmaya kendisi ulaştı, uygulama ayrıntısını (header, MDC, merkezi log) verdi ve en sonda terimi dürüstçe, tahmin olduğunu belli ederek söyledi.
Aynı yaklaşım DLQ için: "Terimi tam hatırlamıyorum ama isminden yola çıkarsam: 'ölü mektup', yani teslim edilemeyen mesajların gittiği bir kuyruk olmalı. Bir consumer bir mesajı işleyemiyorsa ya sonsuza kadar dener ve arkadaki mesajları bekletir, ya da atlar ve veriyi kaybeder. Bu kuyruk muhtemelen üçüncü yol: mesajı kenara koyup devam etmek, sonra incelemek."
Pratik kurallar:
- Netleştirme sorusu sor. "Kafka bağlamında mı, genel olarak mı?" Düşünmek için zaman kazandırır ve cevabı doğru hedefe yöneltir.
- Bildiğin bir yere bağla. "Spring context'i şöyle düşünüyorum: bean'leri oluşturan ve yöneten bir container;
@Autowiredyazdığımda bağımlılığı oradan buluyor." - Emin olmadığın kısmı işaretle. Uydurulan bir ayrıntı bir sonraki takip sorusunda çöker.
- Mülakattan sonra not al. Takıldığın her soru bir sonraki mülakatın hazırlık listesidir; bu bölüm de böyle doğdu.
Üç seviyede cevap
Soru: "Dead letter queue nedir?"
seviye: Başlangıç etiketler: [dlq, messaging] kaynak: Gerçek mülakat
İşlenemeyen mesajların gittiği kuyruktur. Bir mesaj hata verirse DLQ'ya atarız, sonra bakarız. Kafka'da da DLQ vardır, otomatik olarak hatalı mesajları oraya gönderir.
İşlenemeyen mesajları ana akıştan ayırıp saklamak için kullanılan kuyruk ya da topic. Consumer bir mesajı birkaç kez backoff ile dener, olmazsa DLQ'ya gönderir ve sonraki mesaja geçer; böylece bir poison message tüm partition'ı tıkamaz ve mesaj da kaybolmaz. Spring Kafka'da DefaultErrorHandler ile DeadLetterPublishingRecoverer kullanırım; deserialization hataları için ErrorHandlingDeserializer eklerim, bunlar retry edilmeden DLT'ye gider. RabbitMQ'da kuyruğa dead letter exchange tanımlanır, reject edilen mesaj oraya gider.
DLQ, bir consumer'ın "bu mesajla ne yapacağımı bilmiyorum" dediği mesajlar için, akışı tıkamadan ve veriyi kaybetmeden kullanılan bir park alanı. Önce hatayı sınıflandırırım: kalıcı hatalar (parse, validation) retry edilmeden doğrudan DLQ'ya; geçici hatalar (timeout, 503) önce kısa ve üssel backoff'lu retry'a. Downstream tamamen çökmüşse DLQ'ya hiçbir şey atmam, tüketimi pause ederim; aksi halde o sürede gelen her mesaj DLQ'ya dolar.
Teknolojiye göre farkı bilirim: Kafka'da DLQ broker'ın değil uygulamanın işi, Spring Kafka 3.3+ varsayılan adı -dlt ama adı ve partition sayısını açıkça tanımlarım. RabbitMQ'da broker'ın DLX'i var; quorum queue'larda 4.0'dan beri delivery-limit 20 olduğu için DLX'siz kuyrukta poison message sessizce silinir, ve dead lettering varsayılan olarak at-most-once; para akışında at-least-once açarım. SQS'te redrive policy ve maxReceiveCount.
Asıl iş DLQ'dan sonra başlar: her mesaja kaynak, exception, deneme sayısı ve traceId eklerim; DLQ'ya giriş bir alarmdır ve bir sahibi vardır; düzeltmeden sonra hız sınırlı bir replay yaparım ve consumer idempotent olduğu için tekrar işlenen mesaj çift etki yaratmaz. Sıra önemli bir akışta DLQ'nun sırayı bozduğunu bilirim; orada blocking retry ve gerekirse aynı key'in sonraki mesajlarını da park etmeyi seçerim.
Junior tanımı biliyor ama Kafka'nın "otomatik DLQ'su" olduğunu sanıyor; bu yaygın bir yanlış inanç: klasik consumer group'larda DLQ'yu uygulama veya framework yazar. "Sonra bakarız" da sahipsiz bir DLQ'nun habercisi. Mid doğru araçları ve poison message problemini biliyor, Spring Kafka kurulumunu yapabilir. Senior hataları sınıflandırıyor, "downstream çökmüşse DLQ değil pause" diyerek back-pressure düşünüyor, broker'lar arası farkları ve tehlikeli varsayılanları (delivery-limit, at-most-once dead lettering) biliyor, DLQ'nun operasyonel hayatını (alarm, sahip, replay, idempotency, sıra) anlatıyor ve deneyimden bir örnek veriyor.
Takip sorusu: DLQ'da 20.000 mesaj birikmiş ve hepsi aynı deploy'dan sonra düşmüş. Adım adım ne yaparsın?
Soru: "DLQ'daki mesajları ne yaparsın?"
seviye: İleri etiketler: [dlq, replay] kaynak: Tahmini
Logları inceleyip hatayı buluruz, düzelttikten sonra mesajları tekrar ana kuyruğa göndeririz.
Önce DLQ'ya neden düştüklerine bakarım; DLT header'larında exception sınıfı ve mesajı var. Hatayı düzeltip deploy ettikten sonra DLQ'yu okuyup mesajları ana topic'e geri yazan bir araçla replay ederim. Bozuk ve düzeltilemeyecek mesajları atarım. Replay sırasında consumer'ın idempotent olmasına dikkat ederim ki aynı mesaj iki kez işlenmesin.
Önce paniği sınıflandırmaya çeviririm: mesajları exception türüne, kaynağa ve zamana göre gruplarım. Ani bir artış genelde tek bir kök nedene işaret eder, mesela bir deploy ya da üreten servisin şema değişikliği; o zaman önce o değişikliği geri alır ya da düzeltirim ki DLQ büyümeyi durdursun.
Sonra her grup için karar veririm: kod hatasıysa düzeltme sonrası replay; eksik referans verisiyse veriyi tamamlayıp replay; gerçekten geçersiz mesajsa üreten ekiple konuşup kayıt altına alarak atma; bazı finansal kayıtlar için replay yerine operasyon ekibiyle elle düzeltme. Bu kararlar audit'lenir.
Replay'i kontrollü yaparım: önce küçük bir örnekle, hız sınırıyla. Replay edilen mesaja bir sayaç header'ı eklerim; birkaç replay'den sonra hâlâ düşüyorsa insan incelemesine giden bir parking lot'a alırım. Consumer idempotent olmalı, çünkü DLQ'daki mesajların bir kısmı aslında işlenmiş olabilir; sıra önemliyse eski bir event'in yeni durumu ezmemesi için versiyon kontrolü gerekir. Son olarak bu olayın neden daha erken yakalanmadığını sorarım.
Junior'ın cevabı doğru yönde ama süreç yok: sınıflandırma, karar, güvenli replay ve idempotency eksik. Mid replay aracını ve idempotency'yi biliyor, bu iyi bir çıta. Senior önce DLQ'nun büyümesini durduruyor, mesajları gruplayıp farklı kararlar veriyor, replay'i hız sınırı ve sayaçla güvenli hale getiriyor, sıra ve versiyon problemini görüyor ve süreci alarm ile retention açısından iyileştiriyor.
Takip sorusu: Kafka'da DLT'den okumak mesajı silmiyor. Replay aracını iki kez çalıştırırsan ne olur, bunu nasıl önlersin?
Soru: "Bir mesajı retry mı edersin, DLQ'ya mı atarsın? Nasıl karar verirsin?"
seviye: Orta etiketler: [retry, dlq, kafka] kaynak: Tahmini
Önce 3 kez retry ederim, yine olmazsa DLQ'ya atarım. Her hata için aynısını yaparım.
Hatanın türüne bakarım. Timeout, 503 gibi geçici hataları exponential backoff ile birkaç kez retry ederim; bozuk JSON, validation hatası gibi kalıcı hataları retry etmeden doğrudan DLQ'ya gönderirim, çünkü tekrar denemek sonucu değiştirmez. Spring Kafka'da bunu addNotRetryableExceptions ile yaparım. Retry süresinin max.poll.interval.ms'i aşmamasına dikkat ederim.
Karar sorusu şu: aynı mesajı biraz sonra tekrar denesem sonuç değişir mi? Değişmezse (parse, validation, iş kuralı) retry boşa kaynak harcar ve partition'ı bekletir; doğrudan DLQ ya da, iş kuralı ihlaliyse, bir "rejected" event'i. Değişirse retry; ama süresine göre şekli değişir.
Saniyeler süren hatalarda (deadlock, optimistic lock, pod restart) kısa blocking retry. Dakikalar sürebilecek hatalarda, sıra önemli değilse non-blocking retry topic'leri; sıra önemliyse blocking retry ve gerekirse container pause. Saatler süren ya da tüm mesajları etkileyen hatalarda (downstream çökmüş) retry de DLQ de değil, circuit breaker ve tüketimi durdurma; aksi halde DLQ trafiğin tamamıyla dolar.
Sınıflandırmayı metrikle doğrularım: bir exception sürekli retry'ı tüketip DLQ'ya düşüyorsa yanlış sınıflandırılmıştır.
Junior tek bir kural uyguluyor; kalıcı hatayı da üç kez deneyip partition'ı bekletiyor. Mid geçici-kalıcı ayrımını ve Spring Kafka karşılığını biliyor. Senior kararı tek bir soruya indirgiyor, hatanın süresine göre farklı stratejiler (blocking, non-blocking, pause) seçiyor, sıra ve back-pressure'ı hesaba katıyor ve sınıflandırmanın doğru olup olmadığını metrikle ölçüyor.
Takip sorusu: @RetryableTopic kullanan bir consumer'da aynı siparişin "oluştu" ve "iptal" event'leri ters sırada işlendi. Neden oldu, nasıl düzeltirsin?
Soru: "@SpringBootApplication nasıl çalışır?"
seviye: Orta etiketler: [spring-boot, auto-configuration] kaynak: Gerçek mülakat
Spring Boot uygulamasını başlatan anotasyondur. Bunu main sınıfına koyunca her şey otomatik konfigüre olur ve uygulama çalışır.
Üç anotasyonun birleşimidir: @SpringBootConfiguration, @EnableAutoConfiguration, @ComponentScan. Component scan ana sınıfın paketinden başlar ve @Component'leri bulur. Auto-configuration classpath'e bakarak bean'ler oluşturur, mesela JDBC driver varsa DataSource. @ConditionalOnMissingBean sayesinde ben kendi bean'imi tanımlarsam Boot'unki oluşmaz.
Mid'in söylediklerine mekanizmayı eklerim: @EnableAutoConfiguration, AutoConfigurationImportSelector'ı import eder; bu bir DeferredImportSelector, yani kullanıcının configuration class'ları işlendikten sonra çalışır. Bu sıra sayesinde @ConditionalOnMissingBean "kullanıcı zaten tanımladı mı" sorusunu güvenilir şekilde cevaplar. Aday listesi Boot 3'ten beri AutoConfiguration.imports dosyalarından gelir (auto-configuration için spring.factories desteği 3.0'da kaldırıldı).
Pratikte bir bean beklenmedik şekilde yoksa ya da varsa --debug ile condition evaluation report'a bakarım. Ortak kütüphanemiz için auto-configuration yazarken her bean'i @ConditionalOnMissingBean ile korur, bir kapatma property'si koyarım.
Junior "otomatik" kelimesinde duruyor; mekanizma yok. Mid üç parçayı ve geri çekilme davranışını biliyor; çoğu mülakat için yeterli. Senior sıranın neden önemli olduğunu (DeferredImportSelector), sürüm farkını ve bu bilginin hata ayıklamada ve kütüphane yazarken nasıl kullanıldığını anlatıyor.
Takip sorusu: Ana sınıfı com.example.payment.api paketine taşıdın, com.example.payment.infra paketindeki repository bean'i bulunamıyor. Neden ve iki çözüm yolu nedir?
Sorulmadı ama sorulabilir
Aynı mülakatçının, aynı sorulardan bir adım ötede sorabileceği konular.
Transactional outbox nedir?
Veritabanına yazıp aynı işlemde Kafka'ya event göndermek bir dual write problemidir: biri başarılı, diğeri başarısız olursa event kaybolur ya da var olmayan bir işlemin event'i yayınlanır. Outbox'ta event, iş verisiyle aynı DB transaction'ında bir outbox tablosuna yazılır; ayrı bir relay (polling ya da Debezium gibi CDC) bu tabloyu okuyup Kafka'ya yayınlar. Sonuç at-least-once teslimattır, yani consumer idempotent olmalıdır. (bkz. Bölüm 13)
Mülakat özeti: "DB ve broker'a atomik yazamam; event'i aynı transaction'da outbox tablosuna yazar, relay ile yayınlarım."
Idempotent consumer nasıl yazılır?
At-least-once teslimatta aynı mesaj birden çok kez gelir; idempotent consumer etkisini bir kez üretir. Standart yol: mesajın event id'sini primary key'li bir processed_event tablosuna INSERT ... ON CONFLICT DO NOTHING ile, iş yazısıyla aynı transaction'da yazmak; etkilenen satır 0 ise mesaj zaten işlenmiştir. "Önce existsById ile kontrol et, sonra yaz" bir check-then-act yarışıdır. İkinci savunma hattı doğal unique constraint'tir (ör. shipment.order_id UNIQUE). (bkz. Bölüm 13)
Mülakat özeti: "Duplicate kontrolünü uygulama koduna değil, aynı transaction'daki bir unique constraint'e bırakırım."
Kafka'da mesaj sırası nasıl korunur?
Kafka sırayı sadece partition içinde garanti eder. Sırası önemli mesajlar aynı key ile gönderilir (ör. accountId), böylece aynı partition'a düşer. Producer'da idempotence açıkken (Kafka 3.0+ varsayılan) retry'lar sırayı bozmaz. Consumer tarafında kayıtları async bir thread havuzuna dağıtmak ya da non-blocking retry (@RetryableTopic) kullanmak sırayı bozar; sıra kritikse blocking retry kullanılır. Partition sayısını artırmak key-partition eşlemesini değiştirir. (bkz. Bölüm 12)
Mülakat özeti: "Aynı key aynı partition'a gider; sırayı producer'da idempotence, consumer'da tek thread ve blocking retry ile korurum."
Circuit breaker nedir?
Hata veren ya da yavaşlayan bir bağımlılığa çağrıları geçici olarak kesen bir durum makinesidir: CLOSED'da çağrılar geçer ve hata/yavaşlık oranı ölçülür; eşik aşılınca OPEN olur ve çağrılar hemen başarısız olur (fallback çalışır); bir süre sonra HALF_OPEN'da birkaç deneme çağrısıyla toparlanma test edilir. Amacı, thread ve connection'ların tükenip hatanın yayılmasını (cascading failure) önlemektir. Resilience4j'de yavaş çağrı eşiğinin varsayılanı 60 sn'dir, mutlaka ayarlanmalıdır. Exchange vakasındaki 3rd party çağrısı tam bunun yeridir. (bkz. Bölüm 14)
Mülakat özeti: "Circuit breaker hata oranı yükselince bağımlılığa gitmeyi bırakır; kaynaklarımı ve bağımlılığı korur."
@Transactional nasıl çalışır?
Spring, @Transactional taşıyan bean için bir proxy üretir; proxy PlatformTransactionManager ile transaction'ı açar, metodu çağırır, sonuca göre commit ya da rollback yapar. Varsayılan olarak sadece unchecked exception'larda (RuntimeException, Error) rollback olur. Aynı sınıf içinden this.method() çağrısı (self-invocation) proxy'yi atlar ve anotasyon çalışmaz. Exchange vakasında 3rd party çağrısını @Transactional metodun içine koymak, cevap beklenirken DB connection'ını ve kilitleri tutmak demektir. (bkz. Bölüm 8)
Mülakat özeti: "@Transactional bir proxy'dir; self-invocation'da çalışmaz ve varsayılan olarak sadece unchecked exception'da rollback yapar."
Bean scope'ları nelerdir?
Varsayılan scope singleton'dır: context başına bir nesne; bu yüzden stateless ya da thread-safe olmalıdır. Prototype her istekte yeni nesne verir, ama Spring onun destroy callback'ini çağırmaz. Web scope'ları request, session, application'dır. Singleton'a enjekte edilen prototype bir kez enjekte edilir ve hep aynı kalır; her seferinde yenisi için ObjectProvider ya da scoped proxy kullanılır. (bkz. Bölüm 6)
Mülakat özeti: "Varsayılan singleton'dır; singleton bean'de mutable state tutmam, singleton'a prototype gerekiyorsa ObjectProvider kullanırım."
N+1 problemi nedir?
Bir listeyi tek sorguyla çekip her elemanın lazy ilişkisini ayrı sorgularla yüklemektir: 100 hesap için 1 + 100 sorgu. Kod normal göründüğü için SQL logu ya da Hibernate istatistikleri olmadan fark edilmez. Çözümleri: JOIN FETCH, @EntityGraph, batch fetching (hibernate.default_batch_fetch_size) ya da DTO projection. Koleksiyon JOIN FETCH'i sayfalamayla birlikte kullanılırsa Hibernate sayfalamayı bellekte yapabilir. (bkz. Bölüm 8)
Mülakat özeti: "N+1'i SQL logunda görürüm; fetch join, entity graph ya da batch fetching ile tek veya birkaç sorguya indiririm."
Pessimistic lock mu, optimistic lock mu?
Pessimistic lock satırı okurken kilitler (SELECT ... FOR UPDATE); diğer yazanlar bekler. Çakışma sık ve tekrar denemenin pahalı olduğu yerlerde (aynı hesaba yoğun yazma) uygundur; bedeli bekleme, kilit süresi ve deadlock riskidir (birden çok satırda kilit sırası sabit tutulmalı). Optimistic lock kilitlemez; bir version kolonu ile güncellemede WHERE version = ? kontrol eder, başka biri önce yazdıysa 0 satır güncellenir ve JPA OptimisticLockException fırlatır; uygulama tekrar dener. Çakışma seyrekse daha ölçeklenebilirdir. Bakiye düşümü gibi tek satırlık işlemde ikisinden de iyisi çoğu zaman atomik koşullu UPDATE'tir. (bkz. Bölüm 8 ve 10)
Mülakat özeti: "Çakışma sıksa pessimistic, seyrekse optimistic; tek satırlık bakiye düşümünde atomik koşullu UPDATE."
Sık yapılan hatalar
- Exception'ı yakalayıp loglamak ve devam etmek → Kafka'da offset commit edilir, mesaj kalıcı olarak kaybolur → Exception'ı framework'e bırak; retry ve DLQ kur.
- Kalıcı hataları retry etmek → Bozuk JSON on kez denense de düzelmez; partition boşuna bekler → Deserialization, validation ve iş kuralı hatalarını retry dışı bırak.
- Downstream çöktüğünde her mesajı DLQ'ya atmak → DLQ tüm trafikle dolar, sonra toplu ve riskli bir replay gerekir → Circuit breaker ve tüketimi pause etme; Kafka zaten tampon.
- DLQ'yu alarmsız ve sahipsiz bırakmak → "Sessiz çöplük": mesajlar retention dolunca silinir, kayıp günler sonra fark edilir → DLQ'ya giriş alarmı, sahip ekip, runbook.
- DLQ mesajına metadata eklememek → Mesajın neden, nereden ve ne zaman düştüğü bilinmez → Kaynak, exception, deneme sayısı, traceId ve ham payload.
- Idempotent olmayan consumer ile replay → Zaten işlenmiş mesajlar çift etki yaratır (çift para yatırma) → Event id + unique constraint, aynı transaction.
- Kafka'nın yerleşik DLQ'su olduğunu sanmak → Klasik consumer group'larda broker hiçbir şeyi DLQ'ya taşımaz → Spring Kafka
DeadLetterPublishingRecovererya da kendi kodun; Connect'teerrors.deadletterqueue.topic.name. - DLT adını ezbere
.DLTyazmak → Spring Kafka 3.3+ varsayılanı-dlt; alarm boş bir topic'i izler → Adı resolver ile açıkça ver, topic'i kaynakla en az aynı partition sayısıyla tanımla. - RabbitMQ quorum queue'da DLX tanımlamamak → 4.0+ varsayılan
delivery-limit20; aşan mesaj sessizce düşer → DLX tanımla; para akışında at-least-once dead lettering. - Kafka Connect'te
errors.tolerance=allverip DLQ topic'i vermemek → Hatalı kayıtlar sessizce atlanır → DLQ topic'i ve context header'ları aç. - SQS'te
maxReceiveCount=1→ Tek bir geçici hata mesajı DLQ'ya taşır → Geçici hataları kapsayacak bir değer; DLQ retention'ı kaynaktan uzun. - DLQ'nun sırayı bozduğunu unutmak → Aynı key'in sonraki mesajları eski durum üzerinde işlenir → Blocking retry, stop-on-error, key bazında park etme ya da versiyonlu event'ler.
- "Sistemi baştan kur" sorusunda doğrudan koda atlamak → Sayılar, tutarlılık gereksinimleri ve hata senaryoları atlanır → Önce gereksinim ve ölçek, sonra veri modeli, eşzamanlılık, dış bağımlılık, gözlemlenebilirlik.
Hızlı tekrar
S: Dead letter queue nedir, hangi iki kötü seçeneğin arasındaki yoldur?
C: İşlenemeyen mesajların ana akışı tıkamadan park edildiği kuyruk/topic. Mesajı sonsuza kadar denemek (akış tıkanır) ile atlamak (veri kaybolur) arasındaki üçüncü yol.
S: Hangi hatalar retry edilmeden doğrudan DLQ'ya gider?
C: Kalıcı hatalar: deserialization, validation, iş kuralı ihlali. Geçici hatalar (timeout, 503, deadlock) önce backoff ile retry edilir; downstream tamamen çökmüşse DLQ değil, tüketim pause edilir.
S: Kafka'da DLQ'yu kim yapar? Spring Kafka 3.3+ varsayılan DLT adı nedir?
C: Klasik consumer group'larda broker değil, uygulama/framework (DeadLetterPublishingRecoverer). Varsayılan ad <topic>-dlt (3.2 ve öncesi <topic>.DLT), orijinal kayıtla aynı partition numarası.
S: RabbitMQ'da bir mesaj hangi durumlarda dead-letter olur?
C: basic.reject/basic.nack ile requeue=false, TTL dolumu, kuyruk uzunluk limiti aşımı ve quorum queue'da delivery-limit aşımı (4.0+ varsayılan 20). Kuyruğun kendisinin expire olması mesajları dead-letter yapmaz.
S: SQS'te DLQ nasıl kurulur, DLQ mesajında hangi metadata olmalı?
C: Kaynak kuyruğa redrive policy ile maxReceiveCount (1-1.000); DLQ aynı hesap, bölge ve tipte; geri gönderme DLQ redrive ile. Her DLQ kaydında kaynak (topic/kuyruk, partition, offset), exception, deneme sayısı, zaman, traceId ve ham payload olmalı.
S: Replay neden idempotent consumer gerektirir?
C: DLQ'daki mesajların bir kısmı aslında işlenmiş olabilir (ack/commit öncesi çökme, at-least-once dead lettering duplicate'i). Idempotent consumer aynı event id'nin etkisini bir kez üretir.
S: DLQ sırayı nasıl bozar, çözümleri nelerdir?
C: DLQ'ya giden mesajın arkasından aynı key'in sonraki mesajları işlenir. Çözümler: blocking retry, stop-on-error, aynı key'in sonraki mesajlarını da park etmek, versiyonlu event'ler.
S: Para hareketlerinde race condition'ı tek satırlık işlemde en basit nasıl engellersin?
C: Atomik koşullu güncelleme: UPDATE account SET balance = balance - :amount WHERE id = :id AND balance >= :amount; 0 satır etkilendiyse bakiye yetersiz. Yanına CHECK (balance >= 0) ve idempotency key için unique constraint.
S: 3rd party exchange çağrısını neden DB transaction'ı içinde yapmazsın, timeout'ta ne yaparsın?
C: Cevap beklenirken connection ve kilitler tutulur, havuz tükenir. Timeout "sonuç bilinmiyor" demektir: işlem PENDING kalır, sağlayıcıdan durum sorgulayan reconciliation ile tamamlanır ya da telafi edilir.
S: Distributed logging'de loglar neyle birleştirilir, nasıl taşınır?
C: traceId ile. W3C traceparent header'ı HTTP'de header, Kafka'da record header olarak taşınır; Micrometer Tracing traceId ve spanId'yi MDC'ye koyar, loglar merkezi sistemde traceId ile sorgulanır.
S: Serviste hata aldığında kontrol sıran nedir?
C: Etki ve kapsam → ne değişti (deploy, config, flag) → gerekiyorsa önce etkiyi durdur (rollback) → örnek isteğin traceId'siyle log ve trace → servis sağlığı (restart, OOM, GC, pool, lag) → bağımlılıklar (DB kilitleri, downstream, circuit breaker) → doğrula, test ekle, postmortem.
S: @SpringBootApplication hangi anotasyonlardan oluşur, auto-configuration neden kullanıcı bean'lerinden sonra değerlendirilir?
C: @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan. AutoConfigurationImportSelector bir DeferredImportSelector'dır; böylece @ConditionalOnMissingBean kullanıcının tanımlarını görebilir.
S: Reflection nedir, bedelleri nelerdir?
C: Çalışma zamanında sınıf yapısını inceleyip üyeleri çağırmayı sağlayan java.lang.reflect API'si; Spring, Jackson, JUnit bunun üstünde. Bedelleri: tip güvenliği kaybı, performans, kapsüllemeyi delmesi; JDK 16-17 güçlü kapsülleme ve native image'da hint gereksinimi.
S: @ComponentScan ne yapar, bean'ler bu sırada oluşur mu?
C: Base package'tan (verilmezse anotasyonlu sınıfın paketi) @Component stereotiplerini bulur, class metadata'sını sınıfı yüklemeden okur ve BeanDefinition kaydeder. Nesneler oluşmaz; refresh sonunda singleton'lar yaratılırken oluşur.
S: Spring context nedir, refresh()'in ana adımları nelerdir?
C: Bean'leri tanımlayan, oluşturan, enjekte eden ve yaşam döngüsünü yöneten IoC container (BeanFactory + event, environment, i18n, resource). Adımlar: tanımlar → BeanFactoryPostProcessor'lar → BeanPostProcessor kaydı → singleton'lar (proxy'ler) → ContextRefreshedEvent.
Kaynaklar
- Spring for Apache Kafka: Handling Exceptions:
DefaultErrorHandler, varsayılan backoff ve fatal exception'lar,DeadLetterPublishingRecoverer, DLT header'ları ve adlandırma. - Spring for Apache Kafka: Non-Blocking Retries:
@RetryableTopicile retry topic'leri ve DLT; batch listener ve container transaction kısıtları. - RabbitMQ: Dead Letter Exchanges: Dead lettering tetikleyicileri, policy ile konfigürasyon,
x-deathheader'ları, güvenlik garantileri ve delayed retry önerisi. - RabbitMQ: Quorum Queues: Poison message handling, 4.0+ varsayılan
delivery-limitve at-least-once dead lettering. - Spring AMQP: Resilience, Recovering from Errors and Broker Failures: Retry interceptor,
MessageRecoverertürleri,AmqpRejectAndDontRequeueExceptionvedefaultRequeueRejected. - Amazon SQS: Using dead-letter queues: Redrive policy,
maxReceiveCount, retention ve FIFO sıra uyarısı. - Amazon SQS: Dead-letter queue redrive: DLQ'dan mesajları geri taşıma, hız ve süre sınırları.
- Confluent: Kafka Connect Deep Dive, Error Handling and Dead Letter Queues: Connect'te
errors.toleranceve DLQ topic'inin uygulamalı anlatımı. - Confluent: Error Handling Patterns for Apache Kafka Applications: Stop on error, DLQ, retry topic ve yönlendirilen event'lerde sırayı koruma desenleri.
- Uber Engineering: Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka: Gecikmeli retry topic merdiveni ve DLQ'nun production'da işletilmesi.