|

Hybrid Circuit Breaker - In-Memory mit Redis-Sync in NestJS

Wir haben einen In-Memory Circuit Breaker gebaut, dann Redis hinzugefügt. Aber hier ist die Sache: Jede Circuit-Prüfung trifft Redis. Das ist ein Netzwerk-Aufruf. Was, wenn wir In-Memory für Geschwindigkeit und Redis für Sync nutzen könnten?

Lass uns einen Hybrid bauen.

Das Problem mit reinem Redis

Jedes Mal, wenn du einen Circuit-Zustand prüfst, triffst du Redis:

const state = await this.redis.get(`circuit:${key}:state`)

Das ist ein Netzwerk-Aufruf. Wenn du Circuits bei jeder Anfrage prüfst, summiert sich das. Außerdem, wenn Redis einen Schluckauf hat, werden deine Anfragen langsamer.

Der Hybrid-Ansatz

Nutze In-Memory für Lesevorgänge, Redis für Schreibvorgänge. Synchronisiere den Zustand über Instanzen im Hintergrund.

Hier ist die Idee:

  • Halte Circuit-Zustand im Speicher (schnelle Lesevorgänge)
  • Schreibe Zustandsänderungen nach Redis (Sync über Instanzen)
  • Abonniere Redis Pub/Sub (erhalte Updates von anderen Instanzen)
  • Periodische Synchronisation von Redis (falls wir etwas verpasst haben)

Bauen

Starte mit dem Hybrid-Service:

import { Injectable, Inject, OnModuleInit } from '@nestjs/common'
import Redis from 'ioredis'
import { REDIS_CLIENT } from './redis.module'

enum CircuitState {
  CLOSED = 'CLOSED',
  OPEN = 'OPEN',
  HALF_OPEN = 'HALF_OPEN'
}

interface CircuitConfig {
  failureThreshold: number
  successThreshold: number
  timeout: number
}

interface CircuitStats {
  state: CircuitState
  failures: number
  successes: number
  nextAttempt: number
}

@Injectable()
export class HybridCircuitBreakerService implements OnModuleInit {
  private circuits = new Map<string, CircuitStats>()
  private subscriber: Redis
  private redisAvailable = false

  private readonly defaultConfig: CircuitConfig = {
    failureThreshold: 5,
    successThreshold: 2,
    timeout: 60000
  }

  constructor(
    @Inject(REDIS_CLIENT) private readonly redis: Redis
  ) {
    this.subscriber = this.redis.duplicate()
  }

  async onModuleInit() {
    try {
      await this.subscriber.subscribe('circuit:updates')
      this.redisAvailable = true

      this.subscriber.on('message', (channel, message) => {
        if (channel === 'circuit:updates') {
          const update = JSON.parse(message)
          this.circuits.set(update.key, update.stats)
        }
      })

      this.redis.on('error', () => {
        this.redisAvailable = false
      })

      this.redis.on('connect', () => {
        this.redisAvailable = true
      })
    } catch (error) {
      this.redisAvailable = false
    }
  }

  async execute<T>(
    key: string,
    fn: () => Promise<T>,
    config?: Partial<CircuitConfig>
  ): Promise<T> {
    const cfg = { ...this.defaultConfig, ...config }
    const circuit = this.getCircuit(key)

    if (circuit.state === CircuitState.OPEN) {
      if (Date.now() < circuit.nextAttempt) {
        throw new Error(`Circuit breaker is OPEN for ${key}`)
      }
      circuit.state = CircuitState.HALF_OPEN
      circuit.successes = 0
      this.publishUpdate(key, circuit)
    }

    try {
      const result = await fn()
      this.onSuccess(key, cfg)
      return result
    } catch (error) {
      this.onFailure(key, cfg)
      throw error
    }
  }

  private getCircuit(key: string): CircuitStats {
    if (!this.circuits.has(key)) {
      this.circuits.set(key, {
        state: CircuitState.CLOSED,
        failures: 0,
        successes: 0,
        nextAttempt: 0
      })
    }
    return this.circuits.get(key)!
  }

  private onSuccess(key: string, config: CircuitConfig) {
    const circuit = this.getCircuit(key)

    if (circuit.state === CircuitState.HALF_OPEN) {
      circuit.successes++
      
      if (circuit.successes >= config.successThreshold) {
        circuit.state = CircuitState.CLOSED
        circuit.failures = 0
        circuit.successes = 0
      }
    } else {
      circuit.failures = 0
    }

    this.writeToRedis(key, circuit)
    this.publishUpdate(key, circuit)
  }

  private onFailure(key: string, config: CircuitConfig) {
    const circuit = this.getCircuit(key)
    circuit.failures++

    if (circuit.failures >= config.failureThreshold) {
      circuit.state = CircuitState.OPEN
      circuit.nextAttempt = Date.now() + config.timeout
      circuit.successes = 0
    }

    this.writeToRedis(key, circuit)
    this.publishUpdate(key, circuit)
  }

  private async writeToRedis(key: string, stats: CircuitStats) {
    if (!this.redisAvailable) return

    try {
      await this.redis.hset(`circuit:${key}`, {
        state: stats.state,
        failures: stats.failures,
        successes: stats.successes,
        nextAttempt: stats.nextAttempt
      })
      await this.redis.expire(`circuit:${key}`, 3600)
    } catch (error) {
      // Redis write failed, continue with in-memory
    }
  }

  private async publishUpdate(key: string, stats: CircuitStats) {
    if (!this.redisAvailable) return

    try {
      await this.redis.publish(
        'circuit:updates',
        JSON.stringify({ key, stats })
      )
    } catch (error) {
      // Publish failed, other instances won't get update immediately
    }
  }

  getState(key: string): CircuitState {
    return this.getCircuit(key).state
  }

  async reset(key: string) {
    this.circuits.delete(key)
    
    if (this.redisAvailable) {
      try {
        await this.redis.del(`circuit:${key}`)
      } catch (error) {
        // Redis delete failed
      }
    }
  }

  getStats(key: string) {
    return this.getCircuit(key)
  }
}

Parallele Operationen

Der Schlüssel hier ist, dass Redis-Operationen nicht blockieren:

private async onSuccess(key: string, config: CircuitConfig) {
  const circuit = this.getCircuit(key)
  
  // Aktualisiere In-Memory-Zustand sofort
  if (circuit.state === CircuitState.HALF_OPEN) {
    circuit.successes++
    if (circuit.successes >= config.successThreshold) {
      circuit.state = CircuitState.CLOSED
      circuit.failures = 0
      circuit.successes = 0
    }
  } else {
    circuit.failures = 0
  }

  // Fire and forget - nicht awaiten
  // Diese laufen parallel und blockieren die Antwort nicht
  this.writeToRedis(key, circuit)
  this.publishUpdate(key, circuit)
}

Kein await bedeutet, die Funktion kehrt sofort zurück. Redis-Schreibvorgänge passieren im Hintergrund.

Performance-Vergleich

Der Unterschied ist spürbar:

// Reiner Redis-Ansatz
async executeWithRedis() {
  const start = Date.now()
  const state = await this.redis.get('circuit:test:state') // Netzwerk-Aufruf
  const failures = await this.redis.get('circuit:test:failures') // Netzwerk-Aufruf
  console.log(`Redis check took: ${Date.now() - start}ms`)
}

// Hybrid-Ansatz
async executeWithHybrid() {
  const start = Date.now()
  const circuit = this.circuits.get('test') // In-Memory, instant
  console.log(`Hybrid check took: ${Date.now() - start}ms`)
}

In-Memory-Lookups sind praktisch instant. Redis-Aufrufe brauchen einen Netzwerk-Roundtrip. Der genaue Unterschied hängt von deiner Netzwerk-Latenz ab, aber In-Memory ist immer schneller für Lesevorgänge.

Den Hybrid-Ansatz testen

Teste, dass der Zustand über Instanzen synchronisiert:

describe('HybridCircuitBreakerService', () => {
  let service1: HybridCircuitBreakerService
  let service2: HybridCircuitBreakerService
  let redis: Redis

  beforeEach(async () => {
    redis = new Redis()
    service1 = new HybridCircuitBreakerService(redis)
    service2 = new HybridCircuitBreakerService(redis)
    
    await service1.onModuleInit()
    await service2.onModuleInit()
  })

  it('should sync state across instances', async () => {
    const failingFn = async () => {
      throw new Error('API down')
    }

    for (let i = 0; i < 5; i++) {
      try {
        await service1.execute('test', failingFn)
      } catch (e) {}
    }

    // Warte auf Pub/Sub
    await new Promise(resolve => setTimeout(resolve, 100))

    expect(service2.getState('test')).toBe(CircuitState.OPEN)
  })

  it('should work without Redis', async () => {
    await redis.disconnect()

    const result = await service1.execute('test', async () => 'success')
    expect(result).toBe('success')
  })
})

Was ich gelernt habe

In-Memory ist schnell: Lesevorgänge passieren instant ohne Netzwerk-Aufrufe.

Fire and forget: Warte nicht auf Redis-Schreibvorgänge. Lass sie im Hintergrund passieren.

Pub/Sub für Sync: Andere Instanzen erhalten Updates durch Redis Pub/Sub.

Graceful Degradation: Funktioniert ohne Redis, verliert nur Cross-Instance-Sync.

Halte es einfach: Periodische Synchronisation und komplexe Wiederverbindungslogik entfernt. Pub/Sub behandelt die meisten Fälle.

Nutze Redis-Hashes: Atomare Updates für alle Circuit-Felder auf einmal.

Setze TTLs: Alte Circuits laufen automatisch nach 1 Stunde ab.

Fazit

Der Hybrid-Ansatz gibt dir das Beste aus beiden Welten - In-Memory-Geschwindigkeit mit Redis-Sync. Schnelle Lesevorgänge, eventuelle Konsistenz über Instanzen und graceful degradation, wenn Redis down ist.

Das ist, was ich in Production nutze. Es ist schnell, zuverlässig und skaliert.

Keep pushing forward and savor every step of your coding journey.