Go2026-01-23

Go ile Saniyede 20.000 Webhook'u İşlemek: Ödeme Bildirimi Mimarisi

Ödeme sağlayıcısından gelen webhook'ları kaybetmeden, sırayla ve güvenilir şekilde işlemek. İşte Go, Redis ve RabbitMQ ile kurduğumuz gerçek bir production mimarisi.

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:

  1. Spike'lar: Normal zamanda saniyede 200 webhook, kampanya döneminde 20.000. 100 kat fark.
  2. 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.
  3. 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.
  4. 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:

  1. Webhook Receiver (Go HTTP Server): Sadece webhook'u alır, doğrular, RabbitMQ'ya yazar ve 200 OK döner. 1-2ms'de biter.
  2. RabbitMQ: Mesaj kuyruğu. Webhook'lar burada birikir ve sırayla işlenir.
  3. Redis: Deduplication (tekrar önleme) ve rate limiting için.
  4. 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 = true döner. İşle.
  • Sonraki çağrılarda key zaten var, added = false dö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:

  1. payment.initiated
  2. payment.success
  3. refund.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:

  1. Kuyruk Derinliği: RabbitMQ'da kaç mesaj bekliyor? Artıyorsa worker yetişemiyor demek.
  2. İşlem Süresi: Bir webhook'u işlemek ne kadar sürüyor? P95, P99 latency önemli.
  3. Hata Oranı: Dakikada kaç webhook hata veriyor?
  4. 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

  1. [ ] Webhook alıcı ve işleyiciyi ayırdınız mı?
  2. [ ] İmza doğrulaması yapıyor musunuz?
  3. [ ] Deduplication mekanizması var mı? (Redis SETNX veya benzeri)
  4. [ ] Mesaj kuyruğu kullanıyor musunuz? (RabbitMQ, Kafka, SQS)
  5. [ ] Acknowledgement mekanizması aktif mi? (Mesaj kaybını önler)
  6. [ ] Retry stratejiniz var mı? (Exponential backoff)
  7. [ ] Dead Letter Queue kurulu mu?
  8. [ ] Monitoring ve alerting yapılandırıldı mı?
  9. [ ] Graceful shutdown implementasyonu var mı?
  10. [ ] 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.

İlginizi Çekebilir

👨‍💻

Yazar

Doğan Aydın

Kıdemli Yazılım Mühendisi. Karmaşık problemleri basit çözümlere dönüştürmeyi sever. Mimar, mentör ve sürekli öğrenci.