Problem: Webhook'lar Geliyor, Sistem Yetişemiyor
Bir fintech projesinde çalışıyorduk. Ödeme sağlayıcımız (Iyzico, Stripe benzeri) her işlem sonrası bize webhook gönderiyordu: "Ödeme başarılı", "Ödeme başarısız", "Geri ödeme yapıldı" gibi. Normalde sorun yok, günde birkaç bin webhook gelir, işlersin, hayat devam eder.
Ama Black Friday geldi.
Bir anda saniyede 20.000+ webhook gelmeye başladı. Mevcut Node.js servisimiz 5 dakika içinde çöktü. Webhook'lar kayboldu. Müşteriler ödeme yaptı ama siparişleri "Beklemede" kaldı. Telefon hatları patladı, müşteri hizmetleri çıldırdı.
O gece sabaha kadar manuel müdahale ettik. Ertesi gün oturup sistemi baştan tasarladık. Bu yazıda, Go, Redis ve RabbitMQ kullanarak kurduğumuz ve 2+ yıldır sorunsuz çalışan webhook işleme mimarisini anlatacağım.
Webhook'ların Doğası: Neden Zor?
Webhook işlemenin zorluğu, kontrolün sizde olmamasıdır. Ödeme sağlayıcısı ne zaman, ne sıklıkla, hangi sırayla webhook göndereceğine kendisi karar verir. Siz sadece hazır olmalısınız.
Karşılaştığımız sorunlar:
- Spike'lar: Normal zamanda saniyede 200 webhook, kampanya döneminde 20.000. 100 kat fark.
- Sıralama: Aynı sipariş için "Ödeme Alındı" ve "Geri Ödeme" webhook'ları aynı anda gelebilir. Yanlış sırada işlerseniz, müşteriye para hem gider hem geri gelir gibi görünür.
- Tekrar Deneme: Ödeme sağlayıcısı 200 OK alamazsa webhook'u tekrar gönderir. Aynı webhook 3-5 kez gelebilir. Sisteminiz buna hazır değilse, müşteriden 3 kez para çekersiniz.
- Bağımlılıklar: Webhook işlerken veritabanına yazarsınız, e-posta servisini çağırırsınız, stok güncellersiniz. Bunlardan biri yavaşlarsa tüm pipeline tıkanır.
Eski mimarimizde webhook geldiğinde hemen işliyorduk: Veritabanı güncelle, e-posta at, stok düş. Bir webhook 200-500ms sürüyordu. Saniyede 200 webhook = 40-100 saniye işlem süresi. Yetişemez.
Yeni Mimari: Hemen Al, Sonra İşle
Çözüm basit bir prensibe dayanıyordu: Webhook'u hemen kabul et, asıl işi sonra yap.
[Ödeme Sağlayıcı] → [Webhook Receiver] → [RabbitMQ] → [Worker Pool] → [İş Mantığı]
↓
[Redis]
(Deduplication)Bileşenler:
- Webhook Receiver (Go HTTP Server): Sadece webhook'u alır, doğrular, RabbitMQ'ya yazar ve 200 OK döner. 1-2ms'de biter.
- RabbitMQ: Mesaj kuyruğu. Webhook'lar burada birikir ve sırayla işlenir.
- Redis: Deduplication (tekrar önleme) ve rate limiting için.
- Worker Pool (Go): Kuyruktan mesaj alır ve asıl iş mantığını çalıştırır.
Bölüm 1: Webhook Receiver
Bu servisin tek görevi: Webhook'u al, doğrula, kuyruğa yaz, 200 dön. Hiçbir iş mantığı burada çalışmaz.
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
"github.com/go-redis/redis/v8"
amqp "github.com/rabbitmq/amqp091-go"
)
type WebhookPayload struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
Timestamp int64 `json:"timestamp"`
Signature string `json:"signature"`
}
var (
redisClient *redis.Client
rabbitCh *amqp.Channel
webhookSecret = os.Getenv("WEBHOOK_SECRET")
)
func main() {
// Redis bağlantısı
redisClient = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
})
// RabbitMQ bağlantısı
// Production'da reconnection mekanizması eklenmeli
conn, err := amqp.Dial(os.Getenv("RABBITMQ_URL"))
if err != nil {
log.Fatal("RabbitMQ bağlantı hatası:", err)
}
defer conn.Close()
rabbitCh, err = conn.Channel()
if err != nil {
log.Fatal("RabbitMQ channel hatası:", err)
}
// Kuyruk oluştur
_, err = rabbitCh.QueueDeclare(
"webhooks", // Kuyruk adı
true, // Durable (sunucu yeniden başlasa bile kuyruk kalır)
false, // Auto-delete
false, // Exclusive
false, // No-wait
nil, // Arguments
)
if err != nil {
log.Fatal("Queue declare hatası:", err)
}
http.HandleFunc("/webhook", handleWebhook)
log.Println("Webhook receiver başladı: :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 1. Body'yi oku
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Body read error", http.StatusBadRequest)
return
}
// 2. İmzayı doğrula (Güvenlik kritik!)
signature := r.Header.Get("X-Webhook-Signature")
if !verifySignature(body, signature) {
log.Println("Geçersiz imza, webhook reddedildi")
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// 3. JSON parse
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// 4. Deduplication kontrolü (Redis SETNX)
ctx := r.Context()
key := "webhook:processed:" + payload.EventID
added, err := redisClient.SetNX(ctx, key, "1", 24*time.Hour).Result()
if err != nil {
log.Println("Redis hatası:", err)
// Redis hata verirse yine de işle, en kötü duplicate olur
}
if !added {
// Bu webhook daha önce işlendi
log.Printf("Duplicate webhook atlandı: %s", payload.EventID)
w.WriteHeader(http.StatusOK)
return
}
// 5. RabbitMQ'ya yaz
err = rabbitCh.Publish(
"", // Exchange
"webhooks", // Routing key (kuyruk adı)
false, // Mandatory
false, // Immediate
amqp.Publishing{
ContentType: "application/json",
Body: body,
DeliveryMode: amqp.Persistent, // Mesaj kalıcı olsun
},
)
if err != nil {
log.Println("RabbitMQ publish hatası:", err)
http.Error(w, "Queue error", http.StatusInternalServerError)
return
}
// 6. Hemen 200 dön
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func verifySignature(body []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}Neden bu kadar hızlı?
- Veritabanına yazmıyoruz
- E-posta atmıyoruz
- Stok güncellemiyoruz
- Sadece: Doğrula, Redis'e bak, RabbitMQ'ya yaz, 200 dön
Bu işlem 1-2ms'de biter. Saniyede 20.000 webhook? Sorun değil.
Bölüm 2: Deduplication (Tekrar Önleme)
Ödeme sağlayıcıları webhook'u birden fazla kez gönderebilir. "En az bir kez teslim" (at-least-once delivery) garantisi verirler, "tam bir kez" (exactly-once) değil.
Redis SETNX (SET if Not eXists) komutu bu sorunu çözer:
added, _ := redisClient.SetNX(ctx, "webhook:processed:"+eventID, "1", 24*time.Hour).Result()- İlk çağrıda key oluşturulur,
added = truedöner. İşle. - Sonraki çağrılarda key zaten var,
added = falsedöner. Atla.
24 saatlik TTL, Redis'in şişmesini önler. 24 saat sonra aynı event_id tekrar gelse bile zaten çok geç, işlemenin anlamı yok.
Bölüm 3: RabbitMQ Neden?
Neden doğrudan Redis kullanmadık? Neden Kafka değil?
Redis Queue'nun Sorunu:
Redis LPUSH/BRPOP ile basit bir kuyruk yapabilirsiniz. Ama Redis kuyrukları "at most once" garantisi verir. Worker mesajı alır ve işlerken çökerse, mesaj kaybolur.
RabbitMQ'nun Avantajı:
RabbitMQ "acknowledgement" mekanizması sunar. Worker mesajı alır, işler, başarılıysa Ack gönderir. Worker çökerse, RabbitMQ mesajı tekrar kuyruğa koyar.
// Worker tarafında
msg, _ := ch.Consume("webhooks", "", false, false, false, false, nil)
// false = auto-ack kapalı, manuel ack yapacağız
for d := range msg {
err := processWebhook(d.Body)
if err != nil {
// İşlem başarısız, mesajı reddet ve tekrar kuyruğa koy
d.Nack(false, true)
continue
}
// İşlem başarılı
d.Ack(false)
}Kafka Neden Değil? Kafka daha yüksek throughput sağlar ama operasyonel karmaşıklığı çok daha fazladır. Partition yönetimi, consumer group'lar, offset tracking... Bizim senaryomuzda RabbitMQ yeterli ve daha basit.
Bölüm 4: Worker Pool
Tek bir worker tüm kuyruğu işleyemez. Birden fazla worker paralel çalışmalı. Ama dikkat: Aynı sipariş için birden fazla webhook varsa, sıralama önemli.
Çözüm: Consistent Hashing ile Order-Based Routing
Her sipariş hep aynı worker'a gider. Böylece aynı sipariş için gelen webhook'lar sırayla işlenir.
package main
import (
"encoding/json"
"fmt"
"hash/fnv"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
const numWorkers = 8
func main() {
conn, err := amqp.Dial(os.Getenv("RABBITMQ_URL"))
if err != nil {
log.Fatal("RabbitMQ bağlantı hatası:", err)
}
defer conn.Close()
ch, err := conn.Channel()
if err != nil {
log.Fatal("Channel hatası:", err)
}
// Prefetch ayarı: Her worker max 10 mesaj alsın
err = ch.Qos(10, 0, false)
if err != nil {
log.Fatal("QoS ayarı hatası:", err)
}
// Her worker için bir go channel
workerChans := make([]chan amqp.Delivery, numWorkers)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
workerChans[i] = make(chan amqp.Delivery, 100)
wg.Add(1)
go worker(i, workerChans[i], &wg)
}
// RabbitMQ'dan mesaj al
msgs, err := ch.Consume("webhooks", "", false, false, false, false, nil)
if err != nil {
log.Fatal("Consume hatası:", err)
}
// Dispatcher: Mesajları order_id'ye göre worker'lara dağıt
go func() {
for msg := range msgs {
var payload WebhookPayload
if err := json.Unmarshal(msg.Body, &payload); err != nil {
log.Printf("JSON parse hatası: %v", err)
msg.Nack(false, false) // Dead letter queue'ya gönder
continue
}
// Order ID'nin hash'ini al, worker seç
workerIdx := hash(payload.OrderID) % numWorkers
workerChans[workerIdx] <- msg
}
}()
// Graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("Kapatılıyor, worker'lar işlerini bitiriyor...")
for i := 0; i < numWorkers; i++ {
close(workerChans[i])
}
wg.Wait()
log.Println("Temiz kapandı")
}
func hash(s string) int {
h := fnv.New32a()
h.Write([]byte(s))
return int(h.Sum32())
}
func worker(id int, jobs <-chan amqp.Delivery, wg *sync.WaitGroup) {
defer wg.Done()
for msg := range jobs {
var payload WebhookPayload
json.Unmarshal(msg.Body, &payload)
log.Printf("[Worker %d] İşleniyor: %s - %s", id, payload.OrderID, payload.EventType)
err := processWithRetry(payload, 5)
if err != nil {
log.Printf("[Worker %d] Max retry aşıldı: %v", id, err)
msg.Nack(false, false) // Dead letter queue'ya gönder
continue
}
msg.Ack(false)
}
}
func processWithRetry(p WebhookPayload, maxRetries int) error {
var lastErr error
for i := 0; i < maxRetries; i++ {
err := processWebhook(p)
if err == nil {
return nil
}
lastErr = err
// Eksponansiyel bekleme: 1, 2, 4, 8, 16 saniye
waitTime := time.Duration(1<<i) * time.Second
log.Printf("Retry %d/%d, bekleniyor: %v", i+1, maxRetries, waitTime)
time.Sleep(waitTime)
}
return fmt.Errorf("max retry aşıldı: %w", lastErr)
}
func processWebhook(p WebhookPayload) error {
// Gerçek iş mantığı burada
switch p.EventType {
case "payment.success":
return handlePaymentSuccess(p)
case "payment.failed":
return handlePaymentFailed(p)
case "refund.completed":
return handleRefund(p)
default:
log.Printf("Bilinmeyen event tipi: %s", p.EventType)
return nil
}
}
func handlePaymentSuccess(p WebhookPayload) error {
// Örnek: Gerçek implementasyon burada olacak
// 1. Veritabanında siparişi "Ödendi" olarak işaretle
// 2. Stoktan düş
// 3. Müşteriye e-posta gönder
// 4. Satıcıya bildirim gönder
// Simülasyon
time.Sleep(15 * time.Millisecond)
log.Printf("Ödeme işlendi: %s, tutar: %d", p.OrderID, p.Amount)
return nil
}
func handlePaymentFailed(p WebhookPayload) error {
// Ödeme başarısız olduğunda yapılacaklar
time.Sleep(10 * time.Millisecond)
log.Printf("Ödeme başarısız: %s", p.OrderID)
return nil
}
func handleRefund(p WebhookPayload) error {
// Geri ödeme işlemleri
time.Sleep(20 * time.Millisecond)
log.Printf("Geri ödeme tamamlandı: %s, tutar: %d", p.OrderID, p.Amount)
return nil
}Neden Consistent Hashing?
Diyelim ki sipariş #12345 için 3 webhook geldi:
payment.initiatedpayment.successrefund.completed
Eğer bunlar farklı worker'lara giderse, 3 numara 2'den önce işlenebilir. Müşteri önce "Geri ödemeniz yapıldı", sonra "Ödemeniz alındı" e-postası alır. Mantıksız.
Ama hepsi aynı worker'a giderse, sırayla işlenir. Dispatcher kuyruğundan çıkış sırası = RabbitMQ'ya giriş sırası.
Bölüm 5: Dead Letter Queue ve Hata Yönetimi
Her şey her zaman çalışmaz. Veritabanı zaman aşımı verebilir, e-posta servisi 500 dönebilir. Bu durumda ne yapacağız?
Dead Letter Queue (DLQ) Kurulumu:
// Kuyruk oluşturulurken DLQ ayarı
args := amqp.Table{
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": "webhooks-dlq",
}
ch.QueueDeclare("webhooks", true, false, false, false, args)
ch.QueueDeclare("webhooks-dlq", true, false, false, false, nil)5 denemeden sonra hala başarısız olan mesajlar webhooks-dlq kuyruğuna düşer. Operasyon ekibi bu kuyruğu izler, sorun çözüldükten sonra mesajları manuel olarak ana kuyruğa geri taşır.
Bölüm 6: Monitoring ve Metrikler
Sistem canlıyken neyin ne durumda olduğunu bilmek zorundasınız.
İzlenmesi Gereken Metrikler:
- Kuyruk Derinliği: RabbitMQ'da kaç mesaj bekliyor? Artıyorsa worker yetişemiyor demek.
- İşlem Süresi: Bir webhook'u işlemek ne kadar sürüyor? P95, P99 latency önemli.
- Hata Oranı: Dakikada kaç webhook hata veriyor?
- DLQ Derinliği: Kaç mesaj geri dönüşsüz başarısız oldu?
Prometheus ile Metrik Toplama:
package main
import (
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
webhooksProcessed = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "webhooks_processed_total",
Help: "Toplam işlenen webhook sayısı",
},
[]string{"event_type", "status"},
)
webhookProcessingDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "webhook_processing_duration_seconds",
Help: "Webhook işleme süresi",
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5},
},
[]string{"event_type"},
)
queueDepth = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "webhook_queue_depth",
Help: "RabbitMQ kuyruk derinliği",
},
)
)
func init() {
prometheus.MustRegister(webhooksProcessed)
prometheus.MustRegister(webhookProcessingDuration)
prometheus.MustRegister(queueDepth)
}
func main() {
// Metrics endpoint
http.Handle("/metrics", promhttp.Handler())
go http.ListenAndServe(":9090", nil)
// Worker kodu burada devam eder...
}
// Worker içinde kullanım
func worker(id int, jobs <-chan amqp.Delivery, wg *sync.WaitGroup) {
defer wg.Done()
for msg := range jobs {
start := time.Now()
var payload WebhookPayload
json.Unmarshal(msg.Body, &payload)
err := processWithRetry(payload, 5)
duration := time.Since(start).Seconds()
webhookProcessingDuration.WithLabelValues(payload.EventType).Observe(duration)
if err != nil {
webhooksProcessed.WithLabelValues(payload.EventType, "error").Inc()
msg.Nack(false, false)
continue
}
webhooksProcessed.WithLabelValues(payload.EventType, "success").Inc()
msg.Ack(false)
}
}Grafana'da bu metrikleri görselleştirip, kuyruk derinliği belirli bir eşiği aştığında alarm kurabilirsiniz.
Sonuç: Gerçek Rakamlar
Bu mimari 2+ yıldır production'da çalışıyor. İşte gerçek rakamlar:
- Normal Günler: Saniyede 200-500 webhook, kuyruk her zaman boş
- Kampanya Dönemleri: Saniyede 15.000-20.000 webhook, kuyruk derinliği maksimum 5.000 (birkaç saniyede eritiliyor)
- Ortalama İşlem Süresi: 15-20ms per webhook (veritabanı + e-posta dahil)
- Hata Oranı: %0.02'nin altında
- Webhook Kaybı: 2 yılda 0 (RabbitMQ restart'ları oldu ama mesaj kaybı yaşamadık)
Maliyet:
- 2 adet Webhook Receiver pod'u (512MB RAM)
- 1 adet RabbitMQ cluster (3 node, managed service)
- 1 adet Redis cluster (sentinel ile HA, managed)
- 8 adet Worker pod'u (1GB RAM)
Toplam aylık maliyet: ~$450-500 (AWS managed service fiyatları)
Eski Node.js monolith ile aynı yükü kaldırmak için 10 kat daha fazla kaynak harcamak zorundaydık ve yine de güvenilirlik %100 değildi.
Checklist: Kendi Webhook Sisteminizi Kurarken
- [ ] Webhook alıcı ve işleyiciyi ayırdınız mı?
- [ ] İmza doğrulaması yapıyor musunuz?
- [ ] Deduplication mekanizması var mı? (Redis SETNX veya benzeri)
- [ ] Mesaj kuyruğu kullanıyor musunuz? (RabbitMQ, Kafka, SQS)
- [ ] Acknowledgement mekanizması aktif mi? (Mesaj kaybını önler)
- [ ] Retry stratejiniz var mı? (Exponential backoff)
- [ ] Dead Letter Queue kurulu mu?
- [ ] Monitoring ve alerting yapılandırıldı mı?
- [ ] Graceful shutdown implementasyonu var mı?
- [ ] Order-based routing düşündünüz mü? (Sıralama önemliyse)
Bu tarz yüksek trafikli, güvenilirlik gerektiren sistemler kurmak karmaşıktır ama imkansız değildir. Eğer kendi webhook altyapınızı kurmak istiyorsanız veya mevcut sisteminiz ölçeklenme sorunları yaşıyorsa, birlikte çözüm üretebiliriz.
