Il est courant que les systèmes logiciels effectuent des appels à distance vers des logiciels s’exécutant dans différents processus, probablement sur différentes machines d’un réseau. L’une des grandes différences entre les appels en mémoire et les appels distants est que les appels distants peuvent échouer ou se bloquer sans réponse jusqu’à ce qu’une limite de délai d’attente soit atteinte. Pire encore, si vous avez de nombreux appelants sur un fournisseur qui ne répond pas, vous pouvez manquer de ressources critiques, ce qui entraîne des pannes en cascade sur plusieurs systèmes. Dans son excellent livre It , Michael Nygard a popularisé le modèle de disjoncteur pour éviter ce genre de cascade catastrophique.
L’idée de base du disjoncteur est très simple. Vous enveloppez un appel de fonction protégé dans un objet disjoncteur, qui surveille les défaillances. Une fois que les pannes atteignent un certain seuil, le disjoncteur se déclenche et tous les autres appels au disjoncteur reviennent avec une erreur, sans que l’appel protégé ne soit du tout effectué. Habituellement, vous voudrez également une sorte d’alerte de moniteur si le disjoncteur se déclenche.
Voici un exemple simple de ce comportement dans Ruby, protégeant contre les délais d’attente.
J’ai configuré le disjoncteur avec un bloc (Lambda) qui est l’appel protégé.
cb = CircuitBreaker.new {|arg| @supplier.func arg}
Le disjoncteur stocke le bloc, initialise divers paramètres (pour les seuils, les délais d’attente et la surveillance) et remet le disjoncteur dans son état fermé.
disjoncteur de classe…
attr_accessor :invocation_timeout, :failure_threshold, :monitor def initialize &block @circuit = block @invocation_timeout = 0.01 @failure_threshold = 5 @monitor = acquire_monitor reset end
Appeler le disjoncteur appellera le bloc sous-jacent si le circuit est fermé, mais renverra une erreur s’il est ouvert
# client code aCircuitBreaker.call(5)
class CircuitBreaker…
def call args case state when :closed begin do_call args rescue Timeout::Error record_failure raise $! end when :open then raise CircuitBreaker::Open else raise "Unreachable Code" end end def do_call args result = Timeout::timeout(@invocation_timeout) do @circuit.call args end reset return result end
Si nous obtenons un délai d’attente, nous incrémentons le compteur d’échec, les appels réussis le remettent à zéro.
disjoncteur de classe…
def record_failure @failure_count += 1 @monitor.alert(:open_circuit) if :open == state end def reset @failure_count = 0 @monitor.alert :reset_circuit end
Je détermine l’état du disjoncteur en comparant le nombre de défaillances au seuil
disjoncteur de classe…
def state (@failure_count >= @failure_threshold) ? :open : :closed end
Ce disjoncteur simple évite de faire l’appel protégé lorsque le circuit est ouvert, mais nécessiterait une intervention externe pour le réinitialiser lorsque les choses vont bien. C’est une approche raisonnable avec les disjoncteurs électriques dans les bâtiments, mais pour les disjoncteurs logiciels, nous pouvons demander au disjoncteur lui-même de détecter si les appels sous-jacents fonctionnent à nouveau. Nous pouvons implémenter ce comportement d’auto-réinitialisation en essayant à nouveau l’appel protégé après un intervalle approprié et en réinitialisant le disjoncteur s’il réussit.
Créer ce type de disjoncteur signifie ajouter un seuil pour essayer la réinitialisation et configurer une variable pour maintenir l’heure de la dernière erreur.
classe ResetCircuitBreaker…
def initialize &block @circuit = block @invocation_timeout = 0.01 @failure_threshold = 5 @monitor = BreakerMonitor.new @reset_timeout = 0.1 reset end def reset @failure_count = 0 @last_failure_time = nil @monitor.alert :reset_circuit end
Il y a maintenant un troisième état présent – à moitié ouvert – ce qui signifie que le circuit est prêt à faire un appel réel comme essai pour voir si le problème est résolu.
classe ResetCircuitBreaker…
def state case when (@failure_count >= @failure_threshold) && (Time.now - @last_failure_time) > @reset_timeout :half_open when (@failure_count >= @failure_threshold) :open else :closed end end
Demandé d’appeler à l’état semi-ouvert entraîne un appel d’essai, qui réinitialisera le disjoncteur en cas de succès ou redémarrera le délai d’attente sinon.
classe ResetCircuitBreaker…
def call args case state when :closed, :half_open begin do_call args rescue Timeout::Error record_failure raise $! end when :open raise CircuitBreaker::Open else raise "Unreachable" end end def record_failure @failure_count += 1 @last_failure_time = Time.now @monitor.alert(:open_circuit) if :open == state end
Cet exemple est un simple exemple explicatif, en pratique les disjoncteurs fournissent un peu plus de fonctionnalités et de paramétrage. Souvent, ils se protègent contre une gamme d’erreurs que l’appel protégé pourrait déclencher, telles que les échecs de connexion réseau. Toutes les erreurs ne devraient pas déclencher le circuit, certaines devraient refléter des défaillances normales et être traitées dans le cadre d’une logique régulière.
Avec beaucoup de trafic, vous pouvez avoir des problèmes avec de nombreux appels en attente du délai d’attente initial. Étant donné que les appels à distance sont souvent lents, il est souvent judicieux de placer chaque appel sur un thread différent en utilisant un avenir ou une promesse pour gérer les résultats à leur retour. En tirant ces threads d’un pool de threads, vous pouvez organiser la rupture du circuit lorsque le pool de threads est épuisé.
L’exemple montre un moyen simple de déclencher le disjoncteur — un décompte qui se réinitialise lors d’un appel réussi. Une approche plus sophistiquée pourrait examiner la fréquence des erreurs, trébuchant une fois que vous obtenez, disons, un taux d’échec de 50%. Vous pouvez également avoir des seuils différents pour différentes erreurs, tels qu’un seuil de 10 pour les délais d’attente mais de 3 pour les échecs de connexion.
L’exemple que j’ai montré est un disjoncteur pour les appels synchrones, mais les disjoncteurs sont également utiles pour les communications asynchrones. Une technique courante ici consiste à mettre toutes les demandes dans une file d’attente, que le fournisseur consomme à sa vitesse – une technique utile pour éviter de surcharger les serveurs. Dans ce cas, le circuit se casse lorsque la file d’attente se remplit.
À eux seuls, les disjoncteurs aident à réduire les ressources liées aux opérations susceptibles d’échouer. Vous évitez d’attendre les délais d’attente pour le client, et un circuit cassé évite de charger un serveur en difficulté. Je parle ici des appels à distance, qui sont un cas courant pour les disjoncteurs, mais ils peuvent être utilisés dans n’importe quelle situation où vous souhaitez protéger des parties d’un système contre des pannes dans d’autres parties.
Les disjoncteurs sont un lieu précieux pour la surveillance. Tout changement d’état du disjoncteur doit être consigné et les disjoncteurs doivent révéler les détails de leur état pour une surveillance plus approfondie. Le comportement des disjoncteurs est souvent une bonne source d’avertissements concernant des problèmes plus profonds dans l’environnement. Le personnel des opérations devrait pouvoir déclencher ou réinitialiser les disjoncteurs.
Les disjoncteurs à eux seuls sont précieux, mais les clients qui les utilisent doivent réagir aux défaillances des disjoncteurs. Comme pour toute invocation à distance, vous devez réfléchir à ce qu’il faut faire en cas d’échec. Cela échoue-t-il l’opération que vous effectuez ou existe-t-il des solutions de contournement que vous pouvez faire? Une autorisation de carte de crédit pourrait être mise en file d’attente pour être traitée plus tard, l’impossibilité d’obtenir des données peut être atténuée en affichant des données périmées suffisamment bonnes pour être affichées.
Pour en savoir plus
Le blog technique netflix contient de nombreuses informations utiles sur l’amélioration de la fiabilité des systèmes avec de nombreux services. Leur commande de dépendance parle de l’utilisation de disjoncteurs et d’une limite de pool de threads.
Netflix a ouvert Hystrix, un outil sophistiqué pour gérer la latence et la tolérance aux pannes des systèmes distribués. Il comprend une implémentation du modèle de disjoncteur avec la limite de pool de threads
Il existe d’autres implémentations open-source du modèle de disjoncteur dans Ruby, Java, Grails Plugin, C#, AspectJ et Scala
Remerciements
Pavel Shpak a repéré et signalé un bogue dans l’exemple de code