Kasım kampanyasının ertesi sabahı, saat 08:40'ta ödeme sağlayıcımızdaki teknik muhatabım aradı. Ses tonu her şeyi anlatıyordu: "Dün geceki trafiğinizde aynı karttan aynı tutarlı tekrar işlem oranı normalin dokuz katı. Sizde bir şey değişti mi?" Çağrı merkezi paneline baktık; gece yarısından beri 843 müşteri çift çekim şikayetiyle aramıştı. Günde 1,1 milyon ödeme işleyen bir platformda yüz binde 7'lik bir oran kağıt üstünde küçük görünür, ama her biri kartından iki kez para çekilmiş, güveni sarsılmış bir insan demek. O hafta öğrendiğimiz şey, dağıtık sistemlerde "ödeme başarısız oldu" cümlesinin çoğu zaman yalan olduğuydu.
Suçu Önce Karşı Tarafa Attık
İlk hipotezimiz konforluydu: ödeme sağlayıcısı çift işliyor. Mutabakat dosyasını istedik, işlem loglarımızla eşleştirdik ve utandırıcı gerçeği gördük: çift çekilen her işlem için bizim tarafımızdan iki ayrı authorize isteği gitmişti. İkinci hipotez mobil uygulamaydı: "Kullanıcı butona iki kez basıyordur." Client loglarına event bazında baktık; kullanıcıların çoğu tek kez basmıştı. Debounce zaten vardı. Sorun daha derindeydi ve kendi backend'imizdeydi.
Suçlamayı bırakıp sayım yapmaya başlayınca resim netleşti. O geceki bütün çift çekimleri tek tek sınıflandırdık; bu sıkıcı ama paha biçilmez bir egzersizdi. Vakaların %71'i sunucu tarafı timeout retry'ından, %22'si hata ekranındaki "tekrar dene" butonundan, %7'si ise hiç aklımıza gelmeyen bir yoldan geliyordu: mobil uygulamanın arka plana alınıp geri açıldığında yarım kalan ödeme akışını sessizce yeniden tetiklemesi. Bu üçüncü grup, taksonomi çıkarmadan asla bulunamazdı. O günden beri her ödeme incident'ında ilk iş dağılım tablosu çıkarmak; tek bir kök sebep aramak, çoğu zaman iki küçük sebebi görmezden gelmek demek.
Timeout Bir Cevap Değildir
Gerçek zincir şöyle işliyordu: kampanya gecesi ödeme sağlayıcısının yanıt süreleri uzadı. Bizim payment servisimizin HTTP client'ında 30 saniyelik timeout ve "network hatasında bir kez retry" politikası vardı. Sağlayıcı isteği 31. saniyede başarıyla işledi, biz 30. saniyede pes edip aynı isteği yeniden gönderdik. Sağlayıcı gözünden bunlar iki ayrı, ikisi de gayet meşru işlemdi. Timeout size işlemin başarısız olduğunu söylemez; sadece sonucu bilmediğinizi söyler. Bilinmeyen bir sonucun üstüne körlemesine retry atmak, çift çekimin en klasik reçetesidir. İkinci bir sızıntı daha bulduk: hata ekranındaki "tekrar dene" butonu her basışta sıfırdan bir ödeme isteği yaratıyordu; önceki denemenin akıbetini soran kimse yoktu.
Anahtarın Doğru Ölçeği
Çözümün omurgası idempotency key oldu, ama asıl mesele anahtarın neyi temsil ettiğine karar vermekti. İlk taslakta anahtarı sipariş numarasından üretmiştik; kod incelemesinde bir arkadaşımız haklı bir soru sordu: "Kullanıcı ilk kartı reddedilince başka kartla denerse ne olacak?" Sipariş bazlı anahtar bu meşru ikinci denemeyi de bloklardı. Bunun üzerine araya bir kavram koyduk: payment intent. Her sipariş için bir intent, her tahsilat girişimi için intent'e bağlı bir attempt. Idempotency key, attempt seviyesinde ve istemci tarafında üretiliyor; aynı attempt'in retry'ı aynı anahtarı taşıyor, kullanıcının bilinçli yeni denemesi yeni anahtar alıyor. Anahtar hem bizim tarafta hem sağlayıcıya giden istekte yaşıyor, çünkü sağlayıcımız da header üzerinden idempotency destekliyor ve iki katmanlı koruma tek katmandan her zaman iyidir.
Sunucu tarafında anahtar iki yerde birden tutuluyor. Redis'te SETNX ile 15 dakikalık bir işlem kilidi: aynı anahtarla ikinci istek gelirse veritabanına inmeden ilk isteğin kaydedilmiş sonucunu döndürüyoruz, işlem hâlâ uçuştaysa 409 ile "bekle" diyoruz. Kalıcı katmanda ise payment_attempts tablosunda anahtar üzerinde unique index var; Redis failover'ında bile son savunma hattı veritabanının kendisi. Bu ayrımı özellikle vurgularım: dağıtık kilit bir performans optimizasyonudur, doğruluk garantisi değil. Doğruluğu unique constraint verir.
Yeni tasarımı canlıya alırken de temkinli davrandık. İlk iki hafta idempotency katmanı shadow modda çalıştı: anahtarlar üretildi, çakışmalar loglandı ama hiçbir istek bloklanmadı. Bu dönemde sistemin "çift" diye işaretleyeceği işlemleri elle inceledik ve iki false positive senaryosu yakaladık; biri taksit değişikliğinde anahtarın yanlışlıkla aynı kalması, diğeri webview'da sayfa yenilemenin aynı attempt'i meşru şekilde tekrar göndermesi. İkisini de bloklamaya başlamadan önce düzelttik. Staging'de ayrıca kaos senaryosu koştuk: sahte PSP'ye bilerek 31 saniyelik gecikmeler enjekte edip retry zincirinin artık authorize değil inquiry ürettiğini otomatik testle doğruladık. Ödeme kodunda "herhalde çalışır" diye bir kategori yok; ya testi var ya da yok.
Durum Makinesi ve Sorgulama Refleksi
Retry politikasını da baştan yazdık. Attempt'ler artık katı bir durum makinesinde yaşıyor: CREATED, SENT, SUCCEEDED, FAILED, UNKNOWN. Timeout olan işlem FAILED'e değil UNKNOWN'a düşüyor ve UNKNOWN durumundaki bir attempt için asla yeni authorize atılmıyor; önce sağlayıcının inquiry endpoint'ine "bu anahtarla işlem gördün mü?" diye soruluyor. Cevap gelene kadar kullanıcıya dürüst bir ekran gösteriyoruz: "Ödemenizin durumunu kontrol ediyoruz." Bu ekranın dönüşüme zarar vereceğinden korkmuştuk; ölçtük, iptal oranı değişmedi. İnsanlar belirsizliğe değil, kartlarından iki kez para çekilmesine kızıyor.
Peki o 843 müşteriye ne oldu? Olay sabahı ilk kararımız, kimse aramadan bizim aramamızdı. Etkilenen işlemleri mutabakat verisinden çıkarıp iadeleri toplu başlattık, her müşteriye durumu açıklayan bir bildirim ve küçük bir özür kuponu gönderdik. İadelerin %90'ı 24 saat içinde karta yansıdı. Sonradan ölçtük: proaktif aradığımız müşterilerin sonraki 90 gündeki sipariş sıklığı, olaydan hiç etkilenmeyen kontrol grubundan anlamlı biçimde düşük çıkmadı. Teknik borcun faturasını mühendislik öder ama güven borcununkini bütün şirket öder; erken ve dürüst iletişim, o faturayı küçültebilen tek kalem.
Mutabakat: Son Savunma Hattı
Bütün bu katmanlara rağmen her gece bir reconciliation job çalışıyor: sağlayıcının günlük mutabakat dosyasını kendi attempt tablomuzla üç yönlü eşleştiriyor. Bizde başarılı görünüp dosyada olmayan, dosyada olup bizde UNKNOWN kalan, tutarı uyuşmayan her kayıt sabah 07:00'de bir Slack kanalına düşüyor. İlk aylarda bu job günde 30-40 tutarsızlık yakalıyordu; edge case'leri tek tek kapattıkça sayı haftada bire indi. Mutabakatın yan faydası da oldu: çift çekimin tersini, yani bizde başarısız görünüp aslında tahsil edilmiş "hayalet" ödemeleri de aynı job yakalıyor. Bu ikinci tür, müşteri şikayetiyle asla gelmez çünkü müşteri parasının çekildiğini görür ve siparişinin yolda olduğunu sanır; sipariş ise hiç oluşmamıştır. Fark edilmesi haftalar sürebilecek bu sessiz vakaları artık ertesi sabah yakalayıp siparişi elle kurtarıyoruz; ayda ortalama 15-20 müşteri, başına ne geldiğini hiç öğrenmeden yoluna devam ediyor. Çift çekim oranımız yüz binde 7'den, son altı ayda ölçülebilir sıfıra düştü. Çağrı merkezindeki ödeme kaynaklı çağrılar %31 azaldı; iade operasyonunun maliyetini saymıyorum bile.
Bu vakanın bana öğrettiği şeyi tek cümleye sıkıştırırsam: ödeme sistemlerinde mühendislik, mutlu yolu kodlamak değil, "bilmiyorum" durumunu birinci sınıf vatandaş yapmaktır. Timeout'u hata sanan her sistem, yeterince trafik altında er ya da geç birinin kartını iki kez çeker. Kendi platformunuzda retry politikalarınızı en son ne zaman uçtan uca okuduğunuzu hatırlamıyorsanız, bu hafta okuyun; aklınıza takılan bir senaryo olursa iletişim sayfasından ulaşın, mutabakat dosyalarıyla boğuşmuş biri olarak seve seve tartışırım.