Bir kaydı hem kendi sisteminde tutup hem de dışarıdaki bir servise bildirmen gerekiyorsa — bir kamu web servisi, bir CRM, bir muhasebe sistemi — er ya da geç şu soruyla karşılaşıyorsun: servis yanıt vermediğinde ne oluyor?
Bu sorunun cevabı çoğu entegrasyonun kalitesini belirliyor. Çünkü dış servisin geçici olarak erişilemez olması bir istisna değil; yeterince uzun çalışan her sistemde beklenen bir durum.
"Gönder ve sonucu göster" neden yetmiyor
En doğal kurgu şu: kullanıcı kaydı oluşturur, aynı istek içinde dış servise gönderilir, sonuç ekranda gösterilir.
$record = Record::create($data);
$response = $client->notify($record); // dış servis burada
return $response->successful() ? 'Bildirildi' : 'Hata';
Bu akış mutlu günde çalışıyor. Sorun mutsuz günde:
- Servis yanıt vermiyor ve kullanıcı bir hata ekranı görüyor. Kayıt oluştu mu, olmadı mı belli değil.
- Kullanıcı tekrar deniyor. Şimdi iki kayıt var.
- Ya da kullanıcı vazgeçiyor. Kayıt sistemde duruyor ama hiç bildirilmemiş — ve bunu kimse bilmiyor.
- İstek zaman aşımına uğruyor, ama dış servis aslında işlemi almış oluyor. En kötüsü bu.
Ortak kusur şu: kaydın kendisiyle bildiriminin durumu tek bir şeymiş gibi ele alınıyor. Halbuki bunlar iki ayrı olay ve ikisi aynı anda başarılı olmak zorunda değil.
Bildirim durumu ayrı bir varlıktır
Kayıtta bildirildi diye bir boolean alan tutmak yeterli görünüyor ama değil. Bir bildirimin kendi geçmişi var: ne zaman denendi, kaç kez denendi, en son ne cevap geldi, hâlâ denenmeli mi?
Bunu ayrı bir tabloya taşımak işi tümüyle değiştiriyor:
CREATE TABLE outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
record_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
idempotency_key VARCHAR(80) NOT NULL,
status ENUM('pending','sent','failed') NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NULL,
last_error TEXT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uniq_idem (idempotency_key),
KEY idx_due (status, next_retry_at)
);
Artık "bu kayıt bildirildi mi" sorusunun cevabı bir tahmin değil. Ne zaman denendiği, neyle sonuçlandığı ve sırada olup olmadığı görülebiliyor.
Kaydı ve bildirim niyetini aynı anda yaz
Kritik nokta: kaydı oluşturan işlemle bildirim satırını oluşturan işlem aynı transaction içinde olmalı.
DB::transaction(function () use ($data) {
$record = Record::create($data);
Outbox::create([
'record_id' => $record->id,
'event_type' => 'record.created',
'payload' => $record->toNotificationPayload(),
'idempotency_key' => "record.created:{$record->id}",
]);
});
Bunun adı outbox kalıbı ve tek bir şeyi garanti ediyor: kayıt varsa bildirim niyeti de vardır. Kaydın oluşup bildiriminin hiç kuyruğa girmemesi mümkün olmuyor.
Gönderim ise ayrı bir işçi tarafından, isteğin dışında yapılıyor. Kullanıcı dış servisin hızını beklemiyor; kullanıcıya "işlem alındı" demek için dış servisin cevabına ihtiyaç kalmıyor.
Yeniden denemenin sınırları olmalı
Sonsuz yeniden deneme, çalışmayan bir entegrasyonu sonsuza kadar gizler. Üç şeyi belirlemek gerekiyor:
Aralık. Sabit aralıkla denemek, servis toparlandığı anda ona toplu bir yük bindiriyor. Üstel artan bir bekleme ve üzerine rastgele bir sapma (jitter) daha iyi çalışıyor — bütün işçiler aynı saniyede uyanmıyor.
// 1., 2., 3. deneme: ~1dk, ~4dk, ~9dk (üzerine rastgele sapma)
$delay = ($attempt ** 2) * 60 + random_int(0, 30);
Üst sınır. Belirli bir denemeden sonra kayıt failed olmalı. Bu bir kayıp değil, bir görünürlük kazancı: artık birinin bakması gereken bir liste var.
Kalıcı hata ayrımı. Her hata yeniden denemeyi hak etmiyor:
- Yeniden dene: zaman aşımı, bağlantı hatası,
5xx,429. - Deneme, hemen işaretle:
400(geçersiz veri),401/403(yetki),422(doğrulama).
Geçersiz veriyi kırk kez göndermek onu geçerli yapmıyor. Bu ayrımı yapmayan bir kuyruk, çözülemeyecek işlerle doluyor ve gerçekten geçici olan hatalar arada kayboluyor.
Mükerrer gönderimi engellemek
Zaman aşımı, en zor durumu üretiyor: isteği gönderdin, cevap gelmedi, ama karşı taraf işlemi almış olabilir. Yeniden denersen aynı işlem iki kez oluşabilir.
Çözüm iki taraflı. Kendi tarafında her bildirime kararlı bir anahtar veriyorsun — record.created:1042 gibi, rastgele değil, aynı olay için hep aynı üretilen bir değer. Karşı taraf bu anahtarı destekliyorsa (çoğu modern API Idempotency-Key başlığıyla destekliyor) tekrar gönderim güvenli hâle geliyor.
Desteklemiyorsa, göndermeden önce sorgulamak gerekiyor: "bu referansla bir kayıt var mı?" Bu ekstra bir istek maliyeti getiriyor ama mükerrer kayıt üretmekten ucuz.
Bunun neden önemli olduğunu ödeme tarafında çalışırken daha net görüyorsun: aynı ödemenin iki kez işlenmesi sadece bir fazla satır değil, dönüşüm oranını da bozan bir hata. Rapor yanlış çıkıyor ve yanlış olduğunu fark etmek günler alıyor.
Takılan kayıt görünür olmalı
Bir kuyruk, kimse bakmadığında sessizce dolan bir yere dönüşürse hiçbir şey çözmemiş olur. En az iki şey gerekiyor:
- Operasyon ekranı.
faileddurumundaki kayıtlar, hatasıyla birlikte listeleniyor ve elle yeniden denenebiliyor. - Eşik uyarısı. Bekleyen kayıt sayısı ya da en eski bekleyen kaydın yaşı bir eşiği aştığında haber veren bir kontrol.
İkincisi olmadan, dış servisin üç gündür veri almadığını ancak karşı taraf arayınca öğreniyorsun.
İş sağlığı ve güvenliği süreçlerini kamu web servisleriyle entegre eden bir sistemde çalışırken şunu öğrendim: resmi bildirim yapan bir sistemde "bildirildi" bilgisi, kaydın bir alanı değil, kendi geçmişi olan ayrı bir durumdur. Ne zaman denendiği ve neyle sonuçlandığı görülemiyorsa, bildirimin yapıldığını söylemek de mümkün olmuyor.
Özet
Dayanıklı bir entegrasyon şu dört şeyden oluşuyor:
- Kayıt ve bildirim niyeti aynı transaction'da yazılıyor.
- Gönderim isteğin dışında, ayrı bir işçide yapılıyor.
- Yeniden deneme sınırlı, artan aralıklı ve kalıcı hatayı ayırt ediyor.
- Başarısız kayıtlar sessizce birikmiyor, görünür oluyor.
Hiçbiri karmaşık değil. Zor olan, kesintiyi baştan beklenen bir durum olarak kabul etmek — sonradan eklenen bir yeniden deneme mantığı, en başından bu varsayımla kurulmuş bir akış kadar iyi çalışmıyor.
