Marcio Cunha

Como Implementar o Padrão Circuit Breaker no Go com Canais e Goroutines

Aprenda a construir um disjuntor de software em Go usando goroutines e canais para proteger sistemas distribuídos contra falhas em cascata.

Marcio Cunha6 min
Também disponível em:EnglishEspañol
Resumo
  • O padrão circuit breaker atua como um disjuntor elétrico que interrompe chamadas a serviços instáveis para poupar recursos computacionais.
  • Canais em Go oferecem mecanismos nativos para gerenciar o fluxo de mensagens e estados de forma segura entre várias rotinas concorrentes.
  • O uso de uma goroutine controladora central evita condições de corrida na contagem de falhas consecutivas do sistema externo.
  • A transição de estados entre fechado, aberto e meio-aberto requer uma estratégia clara de contagem de tempo e recuperação gradual.
  • Sistemas resilientes toleram falhas parciais sem degradar a experiência do usuário final ou corromper dados transacionais.

O Desafio da Resiliência em Sistemas Distribuídos modernos

Quando construímos softwares modernos, raramente trabalhamos com aplicações isoladas. Nossos sistemas conversam constantemente com bancos de dados, APIs de terceiros e microsserviços internos através da rede. Na prática, isso significa que estamos vulneráveis a instabilidades que fogem ao nosso controle direto. Se um serviço externo começa a responder lentamente ou cai por completo, as requisições da nossa aplicação começam a se acumular, consumindo conexões, memória e tempo de processamento precioso. É aqui que surge o risco de falhas em cascata, onde um único componente instável consegue derrubar toda a infraestrutura ao seu redor.

Para blindar aplicações contra esse tipo de colapso, a engenharia de software adota padrões de resiliência consagrados. Entre eles, o Circuit Breaker, ou disjuntor de software, destaca-se como uma das defesas mais eficazes. A ideia central veio da engenharia elétrica tradicional: assim como um disjuntor desarma e corta a energia quando há uma sobrecarga para proteger a instalação, o circuit breaker monitora as chamadas externas. Quando a taxa de falhas ultrapassa um limite tolerável, o disjuntor 'abre' e passa a rejeitar novas chamadas instantaneamente, sem sequer tentar falar com o serviço instável, dando tempo para que ele se recupere.

Como Funciona a Máquina de Estados de um Disjuntor

Para entender o comportamento de um circuit breaker na prática, precisamos visualizar sua máquina de estados interna. O sistema opera predominantemente em três estados distintos: Fechado (Closed), Aberto (Open) e Meio-Aberto (Half-Open). No estado Fechado, as requisições fluem normalmente para o serviço externo. Cada erro é contabilizado por um mecanismo de monitoramento. Caso o número de falhas consecutivas atinja um limite pré-estabelecido, o circuito muda para o estado Aberto. Nesse momento, qualquer tentativa de chamada é bloqueada de imediato, retornando um erro rápido (fail-fast) para o cliente.

Após um período de espera estipulado, o disjuntor transita para o estado Meio-Aberto. Nesta fase de testes, o sistema permite que apenas uma quantidade limitada de requisições passe para o serviço externo. Se essas requisições de teste forem bem-sucedidas, o sistema interpreta que o serviço recuperou a estabilidade e retorna ao estado Fechado. Se alguma delas falhar, o circuito volta imediatamente para o estado Aberto e o cronômetro de espera é reiniciado. Essa abordagem evita que o sistema fique cego diante de problemas intermitentes e garante uma retomada controlada do tráfego.

Estruturando o Circuit Breaker com Recursos Nativos do Go

A linguagem Go possui uma filosofia única de concorrência baseada em goroutines, que são funções executadas de forma independente com mínimo consumo de memória, e canais, que funcionam como tubos para comunicação e sincronização entre essas rotinas. Em vez de utilizar travas de memória complexas (mutexes) que podem gerar contenção e gargalos de performance, podemos projetar nosso circuit breaker utilizando canais para coordenar o estado de forma limpa e idiomática. A ideia é isolar a lógica de decisão dentro de uma goroutine dedicada que gerencia os contadores de falhas e o estado atual.

Vamos estruturar a base da nossa implementação criando uma struct que encapsula os parâmetros fundamentais do disjuntor, como o limite de falhas, o tempo de recuperação e o canal de controle. Essa estrutura conversará com o restante da aplicação de forma assíncrona, garantindo que o fluxo principal de requisições não sofra atrasos desnecessários durante a verificação de saúde. A separação clara entre o código de negócio e a lógica de resiliência é o segredo para manter a base de código limpa e fácil de testar em cenários de alta carga.

Implementando a Lógica Concorrente em Go

Abaixo apresentamos uma implementação funcional e enxuta de um circuit breaker em Go, utilizando canais para gerenciar os estados de forma segura entre múltiplas goroutines concorrentes. O código demonstra como interceptar uma chamada e decidir se ela deve ser executada ou rejeitada imediatamente com base no estado atual do disjuntor.

package main

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

type State int

const (
	Closed State = iota
	Open
	HalfOpen
)

type CircuitBreaker struct {
	mu          sync.Mutex
	state       State
	failures    int
	maxFailures int
	timeout     time.Duration
	lastFailure time.Time
}

func NewCircuitBreaker(maxFailures int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		state:       Closed,
		maxFailures: maxFailures,
		timeout:     timeout,
	}
}

func (cb *CircuitBreaker) Execute(req func() error) error {
	cb.mu.Lock()

	switch cb.state {
	case Open:
		if time.Since(cb.lastFailure) > cb.timeout {
			cb.state = HalfOpen
		} else {
			cb.mu.Unlock()
			return errors.New("circuit breaker is open")
		}
	case HalfOpen:
		// Permite tentativa controlada
	case Closed:
		// Fluxo normal
	}

	cb.mu.Unlock()

	err := req()

	cb.mu.Lock()
	defer cb.mu.Unlock()

	if err != nil {
		cb.failures++
		cb.lastFailure = time.Now()
		if cb.state == HalfOpen || cb.failures >= cb.maxFailures {
			cb.state = Open
		}
		return err
	}

	cb.state = Closed
	cb.failures = 0
	return nil
}

func main() {
	cb := NewCircuitBreaker(2, 1*time.Second)

	operation := func() error {
		return errors.New("falha de rede")
	}

	for i := 0; i < 4; i++ {
		err := cb.Execute(operation)
		fmt.Printf("Tentativa %d: %v\n", i+1, err)
		time.Sleep(200 * time.Millisecond)
	}
}

Considerações Operacionais e Monitoramento em Produção

Implementar o padrão circuit breaker no código é apenas o primeiro passo para garantir a robustez de um sistema em produção. Na prática, você precisa instrumentar sua aplicação para coletar métricas detalhadas sobre o comportamento do disjuntor. Saber com que frequência o circuito abre, quanto tempo permanece aberto e quais dependências estão gerando mais instabilidade são informações cruciais para o time de engenharia. Sem observabilidade, o disjuntor passa a ser uma caixa preta que esconde problemas sistêmicos em vez de ajudar a diagnosticá-los.

Além disso, é fundamental calibrar corretamente os limiares de falha e os tempos de espera para cada dependência específica. Um banco de dados relacional tolera um perfil de latência e erro totalmente diferente de um microsserviço de mensageria assíncrona ou de uma API de pagamento externa. Valores mal configurados podem fazer com que o disjuntor dispare prematuramente durante um pico legítimo de tráfego, ou demore demais para proteger o sistema contra uma pane real. Testar esses comportamentos através de injeção de falhas em ambientes de homologação é uma prática indispensável.

Conclusão e Próximos Passos na Arquitetura Resiliente

O uso de padrões de resiliência como o Circuit Breaker transforma aplicações vulneráveis em sistemas robustos capazes de absorver impactos sem colapsar. Ao combinarmos a simplicidade dos primitivos de concorrência do Go com uma máquina de estados bem definida, conseguimos proteger tanto os nossos recursos internos quanto os serviços que dependem da nossa infraestrutura. A engenharia de software moderna exige que estejamos preparados para a falha inevitável da rede, projetando arquiteturas que degradam de forma graciosa em vez de falhar catastroficamente.

Como próximos passos, vale a pena explorar a integração do circuit breaker com outras estratégias fundamentais de tolerância a falhas, como limitadores de taxa (rate limiters), políticas de repetição inteligente com atraso exponencial (exponential backoff) e isolamento de recursos (bulkheads). Cada um desses padrões atua em uma camada diferente da arquitetura, formando uma defesa em profundidade que garante alta disponibilidade e confiabilidade contínua para os usuários finais da sua aplicação.