Degradasi Anggun

Timeout, retry dengan jitter, circuit breaker, dan bulkhead agar kegagalan tidak menular.

Daftar isi

Topik ini membahas degradasi anggun (graceful degradation), yaitu kemampuan sistem tetap melayani sebagian fungsinya ketika satu dependensi lambat atau mati, melalui timeout, retry dengan exponential backoff dan jitter, circuit breaker, serta bulkhead. Keempat mekanisme dibahas berurutan karena saling menopang, dan dua simulasi Python beserta satu demo interaktif memperlihatkan perilakunya dengan angka nyata. Setelah membacanya, kegagalan satu layanan di belakang tidak lagi menular ke seluruh jalur permintaan, dan pemanggil selalu punya langkah berikutnya yang terencana.

Kegagalan yang menular#

Kegagalan beruntun (cascading failure) adalah kegagalan yang merambat dari satu komponen ke komponen pemanggilnya. Layanan yang lambat membuat pemanggil menunggu tanpa batas, pemanggil yang menunggu menghabiskan thread dan koneksi, lalu layanan pemanggil ikut lambat kepada pemanggilnya. Dalam hitungan menit, satu dependensi yang macet melumpuhkan seluruh jalur permintaan, meskipun komponen lain berjalan normal. Perambatan semacam ini sulit dilihat dari metrik tunggal, karena setiap lapisan memperlihatkan gejala yang berbeda, mulai dari antrean yang memanjang sampai koneksi yang habis.

Degradasi anggun memilih melayani lebih sedikit daripada gagal total. Fitur sekunder dinonaktifkan, jawaban dari cache yang sedikit usang disajikan, dan permintaan yang tidak dapat dilayani ditolak secara cepat dengan kode status yang jelas. Kebalikannya adalah kegagalan total, yaitu seluruh layanan berhenti hanya karena satu bagian kecil macet. Keempat mekanisme pada topik ini adalah alat yang membuat pilihan degradasi tersebut otomatis dan terukur.

Batas waktu pada setiap panggilan#

Timeout yang sudah diperkenalkan sebelumnya menjadi garis pertama. Setiap panggilan jaringan harus diberi batas waktu, karena tanpa batas, pemanggil menunggu tanpa akhir dan sumber dayanya habis digunakan untuk menunggu. Pemanggil yang melewati batas waktu dapat melanjutkan ke langkah berikutnya, baik mencoba ulang, menjawab dari cache, maupun menolak permintaan, sehingga kendali selalu kembali ke tangannya. Permintaan yang menggantung tanpa batas juga tidak pernah keluar dari laporan, sehingga pengguna yang menunggu tidak mendapat jawaban maupun pesan error.

Nilai timeout ditetapkan per dependensi sesuai kecepatan lazimnya, bukan satu nilai untuk semuanya. Aturan tambahannya adalah timeout di bawah tenggat pemanggilnya sendiri, sehingga waktu tersisa masih cukup untuk bertindak setelah kegagalan terdeteksi. Dengan demikian anggaran waktu mengecil secara teratur sepanjang rantai panggilan, dari load balancer sampai layanan terdalam, dan tidak ada satu lapisan pun yang menunggu lebih lama daripada pihak yang memanggilnya. Nilai yang ditetapkan juga perlu ditinjau pada setiap perubahan perilaku dependensi, karena angka yang dahulu longgar dapat menjadi kendala setelah dependensi diperlambat oleh fitur baru.

Retry dengan exponential backoff dan jitter#

Retry menangani kegagalan sementara seperti gangguan jaringan sesaat atau permintaan yang ditolak sejenak. Exponential backoff adalah pola penundaan yang digandakan pada setiap percobaan, misalnya satu detik, dua detik, lalu empat detik, sehingga dependensi yang sedang pulih mendapat waktu bernapas. Retry diberi batas jumlah percobaan, dan percobaan ulang hanya aman pada operasi idempoten, sedangkan POST yang diulang dapat membuat dua catatan sekaligus.

Masalahnya muncul saat kegagalan menimpa banyak klien sekaligus. Tanpa pengacakan, seluruh klien yang gagal pada saat yang sama menghitung penundaan yang sama dan menyerbu dependensi pada detik yang persis sama, sehingga retry justru menjadi serangan buatan. Jitter yang sudah diperkenalkan pada topik Strategi Cache menjadi penangkalnya, yaitu penggeseran acak pada penundaan setiap klien. Skrip berikut membandingkan 1000 klien yang mencoba ulang setelah gagal bersamaan, dengan backoff dua detik.

python retry_jitter.py
import random

random.seed(42)
jumlah_klien = 1000
backoff = 2  # detik, penundaan percobaan ulang kedua

# Tanpa jitter: seluruh klien menunggu durasi yang sama,
# lalu mencoba ulang pada titik waktu yang sama.
tanpa_jitter = [backoff] * jumlah_klien

# Dengan jitter: penundaan diacak pada rentang 0 sampai backoff.
dengan_jitter = [random.uniform(0, backoff) for _ in range(jumlah_klien)]

serentak = sum(1 for t in tanpa_jitter if t == backoff)
sebelum_1_dtk = sum(1 for t in dengan_jitter if t < 1.0)

print(f"Tanpa jitter : {serentak} klien mencoba ulang pada detik ke-{backoff}")
print(f"Dengan jitter: {sebelum_1_dtk} klien mencoba ulang sebelum detik pertama")
text Keluaran
Tanpa jitter : 1000 klien mencoba ulang pada detik ke-2
Dengan jitter: 480 klien mencoba ulang sebelum detik pertama

Keluaran memperlihatkan kontras yang nyata. Tanpa jitter, seribu klien tiba serentak pada detik kedua, sehingga dependensi yang baru pulih langsung menerima beban yang membuatnya gagal semula. Dengan jitter, penundaan tersebar merata pada rentang dua detik, dan 480 klien sudah mencoba sebelum detik pertama, sedangkan sisanya menunggu pada titik yang berbeda-beda. Beban puncak yang tadinya seribu permintaan dalam satu detik menjadi ratusan permintaan yang menyebar, dan selisih itulah yang menentukan apakah pemulihan berhasil atau gagal lagi.

Circuit breaker#

Circuit breaker adalah pelindung yang menghitung kegagalan panggilan ke satu dependensi dan menolak permintaan secara cepat ketika kegagalan melampaui ambang. Tujuannya bukan menambah kegagalan, melainkan menghentikan penghamburan sumber daya kepada dependensi yang jelas-jelas sedang tidak sehat, sekaligus memberi waktu pemulihan tanpa tekanan tambahan. Mesin statusnya memuat tiga status, yaitu closed ketika permintaan diteruskan seperti biasa, open ketika permintaan ditolak sebelum dikirim, dan half-open ketika sejumlah kecil panggilan uji diperbolehkan untuk menilai pemulihan.

Skrip berikut menjalankan mesin status tersebut dengan ambang tiga kegagalan beruntun dan batas dua uji berhasil pada half-open. Delapan panggilan pertama gagal semua pada bagian awalnya, lalu jeda dingin dinyatakan selesai dan dua panggilan uji menyusul.

python circuit_breaker.py
class CircuitBreaker:
    """Mesin status circuit breaker: closed, open, half-open."""

    def __init__(self, batas_gagal=3, batas_uji=2):
        self.batas_gagal = batas_gagal
        self.batas_uji = batas_uji
        self.status = "closed"
        self.gagal = 0
        self.lulus = 0

    def panggil(self, berhasil):
        if self.status == "open":
            return "ditolak"
        if berhasil:
            self.gagal = 0
            if self.status == "half-open":
                self.lulus += 1
                if self.lulus >= self.batas_uji:
                    self.status = "closed"
                    self.lulus = 0
            return "sukses"
        self.gagal += 1
        if self.status == "half-open":
            self.status = "open"
            return "uji gagal"
        if self.gagal >= self.batas_gagal:
            self.status = "open"
        return "gagal"

    def jeda_dingin_selesai(self):
        """Pindah open ke half-open bila jeda dingin telah lewat."""
        if self.status == "open":
            self.status = "half-open"


cb = CircuitBreaker()
urutan = [False, False, False, True, True, True, True, True]
for berhasil in urutan:
    print(f"panggil({berhasil}) status={cb.status} -> {cb.panggil(berhasil)}")
print("jeda dingin selesai")
cb.jeda_dingin_selesai()
for berhasil in [True, True]:
    print(f"panggil({berhasil}) status={cb.status} -> {cb.panggil(berhasil)}")
text Keluaran
panggil(False) status=closed -> gagal
panggil(False) status=closed -> gagal
panggil(False) status=closed -> gagal
panggil(True) status=open -> ditolak
panggil(True) status=open -> ditolak
panggil(True) status=open -> ditolak
panggil(True) status=open -> ditolak
panggil(True) status=open -> ditolak
jeda dingin selesai
panggil(True) status=half-open -> sukses
panggil(True) status=half-open -> sukses

Keluaran menelusuri seluruh siklusnya. Tiga kegagalan beruntun pada status closed menghabiskan ambangnya, sehingga status berubah menjadi open. Lima panggilan yang berikutnya diberi label berhasil pun tetap ditolak, karena selama sirkuit open, isi panggilan tidak dievaluasi sama sekali dan pemanggil langsung menerima penolakan yang cepat. Jeda dingin memindahkan status ke half-open, dan dua uji yang berhasil menutup sirkuit kembali. Diagram berikut merangkum mesin statusnya beserta peristiwa yang memindahkan status dari satu keadaan ke keadaan lain.

ambang gagal terlampauiuji gagalCLOSEDpermintaan diteruskanHALF-OPENuji terbatasOPENpermintaan ditolakjeda dingin selesaidua uji berhasil
Mesin status circuit breaker dengan tiga status dan peristiwa perpindahannya

Demo interaktif berikut menerapkan mesin status yang sama dengan ambang tiga kegagalan beruntun dan batas dua uji berhasil. Tekan tombol Panggil (gagal) tiga kali untuk melihat closed berubah menjadi open, kemudian tekan Jeda dingin selesai untuk memasuki half-open. Uji yang gagal mengembalikan sirkuit ke open, sedangkan dua uji yang berhasil menutup sirkuit kembali, dan seluruh peristiwa tercatat pada daftar di bawah tombolnya.

Bulkhead#

Bulkhead adalah pemisahan sumber daya pemrosesan per dependensi, misalnya kolam koneksi dan kolam pekerja yang terpisah untuk basis data, layanan pembayaran, dan layanan pencarian. Nama pola ini diambil dari sekat ruang kapal yang membatasi air yang masuk, dan mekanismenya juga demikian, yaitu satu kolam yang habis tidak boleh menyedot sumber daya kolam lain. Tanpa pemisahan, dependensi yang macet menghabiskan seluruh thread dan koneksi bersama, sehingga jalur yang sehat ikut lumpuh.

Dalam praktiknya, bulkhead menetapkan batas jumlah permintaan yang sedang dikerjakan untuk setiap dependensi. Permintaan di atas batasnya ditolak atau diantrekan, sedangkan jalur lain tetap mendapat porsi sumber dayanya. Batas itu juga membuat kapasitas setiap dependensi terukur, karena jumlah maksimum permintaan yang menunggu dan yang dikerjakan menjadi angka yang diset, bukan kebetulan dari beban. Bulkhead melengkapi circuit breaker, karena breaker menutup arus kepada dependensi yang gagal, sedangkan bulkhead menjaga agar lonjakan menunggu pada satu dependensi tidak merembet ke dependensi lain sebelum breaker sempat beraksi.

Kesalahan umum#

  1. Mengaktifkan retry pada operasi non-idempoten. POST yang diulang setelah timeout dapat membuat dua catatan atau dua tagihan sekaligus, padahal kegagalan terjadi setelah pekerjaannya selesai. Perbaikannya, batasi retry pada operasi idempoten atau beri setiap permintaan pengenal unik sehingga duplikatnya dapat ditolak di sisi penerima.
  2. Menetapkan timeout yang lebih besar daripada tenggat pemanggilnya. Pemanggil sudah menyerah sebelum jawaban tiba, sehingga pekerjaan yang dikerjakan dependensi menjadi sia-sia dan hanya menambah beban. Perbaikannya, turunkan anggaran waktu sepanjang rantai panggilan dan tetapkan nilai timeout per dependensi.
  3. Menerapkan circuit breaker tanpa merancang jawaban penggantinya. Permintaan yang ditolak tetap harus dijawab, dan pemanggil yang tidak punya jalur alternatif akan mengubah penolakan cepat menjadi error yang sama besar. Perbaikannya, siapkan jawaban degradasi berupa cache, nilai bawaan, atau fitur yang dinonaktifkan sebelum breaker diterapkan.

Ringkasan#

  • Timeout mengembalikan kendali kepada pemanggil dalam waktu yang pasti, sedangkan retry dengan exponential backoff dan jitter menangani kegagalan sementara tanpa menyerbu dependensi secara serentak.
  • Circuit breaker menolak permintaan lebih cepat ketika kegagalan melampaui ambang, lalu menguji pemulihan secara terbatas melalui half-open sebelum menutup sirkuitnya kembali.
  • Bulkhead memisahkan kolam sumber daya per dependensi, sehingga lonjakan menunggu pada satu jalur tidak menghabiskan sumber daya jalur yang sehat.

Referensi#