Reliability pattern-ləri: failure-u dizayn etmək
Reliability pattern-ləri: failure-u dizayn etmək
Ch07 "sistem nə qədər etibarlı olmalıdır" sualına cavab verdi: SLO-lar, error budget, redundancy. Bu fəsil daha aşağı səviyyəli, amma gündəlik sualla məşğul olur: dependency xarab olanda sizin kodunuz nə edir? Monolith daxilində funksiya çağırışı ya işləyir, ya da exception atır. Şəbəkə üzərindən edilən çağırışın üçüncü, ən pis variantı da var: cavab gəlmir və siz bunun səbəbini bilmirsiniz.
Bu fəsil partial failure-u istisna kimi yox, normal iş rejimi kimi qəbul edir və ona qarşı istifadə olunan pattern-ləri izah edir. Ch02-nin dili ilə desək, bunların hamısı reliability və availability üçün tactic-lərdir. Hər birinin isə səhv konfiqurasiya olunanda özünəməxsus failure-u var.
Partial failure normal vəziyyətdir
1990-cı illərdə Sun Microsystems-də "distributed computing-in yanılmaları" adlı siyahı tərtib olunub: proqramçıların şəbəkə haqqında fərz etdiyi, amma doğru olmayan şeylər. Səkkiz bənddən üçü bu fəsil üçün xüsusilə vacibdir:
- Şəbəkə etibarlıdır. Deyil. Paket-lər itir, connection-lar qırılır, load balancer instance-ı rotasiyadan çıxarır.
- Latency sıfırdır. Hər çağırış vaxt aparır və bu vaxt sabit deyil. Orta latency-si 20 ms olan service-in p99-u 2 saniyə ola bilər.
- Topology dəyişmir. Instance-lar hər deploy-da, hər autoscaling hadisəsində dəyişir.
Buradan çıxan nəticə: distributed sistemdə nəsə həmişə xarabdır. Sual bütün sistemin işləyib-işləməməsi deyil, hansı hissənin hazırda pis olması və bunun yayılıb-yayılmamasıdır.
Ən təhlükəli failure komponentin ölməsi yox, yavaşlamasıdır. Ölü service dərhal "connection refused" qaytarır və çağıran tərəf başqa yol seçə bilir. Yavaş service isə thread-ləri, connection-ları və memory-ni tutub saxlayır. Onu çağıran service cavab gözləyərkən özü də yavaşlayır və beləcə cascading failure zəncir boyunca yuxarı qalxır. Yavaş, ölüdən pisdir. Bu fəsildəki pattern-lərin çoxu yavaşlığı sürətli və idarə olunan failure-a çevirmək üçündür.
Timeout: gözləməyin sərhədi
Hər şəbəkə çağırışının timeout-u olmalıdır. Bu, istisnasız qaydadır. Problem ondadır ki, bir çox kitabxananın default-u ya çox böyükdür, ya da ümumiyyətlə yoxdur: bəzi HTTP client-lər və database driver-ləri default olaraq sonsuz gözləyir.
Timeout-u təxminlə yox, dependency-nin real latency paylanmasına baxaraq seçmək lazımdır. Orta latency aldadıcıdır, timeout tail latency-yə, yəni p99 və ya p99.9-a əsaslanmalıdır. Dependency-nin p99-u 300 ms-dirsə, 400–500 ms-lik timeout normal request-lərin demək olar ki, hamısını keçirir və anormal olanları kəsir.
İkinci qayda: timeout-lar zəncir boyunca uyğunlaşdırılmalıdır. İstifadəçi 2 saniyədən sonra imtina edirsə, arxada 10 saniyə işləyən request boş yerə resurs yandırır. Bunun həlli deadline propagation-dır: ilk nöqtədə ümumi büdcə təyin olunur (məsələn, 2 saniyə) və hər növbəti çağırışa qalan vaxt ötürülür. gRPC bunu hazır verir, HTTP-də isə header ilə edilir. Büdcə bitibsə, service işə başlamadan imtina edir.
Trade-off: çox qısa timeout sağlam, amma bir az yavaş cavabları failure-a çevirir və lazımsız retry-lar yaradır. Çox uzun timeout isə yavaşlığın yayılmasına imkan verir.
Retry, backoff və jitter
Retry keçici xətalara qarşı ən sadə müdafiədir: connection qırıldı, instance restart oldu, leader seçimi gedir. Amma retry yalnız iki şərt ödənəndə təhlükəsizdir:
- Xəta keçicidir. 503, connection reset, timeout keçici ola bilər. 400, 401 və validation xətaları isə yox: onları təkrarlamaq eyni nəticəni verəcək.
- Əməliyyat idempotent-dir. Timeout alan ödəniş request-i server-də artıq icra olunmuş ola bilər. Idempotency key olmadan onu təkrarlamaq müştəridən iki dəfə pul çıxmaq deməkdir (idempotency key-lərə API dizaynı fəslində baxırıq).
Retry-ları dərhal, ardıcıl etmək xarab service-ə ən çətin anında daha çox yük gətirir. Ona görə exponential backoff istifadə olunur: hər cəhddən əvvəl gözləmə müddəti ikiqat artır (100 ms, 200 ms, 400 ms...). Amma min client eyni anda xəta alıbsa, eyni anda da retry edəcək. Jitter, yəni gözləmə müddətinə təsadüfi komponent əlavə etmək, bu sinxron dalğaları zamana yayır. Jitter-siz backoff yarımçıq həlldir.
Ən gizli təhlükə retry-ların qatlar üzrə vurulmasıdır:
Request client → API gateway → service A → service B yolu ilə gedir. Hər qat xəta alanda 3 cəhd edir (1 əsas + 2 retry). Service B yıxılanda A ona 3 dəfə müraciət edir. Gateway A-ya 3 dəfə müraciət edir və hər biri B-yə 3 çağırış yaradır. Client də gateway-ə 3 dəfə müraciət edir. Nəticədə bir istifadəçi hərəkəti B-yə 3 × 3 × 3 = 27 request göndərir. Saniyədə 1 000 request alan B indi 27 000 request alır, özü də məhz bərpa olunmağa çalışdığı anda. Bu, retry storm-dur və outage-i uzadan klassik mexanizmdir.
Həll yolu retry-ı yalnız bir qatda etmək (adətən xətanın mənbəyinə ən yaxın olanda), digər qatlarda isə xətanı dərhal yuxarı ötürməkdir. Əlavə qoruma retry budget-dir: client retry-ların sayını ümumi request-lərin müəyyən faizi ilə (məsələn, 10%) məhdudlaşdırır. Dependency sağlamdırsa, nadir retry-lar sərbəst keçir; hamı xəta alırsa, budget tez bitir.
Circuit breaker və bulkhead
Retry "bir də cəhd et" deyir. Bəzən düzgün cavab "cəhd etmə" olur. Circuit breaker dependency-yə edilən çağırışları izləyir və üç vəziyyətdən birində olur:
- Closed: normal rejim. Çağırışlar keçir, xətalar sayılır.
- Open: xəta faizi həddi keçib (məsələn, son 10 saniyədə 50%). Çağırışlar dependency-yə ümumiyyətlə getmir, dərhal xəta qaytarılır.
- Half-open: müəyyən vaxtdan sonra az sayda sınaq request-i buraxılır. Uğurlu olsalar, breaker bağlanır, olmasalar, yenidən açılır.
Circuit breaker-in əsas faydası fail fast-dır: çağıran service timeout gözləmək əvəzinə millisaniyələr içində cavab alır və resurslarını tutub saxlamır. Amma açıq breaker yalnız fallback ilə birlikdə həqiqətən faydalıdır: xəta əvəzinə nə qaytaracağınızı bilməlisiniz. Fallback-siz breaker sadəcə daha sürətli xətadır.
Trade-off: hədlər səhv seçiləndə breaker ya heç vaxt açılmır, ya da kiçik dalğalanmada açılıb sağlam dependency-ni kəsir. Az traffic olan endpoint-lərdə statistika etibarsızdır: 2 request-dən biri xəta verdisə, bu artıq 50%-dir.
Bulkhead gəmi dizaynından gəlir: gövdə su keçirməyən bölmələrə bölünür ki, bir deşik bütün gəmini batırmasın. Software-də bu, resurs izolyasiyası deməkdir. Bütün dependency-lər bir ortaq thread pool və ya connection pool istifadə edirsə, yavaş recommendation service bütün thread-ləri tutur və checkout da cavab verə bilmir. Hər dependency-yə ayrıca pool və ya concurrency limiti versəniz, recommendation-lar üçün ayrılmış 20 thread dolanda yalnız recommendation-lar xəta verir.
Bulkhead-in qiyməti səmərəlilikdir: bir bölməyə ayrılmış resurs boş qalanda başqası onu istifadə edə bilmir. Böyük miqyasda eyni ideya ch07-dəki cell-lərdir.
Yük altında: rate limiting, load shedding və graceful degradation
Rate limiting client-in müəyyən müddətdə göndərə biləcəyi request sayını məhdudlaşdırır. Onun iki rolu var: reliability (bir client-in və ya səhv yazılmış cron job-un hamının resursunu yeməsinin qarşısını almaq) və abuse-dan qorunma (brute force, scraping, DoS). Ən çox yayılmış iki alqoritm:
- Token bucket: bucket sabit sürətlə token-larla dolur, hər request bir token xərcləyir. Qısa burst-lara icazə verir, orta sürəti məhdudlaşdırır.
- Leaky bucket: request-lər queue-ya girir və sabit sürətlə emal olunur. Çıxışı hamarlayır, burst-lara icazə vermir.
Limit aşılanda düzgün cavab 429 Too Many Requests status-u və Retry-After header-idir. Bu, təsadüfi retry-ları idarə olunan gözləməyə çevirir.
Rate limiting client səviyyəsində işləyir. Load shedding isə server-in özünü qorumasıdır: ümumi yük tutumu aşanda service bəzi request-ləri dərhal rədd edir ki, qalanlarına normal xidmət göstərə bilsin. Bütün request-lərə yavaş cavab vermək 80%-ə vaxtında cavab verib 20%-i rədd etməkdən pisdir. Əsas sual nəyi atmaqdır. Checkout request-ləri analytics event-lərindən, istifadəçi request-ləri isə background job-lardan əvvəl qorunmalıdır.
Graceful degradation dependency xarab olanda funksionallığın bir hissəsini itirib əsasını saxlamaq deməkdir. Tipik fallback-lər:
- cache-dəki köhnə data ("qiymətlər 5 dəqiqə əvvəlki vəziyyətə görədir");
- default cavab (personal tövsiyələr əvəzinə populyar məhsullar);
- feature-u gizlətmək (rəylər bloku sadəcə görünmür).
Ch07-də dediyimiz kimi, tövsiyələr bloku xarab olanda səhifə onsuz render olunursa, onun availability hədəfi checkout-unkundan aşağı ola bilər. Amma fallback özü də kod yoludur: nadir işləyir, nadir test olunur və ən lazım olan anda səhv çıxa bilər. Onu chaos testləri və ya feature flag vasitəsilə müntəzəm işə salmaq lazımdır. Fallback başqa bir zəif dependency-yə müraciət edirsə, o fallback deyil, ikinci failure nöqtəsidir.
Queue-lar: backpressure və dead letter queue
Asinxron sistemlərdə failure fərqli görünür. Consumer yavaşlayanda xəta yox, böyüyən queue görürsünüz. Sonsuz queue problemi gizlədir: memory dolana qədər hər şey qaydasında görünür, sonra isə latency saatlarla ölçülür. Backpressure aşağı axının yuxarı axına "yavaşla" deməsidir: bounded queue dolanda producer bloklanır, xəta alır və ya sürətini azaldır. Bu, problemi gizlətmək əvəzinə görünən edir. Queue-ya yuxarı hədd qoymaq, timeout qoymaq kimi, qaydadır.
İkinci asinxron problem poison message-dır: heç vaxt uğurla emal oluna bilməyən message (səhv format, mövcud olmayan entity-yə istinad, consumer-dəki bug). At-least-once delivery zamanı broker onu yenidən çatdırır, consumer yenidən yıxılır və queue-nun qalan hissəsi bu tək message-in arxasında ilişib qalır. Həll: müəyyən sayda cəhddən sonra message-i dead letter queue-ya (DLQ) köçürmək. Əsas axın davam edir, problemli message isə itmir.
DLQ zibil qutusu deyil. Onun iki öhdəliyi var: monitoring (DLQ-da message sayı sıfırdan fərqlidirsə, kimsə xəbər tutmalıdır) və replay (bug düzəldildikdən sonra message-ləri əsas queue-ya qaytarmaq üçün alət). Heç kimin baxmadığı DLQ səssiz data itkisidir.
Pattern-lər birlikdə
Pattern-lər tək-tək yox, request yolu boyunca qatlar şəklində işləyir:
client → [timeout, retry + backoff + jitter]
→ API gateway [rate limiting, load shedding]
→ service A [deadline, circuit breaker, bulkhead, fallback]
→ service B [load shedding]
→ queue [backpressure] → consumer [retry, DLQ]
Hər pattern bir problemi həll edir və səhv konfiqurasiya olunanda öz problemini yaradır:
| Pattern | Qoruduğu problem | Səhv konfiqurasiya olunanda |
|---|---|---|
| Timeout | Sonsuz gözləmə, resursların tutulması | Çox qısa: sağlam request-lər kəsilir. Çox uzun: yavaşlıq yayılır |
| Retry + backoff | Keçici xətalar | Hər qatda retry: retry storm. Idempotent olmayan əməliyyatda: dublikat |
| Circuit breaker | Xarab dependency-yə israf olunan çağırışlar | Həssas hədd: sağlam dependency kəsilir. Fallback-siz: sadəcə daha sürətli xəta |
| Bulkhead | Bir dependency-nin ortaq resursları tutması | Çox kiçik pool: normal yükdə xəta. Ortaq pool: izolyasiya yoxdur |
| Rate limiting | Bir client-in hamını boğması, abuse | Çox aşağı limit: legitim istifadəçilər bloklanır |
| Load shedding | Overload zamanı hamıya yavaş cavab | Prioritet yoxdur: vacib traffic atılır |
| Backpressure | Queue-ların sonsuz böyüməsi | Limitsiz queue: problem saatlarla gizli qalır |
| DLQ | Poison message axını bloklayır | Monitor olunmur: səssiz data itkisi |
| Graceful degradation | Kiçik dependency-nin bütün feature-u yıxması | Test olunmayan fallback: ən lazım anda işləmir |
Bu pattern-lərin çoxunu service mesh və ya API gateway səviyyəsində kod yazmadan konfiqurasiya etmək olar. Bu rahatdır, amma riskli də ola bilər: retry-lar həm kitabxanada, həm də mesh-də aktiv olanda yuxarıdakı 27 qat amplification siz bilmədən yaranır. Hansı qatın nəyə cavabdeh olduğu ch05-dəki mənada ADR-ə layiq qərardır. Pattern-lərin həqiqətən işlədiyini isə yalnız ölçəndə biləcəksiniz; buna observability fəslində qayıdacağıq.
Hazırkı sisteminiz haqqında suallar
- Ən vacib request yolunuzdakı hər şəbəkə çağırışının timeout-u nə qədərdir? Bunu konfiqurasiyada göstərə bilərsinizmi, yoxsa kitabxananın default-una güvənirsiniz?
- Retry hansı qatlarda baş verir: client-də, gateway-də, service mesh-də, kodda? Ən pis halda bir istifadəçi hərəkəti ən dərindəki dependency-yə neçə request yaradır?
- Ən az vacib dependency-niz (tövsiyələr, analytics) 10 saniyə cavab verməsə, checkout-a nə olur? Onlar ortaq thread və ya connection pool istifadə edirmi?
- DLQ-larınızda hazırda neçə message var və bunu kim bilir?