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.
