Dağıtık sistemler ve mikroservisler dünyasında arızalar bir olup olmama meselesi değil, bir zamanlama meselesidir. Ağ bölünmeleri, veritabanı zaman aşımı hataları ve üçüncü taraf API kesintileri kaçınılmazdır. Go geliştiricileri için bu geçici arızaları zarifçe yönetebilen servisler oluşturmak, sistem kararlılığını ve kullanıcı güvenini korumak açısından kritik öneme sahiptir. Bu dayanıklılığı elde etmek için kullanılan en etkili iki kalıp Yeniden Deneme Mantığı ve Devre Kesici Kalıbı'dır. Bu yazıda, bu kalıpları Go'da nasıl sağlam bir şekilde uygulayacağımızı keşfedeceğiz.
Dayanıklılık Felsefesi
Bir servis harici bir bağımlılığı (veritabanı, mesaj kuyruğu veya başka bir mikroservis) çağırdığında, her zaman bir hata riski vardır. Her hatayı körü körüne yeniden denemek, "Gürültü Sürüsü" (Thundering Herd) sorununa yol açabilir; bu durumda kurtarılan servisler, isteklerin bir seli altında ezilir. Buna karşılık, ilk hatada hemen başarısız olmak kullanılabilirliği azaltır. Amaç bir denge kurmaktır: Geçici hataları yönetmek için yeterince yeniden deneme yapın, ancak zincirleme hatalara neden olmadan önce durun.
Go'da Yeniden Deneme Mantığı Uygulama
Yeniden deneme mantığı kavramsal olarak basittir ancak pratikte dikkatli bir uygulama gerektirir. İdempotent ve idempotent olmayan işlemler arasında ayrım yapmamız gerekir. İdempotent bir işlemi (GET isteği gibi) yeniden denemek genellikle güvenlidir, ancak idempotent olmayan bir işlemi (POST isteği gibi) yeniden denemek veri yinelenmesine yol açabilir.
Bu örnekte, idempotent bir HTTP isteği için basit bir üstel geri çekilme (exponential backoff) stratejisi uygulayacağız. Üstel geri çekilme, yeniden denemeler arasındaki bekleme süresini artırarak başarısız olan servise olan yükü azaltır.
package main
import (
"context"
"fmt"
"math/rand"
"time"
)
// RetryConfig, yeniden deneme mantığı için parametreleri tutar.
type RetryConfig struct {
MaxRetries int
BaseDelay time.Duration
MaxDelay time.Duration
}
// RetryWithBackoff, üstel geri çekilme ile bir fonksiyon çalıştırır.
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
var err error
delay := config.BaseDelay
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
if attempt > 0 {
// Gürültü sürüsü sorununu önlemek için gürültü (rastgelelik) ile sonraki gecikmeyi hesapla
jitter := time.Duration(rand.Int63n(int64(delay)))
time.Sleep(delay + jitter)
// Sonraki yineleme için gecikmeyi artır
delay = delay * 2
if delay > config.MaxDelay {
delay = config.MaxDelay
}
}
err = fn()
if err == nil {
return nil
}
// İsteğe bağlı: Bağlamın sonlandırılıp sonlandırılmadığını kontrol et (örn. kullanıcı iptal etti)
if ctx.Err() != nil {
return ctx.Err()
}
}
return fmt.Errorf("%d denemeden sonra, son hata: %w", config.MaxRetries, err)
}
Yukarıdaki kodda, gecikmeye gürültü (rastgelelik) ekliyoruz. Gürültü olmadan, birden fazla servis aynı anda yeniden deneme yaparsa, hepsi tam olarak aynı miktarı bekleyebilir ve ardından sunucuyu aynı anda isteklerle doldurabilir. Gürültü, yeniden deneme isteklerinin dağılmasına yardımcı olur.
Devre Kesici Kalıbı
Yeniden deneme mantığı geçici hatalarla yardımcı olsa da, bir servis tamamen kapalıysa veya tutarlı bir şekilde başarısız oluyorsa yardımcı olmaz. Bu durumlarda istek göndermeye devam etmek kaynakları israf eder ve gecikmeyi artırır. Devre Kesici Kalıbı, bir elektrik sigortası gibi davranarak bu sorunu çözer: Çok fazla hata oluşursa, "atar" ve belirli bir süre için istek göndermeyi durdurur.
Bir devre kesici genellikle üç duruma sahiptir:
- Kapalı: İstekler normal şekilde geçer. Hatalar bir eşik aşarsa, devre açılır.
- Açık: İstekler, aşağı akış servisini çağırmadan hemen başarısız olur. Bu, başarısız olan servise ek yük gelmesini önler.
- Yarı Açık: Bir bekleme süresi (cooldown) sonunda, devre sınırlı sayıda test isteğinin geçmesine izin verir. Bunlar başarılı olursa devre kapanır; başarısız olursa yeniden açılır.
Üretim ortamındaki Go uygulamalarında, durum makinesini sıfırdan yazmak yerine sony/gobreaker gibi yerleşik kütüphaneler kullanılması şiddetle tavsiye edilir; çünkü bu kütüphaneler eşzamanlılığı ve kenar durumlarını verimli bir şekilde yönetir.
import (
"github.com/sony/gobreaker"
"net/http"
)
var settings = gobreaker.Settings{
Name: "ExternalAPICall",
MaxRequests: 3,
Interval: 60 * time.Second,
Timeout: 30 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures >= 5
},
}
var cb *gobreaker.TypedBreaker[http.Response]
func init() {
cb = gobreaker.NewTypedBreaker[http.Response](settings)
}
func callExternalAPI(url string) (*http.Response, error) {
req, _ := http.NewRequest("GET", url, nil)
resp, err := cb.Execute(func() (interface{}, error) {
return http.DefaultClient.Do(req)
})
if err != nil {
// Devre kesici hatasını işle (istek reddedildi)
if err == gobreaker.ErrOpenState {
return nil, fmt.Errorf("servis geçici olarak kullanılamıyor")
}
return nil, err
}
return resp.(*http.Response), nil
}
Yeniden Denemeleri ve Devre Kesicileri Birleştirme
En sağlam mimariler her iki kalıbı da birleştirir. Ancak işlem sırası önemlidir. Genellikle Devre Kesici, fonksiyonu sarar ve Yeniden Deneme mantığı, çağrılabilir fonksiyonun içinde uygulanır. Bu, devre kesicinin agresif bir şekilde yeniden deneme yapan tek bir istemci tarafından neden edilen hatalara değil, gerçek hatalara dayanarak atmasını sağlar.
Sonuç
Go'da yeniden deneme mekanizmaları ve devre kesicileri uygulamak, hata toleranslı mikroservisler oluşturmak için esastır. Yeniden denemeler için gürültülü üstel geri çekilme kullanmak ve devre kesme için gobreaker gibi sağlam kütüphanelerden yararlanmak, Go geliştiricilerinin uygulamalarının dayanıklılığını ve kullanılabilirliğini önemli ölçüde artırmasına olanak tanır. İşlemlerinizin idempotentliğini her zaman göz önünde bulundurduğunuzdan ve sağlıklı bir dağıtık sistem korumak için devre kesici durumlarınızı izlediğinizden emin olun.