Müşteri e-postası kibar ama öldürücüydü: "Cumartesi 14:20'de aracımız izinli bölge dışına çıkmış. Sisteminizden hiçbir uyarı gelmedi. Sözleşmemizde bu alarm var." Loglara girdim: bölge ihlali tespit edilmiş, alarm mesajı üretilmiş, Redis'e publish edilmiş. PUBLISH komutunun dönüş değeri bile logda duruyordu: 0. Sıfır. Yani o mesajı hiçbir abone almamıştı. Mesaj kaybolmamıştı bile; doğduğu anda ölmüştü, çünkü dinleyen yoktu. Bildirim servisimiz o dakikalarda deployment sebebiyle 40 saniyeliğine yeniden başlıyordu.
Redis Pub/Sub'ın sözleşmesini aslında baştan beri biliyorduk da, kabullenmemiştik: abone o an orada değilse, mesaj yok hükmündedir. Bu yazı, o kabullenmeme sürecinin ve sonunda vardığımız Redis Streams mimarisinin hikâyesi.
Pub/Sub'ın Cazibesi: İki Satırda Dağıtık Sistem
2019 sonunda uyarı altyapısını kurarken Pub/Sub'ı seçmemizin sebebi düpedüz zarafetiydi. Kural motoru ihlali buluyor, PUBLISH alerts:geofence '{...}' diyor; bildirim servisi SUBSCRIBE ile dinliyor, SMS/e-posta/panel bildirimi gönderiyor. Broker kurulumu yok, kuyruk şeması yok, ofset takibi yok. Gecikme milisaniyenin altında. Demo ortamında kusursuz, üretimde aylarca sorunsuz. Sistemler tam da böyle güven verip sonra insanı yanıltıyor zaten.
Kayıplar üç kanaldan geliyordu ve üçünü de tek tek yaşadık. Bir: yukarıdaki deployment penceresi — abone yoksa mesaj buhar. İki: ağ kopması; abone TCP bağlantısı koptuğunda Redis onun adına hiçbir şey biriktirmez, yeniden bağlanan abone yalnızca gelecek mesajları alır. Üç, en az bilineni: client-output-buffer-limit. Aboneniz mesajları yeterince hızlı tüketemezse Redis onun çıkış tamponunu büyütür ve varsayılan pubsub limitine (32 MB sert, 8 MB/60 sn yumuşak) çarpınca bağlantıyı keser. Yani yavaş tüketici, yalnızca yavaş kalmaz; koparılır ve kopma anındaki her şeyi kaybeder. Bunu, SMS sağlayıcımızın API'si yavaşladığı bir öğleden sonra, bildirim servisinin sebepsiz görünen kopma-bağlanma döngüsünde keşfettik.
Yarım Çözümler Sergisi
İlk yaklaşımımız yara bandıydı: alarmları publish etmeden önce bir MySQL tablosuna da yazalım, bildirim servisi açılışta "son 10 dakikanın gönderilmemiş alarmlarını" tarasın. Çalıştı ama iki dert doğurdu. Gönderildi işaretlemesi ile publish arasında yarış koşulu vardı; bazı alarmlar hem canlı hem tarama yolundan geçip iki kez SMS attı. (Müşteri, aracı için gece iki kez uyandırılınca kayıp mesajdan daha çok kızıyor, not edin.) İkincisi, tarama aralığı ile kayıp penceresi arasındaki pazarlık hiç bitmiyordu. Sistemde iki ayrı doğruluk kaynağı yaşatmak, hiçbirine güvenememek demekmiş.
İkinci yarım çözüm, Pub/Sub'ı bırakıp liste tabanlı kuyruğa geçmekti: LPUSH + BRPOP. Teslimat derdini gerçekten çözüyor — mesaj listede durur, tüketici gelince alır. Ama bu sefer yayın (fan-out) özelliğini kaybettik: BRPOP mesajı tek tüketiciye verir, oysa aynı alarmı hem SMS servisi hem panel push servisi hem denetim logu dinliyordu. Her tüketiciye ayrı liste basmak, üretici tarafını tüketicilerin listesinden haberdar etmek demek; bağımsızlık gitti, kablo karmaşası geldi.
Streams: Kuyruk ile Yayının Evliliği
Redis 5'in Streams yapısı tam bu açmaz için tasarlanmış ve biz 2020 yazında nihayet hakkını vererek kullandık. Stream, append-only bir kayıt defteri: XADD ile eklersiniz, her kayıt 1692871632123-0 formatında, zaman damgalı benzersiz bir ID alır ve silinene kadar orada durur. Okuma tarafında consumer group kavramı işi bitiriyor: her servis (SMS, push, denetim) kendi grubunu açıyor, her grup stream'in tamamını bağımsız okuyor — yayın özelliği geri geldi. Grup içinde ise mesajlar üyelere paylaştırılıyor — kuyruk özelliği de burada. Ve kritik mekanizma: XREADGROUP ile alınan mesaj, tüketici XACK diyene kadar o tüketicinin pending listesinde bekliyor. Tüketici mesajı aldıktan sonra çökerse mesaj kaybolmuyor; pending'de sahipli ama onaysız duruyor.
Deployment senaryomuzu yeniden oynatalım: bildirim servisi 40 saniye kapalı. Alarmlar XADD ile stream'e akmaya devam ediyor. Servis açılınca XREADGROUP kaldığı yerden okuyor; 40 saniyenin tüm alarmları sırayla işleniyor. Kayıp pencere: sıfır. Çöken tüketicinin yarım kalan mesajları için de dakikada bir çalışan bir bekçi görevimiz var: XPENDING ile 5 dakikadan yaşlı sahipli mesajları buluyor, XCLAIM ile sağlıklı bir tüketiciye devrediyor. Böylece "aldı ama ölmeden işleyemedi" vakası da kapanıyor.
Bedeller ve Küçük Yazılmış Satırlar
Bedava değil tabii. Birincisi bellek: stream biriktirdiği için büyür. Biz XADD'i MAXLEN ~ 100000 ile kullanıyoruz (yaklaşıklık işareti önemli — kesin budama her eklemede maliyet, yaklaşık budama neredeyse bedava). Alarm hacmimizde bu, birkaç günlük tampon demek; o kadar geriden gelen tüketicinin zaten alarmdan çok otopsi raporuna ihtiyacı var. İkincisi, teslim garantisi at-least-once'a döndü: XACK gönderilemeden çöken tüketici, mesajı ikinci kez işler. SMS gönderimini idempotent yaptık — alarm ID'si üzerinden "bu SMS zaten gitti mi" kontrolü, gönderim servisinin önünde duruyor. Üçüncüsü, kod karmaşıklığı: SUBSCRIBE'ın iki satırı yerine grup oluşturma, ACK döngüsü, pending bekçisi. Toplam belki 300 satır ek kod. Kaybolan tek bir sözleşmeli alarmın bedeli yanında yuvarlama hatası.
"Neden Kafka ya da RabbitMQ değil?" sorusunu çok aldım. Dürüst cevap: alarm hacmimiz saniyede birkaç yüz mesajı geçmiyor ve Redis zaten yığınımızdaydı — konum önbelleği, oturumlar, sayaçlar hep orada. Sırf mesajlaşma için ZooKeeper'ıyla beraber bir Kafka kümesi işletmek, 2020'deki ekip boyutumuzda (altyapıya bakan fiilen iki kişi) kendine hizmet eden karmaşıklık olurdu. Streams, mevcut bir bağımlılığın içinden güvenilir teslimat çıkarmamızı sağladı. Günün birinde hacim ya da saklama ihtiyacı Kafka'yı haklı çıkarırsa geçiş yolu da belli: üretici tarafında XADD çağrısı tek bir arayüzün arkasında duruyor.
İzlemeyi de kurmadan yayına çıkmadık bu kez. XINFO GROUPS çıktısındaki lag benzeri göstergeleri (grubun okumadığı kayıt sayısını son ID farkından hesaplıyoruz) dakikalık grafiğe bağladık; bir tüketici grubu 5.000 mesajdan fazla geriye düşerse uyarı çalıyor. İlk ayda bu alarm iki kez öttü ve ikisinde de sebep aynıydı: SMS sağlayıcısının yavaşlaması. Eskiden bu yavaşlık aboneyi kopartıp mesaj kaybettiriyordu; şimdi sadece grafikte kabaran, sonra sönen bir tepe. Aynı hastalık, iki mimaride iki farklı kader — altyapı tasarımı dediğin şey tam olarak bu farkı satın almak.
Geçişten sonraki altı ayın bilançosu: kayıp alarm sıfır, mükerrer SMS ayda 2-3 (hepsi idempotency kontrolünün yakaladığı, müşteriye yansımayan yeniden işlemeler), stream'in bellek maliyeti 60 MB civarı. Pub/Sub'ı ise çöpe atmadık; hâlâ kullandığımız yerler var. Canlı harita ekranına giden konum güncellemeleri Pub/Sub'da kaldı, çünkü orada kaçan mesajın hükmü yok — iki saniye sonra daha günceli gelecek zaten. Aracın nerede olduğu Pub/Sub'lık veri; bölgeden çıktığı ise Streams'lik olay. Bu ayrımı cümleye dökmek bile mimariyi anlatmaya yetiyor: değeri son halinde olan veri kaybedilebilir, değeri gerçekleşmesinde olan olay kaybedilemez.
Mesajlaşma altyapınızda hangi verinin hangi sınıfa girdiğini ayıklamak, at-most-once ile at-least-once arasındaki faturayı hesaplamak isterseniz bize ulaşın; kaybolan ilk sözleşmeli alarmınızdan önce konuşmak, sonrasından çok daha ucuz.