Setiap circuit breaker produksi yang saya tinjau pada akhirnya memunculkan thread latar belakang. Bisa jadi goroutine Go, ScheduledExecutorService Java, atau task tokio Rust. Tugasnya selalu sama: bangun setiap beberapa detik, periksa apakah layanan downstream sudah pulih, dan transisi dari OPEN kembali ke CLOSED.

Desain itu salah. Ia bocor resource pada skala besar, mempersulit shutdown, dan membuat race conditions yang benar-benar sulit diuji. Lebih buruk lagi, pekerjaan latar belakang itu sama sekali tidak diperlukan. Anda bisa membangun circuit breaker yang tidak pernah bangun sendiri, tidak pernah mengalokasikan timer, dan tetap mendeteksi pemulihan dengan benar.

Biaya tersembunyi dari health-check goroutine

Circuit breaker melacak kegagalan. Setelah cukup banyak error beruntun, ia trip ke OPEN dan mulai menolak request secara langsung. Tujuannya adalah memberikan kesempatan pada layanan yang gagal alih-alih menenggelamkannya dengan retry traffic.

Bagian yang rumit adalah memutuskan kapan mencoba lagi. Kebanyakan library menyelesaikan ini dengan setTimeout atau time.AfterFunc. Di Go, implementasi tipikal terlihat seperti ini:

func (cb *CircuitBreaker) Trip() {
    cb.state.Store(StateOpen)
    time.AfterFunc(cb.timeout, func() {
        cb.state.Store(StateHalfOpen)
    })
}

Ini berfungsi untuk satu breaker. Ini tidak berfungsi untuk sepuluh ribu.

Jika Anda membuat satu circuit breaker per downstream host (pola umum di microservices), Anda kini memiliki sepuluh ribu goroutine yang tidur di latar belakang. Setiap goroutine memakan ~2 KB ruang stack dan menambah scheduling overhead. Saat container restart, goroutine-goroutine itu berlomba dengan tenggat shutdown. Saat timeout, mereka aktif pada momen yang salah dan membuat flapping.

Thread latar belakang ini menyelesaikan masalah yang tidak ada. Pemulihan tidak perlu dideteksi secara proaktif. Ia bisa dideteksi secara lazy, di jalur request.

Bagaimana lazy recovery bekerja

Alih-alih menggunakan timer yang mentransisikan breaker, simpan satu timestamp: momen breaker trip ke OPEN. Pada setiap request yang masuk, bandingkan now dengan timestamp itu ditambah timeout yang dikonfigurasi. Jika waktu yang cukup telah berlalu, izinkan satu probe untuk lewat. Jika probe berhasil, tutup breaker. Jika gagal, perbarui timestamp dan tetap di OPEN.

State machine tetap identik. Hanya trigger transisi yang berubah.

  • CLOSED: request lewat. Kegagalan menaikkan counter. Ketika counter mencapai threshold, secara atomic swap ke OPEN dan catat trippedAt.
  • OPEN: setiap request yang masuk memeriksa time.Now() > trippedAt + timeout. Jika false, fail fast. Jika true, secara atomic swap ke HALF-OPEN dan biarkan satu request ini lewat.
  • HALF-OPEN: tepat satu request sedang dalam penerbangan. Jika berhasil, swap ke CLOSED dan reset failure counter. Jika gagal, swap kembali ke OPEN dan perbarui trippedAt.

Tidak ada goroutine yang pernah bangun. Tidak ada timer yang dialokasikan. Breaker sepenuhnya pasif sampai request datang.

Implementasi yang berfungsi di Go

Berikut adalah circuit breaker zero-background yang lengkap. Ia hanya menggunakan sync/atomic untuk transisi state dan menyimpan timestamp trip sebagai counter nanosecond.

package breaker

import (
	"errors"
	"sync/atomic"
	"time"
)

type State int32

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

type CircuitBreaker struct {
	// state is accessed with atomic operations.
	state      int32
	failures   int32
	threshold  int32
	timeout    time.Duration
	trippedAt  int64 // nanoseconds since Unix epoch
}

func New(threshold int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold: int32(threshold),
		timeout:   timeout,
	}
}

func (cb *CircuitBreaker) State() State {
	return State(atomic.LoadInt32(&cb.state))
}

// Allow reports whether the current request may proceed.
// It returns a done function that must be called with the outcome.
func (cb *CircuitBreaker) Allow() (done func(success bool), err error) {
	switch State(atomic.LoadInt32(&cb.state)) {
	case StateClosed:
		return cb.trackClosed, nil

	case StateOpen:
		// Lazy recovery check: has the timeout elapsed?
		if time.Now().UnixNano()-atomic.LoadInt64(&cb.trippedAt) < int64(cb.timeout) {
			return nil, errors.New("circuit breaker is open")
		}
		// Race: multiple goroutines may see this simultaneously.
		// Only one wins the CAS to HALF-OPEN.
		if atomic.CompareAndSwapInt32(&cb.state, int32(StateOpen), int32(StateHalfOpen)) {
			return cb.trackHalfOpen, nil
		}
		// Another goroutine won the race; fail fast this request.
		return nil, errors.New("circuit breaker is open")

	case StateHalfOpen:
		// Only one probe at a time. Every other request fails fast.
		return nil, errors.New("circuit breaker is half-open")
	}

	return nil, errors.New("unknown circuit breaker state")
}

func (cb *CircuitBreaker) trackClosed(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		return
	}

	// Increment failures and trip if threshold reached.
	if atomic.AddInt32(&cb.failures, 1) >= cb.threshold {
		// Record the trip time before switching state so readers
		// never see OPEN with a stale trippedAt.
		atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
		atomic.StoreInt32(&cb.state, int32(StateOpen))
	}
}

func (cb *CircuitBreaker) trackHalfOpen(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		atomic.StoreInt32(&cb.state, int32(StateClosed))
		return
	}

	atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
	atomic.StoreInt32(&cb.state, int32(StateOpen))
}

Invariant kunci: trippedAt selalu ditulis sebelum state transisi ke OPEN. Reader di Allow() kemudian bisa membaca trippedAt dengan aman setelah melihat OPEN, mengetahui bahwa itu fresh. Di jalur kembali dari HALF-OPEN, kita memperbarui trippedAt sebelum kembali ke OPEN sehingga cooldown dimulai ulang dari nol.

Mengapa kebanyakan library tidak melakukan ini

Desain lazy memiliki satu kelemahan yang tampak: pemulihan hanya dideteksi ketika request datang. Jika layanan Anda tidak menerima traffic selama satu jam, breaker tetap OPEN selama satu jam.

Ini terdengar buruk. Sebenarnya tidak.

Jika tidak ada request, tidak ada yang perlu dilindungi. Circuit breaker ada untuk mencegah cascading failure saat ada traffic, bukan untuk memelihara health dashboard real-time. Ketika request berikutnya datang, pemeriksaan timeout berjalan dalam nanosecond dan probe langsung aktif. Latency pemulihan efektif dibatasi oleh max(timeout, time-between-requests).

Untuk layanan high-traffic, celah antar-request bisa diabaikan. Untuk layanan low-traffic, timeout tetap yang dominan. Timer latar belakang hampir tidak pernah memperbaiki waktu pemulihan nyata dalam praktiknya.

Alasan lain library menggunakan timer adalah historis. Pola circuit breaker dipopulerkan di lingkungan (Java dengan Hystrix, .NET dengan Polly) di mana satu instance breaker melindungi seluruh dependency layanan, bukan koneksi per-host. Satu thread latar belakang masih bisa diterima. Di sistem terdistribusi modern, di mana Anda mungkin memiliki satu breaker per upstream endpoint, asumsi itu tidak lagi berlaku.

Menguji race conditions

Loop CAS pada transisi OPEN ke HALF-OPEN adalah satu-satunya tempat di mana goroutine bersaing. Jika dua request datang secara bersamaan setelah timeout, hanya satu yang berlanjut sebagai probe. Yang lain fail fast dan mencoba lagi pada request berikutnya. Ini adalah perilaku yang benar. Anda tidak pernah menginginkan beberapa probe dalam penerbangan saat pemulihan, karena satu kegagalan di antara beberapa keberhasilan masih bisa membalikkan Anda kembali ke OPEN.

Pengujian mudah dilakukan karena tidak ada timer asynchronous. Anda bisa menulis unit test yang memanipulasi trippedAt secara langsung (atau menggunakan time wrapper) tanpa sleeping:

func TestLazyRecovery(t *testing.T) {
	cb := New(1, time.Minute)

	// Trip the breaker.
	done, _ := cb.Allow()
	done(false)

	if cb.State() != StateOpen {
		t.Fatal("expected OPEN")
	}

	// Simulate timeout by winding back trippedAt.
	atomic.StoreInt64(&cb.trippedAt, time.Now().Add(-2*time.Minute).UnixNano())

	done, err := cb.Allow()
	if err != nil {
		t.Fatalf("expected probe to be allowed: %v", err)
	}

	// Success closes the breaker.
	done(true)
	if cb.State() != StateClosed {
		t.Fatal("expected CLOSED after successful probe")
	}
}

Tidak ada time.Sleep. Tidak ada sync.WaitGroup untuk goroutine. Test ini deterministik karena implementasinya sinkron.

Apa yang kita korbankan

Ada satu kehilangan nyata: Anda tidak bisa dengan eager melakukan pre-warm breaker sebelum mengirim traffic. Jika Anda perlu memprobe dependency pada jadwal tetap (misalnya, setiap 5 detik) untuk menjaga connection pool tetap warm, Anda masih membutuhkan timer. Tapi timer itu milik connection pool atau health checker Anda, bukan circuit breaker. Breaker seharusnya melindungi pool. Ia seharusnya tidak mengelolanya.

Pisahkan concern-nya. Health checking memanaskan koneksi. Circuit breaking mencegah cascading overload. Ketika Anda menggabungkannya, Anda mendapatkan kompleksitas di kedua tempat.

Pola untuk bahasa lain

Struktur yang sama berfungsi di mana pun Anda memiliki atomic compare-and-swap dan monotonic clock. Di Rust dengan std::sync::atomic, di Java dengan AtomicIntegerFieldUpdater dan System.nanoTime(), di C++ dengan std::atomic dan enum kustom. Implementasinya di bawah seratus baris dalam setiap kasus.

Jika bahasa Anda tidak mengekspos CAS, sync.Mutex (atau setara) masih lebih murah daripada thread latar belakang. Mutex hanya dipegang selama nanosecond per request, dan hanya selama transisi state. Ia tidak pernah blocking untuk I/O atau tidur.

Cobalah

Implementasi lengkap di atas siap produksi sebagai titik awal. Tambahkan metrics, logging, dan adaptive threshold di atasnya. Tapi tinggalkan goroutine. Runtime scheduler Anda akan berterima kasih, test Anda akan berjalan lebih cepat, dan Anda akan berhenti bertanya-tanya mengapa container itu menolak untuk shutdown dengan bersih.