<

Aynı Mesaj İki Kez Gelince: Idempotent Tüketici Deseni

Pazartesi sabahı bir müşteri temsilcisi ekran görüntüsüyle geldi: müşterinin telefonuna 04:12'de, aynı dakika içinde, aynı içerikli 7 SMS düşmüş. "Aracınız X bölgesinden çıkış yaptı." Yedi kez. Müşteri haklı olarak "sisteminiz kafayı mı yedi" diye sormuş. Loglara baktık: çıkış olayı bir kez üretilmiş, kuyruğa bir kez yazılmış. Ama bildirim tüketicisi o gece 04:12'de yedi kez yeniden başlamıştı — bellek sızıntısı yüzünden supervisor onu tekrar tekrar ayağa kaldırıyordu. Her yeniden doğuşunda aynı mesajı kuyruktan alıyor, SMS'i gönderiyor ve tam ack edecekken ölüyordu.

Kuyruk sistemi görevini kusursuz yapmıştı. Ack gelmeyen mesajı yeniden teslim etmek onun sözleşmesinin ta kendisi. Kusur bizdeydi: tüketiciyi, her mesajın tam bir kez geleceği hayaline göre yazmıştık.

Exactly Once Diye Bir Adres Yok

Mesajlaşma sistemlerinde teslimat garantileri üç kapıdır: at-most-once (kayıp olabilir, tekrar olmaz), at-least-once (kayıp olmaz, tekrar olabilir) ve herkesin istediği exactly-once. Üçüncü kapının arkasında pratikte boş bir oda vardır: ağ üzerinde "işledim" onayının kendisi kaybolabildiği sürece, gönderen taraf tekrar denemek ile kayba razı olmak arasında seçim yapmak zorundadır. Broker'ların exactly-once vaatleri de kendi sınırları içinde geçerlidir; sizin SMS sağlayıcınıza yaptığınız HTTP çağrısını o sınır kapsamaz. Bizim çıkardığımız net karar şu oldu: teslimat katmanından at-least-once iste, tekrarla başa çıkma işini tüketiciye ver. Tekrarla başa çıkan tüketicinin adı da idempotent tüketici: aynı mesajı bir kez işlemekle yedi kez işlemek arasında gözlemlenebilir hiçbir fark yaratmayan tüketici.

Önce Kimlik: Mesaja Ad Koymak

İdempotentliğin ön şartı, "aynı mesaj" dediğimiz şeyi tanıyabilmek. Rastgele üretilen bir UUID işe yaramaz; yeniden teslimatta aynı kalır ama üretici tarafı aynı olayı iki kez yayınlarsa iki farklı UUID doğar. Biz anahtarı olayın doğasından türettik: cihaz kimliği + cihazın paket sıra numarası + olay tipi. Bu üçlü, dünyada tek bir gerçek olaya karşılık gelir; olay hangi yoldan, kaç kez gelirse gelsin anahtarı değişmez. Deterministik kimlik, sonradan kurduğumuz her şeyin temeli oldu.

Bu soruyu netleştirdikten sonra sistemdeki bütün tüketicileri tek tek gözden geçirip bir risk envanteri çıkardık. On dört tüketicinin dördü zaten doğal idempotentti, yedisi dedup katmanı gerektiriyordu, üçü ise en tehlikeli sınıftaydı: dışarıya yan etki üretenler — SMS, e-posta ve bir müşterinin ERP'sine giden webhook. Öncelik sırası kendiliğinden oluştu ve ilk sprint tamamen o üç tüketiciye harcandı.

Dedup Tablosu: Sıkıcı ve Sarsılmaz

Çekirdek mekanizma utandıracak kadar basit: processed_messages adında, mesaj anahtarı üzerinde unique indeksi olan bir tablo. Tüketici mesajı işlerken, iş mantığının yazdığı satırlarla dedup kaydını aynı veritabanı transaction'ında yazıyor. Anahtar zaten varsa insert unique ihlaliyle düşüyor, tüketici bunu hata değil "bu işi zaten yapmışım" bilgisi olarak okuyup mesajı sessizce ack'liyor. Transaction'ın atomikliği kritik: dedup kaydı iş verisiyle aynı anda ya var olur ya olmaz. Dedup'u ayrı bir adımda, hele ayrı bir depoda tutarsanız, iki yazma arasında çökme ihtimali kapıyı yine aralar.

Tablonun sonsuza dek büyümesine gerek yok; yeniden teslimatlar pratikte dakikalar içinde olur. Biz 7 günden eski kayıtları gecelik bir işle siliyoruz; tablo 40 milyon satır civarında salınıyor ve kimseyi rahatsız etmiyor.

Dedup tablosunun bir alternatifi olarak "doğal idempotentlik" dediğimiz yolu da kullandığımız yerler var; dürüst bir yazı ikisini de anlatmalı. Bazı işlemler doğaları gereği tekrara dayanıklı yazılabiliyor: örneğin aracın son konumunu tutan tabloyu güncelleyen tüketici, INSERT ... ON DUPLICATE KEY UPDATE ile ve yalnızca daha yeni zaman damgasını kabul eden bir koşulla yazıyor. Aynı mesaj yedi kez gelse sonuç bit düzeyinde aynı; dedup kaydına gerek yok. Sayaç artırma, bakiye düşme, e-posta gönderme gibi işlemlerse doğal olarak idempotent değildir ve dedup katmanı şarttır. Ekipte ayrım şöyle netleşti: yazma "durum belirleme" biçimine sokulabiliyorsa sok, sokulamıyorsa dedup tablosuna yaslan. En tehlikeli bölge, idempotent sanılan ama olmayan gri alan; "listeye ekle" işlemi masum görünür, iki kez çalışınca listede iki kayıt olur.

Peki Ya SMS? Dış Dünya Transaction Tanımaz

Veritabanı yazmaları böyle zapt edildi ama bizim vakadaki yara SMS'ti ve SMS gönderimi rollback edilemez. Burada iki savunma katmanı kurduk. Birincisi, sağlayıcıya giden çağrıda idempotency key kullanmak; sağlayıcımız aynı anahtarla gelen ikinci isteği yeni SMS'e çevirmeyip ilkinin sonucunu dönüyor. İkincisi, kendi tarafımızda gönderimi iki faza ayırmak: önce dedup transaction'ı içinde "bu bildirimi gönderme hakkını aldım" kaydı, sonra fiziksel gönderim, sonra sonuç güncellemesi. Süreç gönderimden hemen sonra ölürse, yeniden teslimatta hak kaydını dolu görüp göndermeyi tekrarlamıyoruz. Teorik pencere hâlâ var — gönderim ile kayıt arasında çökersek bir SMS iki kez çıkabilir — ama pencere yedi mesajlık bir utançtan, yılda bir görülür bir tekile indi.

Tekrarın kardeşi olan sıralama meselesine de çarptık. Yeniden teslim edilen mesaj bazen kardeşlerinden sonra, yani sıra dışı gelir: aracın kontak-kapalı olayı işlenmişken, iki dakika önceki kontak-açık olayı ortaya çıkabilir. İlk sürümde bu, araç durum ekranında hayalet bir "çalışıyor" görüntüsü bıraktı. Çözüm yine olay kimliğindeki sıra numarasından geldi: durum güncelleyen tüketiciler, ellerindeki kayıttan daha küçük sıra numaralı mesajı durum için kullanmıyor, yalnızca tarihçe tablosuna işliyor. Geç gelen gerçek kaybolmuyor ama bugünü de yeniden yazamıyor. Idempotentlik ve sıra toleransı birlikte tasarlanınca tüketici, kuyruğun her türlü kaprisine — tekrar, gecikme, karışık sıra — omuz silkebilen bir yapıya dönüşüyor.

Tekrar Bir Hata Değil, Hava Durumu

Deseni yaygınlaştırdıktan sonra izlemeye bir metrik ekledik: duplicate_dropped sayacı. Normal günlerde toplam trafiğin yüzde 0.01'i civarında; deploy günlerinde, tüketiciler yeniden başlarken, yüzde 0.4'e kadar çıkıyor. Bu sayacın sıfır olmaması başta ekibi rahatsız etti; sonra tam tersini anladık. Sayaç sıfırsa dedup mekanizmanız test edilmiyor demektir; ilk gerçek tekrarda çalışıp çalışmayacağını ancak o gün öğrenirsiniz. Sayacın tıkırdaması, paraşütün her gün küçük atlayışlarla denendiği anlamına geliyor.

Desen bir kez oturunca onu kuyruğun dışına da taşıdık. Mobil uygulamalarımız zayıf şebekelerde istekleri yeniden dener ve "kaydet" butonuna basılıp yanıt gelmeyen her istek, potansiyel bir çift kayıttır. API'nin yazma uçlarına Idempotency-Key başlığı ekledik: istemci her mantıksal işlem için bir anahtar üretiyor, sunucu aynı anahtarla gelen ikinci isteğe ilk isteğin saklanmış yanıtını dönüyor. Altyapı, kuyruk tüketicisindekiyle birebir aynı — anahtar, unique indeks, saklanmış sonuç. Bir kez öğrenilen desenin üç ayrı katmanda iş görmesi, iyi desenlerin alamet-i farikası olsa gerek.

Bugün ekipte kural şöyle işliyor: kuyruktan okuyan her yeni tüketici, kod incelemesinde tek bir soruyla karşılanır — "bu handler'ı aynı mesajla iki kez çağırırsam ne olur?" Cevap "aynı sonuç" değilse tasarım geri döner. Bu soruyu sormaya başlamak, mesajlaşma altyapımıza güvenimizi kuyruk yazılımının markasından daha çok artırdı. Dağıtık sisteminizde tekrar eden mesajların açtığı bir yarayla uğraşıyorsanız iletişim sayfası açık; yedi SMS hikâyesini dinledikten sonra kimse kendi vakasından utanmıyor.

📅 Yayınlanma:  ·  Yakup Zengin