Проектирование fault-tolerant систем на Go

от автора

Привет, Хабр!

Fault-tolerant системы — это те, которые способны продолжать функционировать даже в условиях частичных сбоев или неисправностей. Основная фича таких систем заключается в том, чтобы обеспечить непрерывность работы приложения и безопасность данных даже при возникновении ошибок или непредвиденных ситуаций. Это достигается за счет ряда архитектурных и программных решений, направленных на предотвращение полного отказа системы при возникновении отдельных сбоев.

Go благодаря своей простоте, производительности и, что наиболее важно, поддержке конкурентности на уровне языка, становится идеальным выбором для создания fault-tolerant систем.

Кратко про Fault Tolerance

Основа отказоустойчивости – это result, err := someFunction() if err != nil { // Обработка ошибки }

Go позволяет создавать пользовательские типы ошибок, что дает больше контроля над обработкой специфических сценариев ошибок. В Go есть встроенный интерфейс error, который может быть реализован любым типом.

Паника в Go – это аналог исключения в других языках, но используется в более ограниченных случаях. Паника обычно указывает на ошибку программиста или на невозможность продолжить выполнение программы.

Паника может быть вызвана явно с помощью функции panic(). Это приведет к немедленному прекращению выполнения текущей функции и началу «размотки стека» (unwinding the stack):

if someCondition {     panic("something went wrong") }

Панику можно перехватить и обработать с помощью функции recover(), которая должна быть вызвана внутри отложенной (deferred) функции:

defer func() {     if r := recover(); r != nil {         // Обработка паники     } }()

Встроенные функции и пакеты

Встроенные функции

Функция defer используется для гарантии выполнения определенного кода в конце функции, независимо от того, каким образом функция завершается (нормально или из-за ошибки). Это полезно для освобождения ресурсов:

package main  import (     "fmt"     "os" )  func main() {     f, err := os.Open("filename.txt")     if err != nil {         panic(err)     }     // Этот код будет выполнен в конце функции main, аже если произойдет ошибка при чтении файла.z     // даже если произойдет ошибка при чтении файла.     defer f.Close()      // Дальнейшие операции с файлом }

panic и recover используются для обработки неожиданных ошибок и исключений. panic вызывает немедленное прекращение выполнения функции, а recover позволяет восстановиться после паники:

package main  import "fmt"  func mayPanic() {     panic("a problem occurred") }  func main() {     defer func() {         if r := recover(); r != nil {             fmt.Println("Recovered. Error:\n", r)         }     }()      mayPanic()      fmt.Println("After mayPanic()") }

Функция mayPanic вызывает панику. В функции main, блок defer с функцией recover перехватывает эту панику, позволяя программе продолжить выполнение после функции mayPanic. Без recover программа бы завершилась сразу после паники, не выполнив код, следующий за вызовом mayPanic.

Пакеты

Пакет net/http предоставляет HTTP клиент и сервер. Он включает в себя механизмы для управления таймаутами и настройки поведения клиента и сервера:

package main  import (     "fmt"     "net/http"     "time" )  func handler(w http.ResponseWriter, r *http.Request) {     fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:]) }  func main() {     server := &http.Server{         Addr:         ":8080",         Handler:      http.HandlerFunc(handler),         ReadTimeout:  10 * time.Second,         WriteTimeout: 10 * time.Second,     }      fmt.Println("Starting server at port 8080")     server.ListenAndServe() }

Пакет context используется для управления жизненным циклом запросов, включая отмену операций:

package main  import (     "context"     "fmt"     "time" )  func operation(ctx context.Context) {     select {     case <-time.After(500 * time.Millisecond):         fmt.Println("Operation done")     case <-ctx.Done():         fmt.Println("Operation cancelled")     } }  func main() {     ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)     defer cancel()      go operation(ctx)      time.Sleep(200 * time.Millisecond) }

(sync.Mutex) и атомарные операции (sync/atomic) предоставляют примитивы для синхронизации,. Они необходимы для безопасной работы с общими ресурсами в многопоточных приложениях:

package main  import (     "fmt"     "sync"     "sync/atomic" )  func main() {     var count int32     var wg sync.WaitGroup     for i := 0; i < 10; i++ {         wg.Add(1)         go func() {             defer wg.Done()             atomic.AddInt32(&count, 1)         }()     }     wg.Wait()     fmt.Println("Count:", count) }

io и bufio пакеты для работы с вводом-выводом, которые включают утилиты для буферизации и потоковой обработки данных:

package main  import (     "bufio"     "fmt"     "os" )  func main() {     file, err := os.Open("example.txt")     if err != nil {         panic(err)     }     defer file.Close()      scanner := bufio.NewScanner(file)     for scanner.Scan() {         fmt.Println(scanner.Text())     }      if err := scanner.Err(); err != nil {         panic(err)     } }

Пакет time предоставляет функциональность для работы со временем и таймерами:

package main  import (     "fmt"     "time" )  func main() {     timer := time.NewTimer(2 * time.Second)     <-timer.C     fmt.Println("Timer expired") }

Паттерны

Retry и backoff

Exponential backoff – это стратегия, при которой время между повторными попытками увеличивается экспоненциально. Помогает избежать чрезмерной нагрузки на систему при повторных попытках:

package main  import (     "fmt"     "math/rand"     "time" )  // Функция для имитации операции, которая может завершиться неудачей func operation() error {     if rand.Float32() < 0.5 {         return fmt.Errorf("temporary error")     }     fmt.Println("Operation successful")     return nil }  // Retry выполняет операцию с повторными попытками и экспоненциальным отступлением func Retry(attempts int, sleep time.Duration, function func() error) error {     for i := 0; ; i++ {         err := function()         if err == nil {             return nil         }          if i >= (attempts - 1) {             return fmt.Errorf("after %d attempts, last error: %s", attempts, err)         }          time.Sleep(sleep)         sleep = sleep * 2 // Экспоненциальное увеличение времени ожидания     } }  func main() {     rand.Seed(time.Now().UnixNano())     err := Retry(5, 1*time.Second, operation)     if err != nil {         fmt.Println("Operation failed:", err)     } }

Retry принимает количество попыток, начальное время ожидания и функцию, которую необходимо выполнить. Если операция завершается ошибкой, время ожидания увеличивается в два раза, и операция повторяется. Это продолжается до тех пор, пока операция не завершится успешно или не будет достигнуто максимальное количество попыток.

Часто используется некоторая форма рандомизации времени ожидания, чтобы избежать ситуации, когда множество клиентов одновременно повторяют запросы.

Необходимо устанавливать разумные ограничения на количество попыток и максимальное время ожидания, чтобы избежать бесконечных циклов или чрезмерной нагрузки на систему.

Существует также готовая библиотека, к примеру github.com/cenkalti/backoff.

Circuit breaker

Circuit Breaker используется для предотвращения повторения вызовов, которые скорее всего потерпят неудачу, тем самым позволяя временным проблемам в системе «исправиться». В микросервисах он прям топ.

В Go нет встроенной поддержки паттерна Circuit Breaker, но его можно реализовать вручную:

package main  import (     "errors"     "fmt"     "sync"     "time" )  // Состояния Circuit Breaker const (     StateClosed   = "closed"     StateOpen     = "open"     StateHalfOpen = "half-open" )  // CircuitBreaker структура type CircuitBreaker struct {     state          string     failureCount   int     maxFailures    int     resetTimeout   time.Duration     halfOpenTimer  *time.Timer     halfOpenMutex  sync.Mutex     onStateChange  func(string) }  // NewCircuitBreaker создает новый Circuit Breaker func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker {     return &CircuitBreaker{         state:        StateClosed,         maxFailures:  maxFailures,         resetTimeout: resetTimeout,     } }  // Call выполняет операцию через Circuit Breaker func (cb *CircuitBreaker) Call(operation func() error) error {     switch cb.state {     case StateOpen:         return errors.New("circuit breaker is open")     case StateHalfOpen, StateClosed:         err := operation()         if err != nil {             cb.recordFailure()             return err         }         cb.reset()         return nil     }     return nil }  // recordFailure обновляет счетчик неудач и переключает состояние при необходимости func (cb *CircuitBreaker) recordFailure() {     cb.failureCount++     if cb.failureCount >= cb.maxFailures {         cb.transitionTo(StateOpen)         cb.halfOpenTimer = time.AfterFunc(cb.resetTimeout, func() {             cb.transitionTo(StateHalfOpen)         })     } }  // reset сбрасывает счетчик неудач и переключает состояние на закрытое func (cb *CircuitBreaker) reset() {     cb.failureCount = 0     cb.transitionTo(StateClosed) }  // transitionTo переключает состояние Circuit Breaker func (cb *CircuitBreaker) transitionTo(state string) {     cb.state = state     if cb.onStateChange != nil {         cb.onStateChange(state)     } }  func main() {     // Пример использования Circuit Breaker     cb := NewCircuitBreaker(3, 5*time.Second)     cb.onStateChange = func(state string) {         fmt.Println("Circuit Breaker State:", state)     }      for i := 0; i < 10; i++ {         err := cb.Call(func() error {             // Здесь должна быть логика вызова внешнего сервиса             return errors.New("service error")         })         if err != nil {             fmt.Println("Operation failed:", err)         }         time.Sleep(1 * time.Second)     } }

Структура Circuit Breaker включает в себя состояние, счетчик неудач, максимальное количество неудач, таймаут для сброса и таймер для перехода в полуоткрытое состояние.

Call выполняет операцию через Circuit Breaker. Если Circuit Breaker находится в открытом состоянии, операция не выполняется. В полуоткрытом и закрытом состояниях операция выполняется, и в случае ошибки учитывается сбой.

recordFailure и reset управляют счетчиком неудач и состоянием Circuit Breaker.

При достижении максимального количества неудач Circuit Breaker переходит в открытое состояние. После истечения таймаута сброса он переходит в полуоткрытое состояние, где следующая операция определит, должен ли он вернуться в закрытое состояние или остаться открытым.

Timeout и deadline

Go предоставляет несколько механизмов для управления таймаутами и дедлайнами, включая использование контекстов (context) и таймеров (time).

Для простых случаев, когда нужно просто ограничить время выполнения определенной операции, можно использовать таймеры из пакета time:

package main  import (     "fmt"     "time" )  func main() {     timeout := 2 * time.Second     timer := time.NewTimer(timeout)      go func() {         // Длительная операция         time.Sleep(3 * time.Second)         if !timer.Stop() {             fmt.Println("Operation timed out")         } else {             fmt.Println("Operation completed")         }     }()      <-timer.C     fmt.Println("Timeout reached, operation aborted") }

В примере мы запускаем длительную операцию в горутине и устанавливаем таймер на 2 секунды. Если операция не завершается в течение этого времени, программа выводит сообщение о таймауте.

Пакет context в Go предоставляет более гибок, особенно в контексте сетевых запросов и других операций, которые могут быть отменены:

package main  import (     "context"     "fmt"     "time" )  func operation(ctx context.Context) {     select {     case <-time.After(3 * time.Second): // Длительная операция         fmt.Println("Operation completed")     case <-ctx.Done():         fmt.Println("Operation aborted:", ctx.Err())     } }  func main() {     ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)     defer cancel()      go operation(ctx)      // Дождаться завершения операции или таймаута     select {     case <-ctx.Done():         fmt.Println("Main: ", ctx.Err())     } }

Cоздаем контекст с таймаутом и передаем его в функцию operation. Если операция не завершается до истечения таймаута, контекст автоматически отменяется, и функция operation получает уведомление об этом через канал ctx.Done().

Использование контекстов (context) в Go для управления жизненным циклом операций является мощным механизмом, который позволяет обеспечить эффективное управление ресурсами, особенно в распределенных системах и при работе с асинхронными операциями. Контексты в Go предоставляют способ передачи данных о состоянии, таймаутах и отмене операций между различными частями программы.

Использование контекстов для управления жизненным циклом операций

Контекст создается с помощью функций context.Background(), context.TODO(), context.WithCancel(), context.WithDeadline(), context.WithTimeout().

Контекст передается в функции и методы, которые должны учитывать его состояние (например, для отмены операций). Контекст может быть отменен в любой момент, что должно привести к прекращению всех операций, связанных с этим контекстом.

Создание и отмена контекста может выглядеть так:

package main  import (     "context"     "fmt"     "time" )  func operation(ctx context.Context, duration time.Duration) {     select {     case <-time.After(duration):         fmt.Println("Operation completed")     case <-ctx.Done():         fmt.Println("Operation aborted:", ctx.Err())     } }  func main() {     // Создание контекста с таймаутом     ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)     defer cancel()      // Запуск операции с контекстом     go operation(ctx, 100*time.Millisecond)      // Дождаться завершения операции или таймаута     select {     case <-ctx.Done():         fmt.Println("Main: ", ctx.Err())     } }

operation принимает контекст и выполняет некую операцию. Если операция не завершается до истечения таймаута контекста, она прерывается.

Контексты часто используются для пропагации информации в глубоко вложенных вызовах функций:

package main  import (     "context"     "fmt"     "time" )  func operation1(ctx context.Context) {     // Пропагация контекста в другую функцию     operation2(ctx) }  func operation2(ctx context.Context) {     select {     case <-time.After(100 * time.Millisecond):         fmt.Println("Operation 2 completed")     case <-ctx.Done():         fmt.Println("Operation 2 aborted:", ctx.Err())     } }  func main() {     ctx, cancel := context.WithCancel(context.Background())      go operation1(ctx)      // Отмена контекста через 50 мс     time.Sleep(50 * time.Millisecond)     cancel()      // Дать время для завершения операций     time.Sleep(100 * time.Millisecond) }

operation1 вызывает operation2, передавая ей тот же контекст. Если контекст отменяется, обе операции получают уведомление об этом и могут корректно завершить свою работу.


Напомню, что у моих друзей из OTUS есть целый ряд практических курсов про архитектуру приложений, где вы можете изучить не только Fault-tolerant системы, но и много других полезных систем и инструментов. Заходите в каталог и выбирайте подходящее направление.


ссылка на оригинал статьи https://habr.com/ru/articles/781964/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *