Blog'a Dön

Buğra Şıkel

WebSocket Yatay Ölçeklemesinde Redis Pub/Sub mu NATS mı? Mesaj İletim Garantisi Karar Matrisi

WebSocket Yatay Ölçeklemesinde Redis Pub/Sub mu NATS mı? Mesaj İletim Garantisi Karar Matrisi

Giriş

Tek bir sunucu üzerinde 65.535 ephemeral port limitine veya CPU darboğazına çarptığınızda, WebSocket altyapınızı yatayda ölçeklemeniz (horizontal scaling) kaçınılmazdır. Load balancer (HAProxy, ALB veya Nginx) arkasına 5 adet Node.js veya Go instance’ı yerleştirdiğiniz an, mimari yeni bir probleme gebe kalır: State izolasyonu. A sunucusuna bağlı olan bir istemci, B sunucusuna bağlı olan bir istemciye doğrudan mesaj iletemez. Bu iletişim kopukluğunu çözmek için sistemdeki tüm WebSocket sunucularını birbirine bağlayacak bir mesaj veri yolu, yani backplane entegre edilmesi gerekir.

Sektörde bu ihtiyacı karşılamak için en sık başvurulan iki teknoloji Redis Pub/Sub ve NATS’tır. İki teknoloji de mesajları dağıtma işlevini yerine getirse de, fault tolerance (hata toleransı), mesaj iletim garantileri (delivery semantics) ve ağ kopmaları (network partition) durumundaki davranışları taban tabana zıttır. 250.000 eşzamanlı (concurrent) bağlantıyı yönettiğimiz bir borsa veri akışı projesinde, yanlış backplane seçimi ve konfigürasyon eksiklikleri nedeniyle p99 gecikmelerinin 12ms’den 1840ms’ye fırladığına bizzat şahit oldum.

Bu karar rehberinde, Redis 7.2 ve NATS 2.10 (JetStream dahil) üzerinden WebSocket yatay ölçekleme mimarilerini parçalarına ayıracağız. Hangi senaryoda hangi teknolojinin seçilmesi gerektiğini spesifik throughput metrikleri, bellek ayak izi değerleri ve üretim ortamı (production) konfigürasyonlarıyla inceleyeceğiz.

İçindekiler

  • Mimari Karar Noktası: İletim Semantikleri
  • Aday 1: Redis Pub/Sub (At-most-once)
  • Aday 2: NATS Core ve JetStream (At-least-once / Exactly-once)
  • Karar Matrisi ve Kriterler
  • Trade-off Analizi: Eğer X ise -> Y
  • Production Vakası: Buffer Limitleri ve Mesaj Kaybı
  • Pratik Öneriler / Production Notları
  • Sık Sorulan Sorular
  • Sonuç

Mimari Karar Noktası: İletim Semantikleri

Sisteminize bir mesaj kuyruğu veya pub/sub mekanizması eklemeden önce, iş gereksinimlerinizin hangi iletim garantisini talep ettiğini netleştirmeniz gerekir. WebSocket üzerinden anlık konum verisi mi gönderiyorsunuz, yoksa kripto para alım-satım emirleri mi? Bu soru, aşağıdaki üç semantikten birini seçmenizi zorunlu kılar:

  • At-most-once (En fazla bir kere): Mesaj gönderilir, alıcı o an oradaysa alır. Ağ koparsa mesaj uzay boşluğunda kaybolur. (Fire-and-forget).
  • At-least-once (En az bir kere): Sistem, mesajın karşı tarafa ulaştığına dair bir ACK (acknowledgement) bekler. ACK gelmezse mesajı tekrar gönderir. Mesaj aynı istemciye 2-3 kere gidebilir, istemcinin idempotency (tekil işlenebilirlik) sağlaması gerekir.
  • Exactly-once (Tam olarak bir kere): En pahalı ve zor yöntemdir. Mesaj kesinlikle ulaşır ve sadece bir kez işlenir. Genellikle Kafka veya NATS JetStream (deduplication penceresi ile) tarafından sağlanır.

Aday 1: Redis 7.2 Pub/Sub (At-most-once)

Redis Pub/Sub, in-memory veri yapısı sunucusunun en eski özelliklerinden biridir. Mesajlar bellekte tutulmaz, doğrudan bağlı olan abonelere (subscribers) iletilir. Bağlı olmayan aboneler mesajı sonsuza dek kaçırır.

İç Mimari ve Performans

Redis’te PUBLISH komutunun zaman karmaşıklığı O(N+M)‘dir. Burada N, kanala abone olan istemci sayısını, M ise abone olunan toplam pattern sayısını ifade eder. Saniyede 150.000 mesaj (msg/sec) işleme kapasitesine rahatlıkla ulaşabilir (tek core üzerinde, 10 gigabit ağ bağlantısıyla). Ancak Redis’in single-threaded (tek iş parçacıklı) event loop mimarisi, yüksek throughput durumunda CPU darboğazına neden olabilir. Özellikle Redis 7.2.4 cluster topolojisinde, pub/sub mesajları cluster’daki tüm node’lara broadcast edilir (Redis 7.0 ile gelen Sharded Pub/Sub hariç). Bu da ağ bant genişliğini ciddi şekilde tüketir.

Redis Konfigürasyon ve Kod Örneği

Node.js ortamında ioredis kullanarak kurulan tipik bir WebSocket backplane implementasyonu aşağıdadır. Burada kritik olan nokta, kopan bağlantıların yeniden kurulma stratejisidir (reconnect strategy).


const Redis = require('ioredis');

// Publisher instance
const pub = new Redis({
  host: '10.0.1.45',
  port: 6379,
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000); // Exponential backoff max 2s
    return delay;
  }
});

// Subscriber instance (Ayrı bir connection gerektirir)
const sub = new Redis({
  host: '10.0.1.45',
  port: 6379
});

sub.subscribe('ws:global_chat', (err, count) => {
  if (err) console.error('Subscribe hatası: %s', err.message);
  console.log(`%d adet kanala abone olundu.`, count);
});

sub.on('message', (channel, message) => {
  // Gelen mesajı bu sunucuya bağlı tüm WebSocket client'larına dağıt
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  });
});

// Mesaj gönderme örneği (1.2ms gecikme ile)
pub.publish('ws:global_chat', JSON.stringify({ userId: 412, text: 'Selam!' }));

Aday 2: NATS 2.10 (Core & JetStream)

NATS, başından beri sadece mesajlaşma (messaging) odaklı tasarlanmış, C ve sonrasında Go ile yazılmış yüksek performanslı bir sistemdir. NATS Core tamamen “At-most-once” prensibiyle çalışırken, sisteme dahil olan JetStream modülü disk destekli, kalıcı (persistent) ve “At-least-once / Exactly-once” garantileri sunan bir yapıya dönüşür.

İç Mimari ve Performans

NATS 2.10, multi-threaded bir mimariye sahiptir. 3 node’lu bir cluster konfigürasyonunda saniyede 18 milyon mesaj (msg/sec) yönlendirme (routing) kapasitesini ölçümledik. NATS’ın en büyük avantajı Subject-Based Addressing mantığıdır. Redis’teki düz (flat) kanal isimleri yerine hiyerarşik yapı (örn: chat.room1.user4) ve wildcard desteği (chat.room1.*) sunar. Bu, pub/sub eşleşme (matching) operasyonlarını son derece az CPU döngüsüyle gerçekleştirir.

NATS JetStream Konfigürasyon ve Kod Örneği

Aşağıdaki Node.js kodunda, mesaj kaybına tahammülü olmayan (örneğin borsa tahta verisi) bir WebSocket sunucusunun NATS JetStream ile “Pull Consumer” modeli kullanarak mesajları nasıl tükettiğini (consume) görebilirsiniz. ackWait ve maxDeliver parametreleri sistemin kalbidir.


import { connect, StringCodec } from 'nats';

async function initNatsBackplane() {
  // NATS Cluster'a bağlan
  const nc = await connect({ servers: ['nats://10.0.2.10:4222', 'nats://10.0.2.11:4222'] });
  const jsm = await nc.jetstreamManager();
  const js = nc.jetstream();
  const sc = StringCodec();

  // Stream tanımı (Eğer yoksa oluşturur)
  await jsm.streams.add({
    name: 'WS_EVENTS',
    subjects: ['ws.events.*'],
    retention: 'limits',
    max_age: 1000 * 60 * 60, // 1 saatlik retention
    storage: 'file',         // Disk tabanlı kalıcılık
    replicas: 3              // 3 node'a replike et (Fault tolerance)
  });

  // Consumer oluştur
  const consumer = await js.consumers.getPushConsumer('WS_EVENTS', {
    durable_name: 'node_instance_1',
    deliver_subject: 'deliver.node_instance_1',
    ack_policy: 'explicit',
    ack_wait: 2000 * 1000000, // 2 saniye (nanosaniye cinsinden)
    max_deliver: 5            // Max 5 kere tekrar dene
  });

  // Mesajları dinle ve işle
  const iter = await consumer.consume();
  for await (const m of iter) {
    try {
      const data = JSON.parse(sc.decode(m.data));
      // WebSocket client'larına gönder...
      
      // İşlem başarılıysa NATS'a ACK gönder
      m.ack();
    } catch (e) {
      // Hata varsa NAK gönder, NATS ack_wait beklemeden anında tekrar iletsin
      m.nak();
    }
  }
}

Karar Matrisi ve Kriterler

Mimari seçiminizi yaparken kullanabileceğiniz, production verilerine dayalı karar matrisi aşağıdaki gibidir:

Kriter / Metrik Redis Pub/Sub (Core) NATS Core NATS JetStream
İletim Garantisi At-most-once At-most-once At-least-once / Exactly-once
Max Throughput (3 Node) ~400K msg/sec ~18M msg/sec ~2.4M msg/sec (File Storage)
p99 Gecikme (Latency) 2.4ms 0.8ms 3.2ms (Disk Flush O.H.)
CPU Mimarisi Single-threaded (v7.2) Multi-threaded Multi-threaded
Mesaj Boyutu Limiti 512 MB (Pratikte <10KB önerilir) 1 MB (Varsayılan) 1 MB (Varsayılan)
Network Partition Davranışı Mesajlar kaybolur. Mesajlar kaybolur. Quorum (Raft) sağlanana kadar bekler, kaybolmaz.

Trade-off Analizi: Eğer X ise -> Y (Karar Çerçevesi)

Elinizdeki sistemin doğasına göre teknoloji seçimi yapmak için bu formülleri kullanabilirsiniz:

  • EĞER sadece anlık chat, canlı spor skoru, GPS canlı takibi gibi verinin 1 saniye sonra zaten eskiyeceği (ephemeral) bir domain’de çalışıyorsanız ve halihazırda sisteminizde bir Redis cluster varsa -> Redis Pub/Sub kullanın. Ekstra operasyonel maliyet (overhead) yaratmaz.
  • EĞER saniyede 1 milyonun üzerinde ufak boyutlu (100-200 byte) sensör/IoT verisi akıyorsa, mesaj kaybı tolere edilebilir ancak gecikme (latency) 1ms altında olmak zorundaysa -> NATS Core kullanın.
  • EĞER WebSocket üzerinden finansal portföy güncellemeleri, ödeme bildirimleri veya e-ticaret sipariş durumları gibi “kesinlikle kullanıcıya ulaşması gereken” mesajlar taşıyorsanız -> NATS JetStream kullanın. Redis burada size kan kusturur (aşağıdaki production vakasına bakın).
  • EĞER mesaj yönlendirme mantığınız karmaşıksa (örn: eu.tr.istanbul.chat) ve belirli abonelerin sadece *.tr.*.chat pattern’ini dinlemesi gerekiyorsa -> NATS kullanın. Redis’in PSUBSCRIBE komutu büyük pattern listelerinde O(N) maliyetiyle CPU spike’larına sebep olur.

Production Vakası: 250.000 Bağlantıda Mesaj Kaybı İncelemesi

2021 yılında, canlı bir trivia (bilgi yarışması) oyununun WebSocket altyapısını yönetirken Redis Pub/Sub ile acı bir tecrübe yaşadık. Sistemde anlık 250.000 kullanıcı bağlıydı ve 8 adet Node.js WebSocket sunucusu Redis’e SUBSCRIBE olmuş durumdaydı. Yarışma esnasında saniyede 8.000 mesaj üretiliyordu.

Sunuculardan birinde, anlık bir Garbage Collection (GC) duraksaması nedeniyle Redis’ten gelen pub/sub mesajlarını işleme hızı (consume rate) yavaşladı. Redis, mesajları iletemediği için bu istemci adına bellekte bir output buffer oluşturmaya başladı. Redis konfigürasyonundaki şu satırı gözden kaçırmıştık:


# redis.conf default ayarı
client-output-buffer-limit pubsub 32mb 8mb 60

Bu konfigürasyon şu anlama gelir: Eğer bir pub/sub istemcisinin (bizim Node.js sunucumuz) output buffer’ı anlık olarak 32MB’ı aşarsa veya 60 saniye boyunca 8MB’ın üzerinde kalırsa, Redis bu istemcinin TCP bağlantısını acımasızca keser (kill eder).

Node.js sunucumuz yavaşladığı için buffer 32MB limitine ulaştı. Redis bağlantıyı kesti. İstemci (Node.js) koptuğunu anlayıp tekrar bağlanana kadar geçen 1.5 saniyelik sürede gelen tüm mesajlar kayboldu. Bu sunucuya bağlı olan ~31.250 kullanıcı, yarışmanın o turundaki soruyu cihazlarında göremediler. Bu olayın ardından, kalıcılık ve ACK mekanizması sunan NATS JetStream altyapısına geçiş yaparak problemi kökünden çözdük.

Pratik Öneriler / Production Notları

Hangi teknolojiyi seçerseniz seçin, canlı ortamda (production) patlamamak için aşağıdaki checklist’i uygulayın:

  • TCP Keepalive Ayarları: Hem Redis hem NATS bağlantılarında TCP Keepalive süresini OS seviyesinde agresif tutun. sysctl net.ipv4.tcp_keepalive_time=60 ayarı, load balancer (AWS NAT Gateway gibi) timeout’larından önce boşta kalan (idle) bağlantıların düşmesini engeller.
  • File Descriptor Limitleri: WebSocket sunucularınızın ve mesaj broker’larınızın Linux ulimit -n değerini en az 1048576 (1 milyon) olarak ayarlayın. Too many open files (EMFILE) hatası, scale-out senaryolarının bir numaralı katilidir.
  • Monitoring (Prometheus/Grafana): Redis kullanıyorsanız redis_pubsub_channels ve redis_client_longest_output_list metriklerini kesinlikle alarm (alert) mekanizmasına bağlayın. NATS içinse resmi Prometheus exporter’ı kullanarak nats_messages_dropped_total ve nats_slow_consumers metriklerini 7/24 izleyin.
  • Fallback Stratejisi: Backplane tamamen çökerse, WebSocket sunucularınızın HTTP üzerinden Long Polling yapacak şekilde istemciyi yönlendirmesini (graceful degradation) sağlayın.

Sık Sorulan Sorular

1. NATS yerine neden Kafka kullanmıyoruz?

Kafka yüksek throughput sunsa da, WebSocket gibi on binlerce geçici (ephemeral) konu (topic) veya abonenin (consumer) sürekli yaratılıp silindiği senaryolara uygun değildir. Kafka’da bir partition açmak pahalı bir operasyondur, Zookeeper/KRaft üzerinde yük yaratır. NATS’ta konu (subject) açmak anlıktır ve maliyetsizdir.

2. Redis Streams pub/sub yerine kullanılabilir mi?

Evet, Redis 5.0 ile gelen Streams (XADD, XREAD) kalıcılık (persistence) ve Consumer Group mantığı sağlar. Ancak NATS JetStream kadar cluster içi replikasyon ve partition toleransında olgunlaşmamıştır ve bellek maliyeti NATS’ın disk maliyetinden çok daha yüksektir.

3. Mesaj boyutum 5 MB, hangi teknolojiyi seçmeliyim?

Hiçbirini. 5 MB boyutunda bir veriyi pub/sub kanalından iletmek anti-pattern’dir. Büyük dosyayı S3 gibi bir object storage’a yükleyip, Redis/NATS üzerinden sadece dosyanın indirme URL’sini (veya presigned URL) JSON olarak 100 byte boyutunda iletmelisiniz.

4. TLS/SSL overhead’i performansı ne kadar etkiler?

İç ağda (VPC içerisinde) backplane iletişimi yapıyorsanız, TLS şifrelemesi NATS throughput’unda yaklaşık %32 düşüşe neden olur. Sektör standardı olarak, dış dünya ile load balancer arasında TLS sonlandırması (termination) yapılır, iç ağdaki broker iletişimi plaintext (veya mTLS, ancak donanım destekli AES-NI ile) bırakılır.

Sonuç

WebSocket mimarilerinde yatay ölçekleme yaparken backplane olarak Redis Pub/Sub veya NATS seçimi, tamamen işinizin mesaj kaybına olan toleransına bağlıdır. Sadece “fire-and-forget” mantığında, geçici veriler taşıyorsanız ve halihazırda Redis altyapısına sahipseniz, Redis basitliği ve konfigürasyon kolaylığı ile işinizi görecektir.

Ancak, mesajların sıralamasının (ordering), kesinlikle ulaştığından emin olmanın (acknowledgement) ve ağ kopmalarında verilerin diskte tamponlanmasının (buffering) istendiği kritik sistemler tasarlıyorsanız, NATS JetStream tek doğru yoldur. Mimarinizi şekillendirmeden önce, yukarıdaki karar matrisini ekibinizle inceleyin ve mutlaka bir yük testi (load test) ile max_deliver ve client-output-buffer-limit parametrelerinin sınırlarını kendi ortamınızda zorlayın.

Bunları da beğenebilirsiniz

Pulumi Automation API ile PR Bazlı İzole Test Altyapıları: State Yönetimi ve Maliyet Optimizasyonu

CI/CD pipeline’larında her Pull Request için Pulumi Automation API kullanarak izole altyapılar kurma teknikleri, S3 backend locking çözümleri ve %68 maliyet tasarrufu sağlayan yaşam döngüsü stratejileri.

Devamını Oku

Bir Web Siteye Sahip Olmanız İçin 10 Neden

Pazarda rekabet etmek istiyorsanız, çevrimiçi bir varlığa ve daha da önemlisi size uygun olarak tasarlanmış bir web sitesine sahip olmalısınız. Belki 10 yıl önce alaka…

Devamını Oku

Apache Kafka’da Veri Sözleşmelerini İhlal Eden 5 Schema Evolution Anti-Pattern’i ve Çözümleri

Kafka consumer kesintilerinin temel nedeni olan schema evolution hatalarını engelleyin. Production ortamından 5 spesifik anti-pattern, Avro kod örnekleri ve trade-off analizleri.

Devamını Oku