Designing Data-Intensive Applications
Chapitre 1 / 11 · 17 min de lecture

Fiabilité, scalabilité, maintenabilité

Les trois préoccupations de tout système de données — et comment les mesurer : pannes, percentiles de latence, montée en charge et exploitabilité.

La plupart des applications d'aujourd'hui sont gourmandes en données (data-intensive) plutôt que gourmandes en calcul (compute-intensive). La puissance brute du processeur est rarement le facteur limitant : les vrais problèmes sont le volume des données, leur complexité et la vitesse à laquelle elles changent. Pour y répondre, on assemble des briques standard — bases de données, caches, index de recherche, files de messages, traitement par lots (batch) ou par flux (stream). Ces briques sont si bien rodées qu'on les utilise sans réfléchir ; mais dès qu'un seul outil ne suffit plus, c'est le code applicatif qui les recoud ensemble. On devient alors, sans toujours s'en rendre compte, concepteur d'un système de données à part entière.

Or concevoir un système de données soulève des questions épineuses. Comment garder les données correctes et complètes quand un composant interne déraille ? Comment offrir des performances régulières même lorsqu'une partie du système est dégradée ? Comment encaisser une montée en charge ? Kleppmann ramène toute cette complexité à trois préoccupations fondamentales présentes dans la plupart des systèmes logiciels : la fiabilité (reliability), la scalabilité (scalability) et la maintenabilité (maintainability). Ce chapitre clarifie ce que ces mots veulent dire et propose des manières concrètes de les penser et de les mesurer.

Penser en termes de systèmes de données

On range d'instinct bases, files et caches dans des catégories étanches. Mais les frontières se brouillent : Redis sert de file de messages, Kafka offre des garanties de durabilité dignes d'une base. Surtout, les besoins deviennent si vastes qu'un seul outil ne couvre plus tout. On découpe alors le travail en tâches qu'un outil exécute efficacement, puis on les recoud par du code applicatif — par exemple en maintenant un cache (memcached) et un index plein texte (Elasticsearch, Solr) synchronisés avec la base principale.

Quand plusieurs outils sont combinés derrière une même API, vous créez un système de données composite, à usage spécifique, à partir de composants génériques. Son interface cache les détails d'implémentation aux clients et peut fournir des garanties (par exemple : le cache sera correctement invalidé à chaque écriture). C'est précisément pour raisonner sur ces garanties que les trois préoccupations qui suivent comptent.

Note

Le choix d'une architecture dépend de bien plus que de la technique : compétences de l'équipe, dépendances héritées, délais de livraison, tolérance au risque de l'organisation, contraintes réglementaires. Fiabilité, scalabilité et maintenabilité sont des grilles de lecture transverses, pas une recette unique.

Fiabilité : continuer à fonctionner malgré l'adversité

Intuitivement, un logiciel fiable remplit la fonction attendue, tolère les erreurs de l'utilisateur, reste assez rapide sous la charge prévue et empêche les accès non autorisés. En résumé : continuer à fonctionner correctement, même quand les choses tournent mal.

Le vocabulaire mérite une distinction capitale. Une faute (fault) est un composant qui dévie de sa spécification ; une défaillance (failure) est le système entier qui cesse de rendre le service attendu. On ne peut pas ramener la probabilité d'une faute à zéro — on conçoit donc des mécanismes de tolérance aux fautes (fault tolerance) qui empêchent les fautes de dégénérer en défaillances. Un système qui anticipe les fautes et sait composer avec est dit résilient (resilient).

Le terme est trompeur : on ne peut pas tolérer tout type de faute (si la Terre entière disparaissait dans un trou noir, il faudrait héberger ses serveurs dans l'espace). On ne parle donc que de tolérer certains types de faute. Contre-intuitivement, il peut être judicieux d'augmenter délibérément le taux de fautes : en tuant des processus au hasard, on exerce en continu la machinerie de tolérance et on s'assure qu'elle marchera le jour venu — c'est l'idée du chaos monkey de Netflix, car beaucoup de bugs critiques viennent d'une mauvaise gestion des erreurs.

Fautes matérielles

Disques qui lâchent, RAM défectueuse, coupure de courant, mauvais câble réseau débranché : sur des milliers de machines, ces incidents arrivent tout le temps. Avec un temps moyen avant panne (mean time to failure, MTTF) d'un disque estimé entre 10 et 50 ans, un cluster de 10 000 disques voit en moyenne un disque mourir par jour.

La réponse classique est la redondance matérielle : disques en RAID, double alimentation, processeurs remplaçables à chaud, batteries et groupes électrogènes. Cela maintient une machine en vie pendant des années, mais ne suffit plus. À mesure que les applications grandissent en machines, le taux de fautes matérielles croît proportionnellement ; et sur certaines plateformes cloud (AWS), une instance peut devenir indisponible sans préavis, car la plateforme privilégie la flexibilité et l'élasticité à la fiabilité d'une machine isolée. D'où un glissement vers des techniques de tolérance aux fautes logicielles capables d'encaisser la perte de machines entières — avec en prime un avantage opérationnel : on applique les correctifs un nœud à la fois, sans interruption de service.

Fautes logicielles

Les fautes matérielles sont en général aléatoires et indépendantes les unes des autres. Les fautes logicielles, elles, sont systématiques et corrélées entre nœuds, ce qui les rend bien plus susceptibles de provoquer des défaillances massives. Exemples : un bug qui fait planter toutes les instances sur une entrée particulière (la seconde intercalaire du 30 juin 2012, qui a figé quantité d'applications à cause d'un bug du noyau Linux) ; un processus emballé qui épuise une ressource partagée ; un service dépendant qui ralentit ou renvoie des réponses corrompues ; des défaillances en cascade (cascading failures).

Ces bugs dorment souvent longtemps, jusqu'à ce qu'un concours de circonstances inhabituel révèle qu'une hypothèse sur l'environnement — vraie d'ordinaire — a cessé de l'être. Il n'existe pas de remède miracle. Beaucoup de petites mesures aident : examiner soigneusement les hypothèses et interactions, tester en profondeur, isoler les processus, laisser les processus planter et redémarrer, mesurer, surveiller et analyser le comportement en production. Un système qui doit garantir une propriété (par exemple, autant de messages sortants qu'entrants dans une file) peut se vérifier lui-même en continu et alerter en cas d'écart.

Fautes humaines

Ce sont des humains qui conçoivent, construisent et exploitent les systèmes — et les humains sont faillibles. Une étude de grands services Internet a montré que les erreurs de configuration par les opérateurs étaient la première cause de pannes, les fautes matérielles n'intervenant que dans 10 à 25 % des cas. La fiabilité passe donc par une combinaison d'approches.

LevierPrincipe
Bonnes abstractionsAPIs et interfaces d'admin qui rendent facile « la bonne chose » et découragent « la mauvaise » — sans être si restrictives qu'on les contourne.
DécouplageSéparer les endroits où l'on se trompe le plus de ceux où l'on peut causer une défaillance ; fournir des bacs à sable réalistes pour expérimenter sans risque.
Tests à tous les niveauxDe l'unitaire à l'intégration complète, en passant par le test manuel ; l'automatisation couvre les cas limites rares.
Récupération rapideRendre instantané le rollback d'une config, déployer le code progressivement, fournir des outils pour recalculer des données erronées.
Surveillance fineMétriques de performance et taux d'erreurs (la télémétrie), pour repérer tôt les signaux d'alerte et diagnostiquer.

À retenir

La fiabilité n'est pas réservée au nucléaire ou au contrôle aérien. Un bug dans une application de gestion fait perdre en productivité (et expose à des risques légaux), une panne d'e-commerce coûte en revenus et en réputation. Pensez au parent qui confie à votre application toutes les photos de ses enfants : on a une responsabilité envers nos utilisateurs, même pour des services « non critiques ».

Scalabilité : faire face à la montée en charge

Un système fiable aujourd'hui ne le restera pas forcément demain, souvent parce que la charge (load) augmente : de 10 000 à 100 000 utilisateurs simultanés, de 1 à 10 millions, ou des volumes de données bien plus gros. La scalabilité décrit la capacité d'un système à encaisser cette croissance. Attention : ce n'est pas une étiquette binaire. Dire « X est scalable » n'a aucun sens ; la bonne question est : si le système croît de telle façon, quelles sont nos options pour y faire face, et comment ajouter des ressources de calcul ?

Décrire la charge

Pour discuter de croissance, il faut d'abord décrire la charge actuelle avec quelques chiffres : les paramètres de charge (load parameters). Le bon choix dépend de l'architecture : requêtes par seconde sur un serveur web, ratio lectures/écritures en base, nombre d'utilisateurs actifs simultanés, taux de succès d'un cache. Parfois c'est le cas moyen qui compte, parfois une poignée de cas extrêmes domine le goulet d'étranglement.

L'étude de cas de Twitter (données de novembre 2012) est éclairante. Deux opérations principales :

  • Publier un tweet : 4,6 k requêtes/s en moyenne, plus de 12 k au pic.
  • Fil d'accueil (home timeline) : afficher les tweets récents des comptes suivis — 300 k requêtes/s.

Encaisser 12 k écritures/s serait facile. Le vrai défi de Twitter n'est pas le volume de tweets, mais le fan-out : chaque utilisateur suit beaucoup de monde et est suivi par beaucoup de monde. Deux approches s'opposent.

Approche 1 — fan-out à la lectureApproche 2 — fan-out à l'écriture
Publier un tweetSimple insertion dans une collection globale.Insertion dans le cache de fil de chaque abonné (boîte aux lettres).
Lire le filJointure coûteuse : trouver les suivis, récupérer et fusionner leurs tweets récents.Lecture bon marché : le résultat est déjà calculé.
Coût déplacé vers…la lecturel'écriture
-- Approche 1 : tout le travail au moment de la lecture du fil.
SELECT tweets.*, users.* FROM tweets
  JOIN users   ON tweets.sender_id     = users.id
  JOIN follows ON follows.followee_id  = users.id
  WHERE follows.follower_id = current_user

Twitter a démarré avec l'approche 1, mais les requêtes de fil d'accueil n'ont pas tenu. Il a basculé vers l'approche 2, car le débit de lectures est presque deux ordres de grandeur supérieur au débit d'écritures : mieux vaut travailler davantage à l'écriture. La contrepartie : publier un tweet devient lourd. En moyenne, un tweet touche 75 abonnés, donc 4,6 k tweets/s deviennent 345 k écritures/s dans les caches de fils. Et cette moyenne masque une distribution très inégale : certains comptes ont plus de 30 millions d'abonnés, soit plus de 30 millions d'écritures pour un seul tweet — à livrer en moins de 5 secondes.

Astuce

Le paramètre de charge décisif ici n'est ni le débit d'écritures ni celui de lectures, mais la distribution du nombre d'abonnés par utilisateur (pondérée par leur fréquence de publication) : c'est elle qui détermine la charge de fan-out. Identifier le bon paramètre est la moitié du travail.

La fin de l'anecdote : Twitter est passé à une approche hybride. La majorité des tweets sont distribués à l'écriture, mais les comptes à très grand nombre d'abonnés (les célébrités) en sont exemptés ; leurs tweets sont récupérés séparément et fusionnés au moment de la lecture, à la manière de l'approche 1. Ce mélange offre des performances régulières.

Décrire la performance : débit et temps de réponse

Une fois la charge décrite, on observe ce qui se passe quand elle augmente, de deux manières : à ressources constantes, comment la performance évolue-t-elle ? Et pour garder la performance constante, de combien faut-il augmenter les ressources ? Deux métriques selon le contexte :

  • En traitement par lots (Hadoop), on regarde le débit (throughput) : nombre d'enregistrements traités par seconde, ou durée totale d'un job.
  • En système en ligne, le temps de réponse (response time) compte davantage : le délai entre l'envoi d'une requête par le client et la réception de la réponse.

Note

Latence et temps de réponse ne sont pas synonymes. Le temps de réponse est ce que voit le client : temps de traitement réel (service time), plus délais réseau et d'attente en file. La latence (latency) est la durée pendant laquelle une requête est latente, en attente d'être prise en charge.

Pourquoi les percentiles et pas la moyenne

Même en répétant la même requête, le temps de réponse varie : changements de contexte, perte de paquet et retransmission TCP, pause du ramasse-miettes (garbage collection), défaut de page forçant une lecture disque, voire vibrations mécaniques du rack. Il faut donc penser le temps de réponse non comme un nombre unique, mais comme une distribution.

La moyenne (mean) est une mauvaise mesure du temps « typique » : elle ne dit pas combien d'utilisateurs ont réellement subi tel délai. Mieux vaut les percentiles (percentiles). Triez les temps du plus rapide au plus lent : la médiane (median, ou p50) est le point milieu — la moitié des requêtes sont plus rapides, la moitié plus lentes. Pour mesurer les valeurs aberrantes, on regarde des percentiles plus élevés — p95, p99, p999 —, ces seuils en dessous desquels tombent respectivement 95 %, 99 % et 99,9 % des requêtes. Ces percentiles élevés sont les latences de queue (tail latencies).

Temps de réponse triés (ms), échantillon de 10 requêtes :
  86  92 103 119 133 156 187 243 410 920

  p50 (médiane) ≈ 144  -> 5 requêtes sous ce seuil
  p90           ≈ 410  -> 9 requêtes sur 10 plus rapides
  moyenne       ≈ 245  -> tirée vers le haut par le 920 :
                          ne décrit le vécu de personne

Pourquoi les latences de queue importent-elles autant ? Parce qu'elles touchent directement l'expérience. Amazon spécifie ses services internes en p999, alors que cela ne concerne qu'une requête sur 1 000 : ce sont souvent les clients les plus actifs (donc les plus précieux) qui ont accumulé le plus de données et subissent les requêtes les plus lentes. Amazon a observé qu'une hausse de 100 ms du temps de réponse réduit les ventes de 1 %. En revanche, optimiser le p9999 (1 requête sur 10 000) a été jugé trop coûteux : ces valeurs extrêmes dépendent d'événements aléatoires hors de contrôle, et le bénéfice est décroissant.

Les percentiles servent à formuler des objectifs de niveau de service (service level objectives, SLO) et des accords de niveau de service (service level agreements, SLA) : par exemple, le service est « up » si sa médiane est sous 200 ms et son p99 sous 1 s, et il doit être disponible 99,9 % du temps. Au-delà, le client peut réclamer un remboursement.

Piège courant

Aux percentiles élevés, les délais d'attente en file (queueing delays) dominent souvent. Un serveur ne traitant qu'un petit nombre de requêtes en parallèle, quelques requêtes lentes suffisent à retarder les suivantes — le blocage de tête de file (head-of-line blocking). Mesurez donc les temps côté client. Et lors d'un test de charge, le client générateur doit continuer d'envoyer des requêtes indépendamment des réponses, sinon les files restent artificiellement courtes et les mesures sont faussées.

Un piège supplémentaire pour les services backend appelés plusieurs fois au sein d'une requête utilisateur : même en parallèle, la requête finale attend le plus lent des appels. Une seule requête lente suffit à ralentir tout le rendu — et plus une requête déclenche d'appels backend, plus la probabilité d'en toucher un lent grimpe. Pour calculer les percentiles en continu à moindre coût, on utilise des algorithmes d'approximation (forward decay, t-digest, HdrHistogram). À noter : moyenner des percentiles n'a aucun sens mathématique — pour agréger, on additionne les histogrammes.

Stratégies pour encaisser la charge

Une architecture adaptée à un niveau de charge ne tient généralement pas à dix fois ce niveau : un service en forte croissance doit repenser son architecture à chaque ordre de grandeur. On oppose souvent deux voies :

Scaling vertical (scale up)Scaling horizontal (scale out)
PrincipeUne machine plus puissante.Répartir la charge sur plusieurs petites machines.
Aussi appeléArchitecture shared nothing (rien de partagé).
AtoutPlus simple.Indispensable pour les charges très intensives.
LimiteLes machines haut de gamme deviennent très chères.Complexité accrue, surtout pour l'état (stateful).

En pratique, les bonnes architectures mélangent pragmatiquement les deux : quelques machines assez puissantes peuvent être plus simples et moins chères qu'une multitude de petites VM. Certains systèmes sont élastiques (elastic) — ils ajoutent automatiquement des ressources à la détection d'une hausse — ce qui aide quand la charge est imprévisible ; d'autres sont mis à l'échelle manuellement, plus simples et avec moins de surprises opérationnelles.

Répartir des services sans état (stateless) sur plusieurs machines est assez simple ; faire passer un système de données avec état d'un nœud unique à un déploiement distribué introduit beaucoup de complexité. D'où la sagesse répandue : garder la base sur un seul nœud (scale up) tant que le coût ou les exigences de haute disponibilité n'imposent pas le distribué. Enfin, il n'existe pas d'architecture scalable universelle (la magic scaling sauce n'existe pas) : un système conçu pour 100 000 requêtes/s de 1 ko diffère radicalement d'un système pour 3 requêtes/min de 2 Go, même à débit de données égal. Une architecture scalable est bâtie sur des hypothèses quant aux opérations fréquentes et rares — les paramètres de charge ; si ces hypothèses sont fausses, l'effort est au mieux gaspillé. Pour une jeune startup, itérer vite sur le produit prime souvent sur la montée en charge d'une hypothétique charge future.

Maintenabilité : faciliter la vie de ceux qui exploitent le système

L'essentiel du coût d'un logiciel n'est pas son développement initial, mais sa maintenance : corriger des bugs, le garder opérationnel, enquêter sur les pannes, l'adapter à de nouvelles plateformes et de nouveaux usages, rembourser la dette technique, ajouter des fonctionnalités. On peut concevoir le logiciel de façon à minimiser cette douleur — et éviter de produire soi-même le legacy qu'on déteste. Trois principes guident cet effort.

PrincipeDéfinitionCe qu'il exige
Opérabilité (operability)Faciliter le maintien en bon fonctionnement par les équipes d'exploitation.Bonne visibilité (monitoring), automatisation, pas de dépendance à une machine précise, comportement prévisible et bien documenté.
Simplicité (simplicity)Faciliter la compréhension du système par les nouveaux ingénieurs.Éliminer la complexité accidentelle (accidental complexity) au moyen de bonnes abstractions.
Évolutivité (evolvability)Faciliter les changements futurs face à des besoins imprévus.Simplicité et bonnes abstractions, qui rendent un système facile à modifier.

Opérabilité

« De bonnes opérations peuvent souvent compenser un logiciel mauvais ou incomplet, mais un bon logiciel ne tournera pas de façon fiable avec de mauvaises opérations. » Une bonne équipe d'exploitation surveille la santé du système et restaure vite le service, traque la cause des problèmes, maintient les plateformes à jour (correctifs de sécurité), fait de la planification de capacité, préserve la connaissance organisationnelle malgré le va-et-vient des personnes. Un bon système lui rend la vie facile : visibilité sur le comportement interne, support de l'automatisation, possibilité de débrancher une machine sans interrompre le tout, valeurs par défaut saines mais surchargeables, et comportement prévisible qui minimise les surprises.

Simplicité

Les petits projets ont un code délicieusement simple ; en grandissant, ils deviennent souvent un grand tas de boue (big ball of mud). Les symptômes : explosion de l'espace d'états, couplage fort des modules, dépendances enchevêtrées, nommage incohérent, bidouilles de performance, traitements de cas particuliers. La complexité ralentit tout le monde et multiplie le risque de bugs lors des changements.

Simplifier ne veut pas dire amputer des fonctionnalités, mais retirer la complexité accidentelle — celle qui ne tient pas au problème vu par l'utilisateur, mais seulement à l'implémentation. Le meilleur outil pour cela est l'abstraction : elle cache le détail derrière une façade propre et réutilisable. SQL, par exemple, masque des structures de données complexes sur disque et en mémoire, la concurrence et les incohérences après un crash ; un langage de haut niveau masque le code machine et les registres. Trouver de bonnes abstractions est cependant très difficile, en particulier dans les systèmes distribués.

Évolutivité

Les exigences d'un système ne restent jamais figées : nouveaux faits, usages imprévus, priorités métier qui changent, contraintes légales nouvelles. Les méthodes agiles (TDD, refactoring) outillent l'adaptation au changement, mais surtout à petite échelle. L'enjeu ici est l'agilité à l'échelle d'un grand système de données — par exemple, comment « refactorer » l'architecture de Twitter de l'approche 1 vers l'approche 2 ? Cette aptitude au changement est étroitement liée à la simplicité et aux abstractions : un système simple et compréhensible se modifie plus facilement qu'un système complexe. C'est cette agilité au niveau du système que désigne le mot évolutivité.

À retenir

  • Un système data-intensive se construit en assemblant des briques (bases, caches, index, files, batch, flux) ; les recoudre fait de vous un concepteur de système de données, jugé sur trois axes : fiabilité, scalabilité, maintenabilité.
  • Fiabilité = continuer à fonctionner malgré l'adversité. Distinguez la faute (un composant dévie) de la défaillance (le système s'arrête) ; les fautes sont matérielles (aléatoires), logicielles (systématiques et corrélées, plus dangereuses) et humaines (la première cause de pannes).
  • Scalabilité = avoir des stratégies pour tenir la performance quand la charge croît. Décrivez d'abord la charge par les paramètres de charge (le cas Twitter montre que le bon paramètre est la distribution des abonnés, et le compromis du fan-out écriture/lecture).
  • Mesurez la performance avec des percentiles (p50, p95, p99, p999), pas avec la moyenne : les latences de queue affectent les clients les plus précieux et fondent les SLO/SLA.
  • Choisissez entre scaling vertical et horizontal selon le contexte (souvent un mélange) ; il n'existe pas d'architecture scalable universelle — tout repose sur des hypothèses de charge.
  • Maintenabilité = opérabilité (bon monitoring, comportement prévisible), simplicité (éliminer la complexité accidentelle par de bonnes abstractions) et évolutivité (rendre le changement facile).