Cours de Systèmes Distribués
Bienvenue dans le Cours de Systèmes Distribués ! Ce cours vous guidera des concepts fondamentaux jusqu'à la construction d'un système fonctionnel basé sur le consensus.
Pourquoi Apprendre les Systèmes Distribués ?
Les systèmes distribués sont partout. Chaque fois que vous utilisez un service web moderne, vous interagissez avec un système distribué :
- Plateformes de médias sociaux gérant des milliards d'utilisateurs
- Sites de commerce électronique traitant des millions de transactions
- Services de streaming diffusant du contenu à l'échelle mondiale
- Bases de données cloud stockant et répliquant des données à travers les continents
Comprendre les systèmes distribués est essentiel pour construire des applications évolutives et fiables.
Aperçu du Cours
Ce cours enseigne les concepts des systèmes distribués à travers une mise en œuvre pratique. Sur 10 sessions, vous construirez quatre applications distribuées de complexité croissante :
| Application | Sessions | Concepts |
|---|---|---|
| Système File/Travail | 1-2 | Producteur-consommateur, passage de messages, tolérance aux pannes |
| Magasin avec Réplication | 3-5 | Partitionnement, théorème CAP, élection de leader, cohérence |
| Système de Chat | 6-7 | WebSockets, pub/sub, ordonnancement des messages |
| Système de Consensus | 8-10 | Algorithme Raft, réplication de journal, machine à états |
Ce que Vous Apprendrez
À la fin de ce cours, vous serez capable de :
- Expliquer les concepts des systèmes distribués y compris le théorème CAP, les modèles de cohérence et le consensus
- Construire un système de file d'attente fonctionnel avec le modèle producteur-consommateur
- Implémenter un magasin clé-valeur répliqué avec élection de leader
- Créer un système de chat en temps réel avec messagerie pub/sub
- Développer un système basé sur le consensus en utilisant l'algorithme Raft
- Déployer tous les systèmes en utilisant Docker Compose sur votre machine locale
Public Cible
Ce cours est conçu pour les développeurs qui :
- Ont une expérience de base en programmation (fonctions, classes, POO de base)
- Sont novices en systèmes distribués
- Veulent comprendre comment fonctionnent les applications distribuées modernes
- Préfèrent apprendre en pratiquant plutôt que la théorie pure
Prérequis
- Programmation : À l'aise avec TypeScript ou Python
- Ligne de Commande : Familiarité de base avec les commandes du terminal
- Docker : Nous couvrirons la configuration Docker dans la section Configuration Docker
Aucune expérience préalable en systèmes distribués n'est requise !
Progression du Cours
graph TB
subgraph "Partie I : Fondamentaux"
A1[Qu'est-ce qu'un SD ?] --> A2[Passage de Messages]
A2 --> A3[Système de File]
end
subgraph "Partie II : Magasin de Données"
B1[Partitionnement] --> B2[Théorème CAP]
B2 --> B3[Réplication]
B3 --> B4[Cohérence]
end
subgraph "Partie III : Temps Réel"
C1[WebSockets] --> C2[Pub/Sub]
C2 --> C3[Système de Chat]
end
subgraph "Partie IV : Consensus"
D1[Qu'est-ce que le Consensus ?] --> D2[Algorithme Raft]
D2 --> D3[Élection de Leader]
D3 --> D4[Réplication de Journal]
D4 --> D5[Système de Consensus]
end
A3 --> B1
B4 --> C1
C3 --> D1
Format du Cours
Chaque session de 1,5 heure suit cette structure :
graph LR
A[Révision<br/>5 min] --> B[Concept<br/>20 min]
B --> C[Diagramme<br/>10 min]
C --> D[Démonstration<br/>15 min]
D --> E[Exercice<br/>25 min]
E --> F[Test<br/>10 min]
F --> G[Résumé<br/>5 min]
Composants de Session
- Explication de Concept : Des explications claires et adaptées aux débutants des concepts fondamentaux
- Diagrammes Visuels : Des diagrammes Mermaid montrant l'architecture et le flux des données
- Démonstration en Direct : Procédure pas à pas du code
- Exercice Pratique : Exercices pratiques pour renforcer l'apprentissage
- Exécution et Test : Vérifiez que votre implémentation fonctionne correctement
Exemples de Code
Chaque concept inclut des implémentations en TypeScript et Python :
// Exemple TypeScript
interface Message {
id: string;
content: string;
}
# Exemple Python
@dataclass
class Message:
id: str
content: str
Choisissez le langage avec lequel vous êtes le plus à l'aise, ou apprenez les deux !
Avant de Commencer
1. Configurez Votre Environnement
Suivez le Guide de Configuration Docker pour installer :
- Docker et Docker Compose
- Votre langage de programmation préféré (TypeScript ou Python)
2. Vérifiez Votre Installation
docker --version
docker-compose --version
3. Choisissez Votre Langage
Décidez si vous travaillerez avec TypeScript ou Python tout au long du cours. Les deux langages ont des exemples complets pour chaque concept.
Conseils d'Apprentissage
- Ne vous précipitez pas : Chaque concept s'appuie sur les précédents
- Exécutez le code : Suivez les exemples dans votre terminal
- Expérimentez : Modifiez le code et observez ce qui se passe
- Posez des questions : Utilisez le guide de dépannage quand vous êtes bloqué
- Construisez en public : Partagez votre progression et apprenez des autres
Ce que Vous Construirez
À la fin de ce cours, vous aurez quatre systèmes distribués fonctionnels :
- Système de File - Un système de traitement des tâches tolérant aux pannes
- Magasin Répliqué - Un magasin clé-valeur avec élection de leader
- Système de Chat - Un système de messagerie en temps réel avec présence
- Système de Consensus - Une base de données distribuée basée sur Raft
Tous les systèmes fonctionnent localement en utilisant Docker Compose — aucune infrastructure cloud n'est requise !
Commençons !
Prêt à plonger ? Continuez vers Chapitre 1 : Qu'est-ce qu'un Système Distribué ?
Qu'est-ce qu'un Système Distribué ?
Session 1, Partie 1 - 20 minutes
Objectifs d'Apprentissage
- Définir ce qu'est un système distribué
- Identifier les caractéristiques clés des systèmes distribués
- Comprendre pourquoi les systèmes distribués sont importants
- Reconnaître les systèmes distribués dans la vie quotidienne
Définition
Un système distribué (distributed system) est une collection d'ordinateurs indépendants qui apparaît à ses utilisateurs comme un système cohérent unique.
graph TB
subgraph "Utilisateurs Voient"
Single["Système Unique"]
end
subgraph "Réalité"
N1["Nœud 1"]
N2["Nœud 2"]
N3["Nœud 3"]
N4["Nœud N"]
N1 <--> N2
N2 <--> N3
N3 <--> N4
N4 <--> N1
end
Single -->|"apparaît comme"| N1
Single -->|"apparaît comme"| N2
Single -->|"apparaît comme"| N3
Idée Clé
La caractéristique déterminante est l'illusion d'unité — les utilisateurs interagissent avec ce qui semble être un seul système, tandis qu'en coulisses, plusieurs machines travaillent ensemble.
Trois Caractéristiques Clés
Selon Leslie Lamport, un système distribué est :
"Un système dans lequel la défaillance d'un ordinateur dont vous ignoriez même l'existence peut rendre votre propre ordinateur inutilisable."
Cette définition met en évidence trois caractéristiques fondamentales :
1. Concurrence (Plusieurs Choses Se Produisent En Même Temps)
Plusieurs composants s'exécutent simultanément, entraînant des interactions complexes.
sequenceDiagram
participant U as Utilisateur
participant A as Serveur A
participant B as Serveur B
participant C as Serveur C
U->>A: Requête
A->>B: Requête
A->>C: Mise à jour
B-->>A: Réponse
C-->>A: Accusé
A-->>U: Résultat
2. Pas d'Horloge Globale
Chaque nœud a sa propre horloge. Il n'y a pas de "maintenant" unique dans le système.
graph LR
A[Horloge A : 10:00:01.123]
B[Horloge B : 10:00:02.456]
C[Horloge C : 09:59:59.789]
A -.->|latence réseau| B
B -.->|latence réseau| C
C -.->|latence réseau| A
Implication : Vous ne pouvez pas compter sur les horodatages pour ordonner les événements entre les nœuds. Vous avez besoin d'horloges logiques (nous en reparlerons dans les prochaines sessions !).
3. Défaillance Indépendante
Les composants peuvent tomber en panne indépendamment. Lorsqu'une partie tombe en panne, le reste peut continuer — ou peut devenir inutilisable.
stateDiagram-v2
[*] --> TousSains: Démarrage Système
TousSains --> DéfaillancePartielle: Un Nœud Tombe en Panne
TousSains --> DéfaillanceComplète: Nœuds Critiques Tombent en Panne
DéfaillancePartielle --> TousSains: Récupération
DéfaillancePartielle --> DéfaillanceComplète: Défaillance en Cascade
DéfaillanceComplète --> [*]
Pourquoi des Systèmes Distribués ?
Extensibilité
Mise à l'échelle Verticale (Scale Up) :
- Ajouter plus de ressources à une seule machine
- Finit par atteindre les limites matérielles/coût
Mise à l'échelle Horizontale (Scale Out) :
- Ajouter plus de machines au système
- Potentiel d'extensibilité pratiquement illimité
graph TB
subgraph "Mise à l'échelle Verticale"
Big[Gros Serveur Coûteux<br/>100 000 $]
end
subgraph "Mise à l'échelle Horizontale"
S1[Serveur Standard<br/>1 000 $]
S2[Serveur Standard<br/>1 000 $]
S3[Serveur Standard<br/>1 000 $]
S4[...]
end
Big <--> S1
Big <--> S2
Big <--> S3
Fiabilité et Disponibilité
Un point unique de défaillance est inacceptable pour les services critiques :
graph TB
subgraph "Système Unique"
S[Serveur Unique]
S -.-> X[❌ Défaillance = Pas de Service]
end
subgraph "Système Distribué"
N1[Nœud 1]
N2[Nœud 2]
N3[Nœud 3]
N1 <--> N2
N2 <--> N3
N3 <--> N1
N1 -.-> X2[❌ Un Tombe en Panne]
X2 --> OK[✓ Les Autres Continuent]
end
Latence (Distribution Géographique)
Placer les données plus près des utilisateurs améliore l'expérience :
graph TB
User[Utilisateur à New York]
subgraph "Distribution Globale"
NYC[Centre de Données NYC<br/>latence 10ms]
LON[Centre de Données Londres<br/>latence 70ms]
TKY[Centre de Données Tokyo<br/>latence 150ms]
end
User --> NYC
User -.-> LON
User -.-> TKY
NYC <--> LON
LON <--> TKY
TKY <--> NYC
Exemples de Systèmes Distribués
Exemples Quotidiens
| Système | Description | Avantage |
|---|---|---|
| Recherche Web | Serveurs de requêtes, serveurs d'index, serveurs de cache | Réponses rapides, toujours disponibles |
| Vidéo en Streaming | Réseaux de diffusion de contenu (CDNs) | Faible latence, haute qualité |
| Achats en Ligne | Catalogue de produits, panier, paiement, inventaire | Gère les pics de trafic |
| Réseaux Sociaux | Publications, commentaires, j'aime, notifications | Mises à jour en temps réel |
Exemples Techniques
Réplication de Base de Données :
graph LR
W[Écrire sur le Primaire] --> P[(DB Primaire)]
P --> R1[(Réplique 1)]
P --> R2[(Réplique 2)]
P --> R3[(Réplique 3)]
R1 --> Read1[Lire depuis la Réplique]
R2 --> Read2[Lire depuis la Réplique]
R3 --> Read3[Lire depuis la Réplique]
Répartition de Charge :
graph TB
Users[Utilisateurs]
LB[Répartiteur de Charge]
Users --> LB
LB --> S1[Serveur 1]
LB --> S2[Serveur 2]
LB --> S3[Serveur 3]
LB --> S4[Serveur N]
Compromis
Les systèmes distribués introduisent de la complexité :
| Défi | Description |
|---|---|
| Problèmes Réseau | Non fiable, latence variable, partitions |
| Concurrence | Conditions de course, interblocages, coordination |
| Défaillances Partielles | Certains composants fonctionnent, d'autres non |
| Cohérence | Garder les données synchronisées entre les nœuds |
Le Dilemme Fondamental :
"Les avantages de la distribution valent-ils la complexité ajoutée ?"
Pour la plupart des applications modernes, la réponse est oui — c'est pourquoi nous apprenons ceci !
Résumé
Points Clés à Retenir
- Systèmes distribués = plusieurs ordinateurs agissant comme un seul
- Trois caractéristiques : concurrence, pas d'horloge globale, défaillance indépendante
- Avantages : extensibilité, fiabilité, latence réduite
- Coûts : complexité, problèmes réseau, défis de cohérence
Vérifiez Votre Compréhension
- Pouvez-vous expliquer pourquoi il n'y a pas d'horloge globale dans un système distribué ?
- Donnez un exemple de système distribué que vous utilisez quotidiennement
- Pourquoi la défaillance indépendante rend-elle les systèmes distribués plus difficiles à construire ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.
Suite
Maintenant que nous comprenons ce que sont les systèmes distribués, explorons comment ils communiquent : Passage de Messages
Passage de Messages
Session 1, Partie 2 - 25 minutes
Objectifs d'Apprentissage
- Comprendre le passage de messages comme modèle fondamental dans les systèmes distribués
- Distinguer entre la messagerie synchrone et asynchrone
- Apprendre les différentes garanties de livraison de messages
- Implémenter le passage de messages de base en TypeScript et Python
Qu'est-ce que le Passage de Messages ?
Dans les systèmes distribués, le passage de messages (message passing) est la façon dont les nœuds communiquent. Au lieu de la mémoire partagée ou des appels de fonction directs, les composants s'envoient des messages sur le réseau.
graph LR
A[Nœud A]
B[Nœud B]
M[Message]
A -->|envoyer| M
M -->|réseau| B
B -->|traiter| M
Idée Clé
"Dans les systèmes distribués, la communication n'est pas un appel de fonction — c'est une requête envoyée sur un réseau non fiable."
Ce simple fait a des implications profondes sur tout ce que nous construisons.
Synchrone vs Asynchrone
Messagerie Synchrone (Requête-Réponse)
L'expéditeur attend une réponse avant de continuer.
sequenceDiagram
participant C as Client
participant S as Serveur
C->>S: Requête
Note over C: En attente...
S-->>C: Réponse
Note over C: Continuer
Caractéristiques :
- Simple à comprendre et à implémenter
- L'appelant est bloqué pendant l'appel
- Gestion des erreurs plus facile (retour immédiat)
- Peut entraîner de mauvaises performances et des défaillances en cascade
Messagerie Asynchrone (Fire-and-Forget)
L'expéditeur continue sans attendre de réponse.
sequenceDiagram
participant P as Producteur
participant Q as File
participant W as Worker
P->>Q: Envoyer Message
Note over P: Continuer immédiatement
Q->>W: Traiter Plus Tard
Note over W: Travail en cours...
W-->>P: Résultat (optionnel)
Caractéristiques :
- Non bloquant, meilleur débit
- Gestion des erreurs plus complexe
- Nécessite des ID de corrélation pour suivre les requêtes
- Permet un couplage souple entre les composants
Garanties de Livraison des Messages
Trois Sémantiques de Livraison
graph TB
subgraph "Au Plus Une Fois"
A1[Envoyer] --> A2[Peut être perdu]
A2 --> A3[Jamais dupliqué]
end
subgraph "Au Moins Une Fois"
B1[Envoyer] --> B2[Réessayer jusqu'à accusé]
B2 --> B3[Peut être dupliqué]
end
subgraph "Exactement Une Fois"
C1[Envoyer] --> C2[Déduplication]
C2 --> C3[Livraison parfaite]
end
Comparaison
| Garantie | Description | Coût | Cas d'Usage |
|---|---|---|---|
| Au Plus Une Fois | Le message peut être perdu, jamais dupliqué | Le plus bas | Journaux, métriques, données non critiques |
| Au Moins Une Fois | Le message garanti d'arriver, peut être dupliqué | Moyen | Notifications, files de tâches |
| Exactement Une Fois | Livraison parfaite, pas de doublons | Le plus élevé | Transactions financières, paiements |
Le Problème des Deux Généraux
Une preuve classique que la communication parfaite est impossible dans les réseaux non fiables :
graph LR
A[Général A<br/>Ville 1]
B[Général B<br/>Ville 2]
A -->|"Attaque à 20h ?"| B
B -->|"Acc : reçu"| A
A -->|"Acc : accusé reçu"| B
B -->|"Acc : accusé de l'accusé reçu"| A
Note[A : messages infinis nécessaires]
Implication : Vous ne pouvez jamais être certain à 100 % qu'un message a été reçu sans accusés infinis.
En pratique, nous acceptons l'incertitude et concevons des systèmes qui la tolèrent.
Modèles d'Architecture
Communication Directe
graph LR
A[Service A] --> B[Service B]
A --> C[Service C]
B --> D[Service D]
C --> D
- Simple, direct
- Couplage fort
- Difficile à faire évoluer indépendamment
File de Messages (Communication Indirecte)
graph TB
P[Producteur 1] --> Q[File de Messages]
P2[Producteur 2] --> Q
P3[Producteur N] --> Q
Q --> W1[Worker 1]
Q --> W2[Worker 2]
Q --> W3[Worker N]
- Couplage souple
- Facile à faire évoluer
- Met en tampon les requêtes pendant les pics de trafic
- Permet les nouvelles tentatives et la gestion des erreurs
Exemples d'Implémentation
TypeScript : HTTP (Synchrone)
// server.ts
import http from 'http';
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/message') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const message = JSON.parse(body);
console.log('Received:', message);
// Renvoyer la réponse (synchrone)
res.writeHead(200);
res.end(JSON.stringify({ status: 'processed', id: message.id }));
});
}
});
server.listen(3000, () => console.log('Server on :3000'));
// client.ts
import http from 'http';
function sendMessage(data: any): Promise<any> {
return new Promise((resolve, reject) => {
const postData = JSON.stringify(data);
const options = {
hostname: 'localhost',
port: 3000,
method: 'POST',
path: '/message',
headers: { 'Content-Type': 'application/json' }
};
const req = http.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => resolve(JSON.parse(body)));
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
// Usage : attend la réponse
sendMessage({ id: '1', content: 'Hello' })
.then(response => console.log('Got:', response));
Python : HTTP (Synchrone)
# server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class MessageHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/message':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
message = json.loads(post_data.decode())
print(f"Received: {message}")
# Renvoyer la réponse (synchrone)
response = json.dumps({'status': 'processed', 'id': message['id']})
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(response.encode())
server = HTTPServer(('localhost', 3000), MessageHandler)
print("Server on :3000")
server.serve_forever()
# client.py
import requests
import json
def send_message(data):
# Synchrone : attend la réponse
response = requests.post(
'http://localhost:3000/message',
json=data
)
return response.json()
# Usage
result = send_message({'id': '1', 'content': 'Hello'})
print(f"Got: {result}")
TypeScript : File Simple (Asynchrone)
// queue.ts
interface Message {
id: string;
data: any;
timestamp: number;
}
class MessageQueue {
private messages: Message[] = [];
private handlers: Map<string, (msg: Message) => void> = new Map();
publish(topic: string, data: any): string {
const message: Message = {
id: `${Date.now()}-${Math.random()}`,
data,
timestamp: Date.now()
};
this.messages.push(message);
console.log(`Published to ${topic}:`, message.id);
// Fire and forget - ne pas attendre le traitement
setImmediate(() => this.process(topic, message));
return message.id;
}
subscribe(topic: string, handler: (msg: Message) => void) {
this.handlers.set(topic, handler);
}
private process(topic: string, message: Message) {
const handler = this.handlers.get(topic);
if (handler) {
// Traiter de manière asynchrone - l'appelant n'attend pas
handler(message);
}
}
}
// Usage
const queue = new MessageQueue();
queue.subscribe('tasks', (msg) => {
console.log(`Processing task ${msg.id}:`, msg.data);
// Simuler un travail asynchrone
setTimeout(() => console.log(`Task ${msg.id} complete`), 1000);
});
// Publish retourne immédiatement - n'attend pas le traitement
const taskId = queue.publish('tasks', { type: 'email', to: 'user@example.com' });
console.log(`Task ${taskId} queued (not yet processed)`);
Python : File Simple (Asynchrone)
# queue.py
import time
import threading
from dataclasses import dataclass
from typing import Callable, Dict, Any
import uuid
@dataclass
class Message:
id: str
data: Any
timestamp: float
class MessageQueue:
def __init__(self):
self.messages = []
self.handlers: Dict[str, Callable[[Message], None]] = {}
self.lock = threading.Lock()
def publish(self, topic: str, data: Any) -> str:
message = Message(
id=f"{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}",
data=data,
timestamp=time.time()
)
with self.lock:
self.messages.append(message)
print(f"Published to {topic}: {message.id}")
# Fire and forget - ne pas attendre le traitement
threading.Thread(
target=self._process,
args=(topic, message),
daemon=True
).start()
return message.id
def subscribe(self, topic: str, handler: Callable[[Message], None]):
self.handlers[topic] = handler
def _process(self, topic: str, message: Message):
handler = self.handlers.get(topic)
if handler:
# Traiter de manière asynchrone - l'appelant n'attend pas
handler(message)
# Usage
queue = MessageQueue()
def handle_task(msg: Message):
print(f"Processing task {msg.id}: {msg.data}")
# Simuler un travail asynchrone
time.sleep(1)
print(f"Task {msg.id} complete")
queue.subscribe('tasks', handle_task)
# Publish retourne immédiatement - n'attend pas le traitement
task_id = queue.publish('tasks', {'type': 'email', 'to': 'user@example.com'})
print(f"Task {task_id} queued (not yet processed)")
# Garder le thread principal en vie pour voir le traitement
time.sleep(2)
Modèles de Messages Courants
Requête-Réponse
// Appeler et attendre la réponse
const answer = await ask(question);
Fire-and-Forget
// Envoyer et continuer
notify(user);
Publier-S'Abonner
// Plusieurs récepteurs, un expéditeur
broker.publish('events', data);
Requête-Réponse (avec Corrélation)
// Envoyer la requête, obtenir la réponse plus tard
const replyTo = createReplyQueue();
broker.send(request, { replyTo });
// ... plus tard
const reply = await replyTo.receive();
Gestion des Erreurs
Le passage de messages sur les réseaux n'est pas fiable. Problèmes courants :
| Erreur | Cause | Stratégie de Gestion |
|---|---|---|
| Délai d'attente | Pas de réponse, réseau lent | Réessayer avec attente progressive |
| Connexion Refusée | Service indisponible | Disjoncteur, mettre en file pour plus tard |
| Message Perdu | Défaillance du réseau | Accusés de réception, nouvelles tentatives |
| Duplication | Nouvelle tentative après accusé lent | Opérations idempotentes |
Modèle de Nouvelle Tentative
async function sendMessageWithRetry(
message: any,
maxRetries = 3
): Promise<any> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await sendMessage(message);
} catch (error) {
if (attempt === maxRetries) throw error;
// Attente exponentielle : 100ms, 200ms, 400ms
const delay = 100 * Math.pow(2, attempt - 1);
await new Promise(r => setTimeout(r, delay));
console.log(`Retry ${attempt}/${maxRetries}`);
}
}
}
Résumé
Points Clés à Retenir
- Passage de messages = comment les systèmes distribués communiquent
- Synchrone = attendre la réponse ; Asynchrone = fire and forget
- Garanties de livraison : au-plus-une-fois, au-moins-une-fois, exactement-une-fois
- Le réseau n'est pas fiable - concevez pour les défaillances et les nouvelles tentatives
- Choisissez le bon modèle pour votre cas d'usage
Vérifiez Votre Compréhension
- Quand utiliseriez-vous la messagerie synchrone vs asynchrone ?
- Quelle est la différence entre au-moins-une-fois et exactement-une-fois ?
- Pourquoi la communication parfaite est-elle impossible dans les systèmes distribués ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.
Suite
Appliquons maintenant le passage de messages pour construire notre premier système distribué : Implémentation du Système de File
Implémentation du Système de File
Session 2 - Session complète (90 minutes)
Objectifs d'Apprentissage
- Comprendre le modèle producteur-consommateur
- Construire un système de file fonctionnel avec des workers concurrents
- Implémenter la tolérance aux pannes avec une logique de nouvelle tentative
- Déployer et tester le système avec Docker Compose
Le Modèle Producteur-Consommateur
Le modèle producteur-consommateur (producer-consumer pattern) est un modèle fondamental des systèmes distribués où :
- Les Producteurs créent et envoient des tâches à une file
- La File met en tampon les tâches entre les producteurs et les consommateurs
- Les Workers (consommateurs) traitent les tâches de la file
graph TB
subgraph "Producteurs"
P1[Producteur 1<br/>Serveur API]
P2[Producteur 2<br/>Planificateur]
P3[Producteur N<br/>Webhook]
end
subgraph "File"
Q[File de Messages<br/>Tampon de Tâches]
end
subgraph "Workers"
W1[Worker 1<br/>Processus]
W2[Worker 2<br/>Processus]
W3[Worker 3<br/>Processus]
end
P1 --> Q
P2 --> Q
P3 --> Q
Q --> W1
Q --> W2
Q --> W3
style Q fill:#f9f,stroke:#333,stroke-width:4px
Avantages Clés
| Avantage | Explication |
|---|---|
| Découplage | Les producteurs n'ont pas besoin de connaître les workers |
| Mise en Tampon | La file gère les pics de trafic |
| Extensibilité | Ajoutez/supprimez des workers indépendamment |
| Fiabilité | Les tâches persistent si les workers tombent en panne |
| Nouvelle Tentative | Les tâches échouées peuvent être remises en file |
Architecture du Système
Vue Complète du Système
sequenceDiagram
participant C as Client
participant P as Producteur
participant Q as File
participant W as Worker
participant DB as Magasin de Résultats
C->>P: HTTP POST /task
P->>Q: Mettre en File Tâche
Q-->>P: ID de Tâche
P-->>C: 202 Accepté
Note over Q,W: Traitement Asynchrone
Q->>W: Récupérer Tâche
W->>W: Traiter Tâche
W->>DB: Sauvegarder Résultat
W->>Q: Ack (Succès)
Q->>Q: Supprimer Tâche
Cycle de Vie d'une Tâche
stateDiagram-v2
[*] --> EnAttente: Création par le Producteur
EnAttente --> EnCours: Récupération par le Worker
EnCours --> Terminé: Succès
EnCours --> Échoué: Erreur
EnCours --> EnAttente: Nouvelle Tentative
Échoué --> EnAttente: Nombre max de nouvelles tentatives non atteint
Échoué --> LettreMorte: Nombre max de nouvelles tentatives atteint
Terminé --> [*]
LettreMorte --> [*]
Implémentation
Modèles de Données
Définition de Tâche :
interface Task {
id: string;
type: string; // 'email', 'image', 'report', etc.
payload: any;
status: 'pending' | 'processing' | 'completed' | 'failed';
createdAt: number;
retries: number;
maxRetries: number;
result?: any;
error?: string;
}
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class Task:
id: str
type: str # 'email', 'image', 'report', etc.
payload: Any
status: str = 'pending' # pending, processing, completed, failed
created_at: float = field(default_factory=time.time)
retries: int = 0
max_retries: int = 3
result: Optional[Any] = None
error: Optional[str] = None
Implémentation TypeScript
Structure du Projet
queue-system-ts/
├── package.json
├── docker-compose.yml
├── src/
│ ├── queue.ts # Implémentation de la file
│ ├── producer.ts # API du producteur
│ ├── worker.ts # Implémentation du worker
│ └── types.ts # Définitions de types
└── Dockerfile
Code TypeScript Complet
queue-system-ts/src/types.ts
export interface Task {
id: string;
type: string;
payload: any;
status: 'pending' | 'processing' | 'completed' | 'failed';
createdAt: number;
retries: number;
maxRetries: number;
result?: any;
error?: string;
}
export interface QueueMessage {
task: Task;
timestamp: number;
}
queue-system-ts/src/queue.ts
import { Task, QueueMessage } from './types';
export class Queue {
private pending: Task[] = [];
private processing: Map<string, Task> = new Map();
private completed: Task[] = [];
private failed: Task[] = [];
// Mettre en file une nouvelle tâche
enqueue(type: string, payload: any): string {
const task: Task = {
id: this.generateId(),
type,
payload,
status: 'pending',
createdAt: Date.now(),
retries: 0,
maxRetries: 3
};
this.pending.push(task);
console.log(`[Queue] Enqueued task ${task.id} (${type})`);
return task.id;
}
// Obtenir la prochaine tâche en attente (pour les workers)
dequeue(): Task | null {
if (this.pending.length === 0) return null;
const task = this.pending.shift()!;
task.status = 'processing';
this.processing.set(task.id, task);
console.log(`[Queue] Dequeued task ${task.id}`);
return task;
}
// Marquer la tâche comme terminée
complete(taskId: string, result?: any): void {
const task = this.processing.get(taskId);
if (!task) return;
task.status = 'completed';
task.result = result;
this.processing.delete(taskId);
this.completed.push(task);
console.log(`[Queue] Completed task ${taskId}`);
}
// Marquer la tâche comme échouée (réessayer si possible)
fail(taskId: string, error: string): void {
const task = this.processing.get(taskId);
if (!task) return;
task.retries++;
task.error = error;
if (task.retries >= task.maxRetries) {
task.status = 'failed';
this.processing.delete(taskId);
this.failed.push(task);
console.log(`[Queue] Task ${taskId} failed permanently after ${task.retries} retries`);
} else {
task.status = 'pending';
this.processing.delete(taskId);
this.pending.push(task);
console.log(`[Queue] Task ${taskId} failed, retrying (${task.retries}/${task.maxRetries})`);
}
}
// Obtenir les statistiques de la file
getStats() {
return {
pending: this.pending.length,
processing: this.processing.size,
completed: this.completed.length,
failed: this.failed.length
};
}
private generateId(): string {
return `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
queue-system-ts/src/producer.ts
import http from 'http';
import { Queue } from './queue';
const queue = new Queue();
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/task') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const { type, payload } = JSON.parse(body);
if (!type || !payload) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'type and payload required' }));
return;
}
const taskId = queue.enqueue(type, payload);
res.writeHead(202); // Accepted
res.end(JSON.stringify({
taskId,
message: 'Task enqueued',
stats: queue.getStats()
}));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
} else if (req.method === 'GET' && req.url === '/stats') {
res.writeHead(200);
res.end(JSON.stringify(queue.getStats()));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
}
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Producer API listening on port ${PORT}`);
});
export { queue };
queue-system-ts/src/worker.ts
import http from 'http';
import { Queue, Task } from './types';
// Simuler le traitement de tâches
async function processTask(task: Task): Promise<any> {
console.log(`[Worker] Processing task ${task.id} (${task.type})`);
// Simuler le travail
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));
// Simuler des échocs occasionnels (20% de chance)
if (Math.random() < 0.2) {
throw new Error('Random processing error');
}
// Traiter en fonction du type de tâche
switch (task.type) {
case 'email':
return { sent: true, to: task.payload.to };
case 'image':
return { processed: true, url: task.payload.url };
case 'report':
return { generated: true, format: 'pdf' };
default:
return { result: 'processed' };
}
}
class Worker {
private id: string;
private queueUrl: string;
private running: boolean = false;
constructor(id: string, queueUrl: string) {
this.id = id;
this.queueUrl = queueUrl;
}
async start(): Promise<void> {
this.running = true;
console.log(`[Worker ${this.id}] Started`);
while (this.running) {
try {
await this.processNextTask();
} catch (error) {
console.error(`[Worker ${this.id}] Error:`, error);
await this.sleep(1000); // Attendre avant de réessayer
}
}
}
private async processNextTask(): Promise<void> {
// Récupérer la tâche de la file
const task = await this.fetchTask();
if (!task) {
await this.sleep(1000); // Pas de tâche, attendre
return;
}
try {
// Traiter la tâche
const result = await processTask(task);
// Marquer comme terminée
await this.completeTask(task.id, result);
} catch (error: any) {
// Marquer comme échouée
await this.failTask(task.id, error.message);
}
}
private async fetchTask(): Promise<Task | null> {
return new Promise((resolve, reject) => {
http.get(`${this.queueUrl}/dequeue`, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 204) {
resolve(null); // Aucune tâche disponible
} else if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Unexpected status: ${res.statusCode}`));
}
});
}).on('error', reject);
});
}
private async completeTask(taskId: string, result: any): Promise<void> {
return new Promise((resolve, reject) => {
const data = JSON.stringify({ result });
http.request({
hostname: 'localhost',
port: 3000,
path: `/complete/${taskId}`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
}, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Failed to complete task: ${res.statusCode}`));
}
}).on('error', reject).end(data);
});
}
private async failTask(taskId: string, error: string): Promise<void> {
return new Promise((resolve, reject) => {
const data = JSON.stringify({ error });
http.request({
hostname: 'localhost',
port: 3000,
path: `/fail/${taskId}`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
}, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Failed to fail task: ${res.statusCode}`));
}
}).on('error', reject).end(data);
});
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
stop(): void {
this.running = false;
}
}
// Démarrer le worker
const workerId = process.env.WORKER_ID || 'worker-1';
const worker = new Worker(workerId, 'http://localhost:3000');
worker.start();
Implémentation Python
Structure du Projet
queue-system-py/
├── requirements.txt
├── docker-compose.yml
├── src/
│ ├── queue.py # Implémentation de la file
│ ├── producer.py # API du producteur
│ └── worker.py # Implémentation du worker
└── Dockerfile
Code Python Complet
queue-system-py/src/queue.py
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional, List, Dict
from enum import Enum
class TaskStatus(Enum):
PENDING = 'pending'
PROCESSING = 'processing'
COMPLETED = 'completed'
FAILED = 'failed'
@dataclass
class Task:
id: str
type: str
payload: Any
status: str = TaskStatus.PENDING.value
created_at: float = field(default_factory=time.time)
retries: int = 0
max_retries: int = 3
result: Optional[Any] = None
error: Optional[str] = None
class Queue:
def __init__(self):
self.pending: List[Task] = []
self.processing: Dict[str, Task] = {}
self.completed: List[Task] = []
self.failed: List[Task] = []
def enqueue(self, task_type: str, payload: Any) -> str:
"""Mettre en file une nouvelle tâche."""
task = Task(
id=f"task-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}",
type=task_type,
payload=payload
)
self.pending.append(task)
print(f"[Queue] Enqueued task {task.id} ({task_type})")
return task.id
def dequeue(self) -> Optional[Task]:
"""Obtenir la prochaine tâche en attente."""
if not self.pending:
return None
task = self.pending.pop(0)
task.status = TaskStatus.PROCESSING.value
self.processing[task.id] = task
print(f"[Queue] Dequeued task {task.id}")
return task
def complete(self, task_id: str, result: Any = None) -> None:
"""Marquer la tâche comme terminée."""
task = self.processing.pop(task_id, None)
if not task:
return
task.status = TaskStatus.COMPLETED.value
task.result = result
self.completed.append(task)
print(f"[Queue] Completed task {task_id}")
def fail(self, task_id: str, error: str) -> None:
"""Marquer la tâche comme échouée (réessayer si possible)."""
task = self.processing.pop(task_id, None)
if not task:
return
task.retries += 1
task.error = error
if task.retries >= task.max_retries:
task.status = TaskStatus.FAILED.value
self.failed.append(task)
print(f"[Queue] Task {task_id} failed permanently after {task.retries} retries")
else:
task.status = TaskStatus.PENDING.value
self.pending.append(task)
print(f"[Queue] Task {task_id} failed, retrying ({task.retries}/{task.max_retries})")
def get_stats(self) -> Dict[str, int]:
"""Obtenir les statistiques de la file."""
return {
'pending': len(self.pending),
'processing': len(self.processing),
'completed': len(self.completed),
'failed': len(self.failed)
}
queue-system-py/src/producer.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
from queue import Queue
queue = Queue()
class ProducerHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/task':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
try:
data = json.loads(post_data.decode())
task_type = data.get('type')
payload = data.get('payload')
if not task_type or not payload:
self.send_error(400, 'type and payload required')
return
task_id = queue.enqueue(task_type, payload)
response = json.dumps({
'taskId': task_id,
'message': 'Task enqueued',
'stats': queue.get_stats()
})
self.send_response(202) # Accepted
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(response.encode())
except json.JSONDecodeError:
self.send_error(400, 'Invalid JSON')
def do_GET(self):
if self.path == '/stats':
response = json.dumps(queue.get_stats())
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(response.encode())
def log_message(self, format, *args):
pass # Supprimer la journalisation par défaut
if __name__ == '__main__':
import os
port = int(os.environ.get('PORT', 3000))
server = HTTPServer(('0.0.0.0', port), ProducerHandler)
print(f"Producer API listening on port {port}")
server.serve_forever()
queue-system-py/src/worker.py
import os
import time
import random
import requests
from typing import Optional, Dict, Any
from queue import Task
# Simuler le traitement de tâches
def process_task(task: Task) -> Any:
print(f"[Worker] Processing task {task.id} ({task.type})")
# Simuler le travail
time.sleep(1 + random.random() * 2)
# Simuler des échecs occasionnels (20% de chance)
if random.random() < 0.2:
raise Exception('Random processing error')
# Traiter en fonction du type de tâche
if task.type == 'email':
return {'sent': True, 'to': task.payload.get('to')}
elif task.type == 'image':
return {'processed': True, 'url': task.payload.get('url')}
elif task.type == 'report':
return {'generated': True, 'format': 'pdf'}
else:
return {'result': 'processed'}
class Worker:
def __init__(self, worker_id: str, queue_url: str):
self.id = worker_id
self.queue_url = queue_url
self.running = False
def start(self):
"""Démarrer la boucle du worker."""
self.running = True
print(f"[Worker {self.id}] Started")
while self.running:
try:
self.process_next_task()
except Exception as e:
print(f"[Worker {self.id}] Error: {e}")
time.sleep(1)
def process_next_task(self):
"""Récupérer et traiter la prochaine tâche."""
task = self.fetch_task()
if not task:
time.sleep(1) # Pas de tâche, attendre
return
try:
result = process_task(task)
self.complete_task(task['id'], result)
except Exception as e:
self.fail_task(task['id'], str(e))
def fetch_task(self) -> Optional[Dict]:
"""Récupérer la prochaine tâche de la file."""
try:
response = requests.get(f"{self.queue_url}/dequeue", timeout=5)
if response.status_code == 204:
return None # Aucune tâche
return response.json()
except requests.RequestException:
return None
def complete_task(self, task_id: str, result: Any):
"""Marquer la tâche comme terminée."""
requests.post(
f"{self.queue_url}/complete/{task_id}",
json={'result': result},
timeout=5
)
def fail_task(self, task_id: str, error: str):
"""Marquer la tâche comme échouée."""
requests.post(
f"{self.queue_url}/fail/{task_id}",
json={'error': error},
timeout=5
)
def stop(self):
"""Arrêter le worker."""
self.running = False
if __name__ == '__main__':
worker_id = os.environ.get('WORKER_ID', 'worker-1')
queue_url = os.environ.get('QUEUE_URL', 'http://localhost:3000')
worker = Worker(worker_id, queue_url)
worker.start()
Configuration Docker Compose
Version TypeScript (docker-compose.yml)
version: '3.8'
services:
producer:
build: ./src
ports:
- "3000:3000"
environment:
- PORT=3000
volumes:
- ./src:/app/src
command: npm run start:producer
worker-1:
build: ./src
environment:
- WORKER_ID=worker-1
depends_on:
- producer
volumes:
- ./src:/app/src
command: npm run start:worker
worker-2:
build: ./src
environment:
- WORKER_ID=worker-2
depends_on:
- producer
volumes:
- ./src:/app/src
command: npm run start:worker
worker-3:
build: ./src
environment:
- WORKER_ID=worker-3
depends_on:
- producer
volumes:
- ./src:/app/src
command: npm run start:worker
Dockerfile TypeScript
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "start:producer"]
Version Python (docker-compose.yml)
version: '3.8'
services:
producer:
build: ./src
ports:
- "3000:3000"
environment:
- PORT=3000
volumes:
- ./src:/app/src
command: python src/producer.py
worker-1:
build: ./src
environment:
- WORKER_ID=worker-1
- QUEUE_URL=http://producer:3000
depends_on:
- producer
volumes:
- ./src:/app/src
command: python src/worker.py
worker-2:
build: ./src
environment:
- WORKER_ID=worker-2
- QUEUE_URL=http://producer:3000
depends_on:
- producer
volumes:
- ./src:/app/src
command: python src/worker.py
worker-3:
build: ./src
environment:
- WORKER_ID=worker-3
- QUEUE_URL=http://producer:3000
depends_on:
- producer
volumes:
- ./src:/app/src
command: python src/worker.py
Dockerfile Python
FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "src/producer.py"]
Exécution de l'Exemple
Étape 1 : Démarrer le Système
cd examples/01-queue
docker-compose up --build
Vous devriez voir une sortie comme :
producer | Producer API listening on port 3000
worker-1 | [Worker worker-1] Started
worker-2 | [Worker worker-2] Started
worker-3 | [Worker worker-3] Started
Étape 2 : Soumettre des Tâches
Ouvrez un nouveau terminal et soumettez quelques tâches :
# Soumettre une tâche email
curl -X POST http://localhost:3000/task \
-H "Content-Type: application/json" \
-d '{"type": "email", "payload": {"to": "user@example.com", "subject": "Hello"}}'
# Soumettre une tâche de traitement d'image
curl -X POST http://localhost:3000/task \
-H "Content-Type: application/json" \
-d '{"type": "image", "payload": {"url": "https://example.com/image.jpg"}}'
# Soumettre plusieurs tâches
for i in {1..10}; do
curl -X POST http://localhost:3000/task \
-H "Content-Type: application/json" \
-d "{\"type\": \"report\", \"payload\": {\"id\": $i}}"
done
Étape 3 : Observer le Traitement
Dans les journaux Docker, vous verrez :
worker-2 | [Queue] Dequeued task task-1234567890-abc123
worker-2 | [Worker] Processing task task-1234567890-abc123 (report)
worker-2 | [Queue] Completed task task-1234567890-abc123
Étape 4 : Vérifier les Statistiques
curl http://localhost:3000/stats
Réponse :
{
"pending": 5,
"processing": 3,
"completed": 12,
"failed": 0
}
Étape 5 : Tester la Tolérance aux Pannes
Arrêtez un worker :
docker-compose stop worker-1
Les tâches continuent d'être traitées par les workers restants. La file gère automatiquement la redistribution de la charge.
Exercices
Exercice 1 : Ajouter le Support des Priorités
Modifiez la file pour prendre en charge les tâches de priorité haute/normale/basse :
- Ajoutez un champ
priorityau modèle de Tâche - Modifiez
enqueue()pour trier les tâches en attente par priorité - Testez avec des tâches de priorité mixte
Exercice 2 : Implémenter une File des Lettres Mortes
Créez une file séparée pour les tâches définitivement échouées :
- Ajoutez une file
dead_letterpour stocker les tâches échouées - Ajoutez un point de terminaison API pour inspecter/réessayer les tâches de lettres mortes
- Journalisez les tâches échouées dans un fichier pour inspection manuelle
Exercice 3 : Ajouter la Planification de Tâches
Implémentez l'exécution différée des tâches :
- Ajoutez un horodatage
executeAtaux tâches - Modifiez les workers pour ignorer les tâches planifiées dans le futur
- Utilisez une minuterie/planificateur pour déplacer les tâches planifiées vers la file en attente
Résumé
Points Clés à Retenir
- Modèle producteur-consommateur découple la création de tâches du traitement
- Les files mettent en tampon les tâches et gèrent les pics de trafic
- Les workers évoluent indépendamment des producteurs
- La logique de nouvelle tentative fournit une tolérance aux pannes
- Docker Compose permet un déploiement local facile
Vérifiez Votre Compréhension
- Comment la file gère-t-elle les défaillances de workers ?
- Que se passe-t-il lorsqu'une tâche échoue et que le nombre max de nouvelles tentatives est atteint ?
- Pourquoi la file est-elle utile pour gérer les pics de trafic ?
- Comment ajouteriez-vous un nouveau type de worker (par exemple, un worker qui traite uniquement les emails) ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.
Suite
Maintenant que nous avons construit un système de file, explorons comment partitionner les données sur plusieurs nœuds : Partitionnement des Données
Partitionnement des Données
Session 3, Partie 1 - 25 minutes
Objectifs d'Apprentissage
- Comprendre ce qu'est le partitionnement des données (sharding)
- Comparer le partitionnement basé sur le hachage vs par plage
- Apprendre comment le partitionnement affecte les performances des requêtes
- Reconnaître les compromis des différentes stratégies de partitionnement
Qu'est-ce que le Partitionnement ?
Le partitionnement des données (aussi appelé sharding) est le processus de répartition de vos données sur plusieurs nœuds basé sur une clé de partitionnement. Chaque nœud contient un sous-ensemble des données totales.
graph TB
subgraph "Vue de l'Application"
App["Votre Application"]
Data[("Toutes les Données")]
App --> Data
end
subgraph "Réalité : Stockage Partitionné"
Node1["Nœud 1<br/>Clés : user_1<br/>user_4<br/>user_7"]
Node2["Nœud 2<br/>Clés : user_2<br/>user_5<br/>user_8"]
Node3["Nœud 3<br/>Clés : user_3<br/>user_6<br/>user_9"]
end
App -->|"lecture/écriture"| Node1
App -->|"lecture/écriture"| Node2
App -->|"lecture/écriture"| Node3
style Node1 fill:#e1f5fe
style Node2 fill:#e1f5fe
style Node3 fill:#e1f5fe
Pourquoi Partitionner les Données ?
| Avantage | Description |
|---|---|
| Mise à l'échelle | Stocker plus de données que ce qui tient sur une seule machine |
| Performance | Distribuer la charge sur plusieurs nœuds |
| Disponibilité | La défaillance d'une partition n'affecte pas les autres |
Le Défi du Partitionnement
La question clé est : Comment décider quelles données vont sur quel nœud ?
graph LR
Key["user:12345"] --> Router{Fonction de<br/>Partitionnement}
Router -->|"hash(clé) % N"| N1[Nœud 1]
Router --> N2[Nœud 2]
Router --> N3[Nœud 3]
style Router fill:#ff9,stroke:#333,stroke-width:3px
Stratégies de Partitionnement
1. Partitionnement Basé sur le Hachage
Appliquer une fonction de hachage à la clé, puis modulo le nombre de nœuds :
nœud = hash(clé) % nombre_de_nœuds
graph TB
subgraph "Partitionnement Basé sur le Hachage (3 nœuds)"
Key1["user:alice"] --> H1["hash() % 3"]
Key2["user:bob"] --> H2["hash() % 3"]
Key3["user:carol"] --> H3["hash() % 3"]
H1 -->|"= 1"| N1[Nœud 1]
H2 -->|"= 2"| N2[Nœud 2]
H3 -->|"= 0"| N0[Nœud 0]
style N1 fill:#c8e6c9
style N2 fill:#c8e6c9
style N0 fill:#c8e6c9
end
Exemple TypeScript :
function getNode(key: string, totalNodes: number): number {
// Fonction de hachage simple
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash) + key.charCodeAt(i);
hash = hash & hash; // Convertir en entier 32bit
}
return Math.abs(hash) % totalNodes;
}
// Exemples
console.log(getNode('user:alice', 3)); // => 1
console.log(getNode('user:bob', 3)); // => 2
console.log(getNode('user:carol', 3)); // => 0
Exemple Python :
def get_node(key: str, total_nodes: int) -> int:
"""Déterminer quel nœud doit stocker cette clé."""
hash_value = hash(key) # Fonction de hachage intégrée
return abs(hash_value) % total_nodes
# Exemples
print(get_node('user:alice', 3)) # => 1
print(get_node('user:bob', 3)) # => 2
print(get_node('user:carol', 3)) # => 0
Avantages :
- ✅ Distribution uniforme des données
- ✅ Simple à implémenter
- ✅ Pas de points chauds (en supposant une bonne fonction de hachage)
Désavantages :
- ❌ Ne permet pas des requêtes de plage efficaces
- ❌ Le rééquilibrage est coûteux lors de l'ajout/suppression de nœuds
2. Partitionnement Basé sur la Plage
Assigner des plages de clés à chaque nœud :
graph TB
subgraph "Partitionnement Basé sur la Plage (3 nœuds)"
R1["Nœud 1<br/>a-m"]
R2["Nœud 2<br/>n-S"]
R3["Nœud 3<br/>t-Z"]
Key1["alice"] --> R1
Key2["bob"] --> R1
Key3["nancy"] --> R2
Key4["steve"] --> R2
Key5["tom"] --> R3
Key6["zoe"] --> R3
style R1 fill:#c8e6c9
style R2 fill:#c8e6c9
style R3 fill:#c8e6c9
end
Exemple TypeScript :
interface Range {
start: string;
end: string;
node: number;
}
const ranges: Range[] = [
{ start: 'a', end: 'm', node: 1 },
{ start: 'n', end: 's', node: 2 },
{ start: 't', end: 'z', node: 3 }
];
function getNodeByRange(key: string): number {
for (const range of ranges) {
if (key >= range.start && key <= range.end) {
return range.node;
}
}
throw new Error(`Aucune plage trouvée pour la clé : ${key}`);
}
// Exemples
console.log(getNodeByRange('alice')); // => 1
console.log(getNodeByRange('nancy')); // => 2
console.log(getNodeByRange('tom')); // => 3
Exemple Python :
from typing import List, Tuple
ranges: List[Tuple[str, str, int]] = [
('a', 'm', 1),
('n', 's', 2),
('t', 'z', 3)
]
def get_node_by_range(key: str) -> int:
"""Déterminer quel nœud basé sur la plage de clés."""
for start, end, node in ranges:
if start <= key <= end:
return node
raise ValueError(f"Aucune plage trouvée pour la clé : {key}")
# Exemples
print(get_node_by_range('alice')) # => 1
print(get_node_by_range('nancy')) # => 2
print(get_node_by_range('tom')) # => 3
Avantages :
- ✅ Requêtes de plage efficaces
- ✅ Peut optimiser pour les modèles d'accès aux données
Désavantages :
- ❌ Distribution inégale (points chauds)
- ❌ Complexe à équilibrer la charge
Le Problème du Rééquilibrage
Que se passe-t-il lorsque vous ajoutez ou supprimez des nœuds ?
stateDiagram-v2
[*] --> Stable: 3 Nœuds
Stable --> Rééquilibrage: Ajouter Nœud 4
Rééquilibrage --> Stable: Déplacer 25% des données
Stable --> Rééquilibrage: Supprimer Nœud 2
Rééquilibrage --> Stable: Redistribuer les données
Problème du Hachage Modulo Simple
Avec hash(clé) % N, changer N de 3 à 4 signifie que la plupart des clés se déplacent vers différents nœuds :
| Clé | hash % 3 | hash % 4 | Déplacée ? |
|---|---|---|---|
| user:1 | 1 | 1 | ❌ |
| user:2 | 2 | 2 | ❌ |
| user:3 | 0 | 3 | ✅ |
| user:4 | 1 | 0 | ✅ |
| user:5 | 2 | 1 | ✅ |
| user:6 | 0 | 2 | ✅ |
75% des clés se sont déplacées !
Hachage Cohérent (Avancé)
Une technique pour minimiser le déplacement de données lorsque les nœuds changent :
graph TB
subgraph "Anneau de Hachage"
Ring["Anneau Virtuel (0 - 2^32)"]
N1["Nœud 1<br/>position : 100"]
N2["Nœud 2<br/>position : 500"]
N3["Nœud 3<br/>position : 900"]
K1["Clé A<br/>hash : 150"]
K2["Clé B<br/>hash : 600"]
K3["Clé C<br/>hash : 950"]
end
Ring --> N1
Ring --> N2
Ring --> N3
K1 -->|"sens horaire"| N2
K2 -->|"sens horaire"| N3
K3 -->|"sens horaire"| N1
style Ring fill:#f9f,stroke:#333,stroke-width:2px
Idée Clé : Chaque clé est assignée au premier nœud dans le sens horaire à partir de sa position de hachage.
Lors de l'ajout/suppression d'un nœud, seules les clés dans la plage de ce nœud se déplacent.
Modèles de Requêtes et Partitionnement
Vos modèles de requêtes devraient influencer votre stratégie de partitionnement :
Modèles de Requêtes Courants
| Type de Requête | Meilleur Partitionnement | Exemple |
|---|---|---|
| Recherches clé-valeur | Basé sur le hachage | Obtenir un utilisateur par ID |
| Analyses de plage | Basé sur la plage | Utilisateurs inscrits la semaine dernière |
| Accès multi-clés | Hachage composite | Commandes par client |
| Requêtes géographiques | Basé sur la localisation | Restaurants proches |
Exemple : Partitionnement des Données Utilisateur
graph TB
subgraph "Application : Réseau Social"
Query1["Obtenir le Profil Utilisateur<br/>SELECT * FROM users WHERE id = ?"]
Query2["Lister les Amis<br/>SELECT * FROM friends WHERE user_id = ?"]
Query3["Publications de Timeline<br/>SELECT * FROM posts WHERE created_at > ?"]
end
subgraph "Décision de Partitionnement"
Query1 -->|"hash(user_id)"| Hash[Hachage]
Query2 -->|"hash(user_id)"| Hash
Query3 -->|"range(created_at)"| Range[Plage]
end
subgraph "Résultat"
Hash --> H["Données utilisateur & amis<br/>partitionnées par user_id"]
Range --> R["Publications partitionnées<br/>par plage de dates"]
end
Résumé des Compromis
| Stratégie | Distribution | Requêtes de Plage | Rééquilibrage | Complexité |
|---|---|---|---|---|
| Basé sur le hachage | Uniforme | Pauvre | Coûteux | Faible |
| Basé sur la plage | Potentiellement inégale | Excellent | Modéré | Moyen |
| Hachage cohérent | Uniforme | Pauvre | Minimal | Élevé |
Exemples Réels
| Système | Stratégie de Partitionnement | Notes |
|---|---|---|
| Redis Cluster | Slots de hachage (16384 slots) | Hachage cohérent |
| Cassandra | Sensible aux jetons (anneau de hachage) | Partitionneur configurable |
| MongoDB | Plages de clés de sharding | Basé sur la plage sur la clé de sharding |
| DynamoDB | Hachage + plage (composite) | Supporte les clés composites |
| PostgreSQL | Pas natif | Utiliser des extensions comme Citus |
Résumé
Points Clés à Retenir
- Le partitionnement divise les données sur plusieurs nœuds pour la mise à l'échelle
- Le hachage donne une distribution uniforme mais de mauvaises requêtes de plage
- La plage permet les analyses de plage mais peut créer des points chauds
- Le rééquilibrage est un défi clé lorsque les nœuds changent
- Les modèles de requêtes devraient dicter votre stratégie de partitionnement
Vérifiez Votre Compréhension
- Pourquoi le partitionnement basé sur le hachage est-il meilleur pour une distribution uniforme ?
- Quand choisiriez-vous le partitionnement par plage plutôt que par hachage ?
- Qu'arrive-t-il au placement des données lorsque vous ajoutez un nouveau nœud avec le hachage modulo simple ?
- Comment le hachage cohérent minimise-t-il le déplacement de données ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.
Et Ensuite
Maintenant que nous comprenons comment partitionner les données, explorons les compromis fondamentaux dans les systèmes de données distribués : Théorème CAP
Théorème CAP
Session 3, Partie 2 - 30 minutes
Objectifs d'Apprentissage
- Comprendre le théorème CAP et ses trois composantes
- Explorer les compromis entre Cohérence, Disponibilité et Tolérance aux Partitions
- Identifier les systèmes réels et leurs choix CAP
- Apprendre à appliquer la pensée CAP à la conception de systèmes
Qu'est-ce que le Théorème CAP ?
Le théorème CAP stipule qu'un magasin de données distribué ne peut fournir que deux des trois garanties suivantes :
graph TB
subgraph "Triangle CAP - Choisissez-en Deux"
C["Cohérence<br/>Chaque lecture reçoit<br/>l'écriture la plus récente"]
A["Disponibilité<br/>Chaque requête reçoit<br/>une réponse"]
P["Tolérance aux Partitions<br/>Le système opère<br/>malgré les défaillances réseau"]
end
C <--> A
A <--> P
P <--> C
style C fill:#ffcdd2
style A fill:#c8e6c9
style P fill:#bbdefb
Les Trois Composantes
1. Cohérence (C)
Chaque lecture reçoit l'écriture la plus récente ou une erreur.
Tous les nœuds voient les mêmes données au même moment. Si vous écrivez une valeur et la lisez immédiatement, vous obtenez la valeur que vous venez d'écrire.
sequenceDiagram
participant C as Client
participant N1 as Nœud 1
participant N2 as Nœud 2
participant N3 as Nœud 3
C->>N1: Écrire X = 10
N1->>N2: Répliquer X
N1->>N3: Répliquer X
N2-->>N1: Ack
N3-->>N1: Ack
N1-->>C: Écriture confirmée
Note over C,N3: Avant lecture...
C->>N2: Lire X
N2-->>C: X = 10 (plus récent)
Note over C,N3: Tous les nœuds sont d'accord !
Exemple : Un système bancaire où votre solde doit être précis sur toutes les agences.
2. Disponibilité (A)
Chaque requête reçoit une réponse (non-erreur), sans garantie qu'elle contient l'écriture la plus récente.
Le système reste opérationnel même lorsque certains nœuds échouent. Vous pouvez toujours lire et écrire, même si les données peuvent être obsolètes.
sequenceDiagram
participant C as Client
participant N1 as Nœud 1 (en vie)
participant N2 as Nœud 2 (mort)
C->>N1: Écrire X = 10
N1-->>C: Écriture confirmée
Note over C,N2: N2 est en panne mais N1 répond...
C->>N1: Lire X
N1-->>C: X = 10
Note over C,N2: Le système reste disponible !
Exemple : Un fil d'actualités sociales où montrer un contenu légèrement ancien est acceptable.
3. Tolérance aux Partitions (P)
Le système continue à opérer malgré un nombre arbitraire de messages étant abandonnés ou retardés par le réseau entre les nœuds.
Les partitions réseau sont inévitables dans les systèmes distribués. Le système doit les gérer avec grâce.
graph TB
subgraph "Partition Réseau"
N1["Nœud 1<br/>Ne peut atteindre N2, N3"]
N2["Nœud 2<br/>Ne peut atteindre N1"]
N3["Nœud 3<br/>Ne peut atteindre N1"]
end
N1 -.->|"🔴 Partition Réseau"| N2
N1 -.->|"🔴 Partition Réseau"| N3
N2 <--> N3
N2 <--> N3
style N1 fill:#ffcdd2
style N2 fill:#c8e6c9
style N3 fill:#c8e6c9
Aperçu Clé : Dans les systèmes distribués, P n'est pas optionnel — les partitions réseau ARRIVERONT.
Les Compromis
Puisque les partitions sont inévitables dans les systèmes distribués, le vrai choix est entre C et A pendant une partition :
stateDiagram-v2
[*] --> Normal
Normal --> Partitionné: Division Réseau
Partitionné --> CP: Choisir Cohérence
Partitionné --> AP: Choisir Disponibilité
CP --> Normal: Partition guérie
AP --> Normal: Partition guérie
note right of CP
Rejeter les écritures/lectures
jusqu'à la synchronisation des données
end note
note right of AP
Accepter les écritures/lectures
les données peuvent être obsolètes
end note
CP : Cohérence + Tolérance aux Partitions
Sacrifier la Disponibilité
Pendant une partition, le système retourne des erreurs ou bloque jusqu'à ce que la cohérence puisse être garantie.
sequenceDiagram
participant C as Client
participant N1 as Nœud 1 (primaire)
participant N2 as Nœud 2 (isolé)
Note over N1,N2: 🔴 Partition Réseau
C->>N1: Écrire X = 10
N1-->>C: ❌ Erreur : Impossible de répliquer
C->>N2: Lire X
N2-->>C: ❌ Erreur : Données indisponibles
Note over C,N2: Le système bloque plutôt<br/>que de retourner des données obsolètes
Exemples :
- MongoDB (avec souci d'écriture majoritaire)
- HBase
- Redis (avec configuration appropriée)
- SGBD traditionnels avec réplication synchrone
Utiliser lorsque : La précision des données est critique (systèmes financiers, inventaire)
AP : Disponibilité + Tolérance aux Partitions
Sacrifier la Cohérence
Pendant une partition, le système accepte les lectures et écritures, pouvant retourner des données obsolètes.
sequenceDiagram
participant C as Client
participant N1 as Nœud 1 (accepte écritures)
participant N2 as Nœud 2 (a anciennes données)
Note over N1,N2: 🔴 Partition Réseau
C->>N1: Écrire X = 10
N1-->>C: ✅ OK (écrit sur N1 seulement)
C->>N2: Lire X
N2-->>C: ✅ X = 5 (obsolète !)
Note over C,N2: Le système accepte les requêtes<br/>mais les données sont incohérentes
Exemples :
- Cassandra
- DynamoDB
- CouchDB
- Riak
Utiliser lorsque : Toujours répondre est plus important que la cohérence immédiate (médias sociaux, mise en cache, analyses)
CA : Cohérence + Disponibilité
Possible uniquement dans les systèmes à nœud unique
Sans partitions réseau (nœud unique ou réseau parfaitement fiable), vous pouvez avoir à la fois C et A.
graph TB
Single["Base de Données à Nœud Unique"]
Client["Client"]
Client --> Single
Single <--> Client
Note1[Pas de réseau = Pas de partitions]
Note --> Single
style Single fill:#fff9c4
Exemples :
- PostgreSQL à nœud unique
- MongoDB à nœud unique
- SGBD traditionnels sur un serveur
Réalité : Dans les systèmes distribués, CA n'est pas achievable car les réseaux ne sont pas parfaitement fiables.
Exemples CAP Réels
| Système | Choix CAP | Notes |
|---|---|---|
| Google Spanner | CP | Cohérence externe, toujours cohérent |
| Amazon DynamoDB | AP | Cohérence configurable |
| Cassandra | AP | Toujours inscriptible, cohérence ajustable |
| MongoDB | CP (par défaut) | Configurable en AP |
| Redis Cluster | AP | Réplication asynchrone |
| PostgreSQL | CA | Mode nœud unique |
| CockroachDB | CP | Sérialisabilité, gère les partitions |
| Couchbase | AP | Réplication Inter-Centres de Données |
Modèles de Cohérence
La "Cohérence" du théorème CAP est en fait la linéarisabilité (cohérence forte). Il existe plusieurs modèles de cohérence :
graph TB
subgraph "Spectre de Cohérence"
Strong["Cohérence Forte<br/>Linéarisabilité"]
Weak["Cohérence Faible<br/>Cohérence Finale"]
Strong --> S1["Cohérence<br/>Séquentielle"]
S1 --> S2["Cohérence<br/>Causale"]
S2 --> S3["Cohérence de<br/>Session"]
S3 --> S4["Lire Vos<br/>Écritures"]
S4 --> Weak
end
Modèles de Cohérence Forte
| Modèle | Description | Exemple |
|---|---|---|
| Linéarisable | Lecture la plus récente garantie | Transferts bancaires |
| Séquentielle | Les opérations apparaissent dans un certain ordre | Contrôle de version |
| Causale | Opérations causalement liées ordonnées | Applications de chat |
Modèles de Cohérence Faible
| Modèle | Description | Exemple |
|---|---|---|
| Lire Vos Écritures | L'utilisateur voit ses propres écritures | Profil de médias sociaux |
| Cohérence de Session | Cohérence dans une session | Panier d'achat |
| Cohérence Finale | Le système converge au fil du temps | DNS, CDN |
Exemple Pratique : Panier d'Achat
Voyons comment différents choix CAP affectent un système de panier d'achat :
Approche CP (Bloquer sur Partition)
sequenceDiagram
participant U as Utilisateur
participant S as Service
Note over U,S: 🔴 Partition réseau détectée
U->>S: Ajouter article au panier
S-->>U: ❌ Erreur : Service indisponible
Note over U,S: Utilisateur frustré,<br/>mais panier est toujours précis
Compromis : Ventes perdues, panier précis
Approche AP (Accepter Écritures)
sequenceDiagram
participant U as Utilisateur
participant S as Service
Note over U,S: 🔴 Partition réseau détectée
U->>S: Ajouter article au panier
S-->>U: ✅ OK (écrit localement)
Note over U,S: Utilisateur satisfait,<br/>mais panier peut être en conflit
Compromis : Utilisateurs satisfaits, conflits de fusion possibles ultérieurement
La Simplification "2 sur 3"
Le théorème CAP est souvent mal compris. La réalité est plus nuancée :
graph TB
subgraph "Réalité CAP"
CAP["Théorème CAP"]
CAP --> Malcompréhension["Vous devez choisir<br/>exactement 2"]
CAP --> Réalité["Vous pouvez avoir les 3<br/>en opération normale"]
CAP --> Vérité["Pendant partition,<br/>choisir C ou A"]
end
Aperçus Clés :
- P est obligatoire dans les systèmes distribués
- Pendant l'opération normale, vous pouvez avoir C + A + P
- Pendant une partition, vous choisissez entre C et A
- Plusieurs systèmes sont configurables (par exemple, DynamoDB)
Directives de Conception
Choisir CP Lorsque :
- ✅ Transactions financières
- ✅ Gestion d'inventaire
- ✅ Authentification/autorisation
- ✅ Tout système où les données obsolètes sont inacceptables
Choisir AP Lorsque :
- ✅ Fils d'actualités sociaux
- ✅ Recommandations de produits
- ✅ Analyses et journalisation
- ✅ Tout système où la disponibilité est critique
Techniques pour Équilibrer C et A :
| Technique | Description | Exemple |
|---|---|---|
| Lectures/écritures de quorum | Nécessite une reconnaissance majoritaire | DynamoDB |
| Cohérence ajustable | Laisser le client choisir par opération | Cassandra |
| Dégradation gracieuse | Changer de modes pendant partition | Plusieurs systèmes |
| Résolution de conflits | Fusionner les données divergentes ultérieurement | CRDTs |
Résumé
Points Clés à Retenir
- Théorème CAP : Vous ne pouvez pas avoir les trois dans une partition
- La tolérance aux partitions est obligatoire dans les systèmes distribués
- Le vrai choix : Cohérence vs Disponibilité pendant partition
- Plusieurs systèmes offrent des niveaux de cohérence ajustables
- Votre cas d'utilisation détermine le bon compromis
Vérifiez Votre Compréhension
- Pourquoi la tolérance aux partitions n'est-elle pas optionnelle dans les systèmes distribués ?
- Donnez un exemple où vous choisiriez CP plutôt que AP
- Qu'arrive-t-il à un système AP pendant une partition réseau ?
- Comment les lectures/écritures de quorum peuvent-elles aider à équilibrer C et A ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.
Et Ensuite
Maintenant que nous comprenons les compromis CAP, construisons un simple magasin clé-valeur : Bases du Magasin
Bases du Système de Magasin
Session 3, Partie 3 - 35 minutes (démo de codage + pratique)
Objectifs d'Apprentissage
- Comprendre le modèle de données clé-valeur
- Construire un magasin clé-valeur à nœud unique en TypeScript
- Construire le même magasin en Python
- Déployer et tester le magasin en utilisant Docker Compose
- Effectuer des opérations de lecture/écriture de base via HTTP
Qu'est-ce qu'un Magasin Clé-Valeur ?
Un magasin clé-valeur est le type le plus simple de base de données :
graph LR
subgraph "Magasin Clé-Valeur"
KV[("Magasin de Données")]
K1["nom"] --> V1[""Alice""]
K2["âge"] --> V2["30"]
K3["ville"] --> V3[""NYC""]
K4["actif"] --> V4["true"]
K1 --> KV
K2 --> KV
K3 --> KV
K4 --> KV
end
Caractéristiques Clés :
- Modèle de données simple : clé → valeur
- Recherches rapides par clé
- Pas de requêtes complexes
- Sans schéma
Opérations de Base
| Opération | Description | Exemple |
|---|---|---|
| SET | Stocker une valeur pour une clé | SET user:1 Alice |
| GET | Récupérer une valeur par clé | GET user:1 → "Alice" |
| DELETE | Supprimer une clé | DELETE user:1 |
stateDiagram-v2
[*] --> NonExistant
NonExistant --> Existant: SET clé
Existant --> Existant: SET clé (mise à jour)
Existant --> NonExistant: DELETE clé
Existant --> Existant: GET clé (lecture)
NonExistant --> [*]: GET clé (null)
Implémentation
Nous allons construire un simple magasin clé-valeur basé sur HTTP avec des points de terminaison API REST.
Conception de l'API
GET /key/{clé} - Obtenir la valeur par clé
PUT /key/{clé} - Définir la valeur pour la clé
DELETE /key/{clé} - Supprimer la clé
GET /keys - Lister toutes les clés
Implémentation TypeScript
Structure du Projet
store-basics-ts/
├── package.json
├── tsconfig.json
├── Dockerfile
└── src/
└── store.ts # Implémentation complète du magasin
Code TypeScript Complet
store-basics-ts/src/store.ts
import http from 'http';
/**
* Magasin clé-valeur simple en mémoire
*/
class KeyValueStore {
private data: Map<string, any> = new Map();
/**
* Définir une paire clé-valeur
*/
set(key: string, value: any): void {
this.data.set(key, value);
console.log(`[Store] SET ${key} = ${JSON.stringify(value)}`);
}
/**
* Obtenir une valeur par clé
*/
get(key: string): any {
const value = this.data.get(key);
console.log(`[Store] GET ${key} => ${value !== undefined ? JSON.stringify(value) : 'null'}`);
return value;
}
/**
* Supprimer une clé
*/
delete(key: string): boolean {
const existed = this.data.delete(key);
console.log(`[Store] DELETE ${key} => ${existed ? 'succès' : 'non trouvé'}`);
return existed;
}
/**
* Obtenir toutes les clés
*/
keys(): string[] {
return Array.from(this.data.keys());
}
/**
* Obtenir les statistiques du magasin
*/
stats() {
return {
totalKeys: this.data.size,
keys: this.keys()
};
}
}
// Créer l'instance du magasin
const store = new KeyValueStore();
/**
* Serveur HTTP avec API clé-valeur
*/
const server = http.createServer((req, res) => {
// Activer CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Analyser l'URL
const url = new URL(req.url || '', `http://${req.headers.host}`);
// Route : GET /keys - Lister toutes les clés
if (req.method === 'GET' && url.pathname === '/keys') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(store.stats()));
return;
}
// Route : GET /key/{clé} - Obtenir la valeur
if (req.method === 'GET' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5); // Retirer '/key/'
const value = store.get(key);
if (value !== undefined) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ key, value }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Key not found', key }));
}
return;
}
// Route : PUT /key/{clé} - Définir la valeur
if (req.method === 'PUT' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5); // Retirer '/key/'
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const value = JSON.parse(body);
store.set(key, value);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, key, value }));
} catch (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
return;
}
// Route : DELETE /key/{clé} - Supprimer la clé
if (req.method === 'DELETE' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5); // Retirer '/key/'
const existed = store.delete(key);
if (existed) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, key }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Key not found', key }));
}
return;
}
// 404 - Non trouvé
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
const PORT = process.env.PORT || 4000;
server.listen(PORT, () => {
console.log(`Key-Value Store listening on port ${PORT}`);
console.log(`\nAvailable endpoints:`);
console.log(` GET /key/{key} - Get value by key`);
console.log(` PUT /key/{key} - Set value for key`);
console.log(` DELETE /key/{key} - Delete key`);
console.log(` GET /keys - List all keys`);
});
store-basics-ts/package.json
{
"name": "store-basics-ts",
"version": "1.0.0",
"description": "Simple key-value store in TypeScript",
"main": "dist/store.js",
"scripts": {
"build": "tsc",
"start": "node dist/store.js",
"dev": "ts-node src/store.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"ts-node": "^10.9.0"
}
}
store-basics-ts/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
store-basics-ts/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 4000
CMD ["npm", "start"]
Implémentation Python
Structure du Projet
store-basics-py/
├── requirements.txt
├── Dockerfile
└── src/
└── store.py # Implémentation complète du magasin
Code Python Complet
store-basics-py/src/store.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
from typing import Any, Dict
from urllib.parse import urlparse
class KeyValueStore:
"""Magasin clé-valeur simple en mémoire."""
def __init__(self):
self.data: Dict[str, Any] = {}
def set(self, key: str, value: Any) -> None:
"""Stocker une paire clé-valeur."""
self.data[key] = value
print(f"[Store] SET {key} = {json.dumps(value)}")
def get(self, key: str) -> Any:
"""Obtenir la valeur par clé."""
value = self.data.get(key)
print(f"[Store] GET {key} => {json.dumps(value) if value is not None else 'null'}")
return value
def delete(self, key: str) -> bool:
"""Supprimer une clé."""
existed = key in self.data
if existed:
del self.data[key]
print(f"[Store] DELETE {key} => {'success' if existed else 'not found'}")
return existed
def keys(self) -> list:
"""Obtenir toutes les clés."""
return list(self.data.keys())
def stats(self) -> dict:
"""Obtenir les statistiques du magasin."""
return {
'totalKeys': len(self.data),
'keys': self.keys()
}
# Créer l'instance du magasin
store = KeyValueStore()
class StoreHandler(BaseHTTPRequestHandler):
"""Gestionnaire de requêtes HTTP pour le magasin clé-valeur."""
def send_json_response(self, status: int, data: dict):
"""Envoyer une réponse JSON."""
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def do_OPTIONS(self):
"""Gérer les requêtes préalables CORS."""
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_GET(self):
"""Gérer les requêtes GET."""
parsed = urlparse(self.path)
# GET /keys - Lister toutes les clés
if parsed.path == '/keys':
self.send_json_response(200, store.stats())
return
# GET /key/{clé} - Obtenir la valeur
if parsed.path.startswith('/key/'):
key = parsed.path[5:] # Retirer '/key/'
value = store.get(key)
if value is not None:
self.send_json_response(200, {'key': key, 'value': value})
else:
self.send_json_response(404, {'error': 'Key not found', 'key': key})
return
# 404
self.send_json_response(404, {'error': 'Not found'})
def do_PUT(self):
"""Gérer les requêtes PUT (définir valeur)."""
parsed = urlparse(self.path)
# PUT /key/{clé} - Définir la valeur
if parsed.path.startswith('/key/'):
key = parsed.path[5:] # Retirer '/key/'
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
value = json.loads(body)
store.set(key, value)
self.send_json_response(200, {'success': True, 'key': key, 'value': value})
except json.JSONDecodeError:
self.send_json_response(400, {'error': 'Invalid JSON'})
return
# 404
self.send_json_response(404, {'error': 'Not found'})
def do_DELETE(self):
"""Gérer les requêtes DELETE."""
parsed = urlparse(self.path)
# DELETE /key/{clé} - Supprimer la clé
if parsed.path.startswith('/key/'):
key = parsed.path[5:] # Retirer '/key/'
existed = store.delete(key)
if existed:
self.send_json_response(200, {'success': True, 'key': key})
else:
self.send_json_response(404, {'error': 'Key not found', 'key': key})
return
# 404
self.send_json_response(404, {'error': 'Not found'})
def log_message(self, format, *args):
"""Supprimer la journalisation par défaut."""
pass
def run_server(port: int = 4000):
"""Démarrer le serveur HTTP."""
server_address = ('', port)
httpd = HTTPServer(server_address, StoreHandler)
print(f"Key-Value Store listening on port {port}")
print(f"\nAvailable endpoints:")
print(f" GET /key/{{key}} - Get value by key")
print(f" PUT /key/{{key}} - Set value for key")
print(f" DELETE /key/{{key}} - Delete key")
print(f" GET /keys - List all keys")
httpd.serve_forever()
if __name__ == '__main__':
import os
port = int(os.environ.get('PORT', 4000))
run_server(port)
store-basics-py/requirements.txt
# Aucune dépendance externe requise - utilise uniquement la bibliothèque standard
store-basics-py/Dockerfile
FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 4000
CMD ["python", "src/store.py"]
Configuration Docker Compose
Version TypeScript
examples/02-store/ts/docker-compose.yml
version: '3.8'
services:
store:
build: .
ports:
- "4000:4000"
environment:
- PORT=4000
volumes:
- ./src:/app/src
Version Python
examples/02-store/py/docker-compose.yml
version: '3.8'
services:
store:
build: .
ports:
- "4000:4000"
environment:
- PORT=4000
volumes:
- ./src:/app/src
Exécution de l'Exemple
Étape 1 : Démarrer le Magasin
TypeScript :
cd examples/02-store/ts
docker-compose up --build
Python :
cd examples/02-store/py
docker-compose up --build
Vous devriez voir :
store | Key-Value Store listening on port 4000
store |
store | Available endpoints:
store | GET /key/{key} - Get value by key
store | PUT /key/{key} - Set value for key
store | DELETE /key/{key} - Delete key
store | GET /keys - List all keys
Étape 2 : Stocker Quelques Valeurs
# Stocker une chaîne
curl -X PUT http://localhost:4000/key/name \
-H "Content-Type: application/json" \
-d '"Alice"'
# Stocker un nombre
curl -X PUT http://localhost:4000/key/age \
-H "Content-Type: application/json" \
-d '30'
# Stocker un objet
curl -X PUT http://localhost:4000/key/user:1 \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "age": 30, "city": "NYC"}'
# Stocker une liste
curl -X PUT http://localhost:4000/key/tags \
-H "Content-Type: application/json" \
-d '["distributed", "systems", "course"]'
Étape 3 : Récupérer les Valeurs
# Obtenir une chaîne
curl http://localhost:4000/key/name
# Response: {"key":"name","value":"Alice"}
# Obtenir un nombre
curl http://localhost:4000/key/age
# Response: {"key":"age","value":30}
# Obtenir un objet
curl http://localhost:4000/key/user:1
# Response: {"key":"user:1","value":{"name":"Alice","age":30,"city":"NYC"}}
# Obtenir une liste
curl http://localhost:4000/key/tags
# Response: {"key":"tags","value":["distributed","systems","course"]}
# Essayer d'obtenir une clé inexistante
curl http://localhost:4000/key/nonexistent
# Response: {"error":"Key not found","key":"nonexistent"}
Étape 4 : Lister Toutes les Clés
curl http://localhost:4000/keys
# Response: {"totalKeys":4,"keys":["name","age","user:1","tags"]}
Étape 5 : Supprimer une Clé
# Supprimer une clé
curl -X DELETE http://localhost:4000/key/age
# Response: {"success":true,"key":"age"}
# Vérifier qu'elle a disparu
curl http://localhost:4000/key/age
# Response: {"error":"Key not found","key":"age"}
# Vérifier les clés restantes
curl http://localhost:4000/keys
# Response: {"totalKeys":3,"keys":["name","user:1","tags"]}
Architecture du Système
graph TB
subgraph "Magasin Clé-Valeur à Nœud Unique"
Client["Applications Clientes"]
API["API HTTP"]
Store[("Données en<br/>Mémoire")]
Client -->|"GET/PUT/DELETE"| API
API --> Store
end
style Store fill:#f9f,stroke:#333,stroke-width:3px
Exercices
Exercice 1 : Ajouter le Support TTL (Time-To-Live)
Modifier le magasin pour expirer automatiquement les clés après un temps spécifié :
- Ajouter un paramètre
ttloptionnel à l'opération SET - Suivre quand chaque clé devrait expirer
- Retourner null pour les clés expirées
- Implémenter un mécanisme de nettoyage
Indice : Stocker les métadonnées alongside les valeurs, ou utiliser une carte d'expiration séparée.
Exercice 2 : Ajouter des Motifs de Clés
Ajouter le support des caractères génériques pour les recherches de clés :
- Implémenter
GET /keys?pattern=user:*pour lister les clés correspondantes - Supporter les correspondances avec caractère générique
*simple - Tester avec des motifs comme
user:*,*:admin, etc.
Exercice 3 : Ajouter la Persistance des Données
Actuellement les données sont perdues lorsque le serveur redémarre. Ajouter la persistance :
- Sauvegarder les données dans un fichier JSON à chaque écriture
- Charger les données depuis le fichier au démarrage
- Gérer les écritures simultanées en toute sécurité
Résumé
Points Clés à Retenir
- Les magasins clé-valeur sont des systèmes de stockage de données simples mais puissants
- Opérations de base : SET, GET, DELETE
- L'API HTTP fournit une interface simple pour l'accès à distance
- Les magasins à nœud unique sont CA (Cohérent + Disponible) selon la perspective CAP
- Prochaines étapes : Ajouter la réplication pour la tolérance aux pannes (Session 4)
Vérifiez Votre Compréhension
- Quelles sont les quatre opérations de base que nous avons implémentées ?
- Comment notre magasin gère-t-il les requêtes pour les clés inexistantes ?
- Qu'arrive-t-il aux données lorsque le conteneur Docker s'arrête ?
- Pourquoi ce magasin à nœud unique est-il "CA" selon les termes CAP ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.
Et Ensuite
Notre simple magasin fonctionne, mais qu'arrive-t-il lorsqu'un nœud échoue ? Ajoutons la réplication : Réplication (Session 4)
Replication et Election de Leader
Session 4 - Session complète
Objectifs d'Apprentissage
- Comprendre pourquoi nous répliquons les données
- Apprendre la réplication à leader unique vs multi-leader
- Implémenter la réplication basée sur un leader
- Construire un mécanisme simple d'élection de leader
- Déployer un magasin répliqué à 3 nœuds
Pourquoi Répliquer les Données ?
Dans notre magasin à nœud unique de la Session 3, que se passe-t-il lorsque le nœud tombe en panne ?
Réponse : Toutes les données sont perdues et le système devient indisponible.
graph LR
subgraph "Nœud Unique - Pas de Tolérance aux Pannes"
C[Clients] --> N[Node 1]
N1[Node 1<br/>❌ FAILED]
style N1 fill:#f66,stroke:#333,stroke-width:3px
end
La réplication résout ce problème en gardant des copies des données sur plusieurs nœuds :
graph TB
subgraph "Magasin Répliqué - Tolérant aux Pannes"
C[Clients]
L[Leader<br/>Node 1]
F1[Suiveur<br/>Node 2]
F2[Suiveur<br/>Node 3]
C --> L
L -->|"réplique"| F1
L -->|"réplique"| F2
end
style L fill:#6f6,stroke:#333,stroke-width:3px
Avantages de la Réplication :
- Tolérance aux pannes : Si un nœud tombe en panne, les autres ont les données
- Mise à l'échelle des lectures : Les clients peuvent lire depuis n'importe quel réplica
- Faible latence : Placer les répliques plus près des utilisateurs
- Haute disponibilité : Le système continue pendant les pannes de nœuds
Stratégies de Réplication
Réplication à Leader Unique
Également appelée : primaire-réplique, maître-esclave, actif-passif
sequenceDiagram
participant C as Client
participant L as Leader
participant F1 as Suiveur 1
participant F2 as Suiveur 2
Note over C,F2: Opération d'Écriture
C->>L: PUT /key/name "Alice"
L->>L: Écrire dans le stockage local
L->>F1: Répliquer : SET name = "Alice"
L->>F2: Répliquer : SET name = "Alice"
F1->>L: ACK
F2->>L: ACK
L->>C: Réponse : Success
Note over C,F2: Opération de Lecture
C->>L: GET /key/name
L->>C: Réponse : "Alice"
Note over C,F2: Ou lire depuis le suiveur
C->>F1: GET /key/name
F1->>C: Réponse : "Alice"
Caractéristiques :
- Le Leader gère toutes les écritures
- Les Suiveurs se répliquent depuis le leader
- Les Lectures peuvent aller vers le leader ou les suiveurs
- Modèle de cohérence simple
Réplication Multi-Leader
Également appelée : multi-maître, actif-actif
graph TB
subgraph "Réplication Multi-Leader"
C1[Client 1]
C2[Client 2]
L1[Leader 1<br/>Datacenter A]
L2[Leader 2<br/>Datacenter B]
F1[Suiveur 1]
F2[Suiveur 2]
C1 --> L1
C2 --> L2
L1 <-->|"résoudre les conflits"| L2
L1 --> F1
L2 --> F2
end
style L1 fill:#6f6,stroke:#333,stroke-width:3px
style L2 fill:#6f6,stroke:#333,stroke-width:3px
Caractéristiques :
- Plusieurs nœuds acceptent les écritures
- Résolution de conflits plus complexe
- Mieux pour les configurations géo-distribuées
- Nous ne l'implémenterons pas (sujet avancé)
Réplication Synchrone vs Asynchrone
sequenceDiagram
participant C as Client
participant L as Leader
par Réplication Synchrone
L->>F: Répliquer l'écriture
F->>L: ACK (doit attendre)
L->>C: Success (après confirmation des répliques)
and Réplication Asynchrone
L->>C: Success (immédiatement)
L--xF: Répliquer en arrière-plan
end
participant F as Suiveur
| Stratégie | Avantages | Inconvénients |
|---|---|---|
| Synchrone | Cohérence forte, aucune perte de données | Écritures plus lentes, bloquant |
| Asynchrone | Écritures rapides, non-bloquant | Perte de données en cas de panne du leader, lectures périmées |
Pour ce cours, nous utiliserons la réplication asynchrone pour simplifier.
Élection de Leader
Lorsque le leader tombe en panne, les suiveurs doivent élire un nouveau leader :
stateDiagram-v2
[*] --> Suiveur: Le nœud démarre
Suiveur --> Candidat: Pas de heartbeat du leader
Candidat --> Leader: Gagne l'élection (majorité des votes)
Candidat --> Suiveur: Perd l'élection
Leader --> Suiveur: Détecte un terme/nœud supérieur
Suiveur --> [*]: Le nœud s'arrête
L'Algorithme du Bully
Un algorithme simple d'élection de leader :
- Détecter la panne du leader : Pas de heartbeat pendant la période de timeout
- Démarrer l'élection : Le nœud avec l'ID le plus élevé devient candidat leader
- Voter : Les nœuds avec des numéros inférieurs votent pour le candidat
- Devenir leader : Le candidat devient leader si la majorité est d'accord
sequenceDiagram
participant N1 as Nœud 1<br/>(Leader)
participant N2 as Nœud 2
participant N3 as Nœud 3
Note over N1,N3: Fonctionnement Normal
N1->>N2: Heartbeat
N1->>N3: Heartbeat
Note over N1,N3: Panne du Leader
N1--xN2: Heartbeat timeout !
N1--xN3: Heartbeat timeout !
Note over N2,N3: Début de l'Élection
N2->>N3: Demande de vote (ID=2)
N3->>N2: Voter pour N2 (2 > 3 ? Non, attendre)
Note over N2,N3: En fait, N3 a un ID plus élevé
N3->>N2: Demande de vote (ID=3)
N2->>N3: Voter pour N3 (3 > 2, oui !)
Note over N2,N3: N3 Devient Leader
N3->>N2: Je suis le leader
N3->>N2: Heartbeat
Pour simplifier, nous utiliserons une approche plus simple :
- Le nœud avec l'ID le plus bas devient leader
- Si le leader tombe en panne, le prochain plus bas devient leader
- Pas de vote, juste une sélection basée sur l'ordre
Implémentation
Implémentation TypeScript
Structure du Projet :
replicated-store-ts/
├── package.json
├── tsconfig.json
├── Dockerfile
├── docker-compose.yml
└── src/
└── node.ts # Nœud répliqué avec élection de leader
replicated-store-ts/src/node.ts
import http from 'http';
/**
* Configuration du nœud
*/
const config = {
nodeId: process.env.NODE_ID || 'node-1',
port: parseInt(process.env.PORT || '4000'),
peers: (process.env.PEERS || '').split(',').filter(Boolean),
heartbeatInterval: 2000, // ms
electionTimeout: 6000, // ms
};
type NodeRole = 'leader' | 'follower' | 'candidate';
/**
* Nœud de Magasin Répliqué
*/
class StoreNode {
public nodeId: string;
public role: NodeRole;
public term: number;
public data: Map<string, any>;
public peers: string[];
private leaderId: string | null;
private lastHeartbeat: number;
private heartbeatTimer?: NodeJS.Timeout;
private electionTimer?: NodeJS.Timeout;
constructor(nodeId: string, peers: string[]) {
this.nodeId = nodeId;
this.role = 'follower';
this.term = 0;
this.data = new Map();
this.peers = peers;
this.leaderId = null;
this.lastHeartbeat = Date.now();
this.startElectionTimer();
this.startHeartbeat();
}
/**
* Démarrer le timer de timeout d'élection
*/
private startElectionTimer() {
this.electionTimer = setTimeout(() => {
const timeSinceHeartbeat = Date.now() - this.lastHeartbeat;
if (timeSinceHeartbeat > config.electionTimeout && this.role !== 'leader') {
console.log(`[${this.nodeId}] Election timeout ! Démarrage de l'élection...`);
this.startElection();
}
this.startElectionTimer();
}, config.electionTimeout);
}
/**
* Démarrer l'élection de leader (simplifié : l'ID le plus bas gagne)
*/
private startElection() {
this.term++;
this.role = 'candidate';
// Stratégie simple : le nœud avec l'ID le plus bas devient leader
const allNodes = [this.nodeId, ...this.peers].sort();
const lowestNode = allNodes[0];
if (this.nodeId === lowestNode) {
this.becomeLeader();
} else {
this.role = 'follower';
this.leaderId = lowestNode;
console.log(`[${this.nodeId}] En attente de ${lowestNode} pour devenir leader`);
}
}
/**
* Devenir le leader
*/
private becomeLeader() {
this.role = 'leader';
this.leaderId = this.nodeId;
console.log(`[${this.nodeId}] 👑 Devenu LEADER pour le terme ${this.term}`);
// Répliquer immédiatement aux suiveurs
this.replicateToFollowers();
}
/**
* Démarrer le heartbeat vers les suiveurs
*/
private startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.role === 'leader') {
this.sendHeartbeat();
}
}, config.heartbeatInterval);
}
/**
* Envoyer le heartbeat à tous les suiveurs
*/
private sendHeartbeat() {
const heartbeat = {
type: 'heartbeat',
leaderId: this.nodeId,
term: this.term,
timestamp: Date.now(),
};
this.peers.forEach(peerUrl => {
this.sendToPeer(peerUrl, '/internal/heartbeat', heartbeat)
.catch(err => console.log(`[${this.nodeId}] Échec de l'envoi du heartbeat à ${peerUrl}:`, err.message));
});
}
/**
* Répliquer les données à tous les suiveurs
*/
private replicateToFollowers() {
// Convertir Map en objet pour la réplication
const dataObj = Object.fromEntries(this.data);
this.peers.forEach(peerUrl => {
this.sendToPeer(peerUrl, '/internal/replicate', {
type: 'replicate',
leaderId: this.nodeId,
term: this.term,
data: dataObj,
}).catch(err => console.log(`[${this.nodeId}] Réplication échouée vers ${peerUrl}:`, err.message));
});
}
/**
* Gérer le heartbeat du leader
*/
handleHeartbeat(heartbeat: any) {
if (heartbeat.term >= this.term) {
this.term = heartbeat.term;
this.lastHeartbeat = Date.now();
this.leaderId = heartbeat.leaderId;
this.role = 'follower';
if (this.role !== 'follower') {
console.log(`[${this.nodeId}] Rétrogradation en suiveur, terme ${this.term}`);
}
}
}
/**
* Gérer la réplication du leader
*/
handleReplication(message: any) {
if (message.term >= this.term) {
this.term = message.term;
this.leaderId = message.leaderId;
this.role = 'follower';
this.lastHeartbeat = Date.now();
// Fusionner les données répliquées
Object.entries(message.data).forEach(([key, value]) => {
this.data.set(key, value);
});
console.log(`[${this.nodeId}] ${Object.keys(message.data).length} clés répliquées depuis le leader`);
}
}
/**
* Envoyer des données à un nœud pair
*/
private async sendToPeer(peerUrl: string, path: string, data: any): Promise<void> {
return new Promise((resolve, reject) => {
const url = new URL(path, peerUrl);
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(url, options, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status ${res.statusCode}`));
}
});
req.on('error', reject);
req.write(JSON.stringify(data));
req.end();
});
}
/**
* Définir une paire clé-valeur (seulement sur le leader)
*/
set(key: string, value: any): boolean {
if (this.role !== 'leader') {
return false;
}
this.data.set(key, value);
console.log(`[${this.nodeId}] SET ${key} = ${JSON.stringify(value)}`);
// Répliquer aux suiveurs
this.replicateToFollowers();
return true;
}
/**
* Obtenir une valeur par clé
*/
get(key: string): any {
const value = this.data.get(key);
console.log(`[${this.nodeId}] GET ${key} => ${value !== undefined ? JSON.stringify(value) : 'null'}`);
return value;
}
/**
* Supprimer une clé
*/
delete(key: string): boolean {
if (this.role !== 'leader') {
return false;
}
const existed = this.data.delete(key);
console.log(`[${this.nodeId}] DELETE ${key} => ${existed ? 'success' : 'not found'}`);
// Répliquer aux suiveurs
this.replicateToFollowers();
return existed;
}
/**
* Obtenir le statut du nœud
*/
getStatus() {
return {
nodeId: this.nodeId,
role: this.role,
term: this.term,
leaderId: this.leaderId,
totalKeys: this.data.size,
keys: Array.from(this.data.keys()),
};
}
}
// Créer le nœud
const node = new StoreNode(config.nodeId, config.peers);
/**
* Serveur HTTP
*/
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url || '', `http://${req.headers.host}`);
// Route : POST /internal/heartbeat - Heartbeat du leader
if (req.method === 'POST' && url.pathname === '/internal/heartbeat') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const heartbeat = JSON.parse(body);
node.handleHeartbeat(heartbeat);
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
return;
}
// Route : POST /internal/replicate - Réplication du leader
if (req.method === 'POST' && url.pathname === '/internal/replicate') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const message = JSON.parse(body);
node.handleReplication(message);
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
return;
}
// Route : GET /status - Statut du nœud
if (req.method === 'GET' && url.pathname === '/status') {
res.writeHead(200);
res.end(JSON.stringify(node.getStatus()));
return;
}
// Route : GET /key/{key} - Obtenir une valeur
if (req.method === 'GET' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5);
const value = node.get(key);
if (value !== undefined) {
res.writeHead(200);
res.end(JSON.stringify({ key, value, nodeRole: node.role }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Key not found', key }));
}
return;
}
// Route : PUT /key/{key} - Définir une valeur (leader uniquement)
if (req.method === 'PUT' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5);
if (node.role !== 'leader') {
res.writeHead(503);
res.end(JSON.stringify({
error: 'Not the leader',
currentRole: node.role,
leaderId: node.leaderId || 'Unknown',
}));
return;
}
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const value = JSON.parse(body);
node.set(key, value);
res.writeHead(200);
res.end(JSON.stringify({ success: true, key, value, leaderId: node.nodeId }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
return;
}
// Route : DELETE /key/{key} - Supprimer une clé (leader uniquement)
if (req.method === 'DELETE' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5);
if (node.role !== 'leader') {
res.writeHead(503);
res.end(JSON.stringify({
error: 'Not the leader',
currentRole: node.role,
leaderId: node.leaderId || 'Unknown',
}));
return;
}
const existed = node.delete(key);
if (existed) {
res.writeHead(200);
res.end(JSON.stringify({ success: true, key, leaderId: node.nodeId }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Key not found', key }));
}
return;
}
// 404
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
});
server.listen(config.port, () => {
console.log(`[${config.nodeId}] Store Node écoutant sur le port ${config.port}`);
console.log(`[${config.nodeId}] Pairs : ${config.peers.join(', ') || 'none'}`);
console.log(`[${config.nodeId}] Points de terminaison disponibles :`);
console.log(` GET /status - Statut et rôle du nœud`);
console.log(` GET /key/{key} - Obtenir une valeur`);
console.log(` PUT /key/{key} - Définir une valeur (leader uniquement)`);
console.log(` DEL /key/{key} - Supprimer une clé (leader uniquement)`);
});
replicated-store-ts/package.json
{
"name": "replicated-store-ts",
"version": "1.0.0",
"description": "Replicated key-value store with leader election in TypeScript",
"main": "dist/node.js",
"scripts": {
"build": "tsc",
"start": "node dist/node.js",
"dev": "ts-node src/node.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"ts-node": "^10.9.0"
}
}
replicated-store-ts/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
replicated-store-ts/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 4000
CMD ["npm", "start"]
Implémentation Python
replicated-store-py/src/node.py
import os
import json
import time
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse, parse_qs
from urllib.request import Request, urlopen
from urllib.error import URLError
class StoreNode:
"""Nœud de magasin répliqué avec élection de leader."""
def __init__(self, node_id: str, peers: List[str]):
self.node_id = node_id
self.role: str = 'follower' # leader, follower, candidate
self.term = 0
self.data: Dict[str, Any] = {}
self.peers = peers
self.leader_id: Optional[str] = None
self.last_heartbeat = time.time()
# Configuration
self.heartbeat_interval = 2.0 # secondes
self.election_timeout = 6.0 # secondes
# Démarrer le timer d'élection
self.start_election_timer()
# Démarrer le thread de heartbeat
self.start_heartbeat_thread()
def start_election_timer(self):
"""Démarrer le timer de timeout d'élection."""
def election_timer():
while True:
time.sleep(1)
time_since = time.time() - self.last_heartbeat
if time_since > self.election_timeout and self.role != 'leader':
print(f"[{self.node_id}] Election timeout ! Démarrage de l'élection...")
self.start_election()
thread = threading.Thread(target=election_timer, daemon=True)
thread.start()
def start_election(self):
"""Démarrer l'élection de leader (le plus simple : l'ID le plus bas gagne)."""
self.term += 1
self.role = 'candidate'
# Stratégie simple : le nœud avec l'ID le plus bas devient leader
all_nodes = sorted([self.node_id] + self.peers)
lowest_node = all_nodes[0]
if self.node_id == lowest_node:
self.become_leader()
else:
self.role = 'follower'
self.leader_id = lowest_node
print(f"[{self.node_id}] En attente de {lowest_node} pour devenir leader")
def become_leader(self):
"""Devenir le leader."""
self.role = 'leader'
self.leader_id = self.node_id
print(f"[{self.node_id}] 👑 Devenu LEADER pour le terme {self.term}")
# Répliquer immédiatement aux suiveurs
self.replicate_to_followers()
def start_heartbeat_thread(self):
"""Démarrer le heartbeat vers les suiveurs."""
def heartbeat_loop():
while True:
time.sleep(self.heartbeat_interval)
if self.role == 'leader':
self.send_heartbeat()
thread = threading.Thread(target=heartbeat_loop, daemon=True)
thread.start()
def send_heartbeat(self):
"""Envoyer le heartbeat à tous les suiveurs."""
heartbeat = {
'type': 'heartbeat',
'leader_id': self.node_id,
'term': self.term,
'timestamp': int(time.time() * 1000),
}
for peer in self.peers:
try:
self.send_to_peer(peer, '/internal/heartbeat', heartbeat)
except Exception as e:
print(f"[{self.node_id}] Échec de l'envoi du heartbeat à {peer} : {e}")
def replicate_to_followers(self):
"""Répliquer les données à tous les suiveurs."""
message = {
'type': 'replicate',
'leader_id': self.node_id,
'term': self.term,
'data': self.data,
}
for peer in self.peers:
try:
self.send_to_peer(peer, '/internal/replicate', message)
except Exception as e:
print(f"[{self.node_id}] Réplication échouée vers {peer} : {e}")
def handle_heartbeat(self, heartbeat: dict):
"""Gérer le heartbeat du leader."""
if heartbeat['term'] >= self.term:
self.term = heartbeat['term']
self.last_heartbeat = time.time()
self.leader_id = heartbeat['leader_id']
if self.role != 'follower':
print(f"[{self.node_id}] Rétrogradation en suiveur, terme {self.term}")
self.role = 'follower'
def handle_replication(self, message: dict):
"""Gérer la réplication du leader."""
if message['term'] >= self.term:
self.term = message['term']
self.leader_id = message['leader_id']
self.role = 'follower'
self.last_heartbeat = time.time()
# Fusionner les données répliquées
self.data.update(message['data'])
print(f"[{self.node_id}] {len(message['data'])} clés répliquées depuis le leader")
def send_to_peer(self, peer_url: str, path: str, data: dict) -> None:
"""Envoyer des données à un nœud pair."""
url = f"{peer_url}{path}"
body = json.dumps(data).encode('utf-8')
req = Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
with urlopen(req, timeout=1) as response:
if response.status != 200:
raise Exception(f"Status {response.status}")
def set(self, key: str, value: Any) -> bool:
"""Définir une paire clé-valeur (seulement sur le leader)."""
if self.role != 'leader':
return False
self.data[key] = value
print(f"[{self.node_id}] SET {key} = {json.dumps(value)}")
# Répliquer aux suiveurs
self.replicate_to_followers()
return True
def get(self, key: str) -> Any:
"""Obtenir une valeur par clé."""
value = self.data.get(key)
print(f"[{self.node_id}] GET {key} => {json.dumps(value) if value is not None else 'null'}")
return value
def delete(self, key: str) -> bool:
"""Supprimer une clé (seulement sur le leader)."""
if self.role != 'leader':
return False
existed = key in self.data
if existed:
del self.data[key]
print(f"[{self.node_id}] DELETE {key} => {'success' if existed else 'not found'}")
# Répliquer aux suiveurs
self.replicate_to_followers()
return existed
def get_status(self) -> dict:
"""Obtenir le statut du nœud."""
return {
'node_id': self.node_id,
'role': self.role,
'term': self.term,
'leader_id': self.leader_id,
'total_keys': len(self.data),
'keys': list(self.data.keys()),
}
# Créer le nœud
config = {
'node_id': os.environ.get('NODE_ID', 'node-1'),
'port': int(os.environ.get('PORT', '4000')),
'peers': [p for p in os.environ.get('PEERS', '').split(',') if p],
}
node = StoreNode(config['node_id'], config['peers'])
class NodeHandler(BaseHTTPRequestHandler):
"""Gestionnaire de requêtes HTTP pour le nœud de magasin."""
def send_json_response(self, status: int, data: dict):
"""Envoyer une réponse JSON."""
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def do_OPTIONS(self):
"""Gérer le pré-vol CORS."""
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_POST(self):
"""Gérer les requêtes POST."""
parsed = urlparse(self.path)
# POST /internal/heartbeat
if parsed.path == '/internal/heartbeat':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
heartbeat = json.loads(body)
node.handle_heartbeat(heartbeat)
self.send_json_response(200, {'success': True})
except (json.JSONDecodeError, KeyError):
self.send_json_response(400, {'error': 'Invalid request'})
return
# POST /internal/replicate
if parsed.path == '/internal/replicate':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
message = json.loads(body)
node.handle_replication(message)
self.send_json_response(200, {'success': True})
except (json.JSONDecodeError, KeyError):
self.send_json_response(400, {'error': 'Invalid request'})
return
self.send_json_response(404, {'error': 'Not found'})
def do_GET(self):
"""Gérer les requêtes GET."""
parsed = urlparse(self.path)
# GET /status
if parsed.path == '/status':
self.send_json_response(200, node.get_status())
return
# GET /key/{key}
if parsed.path.startswith('/key/'):
key = parsed.path[5:] # Retirer '/key/'
value = node.get(key)
if value is not None:
self.send_json_response(200, {'key': key, 'value': value, 'node_role': node.role})
else:
self.send_json_response(404, {'error': 'Key not found', 'key': key})
return
self.send_json_response(404, {'error': 'Not found'})
def do_PUT(self):
"""Gérer les requêtes POST (définir une valeur)."""
parsed = urlparse(self.path)
# PUT /key/{key}
if parsed.path.startswith('/key/'):
key = parsed.path[5:]
if node.role != 'leader':
self.send_json_response(503, {
'error': 'Not the leader',
'current_role': node.role,
'leader_id': node.leader_id or 'Unknown',
})
return
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
value = json.loads(body)
node.set(key, value)
self.send_json_response(200, {'success': True, 'key': key, 'value': value, 'leader_id': node.node_id})
except json.JSONDecodeError:
self.send_json_response(400, {'error': 'Invalid JSON'})
return
self.send_json_response(404, {'error': 'Not found'})
def do_DELETE(self):
"""Gérer les requêtes DELETE."""
parsed = urlparse(self.path)
# DELETE /key/{key}
if parsed.path.startswith('/key/'):
key = parsed.path[5:]
if node.role != 'leader':
self.send_json_response(503, {
'error': 'Not the leader',
'current_role': node.role,
'leader_id': node.leader_id or 'Unknown',
})
return
existed = node.delete(key)
if existed:
self.send_json_response(200, {'success': True, 'key': key, 'leader_id': node.node_id})
else:
self.send_json_response(404, {'error': 'Key not found', 'key': key})
return
self.send_json_response(404, {'error': 'Not found'})
def log_message(self, format, *args):
"""Supprimer la journalisation par défaut."""
pass
def run_server(port: int):
"""Démarrer le serveur HTTP."""
server_address = ('', port)
httpd = HTTPServer(server_address, NodeHandler)
print(f"[{config['node_id']}] Store Node écoutant sur le port {port}")
print(f"[{config['node_id']}] Pairs : {', '.join(config['peers']) or 'none'}")
print(f"[{config['node_id']}] Points de terminaison disponibles :")
print(f" GET /status - Statut et rôle du nœud")
print(f" GET /key/{{key}} - Obtenir une valeur")
print(f" PUT /key/{{key}} - Définir une valeur (leader uniquement)")
print(f" DEL /key/{{key}} - Supprimer une clé (leader uniquement)")
httpd.serve_forever()
if __name__ == '__main__':
run_server(config['port'])
replicated-store-py/requirements.txt
# Pas de dépendances externes - utilise uniquement la bibliothèque standard
replicated-store-py/Dockerfile
FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 4000
CMD ["python", "src/node.py"]
Configuration Docker Compose
Version TypeScript
examples/02-store/ts/docker-compose.yml
version: '3.8'
services:
node1:
build: .
container_name: store-ts-node1
ports:
- "4001:4000"
environment:
- NODE_ID=node-1
- PORT=4000
- PEERS=http://node2:4000,http://node3:4000
networks:
- store-network
node2:
build: .
container_name: store-ts-node2
ports:
- "4002:4000"
environment:
- NODE_ID=node-2
- PORT=4000
- PEERS=http://node1:4000,http://node3:4000
networks:
- store-network
node3:
build: .
container_name: store-ts-node3
ports:
- "4003:4000"
environment:
- NODE_ID=node-3
- PORT=4000
- PEERS=http://node1:4000,http://node2:4000
networks:
- store-network
networks:
store-network:
driver: bridge
Version Python
examples/02-store/py/docker-compose.yml
version: '3.8'
services:
node1:
build: .
container_name: store-py-node1
ports:
- "4001:4000"
environment:
- NODE_ID=node-1
- PORT=4000
- PEERS=http://node2:4000,http://node3:4000
networks:
- store-network
node2:
build: .
container_name: store-py-node2
ports:
- "4002:4000"
environment:
- NODE_ID=node-2
- PORT=4000
- PEERS=http://node1:4000,http://node3:4000
networks:
- store-network
node3:
build: .
container_name: store-py-node3
ports:
- "4003:4000"
environment:
- NODE_ID=node-3
- PORT=4000
- PEERS=http://node1:4000,http://node2:4000
networks:
- store-network
networks:
store-network:
driver: bridge
Exécution de l'Exemple
Étape 1 : Démarrer le Cluster à 3 Nœuds
TypeScript :
cd distributed-systems-course/examples/02-store/ts
docker-compose up --build
Python :
cd distributed-systems-course/examples/02-store/py
docker-compose up --build
Vous devriez voir l'élection de leader se produire automatiquement :
store-ts-node1 | [node-1] Store Node écoutant sur le port 4000
store-ts-node2 | [node-2] Store Node écoutant sur le port 4000
store-ts-node3 | [node-3] Store Node écoutant sur le port 4000
store-ts-node1 | [node-1] 👑 Devenu LEADER pour le terme 1
store-ts-node2 | [node-2] En attente de node-1 pour devenir leader
store-ts-node3 | [node-3] En attente de node-1 pour devenir leader
Étape 2 : Vérifier le Statut des Nœuds
# Vérifier tous les nœuds
curl http://localhost:4001/status
curl http://localhost:4002/status
curl http://localhost:4003/status
Réponse du node-1 (leader) :
{
"nodeId": "node-1",
"role": "leader",
"term": 1,
"leaderId": "node-1",
"totalKeys": 0,
"keys": []
}
Réponse du node-2 (suiveur) :
{
"nodeId": "node-2",
"role": "follower",
"term": 1,
"leaderId": "node-1",
"totalKeys": 0,
"keys": []
}
Étape 3 : Écrire au Leader
# Écrire au leader (node-1)
curl -X PUT http://localhost:4001/key/name \
-H "Content-Type: application/json" \
-d '"Alice"'
curl -X PUT http://localhost:4001/key/age \
-H "Content-Type: application/json" \
-d '30'
curl -X PUT http://localhost:4001/key/city \
-H "Content-Type: application/json" \
-d '"NYC"'
Réponse :
{
"success": true,
"key": "name",
"value": "Alice",
"leaderId": "node-1"
}
Étape 4 : Lire depuis les Suiveurs
Les données devraient être répliquées à tous les suiveurs :
curl http://localhost:4002/key/name
curl http://localhost:4003/key/city
Réponse :
{
"key": "name",
"value": "Alice",
"nodeRole": "follower"
}
Étape 5 : Essayer d'Écrire à un Suiveur (Devrait Échouer)
curl -X PUT http://localhost:4002/key/test \
-H "Content-Type: application/json" \
-d '"should fail"'
Réponse :
{
"error": "Not the leader",
"currentRole": "follower",
"leaderId": "node-1"
}
Étape 6 : Simuler une Panne de Leader
# Dans un terminal séparé, arrêter le leader
docker-compose stop node1
# Vérifier le statut de node-2 - devrait devenir le nouveau leader
curl http://localhost:4002/status
Après quelques secondes :
store-ts-node2 | [node-2] Election timeout ! Démarrage de l'élection...
store-ts-node2 | [node-2] 👑 Devenu LEADER pour le terme 2
store-ts-node3 | [node-3] En attente de node-2 pour devenir leader
Étape 7 : Écrire au Nouveau Leader
# Maintenant node-2 est le leader
curl -X PUT http://localhost:4002/key/newleader \
-H "Content-Type: application/json" \
-d '"node-2"'
Étape 8 : Redémarrer l'Ancien Leader
# Redémarrer node-1
docker-compose start node1
# Vérifier le statut - devrait devenir suiveur
curl http://localhost:4001/status
Réponse :
{
"nodeId": "node-1",
"role": "follower",
"term": 2,
"leaderId": "node-2",
...
}
Architecture du Système
graph TB
subgraph "Magasin Répliqué à 3 Nœuds"
Clients["Clients"]
N1["Node 1<br/>👑 Leader"]
N2["Node 2<br/>Suiveur"]
N3["Node 3<br/>Suiveur"]
Clients -->|"Write"| N1
Clients -->|"Read"| N1
Clients -->|"Read"| N2
Clients -->|"Read"| N3
N1 <-->|"Heartbeat<br/>Réplication"| N2
N1 <-->|"Heartbeat<br/>Réplication"| N3
end
style N1 fill:#6f6,stroke:#333,stroke-width:3px
Exercices
Exercice 1 : Tester la Tolérance aux Pannes
- Démarrer le cluster et écrire quelques données
- Arrêter différents nœuds un par un
- Vérifier que le système continue de fonctionner
- Que se passe-t-il lorsque vous arrêtez 2 nœuds sur 3 ?
Exercice 2 : Observer le Délai de Réplication
- Ajouter un petit délai (par ex. 100ms) à la réplication
- Écrire des données au leader
- Lire immédiatement depuis un suiveur
- Que voyez-vous ? Cela démontre la cohérence événementielle.
Exercice 3 : Améliorer l'Élection de Leader
L'élection actuelle est très simple. Essayez de l'améliorer :
- Ajouter des timeouts d'élection aléatoires (comme Raft)
- Implémenter un vrai vote (pas seulement le plus petit ID)
- Ajouter un pré-vote pour éviter de perturber le leader actuel
Résumé
Points Clés à Retenir
- La Réplication copie les données sur plusieurs nœuds pour la tolérance aux pannes
- La Réplication à leader unique est simple mais toutes les écritures passent par le leader
- L'élection de leader assure qu'un nouveau leader est choisi quand le leader actuel tombe en panne
- La Réplication asynchrone est rapide mais peut perdre des données en cas de panne du leader
- La Cohérence lecture-après-écriture n'est PAS garantie lors de la lecture depuis les suiveurs
Compromis
| Approche | Avantages | Inconvénients |
|---|---|---|
| Leader unique | Simple, cohérence forte | Le leader est un goulot d'étranglement, point de défaillance unique |
| Multi-leader | Pas de goulot d'étranglement, écritures n'importe où | Résolution de conflits complexe |
| Réplication synchrone | Aucune perte de données | Écritures lentes, bloquant |
| Réplication asynchrone | Écritures rapides | Perte de données possible, lectures périmées |
Vérifiez Votre Compréhension
- Pourquoi répliquons-nous les données ?
- Quelle est la différence entre leader et suiveur ?
- Que se passe-t-il lorsqu'un client essaie d'écrire à un suiveur ?
- Comment fonctionne l'élection de leader dans notre implémentation ?
- Quel est le compromis entre la réplication synchrone et asynchrone ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.
Suite
Nous avons une réplication fonctionnelle, mais notre modèle de cohérence est basique. Explorons les niveaux de cohérence : Modèles de Cohérence (Session 5)
Modèles de Cohérence
Session 5 - Session complète
Objectifs d'Apprentissage
- Comprendre les différents modèles de cohérence dans les systèmes distribués
- Apprendre les compromis entre la cohérence forte et la cohérence événementielle
- Implémenter des niveaux de cohérence configurables dans un magasin répliqué
- Expérimenter les effets des niveaux de cohérence à travers des exercices pratiques
Qu'est-ce que la Cohérence ?
Dans un magasin répliqué, la cohérence définit les garanties que vous avez sur les données que vous lisez. Lorsque les données sont copiées sur plusieurs nœuds, vous ne verrez pas toujours l'écriture la plus récente immédiatement.
graph TB
subgraph "Une Écriture se Produit"
C[Client]
L[Leader]
L -->|Write "name = Alice"| L
end
subgraph "Mais Que Lisez-Vous ?"
F1[Suiveur 1<br/>name = Alice]
F2[Suiveur 2<br/>name = ???]
F3[Suiveur 3<br/>name = ???]
C -->|Read| F1
C -->|Read| F2
C -->|Read| F3
end
La question : Si vous lisez depuis un suiveur, verrez-vous "Alice" ou l'ancienne valeur ?
La réponse dépend de votre modèle de cohérence.
Spectre de Cohérence
Les modèles de cohérence existent sur un spectre du plus fort au plus faible :
graph LR
A[Cohérence<br/>Forte]
B[Read Your Writes]
C[Lectures Monotones]
D[Cohérence Causale]
E[Cohérence<br/>Événementielle]
A ====> B ====> C ====> D ====> E
style A fill:#6f6
style B fill:#9f6
style C fill:#cf6
style D fill:#ff6
style E fill:#f96
Cohérence Forte
Définition : Chaque lecture reçoit l'écriture la plus récente ou une erreur.
sequenceDiagram
participant C as Client
participant L as Leader
participant F as Suiveur
Note over C,F: Le temps s'écoule vers le bas
C->>L: SET name = "Alice"
L->>L: Écriture confirmée
Note over C,F: La cohérence forte nécessite :
Note over C,F: Attendre la réplication...
L->>F: Répliquer : name = "Alice"
F->>L: ACK
L->>C: Réponse : Success
C->>F: GET name
F->>C: "Alice" (toujours à jour !)
Caractéristiques :
- Les lecteurs voient toujours les données les plus récentes
- Aucune lecture périmée possible
- Performances plus lentes (doit attendre la réplication)
- Modèle mental simple
Quand l'utiliser : Transactions financières, gestion des stocks, opérations critiques
Cohérence Événementielle
Définition : Si aucune nouvelle mise à jour n'est faite, éventuellement tous les accès retourneront la dernière valeur mise à jour.
sequenceDiagram
participant C as Client
participant L as Leader
participant F1 as Suiveur 1
participant F2 as Suiveur 2
Note over C,F2: Le temps s'écoule vers le bas
C->>L: SET name = "Alice"
L->>C: Réponse : Success (immédiatement !)
Note over C,F2: Le leader n'a pas encore répliqué...
C->>F1: GET name
F1->>C: "Alice" (répliqué !)
C->>F2: GET name
F2->>C: "Bob" (périmé !)
Note over C,F2: Un moment plus tard...
L->>F2: Répliquer : name = "Alice"
C->>F2: GET name
F2->>C: "Alice" (mis à jour !)
Caractéristiques :
- Les lectures sont rapides (pas d'attente de réplication)
- Vous pouvez voir des données périmées
- Éventuellement, tous les nœuds convergent
- Modèle mental plus complexe
Quand l'utiliser : Flux de médias sociaux, recommandations de produits, analyses
Cohérence Read-Your-Writes
Un terrain d'entente : vous voyez toujours vos propres écritures, mais ne voyez pas forcément les écritures des autres immédiatement.
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant L as Leader
participant F as Suiveur
C1->>L: SET name = "Alice"
L->>C1: Success
C1->>F: GET name
Note over C1,F: Read-your-writes:<br/>C1 voit "Alice"
F->>C1: "Alice"
C2->>F: GET name
Note over C2,F: Peut voir des données périmées
F->>C2: "Bob" (périmé !)
Le Théorème CAP Réexaminé
Vous avez appris CAP dans la Session 4. Relions-le à la cohérence :
| Combinaison | Modèle de Cohérence | Systèmes Exemple |
|---|---|---|
| CP | Cohérence forte | ZooKeeper, etcd, MongoDB (avec w:majority) |
| AP | Cohérence événementielle | Cassandra, DynamoDB, CouchDB |
| CA (impossible à grande échelle) | Cohérence forte | Bases de données à nœud unique (SGBDR) |
Cohérence Basée sur Quorum
Un moyen pratique de contrôler la cohérence est d'utiliser des quorums. Un quorum est une majorité de nœuds.
graph TB
subgraph "Cluster à 3 Nœuds"
N1[Nœud 1]
N2[Nœud 2]
N3[Nœud 3]
Q[Quorum = 2<br/>⌈3/2⌉ = 2]
end
N1 -.-> Q
N2 -.-> Q
N3 -.-> Q
style Q fill:#6f6,stroke:#333,stroke-width:3px
Quorum d'Écriture (W)
Nombre de nœuds qui doivent acquitter une écriture :
W > N/2 → Cohérence forte (majorité)
W = 1 → Rapide mais cohérence faible
W = N → La plus forte mais la plus lente
Quorum de Lecture (R)
Nombre de nœuds à interroger et comparer pour une lecture :
R + W > N → Cohérence forte garantie
R + W ≤ N → Cohérence événementielle
Niveaux de Cohérence
| R + W | Cohérence | Performance | Cas d'Usage |
|---|---|---|---|
| N + 1 > N (impossible) | La plus forte | Lente | Données critiques |
| R + W > N | Forte | Moyenne | Banque, stocks |
| R + W ≤ N | Événementielle | Rapide | Médias sociaux, cache |
Implémentation
Nous allons étendre notre magasin répliqué de la Session 4 pour supporter des niveaux de cohérence configurables.
Implémentation TypeScript
Structure du Projet :
consistent-store-ts/
├── package.json
├── tsconfig.json
├── Dockerfile
├── docker-compose.yml
└── src/
└── node.ts # Nœud avec cohérence configurable
consistent-store-ts/src/node.ts
import http from 'http';
/**
* Configuration du nœud
*/
const config = {
nodeId: process.env.NODE_ID || 'node-1',
port: parseInt(process.env.PORT || '4000'),
peers: (process.env.PEERS || '').split(',').filter(Boolean),
heartbeatInterval: 2000,
electionTimeout: 6000,
// Paramètres de cohérence
writeQuorum: parseInt(process.env.WRITE_QUORUM || '2'), // W
readQuorum: parseInt(process.env.READ_QUORUM || '1'), // R
};
type NodeRole = 'leader' | 'follower' | 'candidate';
type ConsistencyLevel = 'strong' | 'eventual' | 'read_your_writes';
/**
* Nœud de Magasin Répliqué avec Cohérence Configurable
*/
class StoreNode {
public nodeId: string;
public role: NodeRole;
public term: number;
public data: Map<string, any>;
public peers: string[];
private leaderId: string | null;
private lastHeartbeat: number;
private heartbeatTimer?: NodeJS.Timeout;
private electionTimer?: NodeJS.Timeout;
private pendingWrites: Map<string, any[]>; // Pour read-your-writes
constructor(nodeId: string, peers: string[]) {
this.nodeId = nodeId;
this.role = 'follower';
this.term = 0;
this.data = new Map();
this.peers = peers;
this.leaderId = null;
this.lastHeartbeat = Date.now();
this.pendingWrites = new Map();
this.startElectionTimer();
this.startHeartbeat();
}
/**
* Démarrer le timer de timeout d'élection
*/
private startElectionTimer() {
this.electionTimer = setTimeout(() => {
const timeSinceHeartbeat = Date.now() - this.lastHeartbeat;
if (timeSinceHeartbeat > config.electionTimeout && this.role !== 'leader') {
console.log(`[${this.nodeId}] Election timeout ! Démarrage de l'élection...`);
this.startElection();
}
this.startElectionTimer();
}, config.electionTimeout);
}
/**
* Démarrer l'élection de leader
*/
private startElection() {
this.term++;
this.role = 'candidate';
const allNodes = [this.nodeId, ...this.peers].sort();
const lowestNode = allNodes[0];
if (this.nodeId === lowestNode) {
this.becomeLeader();
} else {
this.role = 'follower';
this.leaderId = lowestNode;
console.log(`[${this.nodeId}] En attente de ${lowestNode} pour devenir leader`);
}
}
/**
* Devenir leader
*/
private becomeLeader() {
this.role = 'leader';
this.leaderId = this.nodeId;
console.log(`[${this.nodeId}] 👑 Devenu LEADER pour le terme ${this.term}`);
this.replicateToFollowers();
}
/**
* Démarrer le heartbeat vers les suiveurs
*/
private startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.role === 'leader') {
this.sendHeartbeat();
}
}, config.heartbeatInterval);
}
/**
* Envoyer le heartbeat à tous les suiveurs
*/
private sendHeartbeat() {
const heartbeat = {
type: 'heartbeat',
leaderId: this.nodeId,
term: this.term,
timestamp: Date.now(),
};
this.peers.forEach(peerUrl => {
this.sendToPeer(peerUrl, '/internal/heartbeat', heartbeat)
.catch(err => console.log(`[${this.nodeId}] Échec du heartbeat vers ${peerUrl}`));
});
}
/**
* Répliquer les données aux suiveurs avec accusé de réception du quorum
*/
private async replicateToFollowers(): Promise<boolean> {
const dataObj = Object.fromEntries(this.data);
// Envoyer à tous les suiveurs en parallèle
const promises = this.peers.map(peerUrl =>
this.sendToPeer(peerUrl, '/internal/replicate', {
type: 'replicate',
leaderId: this.nodeId,
term: this.term,
data: dataObj,
}).catch(err => {
console.log(`[${this.nodeId}] Réplication échouée vers ${peerUrl}`);
return false;
})
);
// Attendre que tous se terminent
const results = await Promise.all(promises);
// Compter les succès (ce nœud compte comme 1)
const successes = results.filter(r => r !== false).length + 1;
// Vérifier si nous avons atteint le quorum d'écriture
const achievedQuorum = successes >= config.writeQuorum;
console.log(`[${this.nodeId}] Réplication : ${successes}/${this.peers.length + 1} nœuds (W=${config.writeQuorum})`);
return achievedQuorum;
}
/**
* Gérer le heartbeat du leader
*/
handleHeartbeat(heartbeat: any) {
if (heartbeat.term >= this.term) {
this.term = heartbeat.term;
this.lastHeartbeat = Date.now();
this.leaderId = heartbeat.leaderId;
if (this.role !== 'follower') {
this.role = 'follower';
}
}
}
/**
* Gérer la réplication du leader
*/
handleReplication(message: any) {
if (message.term >= this.term) {
this.term = message.term;
this.leaderId = message.leaderId;
this.role = 'follower';
this.lastHeartbeat = Date.now();
Object.entries(message.data).forEach(([key, value]) => {
this.data.set(key, value);
});
}
}
/**
* Envoyer des données à un nœud pair
*/
private async sendToPeer(peerUrl: string, path: string, data: any): Promise<void> {
return new Promise((resolve, reject) => {
const url = new URL(path, peerUrl);
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(url, options, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status ${res.statusCode}`));
}
});
req.on('error', reject);
req.write(JSON.stringify(data));
req.end();
});
}
/**
* Définir une paire clé-valeur avec accusé de réception du quorum
*/
async set(key: string, value: any): Promise<{ success: boolean; achievedQuorum: boolean }> {
if (this.role !== 'leader') {
return { success: false, achievedQuorum: false };
}
this.data.set(key, value);
console.log(`[${this.nodeId}] SET ${key} = ${JSON.stringify(value)}`);
// Répliquer aux suiveurs
const achievedQuorum = await this.replicateToFollowers();
return { success: true, achievedQuorum };
}
/**
* Obtenir une valeur avec cohérence configurable
*/
async get(key: string, consistency: ConsistencyLevel = 'eventual'): Promise<any> {
const localValue = this.data.get(key);
// Pour la cohérence événementielle, retourner la valeur locale immédiatement
if (consistency === 'eventual') {
console.log(`[${this.nodeId}] GET ${key} => ${JSON.stringify(localValue)} (événementielle)`);
return localValue;
}
// Pour la cohérence forte, interroger un quorum de nœuds
if (consistency === 'strong') {
const values = await this.getFromQuorum(key);
console.log(`[${this.nodeId}] GET ${key} => ${JSON.stringify(values.latest)} (forte depuis ${values.responses} nœuds)`);
return values.latest;
}
// Pour read-your-writes, vérifier les écritures en attente
if (consistency === 'read_your_writes') {
const pending = this.pendingWrites.get(key);
const valueToReturn = pending && pending.length > 0 ? pending[pending.length - 1] : localValue;
console.log(`[${this.nodeId}] GET ${key} => ${JSON.stringify(valueToReturn)} (read-your-writes)`);
return valueToReturn;
}
return localValue;
}
/**
* Interroger un quorum de nœuds et retourner la valeur la plus récente
*/
private async getFromQuorum(key: string): Promise<{ latest: any; responses: number }> {
// Interroger tous les pairs
const promises = this.peers.map(peerUrl =>
this.queryPeer(peerUrl, '/internal/get', { key })
.then(result => ({ success: true, value: result.value, version: result.version || 0 }))
.catch(err => {
console.log(`[${this.nodeId}] Query échouée vers ${peerUrl}`);
return { success: false, value: null, version: 0 };
})
);
const results = await Promise.all(promises);
// Ajouter la valeur locale
results.push({
success: true,
value: this.data.get(key),
version: this.data.has(key) ? 1 : 0,
});
// Compter les réponses réussies
const successful = results.filter(r => r.success);
// Retourner si nous avons le quorum de lecture
if (successful.length >= config.readQuorum) {
// Retourner la valeur la plus récente (version simple : première non-nulle)
const latest = successful.find(r => r.value !== undefined)?.value;
return { latest, responses: successful.length };
}
// Retour à la valeur locale
return { latest: this.data.get(key), responses: successful.length };
}
/**
* Interroger un pair pour une clé
*/
private async queryPeer(peerUrl: string, path: string, data: any): Promise<any> {
return new Promise((resolve, reject) => {
const url = new URL(path, peerUrl);
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(url, options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Status ${res.statusCode}`));
}
});
});
req.on('error', reject);
req.write(JSON.stringify(data));
req.end();
});
}
/**
* Supprimer une clé
*/
async delete(key: string): Promise<{ success: boolean; achievedQuorum: boolean }> {
if (this.role !== 'leader') {
return { success: false, achievedQuorum: false };
}
const existed = this.data.delete(key);
console.log(`[${this.nodeId}] DELETE ${key}`);
await this.replicateToFollowers();
return { success: existed, achievedQuorum: true };
}
/**
* Obtenir le statut du nœud
*/
getStatus() {
return {
nodeId: this.nodeId,
role: this.role,
term: this.term,
leaderId: this.leaderId,
totalKeys: this.data.size,
keys: Array.from(this.data.keys()),
config: {
writeQuorum: config.writeQuorum,
readQuorum: config.readQuorum,
totalNodes: this.peers.length + 1,
},
};
}
}
// Créer le nœud
const node = new StoreNode(config.nodeId, config.peers);
/**
* Serveur HTTP
*/
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url || '', `http://${req.headers.host}`);
// Route : POST /internal/heartbeat
if (req.method === 'POST' && url.pathname === '/internal/heartbeat') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const heartbeat = JSON.parse(body);
node.handleHeartbeat(heartbeat);
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
return;
}
// Route : POST /internal/replicate
if (req.method === 'POST' && url.pathname === '/internal/replicate') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const message = JSON.parse(body);
node.handleReplication(message);
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
return;
}
// Route : POST /internal/get - Requête interne pour les lectures de quorum
if (req.method === 'POST' && url.pathname === '/internal/get') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const { key } = JSON.parse(body);
const value = node.data.get(key);
res.writeHead(200);
res.end(JSON.stringify({ value, version: value !== undefined ? 1 : 0 }));
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
return;
}
// Route : GET /status
if (req.method === 'GET' && url.pathname === '/status') {
res.writeHead(200);
res.end(JSON.stringify(node.getStatus()));
return;
}
// Route : GET /key/{key}?consistency=strong|eventual|read_your_writes
if (req.method === 'GET' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5);
const consistency = (url.searchParams.get('consistency') || 'eventual') as ConsistencyLevel;
node.get(key, consistency).then(value => {
if (value !== undefined) {
res.writeHead(200);
res.end(JSON.stringify({ key, value, nodeRole: node.role, consistency }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Key not found', key }));
}
});
return;
}
// Route : PUT /key/{key}
if (req.method === 'PUT' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5);
if (node.role !== 'leader') {
res.writeHead(503);
res.end(JSON.stringify({
error: 'Not the leader',
currentRole: node.role,
leaderId: node.leaderId || 'Unknown',
}));
return;
}
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const value = JSON.parse(body);
node.set(key, value).then(result => {
res.writeHead(200);
res.end(JSON.stringify({
success: result.success,
key,
value,
leaderId: node.nodeId,
achievedQuorum: result.achievedQuorum,
writeQuorum: config.writeQuorum,
}));
});
} catch (error) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
return;
}
// Route : DELETE /key/{key}
if (req.method === 'DELETE' && url.pathname.startsWith('/key/')) {
const key = url.pathname.slice(5);
if (node.role !== 'leader') {
res.writeHead(503);
res.end(JSON.stringify({
error: 'Not the leader',
currentRole: node.role,
leaderId: node.leaderId || 'Unknown',
}));
return;
}
node.delete(key).then(result => {
if (result.success) {
res.writeHead(200);
res.end(JSON.stringify({ success: true, key, leaderId: node.nodeId }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Key not found', key }));
}
});
return;
}
// 404
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
});
server.listen(config.port, () => {
console.log(`[${config.nodeId}] Consistent Store écoutant sur le port ${config.port}`);
console.log(`[${config.nodeId}] Quorum d'Écriture (W) : ${config.writeQuorum}, Quorum de Lecture (R) : ${config.readQuorum}`);
console.log(`[${config.nodeId}] Pairs : ${config.peers.join(', ') || 'none'}`);
console.log(`[${config.nodeId}] Points de terminaison disponibles :`);
console.log(` GET /status - Statut du nœud`);
console.log(` GET /key/{key}?consistency=level - Obtenir avec niveau de cohérence`);
console.log(` PUT /key/{key} - Définir une valeur (leader uniquement)`);
console.log(` DEL /key/{key} - Supprimer une clé (leader uniquement)`);
});
consistent-store-ts/package.json
{
"name": "consistent-store-ts",
"version": "1.0.0",
"description": "Replicated key-value store with configurable consistency",
"main": "dist/node.js",
"scripts": {
"build": "tsc",
"start": "node dist/node.js",
"dev": "ts-node src/node.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"ts-node": "^10.9.0"
}
}
consistent-store-ts/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
consistent-store-ts/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 4000
CMD ["npm", "start"]
Implémentation Python
consistent-store-py/src/node.py
import os
import json
import time
import threading
import asyncio
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Any, Dict, List, Optional, Literal
from urllib.parse import urlparse, parse_qs
from urllib.request import Request, urlopen
from urllib.error import URLError
ConsistencyLevel = Literal['strong', 'eventual', 'read_your_writes']
class StoreNode:
"""Nœud de magasin répliqué avec cohérence configurable."""
def __init__(self, node_id: str, peers: List[str]):
self.node_id = node_id
self.role: str = 'follower'
self.term = 0
self.data: Dict[str, Any] = {}
self.peers = peers
self.leader_id: Optional[str] = None
self.last_heartbeat = time.time()
self.pending_writes: Dict[str, List[Any]] = {}
# Configuration
self.heartbeat_interval = 2.0
self.election_timeout = 6.0
self.write_quorum = int(os.environ.get('WRITE_QUORUM', '2'))
self.read_quorum = int(os.environ.get('READ_QUORUM', '1'))
# Démarrer les timers
self.start_election_timer()
self.start_heartbeat_thread()
def start_election_timer(self):
"""Démarrer le timer de timeout d'élection."""
def election_timer():
while True:
time.sleep(1)
time_since = time.time() - self.last_heartbeat
if time_since > self.election_timeout and self.role != 'leader':
print(f"[{self.node_id}] Election timeout ! Démarrage de l'élection...")
self.start_election()
thread = threading.Thread(target=election_timer, daemon=True)
thread.start()
def start_election(self):
"""Démarrer l'élection de leader."""
self.term += 1
self.role = 'candidate'
all_nodes = sorted([self.node_id] + self.peers)
lowest_node = all_nodes[0]
if self.node_id == lowest_node:
self.become_leader()
else:
self.role = 'follower'
self.leader_id = lowest_node
print(f"[{self.node_id}] En attente de {lowest_node} pour devenir leader")
def become_leader(self):
"""Devenir leader."""
self.role = 'leader'
self.leader_id = self.node_id
print(f"[{self.node_id}] 👑 Devenu LEADER pour le terme {self.term}")
self.replicate_to_followers()
def start_heartbeat_thread(self):
"""Démarrer le heartbeat vers les suiveurs."""
def heartbeat_loop():
while True:
time.sleep(self.heartbeat_interval)
if self.role == 'leader':
self.send_heartbeat()
thread = threading.Thread(target=heartbeat_loop, daemon=True)
thread.start()
def send_heartbeat(self):
"""Envoyer le heartbeat à tous les suiveurs."""
heartbeat = {
'type': 'heartbeat',
'leader_id': self.node_id,
'term': self.term,
'timestamp': int(time.time() * 1000),
}
for peer in self.peers:
try:
self.send_to_peer(peer, '/internal/heartbeat', heartbeat)
except Exception as e:
print(f"[{self.node_id}] Échec du heartbeat vers {peer} : {e}")
def replicate_to_followers(self) -> bool:
"""Répliquer les données aux suiveurs et vérifier le quorum."""
message = {
'type': 'replicate',
'leader_id': self.node_id,
'term': self.term,
'data': self.data,
}
successes = 1 # Ce nœud compte
for peer in self.peers:
try:
self.send_to_peer(peer, '/internal/replicate', message)
successes += 1
except Exception as e:
print(f"[{self.node_id}] Réplication échouée vers {peer} : {e}")
achieved_quorum = successes >= self.write_quorum
print(f"[{self.node_id}] Réplication : {successes}/{len(self.peers) + 1} nœuds (W={self.write_quorum})")
return achieved_quorum
def handle_heartbeat(self, heartbeat: dict):
"""Gérer le heartbeat du leader."""
if heartbeat['term'] >= self.term:
self.term = heartbeat['term']
self.last_heartbeat = time.time()
self.leader_id = heartbeat['leader_id']
if self.role != 'follower':
self.role = 'follower'
def handle_replication(self, message: dict):
"""Gérer la réplication du leader."""
if message['term'] >= self.term:
self.term = message['term']
self.leader_id = message['leader_id']
self.role = 'follower'
self.last_heartbeat = time.time()
self.data.update(message['data'])
def send_to_peer(self, peer_url: str, path: str, data: dict) -> None:
"""Envoyer des données à un nœud pair."""
url = f"{peer_url}{path}"
body = json.dumps(data).encode('utf-8')
req = Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
with urlopen(req, timeout=1) as response:
if response.status != 200:
raise Exception(f"Status {response.status}")
def set(self, key: str, value: Any) -> Dict[str, Any]:
"""Définir une paire clé-valeur avec accusé de réception du quorum."""
if self.role != 'leader':
return {'success': False, 'achieved_quorum': False}
self.data[key] = value
print(f"[{self.node_id}] SET {key} = {json.dumps(value)}")
achieved_quorum = self.replicate_to_followers()
return {'success': True, 'achieved_quorum': achieved_quorum}
def get(self, key: str, consistency: ConsistencyLevel = 'eventual') -> Any:
"""Obtenir une valeur avec cohérence configurable."""
local_value = self.data.get(key)
if consistency == 'eventual':
print(f"[{self.node_id}] GET {key} => {json.dumps(local_value)} (événementielle)")
return local_value
if consistency == 'strong':
latest, responses = self.get_from_quorum(key)
print(f"[{self.node_id}] GET {key} => {json.dumps(latest)} (forte depuis {responses} nœuds)")
return latest
if consistency == 'read_your_writes':
pending = self.pending_writes.get(key, [])
value_to_return = pending[-1] if pending else local_value
print(f"[{self.node_id}] GET {key} => {json.dumps(value_to_return)} (read-your-writes)")
return value_to_return
return local_value
def get_from_quorum(self, key: str) -> tuple:
"""Interroger un quorum de nœuds et retourner la valeur la plus récente."""
results = []
# Interroger tous les pairs
for peer in self.peers:
try:
result = self.query_peer(peer, '/internal/get', {'key': key})
results.append({
'success': True,
'value': result.get('value'),
'version': result.get('version', 0),
})
except Exception as e:
print(f"[{self.node_id}] Query échouée vers {peer} : {e}")
results.append({'success': False, 'value': None, 'version': 0})
# Ajouter la valeur locale
results.append({
'success': True,
'value': self.data.get(key),
'version': 1 if key in self.data else 0,
})
# Filtrer les réponses réussies
successful = [r for r in results if r['success']]
if len(successful) >= self.read_quorum:
# Retourner la première valeur non-nulle
for r in successful:
if r['value'] is not None:
return r['value'], len(successful)
return self.data.get(key), len(successful)
def query_peer(self, peer_url: str, path: str, data: dict) -> dict:
"""Interroger un pair pour une clé."""
url = f"{peer_url}{path}"
body = json.dumps(data).encode('utf-8')
req = Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
with urlopen(req, timeout=1) as response:
if response.status == 200:
return json.loads(response.read().decode('utf-8'))
raise Exception(f"Status {response.status}")
def delete(self, key: str) -> Dict[str, Any]:
"""Supprimer une clé."""
if self.role != 'leader':
return {'success': False, 'achieved_quorum': False}
existed = key in self.data
if existed:
del self.data[key]
print(f"[{self.node_id}] DELETE {key}")
self.replicate_to_followers()
return {'success': existed, 'achieved_quorum': True}
def get_status(self) -> dict:
"""Obtenir le statut du nœud."""
return {
'node_id': self.node_id,
'role': self.role,
'term': self.term,
'leader_id': self.leader_id,
'total_keys': len(self.data),
'keys': list(self.data.keys()),
'config': {
'write_quorum': self.write_quorum,
'read_quorum': self.read_quorum,
'total_nodes': len(self.peers) + 1,
},
}
# Créer le nœud
config = {
'node_id': os.environ.get('NODE_ID', 'node-1'),
'port': int(os.environ.get('PORT', '4000')),
'peers': [p for p in os.environ.get('PEERS', '').split(',') if p],
}
node = StoreNode(config['node_id'], config['peers'])
class NodeHandler(BaseHTTPRequestHandler):
"""Gestionnaire de requêtes HTTP pour le nœud de magasin."""
def send_json_response(self, status: int, data: dict):
"""Envoyer une réponse JSON."""
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def do_OPTIONS(self):
"""Gérer le pré-vol CORS."""
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_POST(self):
"""Gérer les requêtes POST."""
parsed = urlparse(self.path)
if parsed.path == '/internal/heartbeat':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
heartbeat = json.loads(body)
node.handle_heartbeat(heartbeat)
self.send_json_response(200, {'success': True})
except (json.JSONDecodeError, KeyError):
self.send_json_response(400, {'error': 'Invalid request'})
return
if parsed.path == '/internal/replicate':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
message = json.loads(body)
node.handle_replication(message)
self.send_json_response(200, {'success': True})
except (json.JSONDecodeError, KeyError):
self.send_json_response(400, {'error': 'Invalid request'})
return
if parsed.path == '/internal/get':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
data = json.loads(body)
key = data.get('key')
value = node.data.get(key)
self.send_json_response(200, {'value': value, 'version': 1 if value is not None else 0})
except (json.JSONDecodeError, KeyError):
self.send_json_response(400, {'error': 'Invalid request'})
return
self.send_json_response(404, {'error': 'Not found'})
def do_GET(self):
"""Gérer les requêtes GET."""
parsed = urlparse(self.path)
if parsed.path == '/status':
self.send_json_response(200, node.get_status())
return
if parsed.path.startswith('/key/'):
key = parsed.path[5:]
consistency = parsed.query.split('=')[-1] if '=' in parsed.query else 'eventual'
if consistency not in ['strong', 'eventual', 'read_your_writes']:
consistency = 'eventual'
value = node.get(key, consistency)
if value is not None:
self.send_json_response(200, {
'key': key,
'value': value,
'node_role': node.role,
'consistency': consistency,
})
else:
self.send_json_response(404, {'error': 'Key not found', 'key': key})
return
self.send_json_response(404, {'error': 'Not found'})
def do_PUT(self):
"""Gérer les requêtes PUT."""
parsed = urlparse(self.path)
if parsed.path.startswith('/key/'):
key = parsed.path[5:]
if node.role != 'leader':
self.send_json_response(503, {
'error': 'Not the leader',
'current_role': node.role,
'leader_id': node.leader_id or 'Unknown',
})
return
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
value = json.loads(body)
result = node.set(key, value)
self.send_json_response(200, {
'success': result['success'],
'key': key,
'value': value,
'leader_id': node.node_id,
'achieved_quorum': result['achieved_quorum'],
'write_quorum': node.write_quorum,
})
except json.JSONDecodeError:
self.send_json_response(400, {'error': 'Invalid JSON'})
return
self.send_json_response(404, {'error': 'Not found'})
def do_DELETE(self):
"""Gérer les requêtes DELETE."""
parsed = urlparse(self.path)
if parsed.path.startswith('/key/'):
key = parsed.path[5:]
if node.role != 'leader':
self.send_json_response(503, {
'error': 'Not the leader',
'current_role': node.role,
'leader_id': node.leader_id or 'Unknown',
})
return
result = node.delete(key)
if result['success']:
self.send_json_response(200, {'success': True, 'key': key, 'leader_id': node.node_id})
else:
self.send_json_response(404, {'error': 'Key not found', 'key': key})
return
self.send_json_response(404, {'error': 'Not found'})
def log_message(self, format, *args):
"""Supprimer la journalisation par défaut."""
pass
def run_server(port: int):
"""Démarrer le serveur HTTP."""
server_address = ('', port)
httpd = HTTPServer(server_address, NodeHandler)
print(f"[{config['node_id']}] Consistent Store écoutant sur le port {port}")
print(f"[{config['node_id']}] Quorum d'Écriture (W) : {node.write_quorum}, Quorum de Lecture (R) : {node.read_quorum}")
print(f"[{config['node_id']}] Pairs : {', '.join(config['peers']) or 'none'}")
print(f"[{config['node_id']}] Points de terminaison disponibles :")
print(f" GET /status - Statut du nœud")
print(f" GET /key/{{key}}?consistency=level - Obtenir avec niveau de cohérence")
print(f" PUT /key/{{key}} - Définir une valeur (leader uniquement)")
print(f" DEL /key/{{key}} - Supprimer une clé (leader uniquement)")
httpd.serve_forever()
if __name__ == '__main__':
run_server(config['port'])
consistent-store-py/requirements.txt
# Pas de dépendances externes - utilise uniquement la bibliothèque standard
consistent-store-py/Dockerfile
FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 4000
CMD ["python", "src/node.py"]
Configuration Docker Compose
Version TypeScript
examples/03-consistent-store/ts/docker-compose.yml
version: '3.8'
services:
node1:
build: .
container_name: consistent-ts-node1
ports:
- "4001:4000"
environment:
- NODE_ID=node-1
- PORT=4000
- PEERS=http://node2:4000,http://node3:4000
- WRITE_QUORUM=2
- READ_QUORUM=1
networks:
- consistent-network
node2:
build: .
container_name: consistent-ts-node2
ports:
- "4002:4000"
environment:
- NODE_ID=node-2
- PORT=4000
- PEERS=http://node1:4000,http://node3:4000
- WRITE_QUORUM=2
- READ_QUORUM=1
networks:
- consistent-network
node3:
build: .
container_name: consistent-ts-node3
ports:
- "4003:4000"
environment:
- NODE_ID=node-3
- PORT=4000
- PEERS=http://node1:4000,http://node2:4000
- WRITE_QUORUM=2
- READ_QUORUM=1
networks:
- consistent-network
networks:
consistent-network:
driver: bridge
Version Python
examples/03-consistent-store/py/docker-compose.yml
version: '3.8'
services:
node1:
build: .
container_name: consistent-py-node1
ports:
- "4001:4000"
environment:
- NODE_ID=node-1
- PORT=4000
- PEERS=http://node2:4000,http://node3:4000
- WRITE_QUORUM=2
- READ_QUORUM=1
networks:
- consistent-network
node2:
build: .
container_name: consistent-py-node2
ports:
- "4002:4000"
environment:
- NODE_ID=node-2
- PORT=4000
- PEERS=http://node1:4000,http://node3:4000
- WRITE_QUORUM=2
- READ_QUORUM=1
networks:
- consistent-network
node3:
build: .
container_name: consistent-py-node3
ports:
- "4003:4000"
environment:
- NODE_ID=node-3
- PORT=4000
- PEERS=http://node1:4000,http://node2:4000
- WRITE_QUORUM=2
- READ_QUORUM=1
networks:
- consistent-network
networks:
consistent-network:
driver: bridge
Exécution de l'Exemple
Étape 1 : Démarrer le Cluster
TypeScript :
cd distributed-systems-course/examples/03-consistent-store/ts
docker-compose up --build
Python :
cd distributed-systems-course/examples/03-consistent-store/py
docker-compose up --build
Vous devriez voir :
consistent-ts-node1 | [node-1] 👑 Devenu LEADER pour le terme 1
consistent-ts-node1 | [node-1] Quorum d'Écriture (W) : 2, Quorum de Lecture (R) : 1
consistent-ts-node2 | [node-2] En attente de node-1 pour devenir leader
consistent-ts-node3 | [node-3] En attente de node-1 pour devenir leader
Étape 2 : Tester la Cohérence Événementielle (Défaut)
# Écrire au leader
curl -X PUT http://localhost:4001/key/name \
-H "Content-Type: application/json" \
-d '"Alice"'
# Lire immédiatement depuis le suiveur (cohérence événementielle)
curl http://localhost:4002/key/name
Vous pourriez voir :
- Immédiatement après l'écriture :
null(le suiveur n'a pas encore reçu la réplication) - Un moment plus tard :
"Alice"(le suiveur a récupéré)
Étape 3 : Tester la Cohérence Forte
# Lire avec cohérence forte (attend le quorum)
curl "http://localhost:4002/key/name?consistency=strong"
Cela interroge plusieurs nœuds et retourne la valeur confirmée la plus récente.
Étape 4 : Observer le Comportement du Quorum
Vérifiez le statut pour voir vos paramètres de quorum :
curl http://localhost:4001/status
Réponse :
{
"nodeId": "node-1",
"role": "leader",
"config": {
"writeQuorum": 2,
"readQuorum": 1,
"totalNodes": 3
}
}
Étape 5 : Tester Différents Paramètres de Quorum
Arrêtez docker-compose et modifiez les variables d'environnement :
Essayer W=3 (Le plus fort) :
environment:
- WRITE_QUORUM=3
- READ_QUORUM=1
Essayer W=1 (Le plus faible) :
environment:
- WRITE_QUORUM=1
- READ_QUORUM=1
Observez comment le système se comporte différemment avec chaque paramètre.
Comparaison de Cohérence
graph TB
subgraph "Mêmes Données, Différents Niveaux de Cohérence"
W[Write : name = Alice]
subgraph "Cohérence Forte<br/>Lente mais Précise"
S1[Nœud 1 : Alice]
S2[Nœud 2 : Alice]
S3[Nœud 3 : Alice]
R1[Lire → Alice]
end
subgraph "Cohérence Événementielle<br/>Rapide mais Possiblement Périmée"
E1[Nœud 1 : Alice]
E2[Nœud 2 : Bob]
E3[Nœud 3 : ???]
R2[Lire → Bob ou ???]
end
end
W --> S1
W --> S2
W --> S3
W --> E1
W -.->|retardé| E2
W -.->|retardé| E3
style R1 fill:#6f6
style R2 fill:#f96
Exercices
Exercice 1 : Expérimenter la Cohérence Événementielle
- Démarrer le cluster
- Écrire une valeur au leader
- Immédiatement lire depuis un suiveur (dans les 100ms)
- Que voyez-vous ? Est-ce la nouvelle valeur ou l'ancienne ?
Exercice 2 : Comparer les Niveaux de Cohérence
Écrivez un script qui :
- Définit une clé à une nouvelle valeur
- Lit immédiatement avec
consistency=eventual - Lit immédiatement avec
consistency=strong - Compare les résultats
Exercice 3 : Ajuster le Quorum pour Différents Cas d'Usage
Pour chaque scénario, quels paramètres de quorum choisiriez-vous ?
| Scénario | W (Écriture) | R (Lecture) | R + W | Cohérence | Pourquoi ? |
|---|---|---|---|---|---|
| Transfert de solde bancaire | ? | ? | ? | ? | |
| J'aime sur les médias sociaux | ? | ? | ? | ? | |
| Panier d'achat | ? | ? | ? | ? | |
| Vue du profil utilisateur | ? | ? | ? | ? |
Exercice 4 : Implémenter la Réparation de Lecture
Lorsqu'une lecture périmée est détectée, mettre à jour le nœud périmé avec la valeur la plus récente. Indice : Dans la lecture forte, si vous trouvez une valeur plus récente sur un nœud, envoyez-la aux nœuds avec des valeurs plus anciennes.
Résumé
Points Clés à Retenir
- La cohérence est un spectre de la forte à l'événementielle
- Cohérence forte = toujours voir les données les plus récentes, mais plus lent
- Cohérence événementielle = lectures rapides, mais peut voir des données périmées
- Configuration du quorum (W + R) contrôle le niveau de cohérence :
R + W > N→ Cohérence forteR + W ≤ N→ Cohérence événementielle
- Compromis : Vous ne pouvez pas avoir à la fois la cohérence forte ET la haute disponibilité (théorème CAP)
Arbre de Décision de Cohérence
Besoin de lire les données les plus récentes immédiatement ?
├─ Oui → Utiliser la cohérence forte (R + W > N)
│ └─ Accepter des performances plus lentes
└─ Non → Utiliser la cohérence événementielle (R + W ≤ N)
└─ Obtenir des lectures plus rapides, accepter un certain péremé
Exemples du Monde Réel
| Système | Cohérence par Défaut | Configurable ? |
|---|---|---|
| DynamoDB | Cohérence événementielle | Oui (paramètre ConsistentRead) |
| Cassandra | Cohérence événementielle | Oui (niveau CONSISTENCY) |
| MongoDB | Forte (w:majority) | Oui (writeConcern, readConcern) |
| CouchDB | Cohérence événementielle | Oui (paramètres r, w) |
| etcd | Forte | Non (toujours forte) |
Vérifiez Votre Compréhension
- Quelle est la différence entre la cohérence forte et événementielle ?
- Comment la configuration du quorum (R, W) affecte-t-elle la cohérence ?
- Quand choisiriez-vous la cohérence événementielle plutôt que forte ?
-
Que garantit
R + W > N? - Pourquoi ne pouvons-nous pas avoir à la fois la cohérence forte et la haute disponibilité pendant les partitions ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.
Suite
Nous avons construit un magasin répliqué avec cohérence configurable. Ajoutons maintenant la communication en temps réel : WebSockets (Session 6)
WebSockets
Session 6, Partie 1 - 20 minutes
Objectifs d'apprentissage
- Comprendre le protocole WebSocket et ses avantages par rapport à HTTP
- Apprendre le cycle de vie d'une connexion WebSocket
- Implémenter des serveurs et clients WebSocket en TypeScript et Python
- Gérer la gestion des connexions et les scénarios d'erreur
Introduction
Dans les sessions précédentes, nous avons construit des systèmes utilisant HTTP - un protocole requête-réponse. Le client demande, le serveur répond. Mais que se passe-t-il si nous avons besoin d'une communication en temps réel, bidirectionnelle ?
Voici les WebSockets : un protocole qui permet la communication full-duplex sur une seule connexion TCP.
sequenceDiagram
participant Client
participant Server
Note over Client,Server: HTTP Requête-Réponse (Traditionnel)
Client->>Server: GET /data
Server-->>Client: Response
Client->>Server: GET /data
Server-->>Client: Response
Note over Client,Server: WebSocket (Temps Réel)
Client->>Server: HTTP Upgrade Request
Server-->>Client: 101 Switching Protocols
Client->>Server: Message 1
Server-->>Client: Message 2
Client->>Server: Message 3
Server-->>Client: Message 4
Client->>Server: Message 5
WebSocket vs HTTP
| Aspect | HTTP | WebSocket |
|---|---|---|
| Communication | Half-duplex (requête-réponse) | Full-duplex (bidirectionnelle) |
| Connexion | Nouvelle connexion par requête | Connexion persistante |
| Latence | Plus élevée (surcharge HTTP) | Plus faible (trames, non paquets) |
| État | Sans état (stateless) | Connexion avec état (stateful) |
| Push serveur | Nécessite polling/SSE | Support natif du push |
Quand utiliser les WebSockets
Idéal pour :
- Les applications de chat
- La collaboration en temps réel (édition, jeux)
- Les tableaux de bord et monitoring en direct
- Les jeux multijoueurs
Pas idéal pour :
- Les opérations CRUD simples (utiliser REST)
- La récupération de données unique
- L'accès aux ressources sans état
Le protocole WebSocket
Poignée de main (Handshake)
Les WebSockets commencent par HTTP, puis effectuent une mise à niveau (upgrade) vers le protocole WebSocket :
stateDiagram-v2
[*] --> HTTP: Client envoie requête HTTP
HTTP --> Handshake: Serveur reçoit
Handshake --> WebSocket: 101 Switching Protocols
WebSocket --> Connected: Full-duplex établi
Connected --> Messaging: Envoyer/recevoir trames
Messaging --> Closing: Trame de fermeture envoyée
Closing --> [*]: Connexion terminée
Requête HTTP (mise à niveau) :
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Réponse HTTP (acceptation) :
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Structure des trames
Les messages WebSocket sont envoyés sous forme de trames, non de paquets HTTP :
+--------+--------+--------+--------+ +--------+
| FIN | RSV1-3 | Opcode | Mask | ... | Payload|
| 1 bit | 3 bits | 4 bits | 1 bit | | |
+--------+--------+--------+--------+ +--------+
Opcodes courants :
- 0x1: Trame de texte
- 0x2: Trame binaire
- 0x8: Fermer la connexion
- 0x9: Ping
- 0xA: Pong
Cycle de vie WebSocket
stateDiagram-v2
[*] --> Connecting: ws://localhost:8080
Connecting --> Open: Handshake terminé (101)
Open --> Message: Envoyer/Recevoir des données
Message --> Open: Continuer
Open --> Closing: Fermeture normale ou erreur
Closing --> Closed: Connexion TCP terminée
Closed --> [*]
note right of Connecting
Le client envoie HTTP Upgrade
Le serveur répond avec 101
end note
note right of Message
Messagerie full-duplex
Aucune surcharge par message
end note
note right of Closing
Échange de trames de fermeture
Arrêt gracieux
end note
Implémentation : TypeScript
Nous utiliserons la bibliothèque ws - le standard de facto pour WebSockets dans Node.js.
Implémentation du serveur
// examples/03-chat/ts/ws-server.ts
import { WebSocketServer, WebSocket } from 'ws';
interface ChatMessage {
type: 'message' | 'join' | 'leave';
username: string;
content: string;
timestamp: number;
}
const wss = new WebSocketServer({ port: 8080 });
const clients = new Map<WebSocket, string>();
console.log('WebSocket server running on ws://localhost:8080');
wss.on('connection', (ws: WebSocket) => {
console.log('New client connected');
// Message de bienvenue
ws.send(JSON.stringify({
type: 'message',
username: 'System',
content: 'Welcome! Please identify yourself.',
timestamp: Date.now()
} as ChatMessage));
// Gérer les messages entrants
ws.on('message', (data: Buffer) => {
try {
const message: ChatMessage = JSON.parse(data.toString());
if (message.type === 'join') {
// Enregistrer le nom d'utilisateur
clients.set(ws, message.username);
console.log(`${message.username} joined`);
// Diffuser à tous les clients
broadcast({
type: 'message',
username: 'System',
content: `${message.username} has joined the chat`,
timestamp: Date.now()
});
} else if (message.type === 'message') {
const username = clients.get(ws) || 'Anonymous';
console.log(`${username}: ${message.content}`);
// Diffuser le message
broadcast({
type: 'message',
username,
content: message.content,
timestamp: Date.now()
});
}
} catch (error) {
console.error('Invalid message:', error);
}
});
// Gérer la déconnexion
ws.on('close', () => {
const username = clients.get(ws);
if (username) {
console.log(`${username} disconnected`);
clients.delete(ws);
broadcast({
type: 'message',
username: 'System',
content: `${username} has left the chat`,
timestamp: Date.now()
});
}
});
// Gérer les erreurs
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
function broadcast(message: ChatMessage): void {
const data = JSON.stringify(message);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
// Heartbeat pour détecter les connexions obsolètes
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(interval);
});
Implémentation du client
// examples/03-chat/ts/ws-client.ts
import { WebSocket } from 'ws';
interface ChatMessage {
type: 'message' | 'join' | 'leave';
username: string;
content: string;
timestamp: number;
}
class ChatClient {
private ws: WebSocket;
private username: string;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
constructor(url: string, username: string) {
this.username = username;
this.ws = this.connect(url);
}
private connect(url: string): WebSocket {
const ws = new WebSocket(url);
ws.on('open', () => {
console.log('Connected to chat server');
this.reconnectAttempts = 0;
// Nous identifier
this.send({
type: 'join',
username: this.username,
content: '',
timestamp: Date.now()
});
});
ws.on('message', (data: Buffer) => {
const message: ChatMessage = JSON.parse(data.toString());
this.displayMessage(message);
});
ws.on('close', () => {
console.log('Disconnected from server');
// Tenter la reconnexion
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms... (attempt ${this.reconnectAttempts})`);
setTimeout(() => {
this.ws = this.connect(url);
}, delay);
}
});
ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
});
// Répondre aux pings
ws.on('ping', () => {
ws.pong();
});
return ws;
}
public send(message: ChatMessage): void {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.error('Cannot send message: connection not open');
}
}
public sendMessage(content: string): void {
this.send({
type: 'message',
username: this.username,
content,
timestamp: Date.now()
});
}
private displayMessage(message: ChatMessage): void {
const time = new Date(message.timestamp).toLocaleTimeString();
console.log(`[${time}] ${message.username}: ${message.content}`);
}
public close(): void {
this.ws.close();
}
}
// Interface CLI
const username = process.argv[2] || `User${Math.floor(Math.random() * 1000)}`;
const client = new ChatClient('ws://localhost:8080', username);
console.log(`You are logged in as: ${username}`);
console.log('Type a message and press Enter to send. Press Ctrl+C to exit.');
// Lire depuis stdin
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) {
client.sendMessage(text);
}
});
// Gérer l'arrêt gracieux
process.on('SIGINT', () => {
console.log('\nShutting down...');
client.close();
process.exit(0);
});
Configuration du package
// examples/03-chat/ts/package.json
{
"name": "chat-websocket-example",
"version": "1.0.0",
"type": "module",
"scripts": {
"server": "node --loader ts-node/esm ws-server.ts",
"client": "node --loader ts-node/esm ws-client.ts"
},
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.12",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}
Implémentation : Python
Nous utiliserons la bibliothèque websockets - une implémentation WebSocket entièrement conforme.
Implémentation du serveur
# examples/03-chat/py/ws_server.py
import asyncio
import json
import logging
from datetime import datetime
from typing import Set
import websockets
from websockets.server import WebSocketServerProtocol
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Suivre les clients connectés
clients: Set[WebSocketServerProtocol] = set()
usernames: dict[WebSocketServerProtocol, str] = {}
async def broadcast(message: dict) -> None:
"""Envoyer un message à tous les clients connectés."""
if clients:
await asyncio.gather(
*[client.send(json.dumps(message)) for client in clients if client.open],
return_exceptions=True
)
async def handle_client(websocket: WebSocketServerProtocol) -> None:
"""Gérer une connexion client."""
clients.add(websocket)
logger.info(f"New client connected. Total clients: {len(clients)}")
try:
# Envoyer un message de bienvenue
welcome_msg = {
"type": "message",
"username": "System",
"content": "Welcome! Please identify yourself.",
"timestamp": datetime.now().timestamp()
}
await websocket.send(json.dumps(welcome_msg))
# Gérer les messages
async for message in websocket:
try:
data = json.loads(message)
if data.get("type") == "join":
# Enregistrer le nom d'utilisateur
username = data.get("username", "Anonymous")
usernames[websocket] = username
logger.info(f"{username} joined")
# Diffuser la notification de rejoindre
await broadcast({
"type": "message",
"username": "System",
"content": f"{username} has joined the chat",
"timestamp": datetime.now().timestamp()
})
elif data.get("type") == "message":
# Diffuser le message
username = usernames.get(websocket, "Anonymous")
content = data.get("content", "")
logger.info(f"{username}: {content}")
await broadcast({
"type": "message",
"username": username,
"content": content,
"timestamp": datetime.now().timestamp()
})
except json.JSONDecodeError:
logger.error("Invalid JSON received")
except Exception as e:
logger.error(f"Error handling message: {e}")
except websockets.exceptions.ConnectionClosed:
logger.info("Client disconnected unexpectedly")
finally:
# Nettoyage
username = usernames.get(websocket)
if username:
del usernames[websocket]
await broadcast({
"type": "message",
"username": "System",
"content": f"{username} has left the chat",
"timestamp": datetime.now().timestamp()
})
clients.discard(websocket)
logger.info(f"Client removed. Total clients: {len(clients)}")
async def main():
"""Démarrer le serveur WebSocket."""
async with websockets.serve(handle_client, "localhost", 8080):
logger.info("WebSocket server running on ws://localhost:8080")
await asyncio.Future() # Run forever
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Server stopped")
Implémentation du client
# examples/03-chat/py/ws_client.py
import asyncio
import json
import sys
from datetime import datetime
import websockets
from websockets.client import WebSocketClientProtocol
class ChatClient:
def __init__(self, url: str, username: str):
self.url = url
self.username = username
self.websocket: WebSocketClientProtocol | None = None
self.reconnect_attempts = 0
self.max_reconnect_attempts = 5
async def connect(self) -> None:
"""Connecter au serveur WebSocket."""
backoff = 1
while self.reconnect_attempts < self.max_reconnect_attempts:
try:
async with websockets.connect(self.url) as ws:
self.websocket = ws
self.reconnect_attempts = 0
print(f"Connected to {self.url}")
# Nous identifier
await self.send({
"type": "join",
"username": self.username,
"content": "",
"timestamp": datetime.now().timestamp()
})
# Commencer à recevoir des messages
receive_task = asyncio.create_task(self.receive_messages())
# Attendre la fermeture de la connexion
await ws.wait_closed()
# Annuler la tâche de réception
receive_task.cancel()
try:
await receive_task
except asyncio.CancelledError:
pass
print("Disconnected from server")
except (ConnectionRefusedError, OSError) as e:
self.reconnect_attempts += 1
print(f"Connection failed: {e}")
print(f"Reconnecting in {backoff}s... (attempt {self.reconnect_attempts})")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
print("Max reconnection attempts reached. Giving up.")
async def receive_messages(self) -> None:
"""Recevoir et afficher les messages du serveur."""
if not self.websocket:
return
try:
async for message in self.websocket:
data = json.loads(message)
self.display_message(data)
except asyncio.CancelledError:
pass
except Exception as e:
print(f"Error receiving message: {e}")
async def send(self, message: dict) -> None:
"""Envoyer un message au serveur."""
if self.websocket and not self.websocket.closed:
await self.websocket.send(json.dumps(message))
else:
print("Cannot send message: connection not open")
def display_message(self, message: dict) -> None:
"""Afficher un message reçu."""
timestamp = datetime.fromtimestamp(message["timestamp"]).strftime("%H:%M:%S")
print(f"[{timestamp}] {message['username']}: {message['content']}")
async def stdin_reader(client: ChatClient):
"""Lire depuis stdin et envoyer des messages."""
loop = asyncio.get_event_loop()
while True:
line = await loop.run_in_executor(None, sys.stdin.readline)
text = line.strip()
if text:
await client.send({
"type": "message",
"username": client.username,
"content": text,
"timestamp": datetime.now().timestamp()
})
async def main():
"""Exécuter le client de chat."""
username = sys.argv[1] if len(sys.argv) > 1 else f"User{asyncio.get_event_loop().time() % 1000:.0f}"
client = ChatClient("ws://localhost:8080", username)
print(f"You are logged in as: {username}")
print("Type a message and press Enter to send. Press Ctrl+C to exit.")
# Exécuter la connexion et le lecteur stdin simultanément
connect_task = asyncio.create_task(client.connect())
# Donner du temps à la connexion pour s'établir
await asyncio.sleep(0.5)
stdin_task = asyncio.create_task(stdin_reader(client))
try:
await asyncio.gather(connect_task, stdin_task)
except KeyboardInterrupt:
print("\nShutting down...")
finally:
connect_task.cancel()
stdin_task.cancel()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Configuration requise
# examples/03-chat/py/requirements.txt
websockets==13.1
Configuration Docker Compose
Version TypeScript
# examples/03-chat/ts/docker-compose.yml
version: '3.8'
services:
server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- NODE_ENV=production
restart: unless-stopped
# examples/03-chat/ts/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
RUN npx tsc
EXPOSE 8080
CMD ["node", "dist/ws-server.js"]
Version Python
# examples/03-chat/py/docker-compose.yml
version: '3.8'
services:
server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
restart: unless-stopped
# examples/03-chat/py/Dockerfile
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "ws_server.py"]
Exécution des exemples
TypeScript
# Installer les dépendances
cd examples/03-chat/ts
npm install
# Démarrer le serveur
npm run server
# Dans un autre terminal, démarrer un client
npm run client Alice
# Dans un autre terminal, démarrer un autre client
npm run client Bob
Python
# Installer les dépendances
cd examples/03-chat/py
pip install -r requirements.txt
# Démarrer le serveur
python ws_server.py
# Dans un autre terminal, démarrer un client
python ws_client.py Alice
# Dans un autre terminal, démarrer un autre client
python ws_client.py Bob
Avec Docker
# Démarrer le serveur
docker-compose up -d
# Vérifier les logs
docker-compose logs -f
# Se connecter avec un client (exécuter depuis l'hôte)
npm run client Alice # ou python ws_client.py Alice
Bonnes pratiques de gestion des connexions
1. Heartbeat/Ping-Pong
Détecter les connexions obsolètes avant qu'elles ne causent des problèmes :
// Le serveur envoie un ping toutes les 30 secondes
setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
// Le client répond automatiquement
ws.on('ping', () => ws.pong());
2. Reconnexion avec backoff exponentiel
Ne pas surcharger le serveur lorsqu'il est en panne :
function reconnect(attempts: number) {
const delay = Math.min(1000 * Math.pow(2, attempts), 30000);
setTimeout(() => connect(), delay);
}
3. Arrêt gracieux
// Envoyer une trame de fermeture avant de terminer
ws.close(1000, 'Normal closure');
// Attendre l'accusé de réception de la trame de fermeture
ws.on('close', () => {
console.log('Connection closed cleanly');
});
4. Sérialisation des messages
Toujours valider les messages entrants :
function safeParse(data: string): Message | null {
try {
const msg = JSON.parse(data);
if (msg.type && msg.username) {
return msg;
}
} catch {}
return null;
}
Pièges courants
| Piège | Symptôme | Solution |
|---|---|---|
| Pas de gestion de la reconnexion | Le client cesse de fonctionner sur une coupure réseau | Implémenter la reconnexion avec backoff exponentiel |
Ignorer l'événement close | Fuites de mémoire des clients obsolètes | Toujours nettoyer à la déconnexion |
| Blocage de la boucle d'événements | Messages retardés | Utiliser async/await correctement, éviter le travail CPU intensif |
- Heartbeat manquant | Les connexions obsolètes restent | Implémenter ping/pong |
- Pas de validation des messages | Plantages sur des données malformées | Toujours essayer/attraper l'analyse JSON |
Test de votre implémentation WebSocket
# Utiliser websocat (comme curl pour WebSockets)
# Installation : cargo install websocat
# Connecter et envoyer/recevoir des messages
echo '{"type":"join","username":"TestUser","content":"","timestamp":123456}' | \
websocat ws://localhost:8080
# Mode interactif
websocat ws://localhost:8080
Résumé
Les WebSockets permettent la communication en temps réel bidirectionnelle entre clients et serveurs :
- Protocole : Poignée de main HTTP avec mise à niveau → connexion TCP persistante
- Communication : Messagerie full-duplex avec une surcharge minimale
- Cycle de vie : Connecting → Open → Messaging → Closing → Closed
- Bonnes pratiques : Heartbeats, arrêt gracieux, gestion de la reconnexion
Dans la section suivante, nous développerons cette base pour implémenter la messagerie pub/sub pour les systèmes de chat multi-salles.
Exercices
Exercice 1 : Ajouter la messagerie privée
Étendre le système de chat pour prendre en charge les messages privés entre utilisateurs :
// Format de message pour les messages privés
{
type: 'private',
from: 'Alice',
to: 'Bob',
content: 'Hey Bob, are you there?',
timestamp: 1234567890
}
Exigences :
- Ajouter un type de message
private - Acheminer les messages privés uniquement au destinataire prévu
- Afficher un indicateur de "message privé" dans l'interface
Exercice 2 : Indicateurs de frappe
Afficher quand un utilisateur est en train de taper :
// Message d'indicateur de frappe
{
type: 'typing',
username: 'Alice',
isTyping: true,
timestamp: 1234567890
}
Exigences :
- Envoyer
typing.startlorsque l'utilisateur commence à taper - Envoyer
typing.stopaprès 2 secondes d'inactivité - Afficher "Alice est en train de taper..." aux autres utilisateurs
Exercice 3 : État de connexion
Afficher l'état de connexion en temps réel à l'utilisateur :
Exigences :
- Afficher : Connecting → Connected → Disconnected → Reconnecting
- Utiliser des indicateurs visuels (point vert, point rouge, spinner)
- Afficher la latence ping/pong en millisecondes
Exercice 4 : Historique des messages avec reconnexion
Lorsqu'un client se reconnecte, lui envoyer les messages qu'il a manqués :
Exigences :
- Stocker les 100 derniers messages sur le serveur
- Lors de la reconnexion du client, envoyer les messages depuis son dernier horodatage
- Dédupliquer les messages que le client possède déjà
🧠 Quiz du chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.
Messagerie Pub/Sub et Ordonnancement des Messages
Session 7, Partie 1 - 45 minutes
Objectifs d'apprentissage
- Comprendre le modèle de messagerie publish-subscribe
- Apprendre le routage basé sur les sujets et le routage basé sur le contenu
- Implémenter le suivi de présence et les abonnements
- Comprendre les défis de l'ordonnancement des messages dans les systèmes distribués
- Implémenter des numéros de séquence pour l'ordonnancement causal
Qu'est-ce que Pub/Sub ?
Le modèle publish-subscribe est un modèle de messagerie où les expéditeurs (publishers) envoient des messages à un système intermédiaire, et le système achemine les messages aux récepteurs intéressés (subscribers). Les publishers et subscribers sont découplés - ils ne se connaissent pas.
Avantages clés
- Découplage : Les publishers n'ont pas besoin de savoir qui s'abonne
- Extensibilité : Ajouter des subscribers sans modifier les publishers
- Flexibilité : Gestion dynamique des abonnements
- Asynchronie : Les publishers envoient et continuent ; les subscribers traitent quand ils sont prêts
Pub/Sub vs Messagerie directe
graph TB
subgraph "Messagerie directe"
P1[Producer] -->|Direct| C1[Consumer 1]
P1 -->|Direct| C2[Consumer 2]
P1 -->|Direct| C3[Consumer 3]
end
subgraph "Messagerie Pub/Sub"
P2[Publisher] -->|Publish| B[Broker]
S1[Subscriber 1] -->|Subscribe| B
S2[Subscriber 2] -->|Subscribe| B
S3[Subscriber 3] -->|Subscribe| B
end
| Aspect | Messagerie directe | Pub/Sub |
|---|---|---|
| Couplage | Fort (le producteur connaît les consumers) | Faible (le producteur ne connaît pas les consumers) |
| Flexibilité | Faible (les changements affectent le producteur) | Élevée (abonnements dynamiques) |
| Complexité | Simple | Modérée (nécessite un broker) |
| Cas d'usage | Point-à-point, requête-réponse | Diffusion, événements, notifications |
Modèles Pub/Sub
1. Routage basé sur les sujets
Les subscribers expriment leur intérêt pour des sujets (channels). Les messages sont acheminés en fonction du sujet auquel ils sont publiés.
sequenceDiagram
participant S1 as Subscriber 1
participant S2 as Subscriber 2
participant S3 as Subscriber 3
participant B as Broker
participant P as Publisher
Note over S1,S3: Phase d'abonnement
S1->>B: subscribe("sports")
S2->>B: subscribe("sports")
S3->>B: subscribe("news")
Note over S1,S3: Phase de publication
P->>B: publish("sports", "Game starts!")
B->>S1: deliver("Game starts!")
B->>S2: deliver("Game starts!")
P->>B: publish("news", "Breaking story!")
B->>S3: deliver("Breaking story!")
Cas d'usage : Salles de chat, catégories de notifications, flux d'événements
2. Routage basé sur le contenu
Les subscribers spécifient des critères de filtrage. Les messages sont acheminés en fonction de leur contenu.
graph LR
P[Publisher] -->|{"type": "order", "value": >100}| B[Content Router]
B -->|Matches filter| S1[High-Value Handler]
B -->|Matches filter| S2[Order Logger]
B -.->|No match| S3[Low-Value Handler]
Cas d'usage : Filtrage d'événements, règles de routage complexes, données de capteurs IoT
3. Suivi de présence
Dans les systèmes en temps réel, savoir qui est en ligne (presence) est essentiel pour :
- Afficher le statut en ligne/hors ligne
- Livrer les messages uniquement aux utilisateurs actifs
- Gérer les connexions et reconnexions
- Gérer gracieusement les déconnexions utilisateurs
stateDiagram-v2
[*] --> Offline: Utilisateur créé
Offline --> Connecting: Demande de connexion
Connecting --> Online: Auth réussie
Connecting --> Offline: Auth échouée
Online --> Away: Pas d'activité
Online --> Offline: Déconnexion
Away --> Online: Activité détectée
Online --> [*]: Utilisateur supprimé
Ordonnancement des messages
Le problème de l'ordonnancement
Dans les systèmes distribués, les messages peuvent arriver dans le désordre en raison de :
- Variations de latence réseau
- Serveurs multiples traitant des messages
- Nouvelles tentatives et retransmissions de messages
- Publishers simultanés
Types d'ordonnancement
| Type d'ordonnancement | Description | Difficulté |
|---|---|---|
| FIFO | Les messages du même expéditeur arrivent dans l'ordre d'envoi | Facile |
| Causal | Les messages causalement liés sont ordonnés | Modérée |
| Total | Tous les messages sont ordonnés globalement | Difficile |
Pourquoi l'ordonnancement est important
Considérons une application de chat :
sequenceDiagram
participant A as Alice
participant S as Server
participant B as Bob
Note over A,B: Sans ordonnancement - confusion !
A->>S: "Let's meet at 5pm"
A->>S: "Never mind, 6pm instead"
S--xB: "Never mind, 6pm instead"
S--xB: "Let's meet at 5pm"
Note over B: Bob voit les messages dans le désordre !
Avec un ordonnancement approprié utilisant des numéros de séquence :
sequenceDiagram
participant A as Alice
participant S as Server
participant B as Bob
Note over A,B: Avec numéros de séquence - correct !
A->>S: [msg#1] "Let's meet at 5pm"
A->>S: [msg#2] "Never mind, 6pm instead"
S--xB: [msg#1] "Let's meet at 5pm"
S--xB: [msg#2] "Never mind, 6pm instead"
Note over B: Bob livre dans l'ordre par numéro de séquence
Implémentation : Chat Pub/Sub avec ordonnancement
Construisons un système de chat pub/sub avec :
- Routage basé sur les sujets (salles de chat)
- Suivi de présence
- Ordonnancement des messages avec numéros de séquence
Implémentation TypeScript
pubsub-server.ts - Serveur Pub/Sub avec ordonnancement :
// src: examples/03-chat/ts/pubsub-server.ts
interface Message {
id: string;
room: string;
sender: string;
content: string;
sequence: number;
timestamp: number;
}
interface Subscriber {
id: string;
userId: string;
rooms: Set<string>;
ws: WebSocket;
}
class PubSubServer {
private subscribers: Map<string, Subscriber> = new Map();
private roomSequences: Map<string, number> = new Map();
private messageHistory: Map<string, Message[]> = new Map();
private server: WebSocketServer;
constructor(port: number = 8080) {
this.server = new WebSocketServer({ port });
this.setupHandlers();
console.log(`Pub/Sub server running on port ${port}`);
}
private setupHandlers() {
this.server.on('connection', (ws: WebSocket) => {
const subscriberId = this.generateId();
ws.on('message', (data: string) => {
try {
const msg = JSON.parse(data.toString());
this.handleMessage(subscriberId, msg, ws);
} catch (err) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
this.handleDisconnect(subscriberId);
});
});
}
private handleMessage(subscriberId: string, msg: any, ws: WebSocket) {
switch (msg.type) {
case 'subscribe':
this.handleSubscribe(subscriberId, msg.room, msg.userId, ws);
break;
case 'unsubscribe':
this.handleUnsubscribe(subscriberId, msg.room);
break;
case 'publish':
this.handlePublish(msg);
break;
case 'get_history':
this.handleGetHistory(msg.room, ws);
break;
}
}
private handleSubscribe(
subscriberId: string,
room: string,
userId: string,
ws: WebSocket
) {
if (!this.subscribers.has(subscriberId)) {
this.subscribers.set(subscriberId, {
id: subscriberId,
userId,
rooms: new Set(),
ws,
});
}
const subscriber = this.subscribers.get(subscriberId)!;
subscriber.rooms.add(room);
// Initialiser l'état de la salle si nécessaire
if (!this.roomSequences.has(room)) {
this.roomSequences.set(room, 0);
this.messageHistory.set(room, []);
}
// Envoyer une notification de présence
this.broadcast(room, {
type: 'presence',
userId,
action: 'join',
timestamp: Date.now(),
});
// Envoyer le numéro de séquence actuel
ws.send(JSON.stringify({
type: 'subscribed',
room,
sequence: this.roomSequences.get(room),
}));
console.log(`${userId} subscribed to ${room}`);
}
private handleUnsubscribe(subscriberId: string, room: string) {
const subscriber = this.subscribers.get(subscriberId);
if (subscriber) {
subscriber.rooms.delete(room);
// Envoyer une notification de présence
this.broadcast(room, {
type: 'presence',
userId: subscriber.userId,
action: 'leave',
timestamp: Date.now(),
});
}
}
private handlePublish(msg: any) {
const { room, sender, content } = msg;
const sequence = (this.roomSequences.get(room) || 0) + 1;
this.roomSequences.set(room, sequence);
const message: Message = {
id: this.generateId(),
room,
sender,
content,
sequence,
timestamp: Date.now(),
};
// Stocker dans l'historique
const history = this.messageHistory.get(room) || [];
history.push(message);
this.messageHistory.set(room, history.slice(-100)); // Garder les 100 derniers
// Diffuser à tous les subscribers
this.broadcast(room, {
type: 'message',
...message,
});
}
private handleGetHistory(room: string, ws: WebSocket) {
const history = this.messageHistory.get(room) || [];
ws.send(JSON.stringify({
type: 'history',
room,
messages: history,
}));
}
private broadcast(room: string, payload: any) {
const payloadStr = JSON.stringify(payload);
for (const [_, subscriber] of this.subscribers) {
if (subscriber.rooms.has(room) && subscriber.ws.readyState === WebSocket.OPEN) {
subscriber.ws.send(payloadStr);
}
}
}
private handleDisconnect(subscriberId: string) {
const subscriber = this.subscribers.get(subscriberId);
if (subscriber) {
// Notifier toutes les salles où l'utilisateur était
for (const room of subscriber.rooms) {
this.broadcast(room, {
type: 'presence',
userId: subscriber.userId,
action: 'leave',
timestamp: Date.now(),
});
}
this.subscribers.delete(subscriberId);
}
}
private generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
}
const PORT = parseInt(process.env.PORT || '8080');
new PubSubServer(PORT);
pubsub-client.ts - Client avec tampon d'ordonnancement :
// src: examples/03-chat/ts/pubsub-client.ts
interface ClientMessage {
type: string;
sequence?: number;
[key: string]: any;
}
class PubSubClient {
private ws: WebSocket | null = null;
private userId: string;
private messageBuffer: Map<string, Map<number, ClientMessage>> = new Map();
private expectedSequence: Map<string, number> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
constructor(
private url: string,
userId?: string
) {
this.userId = userId || `user-${Math.random().toString(36).substring(7)}`;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log(`Connected as ${this.userId}`);
this.reconnectAttempts = 0;
});
this.ws.on('message', (data: string) => {
const msg: ClientMessage = JSON.parse(data.toString());
this.handleMessage(msg);
});
this.ws.on('close', () => {
console.log('Disconnected. Attempting to reconnect...');
this.reconnect();
});
this.ws.on('error', (err) => {
console.error('WebSocket error:', err);
});
}
private handleMessage(msg: ClientMessage) {
switch (msg.type) {
case 'subscribed':
this.expectedSequence.set(msg.room, (msg.sequence || 0) + 1);
console.log(`Subscribed to ${msg.room} at sequence ${msg.sequence}`);
break;
case 'message':
this.handleOrderedMessage(msg.room, msg);
break;
case 'presence':
console.log(`${msg.userId} ${msg.action}ed`);
break;
case 'history':
console.log(`Received ${msg.messages.length} historical messages`);
msg.messages.forEach((m: ClientMessage) => this.displayMessage(m));
break;
}
}
private handleOrderedMessage(room: string, msg: ClientMessage) {
const seq = msg.sequence!;
// Initialiser le tampon si nécessaire
if (!this.messageBuffer.has(room)) {
this.messageBuffer.set(room, new Map());
}
const buffer = this.messageBuffer.get(room)!;
const expected = this.expectedSequence.get(room) || 1;
if (seq === expected) {
// Message attendu - livrer immédiatement
this.displayMessage(msg);
this.expectedSequence.set(room, seq + 1);
// Vérifier le tampon pour les messages suivants
this.deliverBufferedMessages(room);
} else if (seq > expected) {
// Message futur - le mettre en tampon
buffer.set(seq, msg);
console.log(`Buffered message ${seq} (expecting ${expected})`);
}
// seq < expected: ancien message, ignorer
}
private deliverBufferedMessages(room: string) {
const buffer = this.messageBuffer.get(room);
if (!buffer) return;
const expected = this.expectedSequence.get(room) || 1;
while (buffer.has(expected)) {
const msg = buffer.get(expected)!;
this.displayMessage(msg);
buffer.delete(expected);
this.expectedSequence.set(room, expected + 1);
}
}
private displayMessage(msg: ClientMessage) {
console.log(`[${msg.sequence}] ${msg.sender}: ${msg.content}`);
}
subscribe(room: string) {
this.send({ type: 'subscribe', room, userId: this.userId });
}
unsubscribe(room: string) {
this.send({ type: 'unsubscribe', room });
}
publish(room: string, content: string) {
this.send({
type: 'publish',
room,
sender: this.userId,
content,
});
}
getHistory(room: string) {
this.send({ type: 'get_history', room });
}
private send(payload: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(payload));
} else {
console.error('WebSocket not connected');
}
}
private reconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
setTimeout(() => this.connect(), delay);
} else {
console.error('Max reconnection attempts reached');
}
}
}
// Usage en CLI
const args = process.argv.slice(2);
const url = args[0] || 'ws://localhost:8080';
const client = new PubSubClient(url);
client.connect();
// Interface readline simple
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('Commands: /join <room>, /leave <room>, /history <room>, /quit');
console.log('Any other input will be sent to the current room');
let currentRoom = '';
const showPrompt = () => {
if (currentRoom) {
rl.question(`[${currentRoom}]> `, (input) => {
if (input === '/quit') {
client.ws?.close();
rl.close();
process.exit(0);
} else if (input.startsWith('/join ')) {
currentRoom = input.substring(6);
client.subscribe(currentRoom);
} else if (input.startsWith('/leave ')) {
const room = input.substring(7);
client.unsubscribe(room);
if (room === currentRoom) currentRoom = '';
} else if (input.startsWith('/history ')) {
const room = input.substring(9);
client.getHistory(room);
} else if (input && currentRoom) {
client.publish(currentRoom, input);
}
showPrompt();
});
} else {
rl.question('(no room)> ', (input) => {
if (input.startsWith('/join ')) {
currentRoom = input.substring(6);
client.subscribe(currentRoom);
}
showPrompt();
});
}
};
showPrompt();
Implémentation Python
pubsub_server.py - Serveur Pub/Sub avec ordonnancement :
# src: examples/03-chat/py/pubsub_server.py
import asyncio
import json
import time
from typing import Dict, Set, List
from dataclasses import dataclass, asdict
import websockets
from websockets.server import WebSocketServerProtocol
@dataclass
class Message:
id: str
room: str
sender: str
content: str
sequence: int
timestamp: int
class PubSubServer:
def __init__(self, port: int = 8080):
self.port = port
self.subscribers: Dict[str, dict] = {}
self.room_sequences: Dict[str, int] = {}
self.message_history: Dict[str, List[Message]] = {}
async def handle_connection(self, ws: WebSocketServerProtocol):
subscriber_id = self._generate_id()
try:
async for message in ws:
try:
data = json.loads(message)
await self.handle_message(subscriber_id, data, ws)
except json.JSONDecodeError:
await ws.send(json.dumps({"error": "Invalid message format"}))
finally:
await self.handle_disconnect(subscriber_id)
async def handle_message(self, subscriber_id: str, msg: dict, ws: WebSocketServerProtocol):
msg_type = msg.get("type")
if msg_type == "subscribe":
await self.handle_subscribe(subscriber_id, msg["room"], msg["userId"], ws)
elif msg_type == "unsubscribe":
await self.handle_unsubscribe(subscriber_id, msg["room"])
elif msg_type == "publish":
await self.handle_publish(msg)
elif msg_type == "get_history":
await self.handle_get_history(msg["room"], ws)
async def handle_subscribe(
self, subscriber_id: str, room: str, user_id: str, ws: WebSocketServerProtocol
):
if subscriber_id not in self.subscribers:
self.subscribers[subscriber_id] = {
"id": subscriber_id,
"userId": user_id,
"rooms": set(),
"ws": ws,
}
subscriber = self.subscribers[subscriber_id]
subscriber["rooms"].add(room)
# Initialiser l'état de la salle
if room not in self.room_sequences:
self.room_sequences[room] = 0
self.message_history[room] = []
# Envoyer une notification de présence
await self.broadcast(room, {
"type": "presence",
"userId": user_id,
"action": "join",
"timestamp": int(time.time() * 1000),
})
# Envoyer le numéro de séquence actuel
await ws.send(json.dumps({
"type": "subscribed",
"room": room,
"sequence": self.room_sequences[room],
}))
print(f"{user_id} subscribed to {room}")
async def handle_unsubscribe(self, subscriber_id: str, room: str):
subscriber = self.subscribers.get(subscriber_id)
if subscriber:
subscriber["rooms"].discard(room)
await self.broadcast(room, {
"type": "presence",
"userId": subscriber["userId"],
"action": "leave",
"timestamp": int(time.time() * 1000),
})
async def handle_publish(self, msg: dict):
room = msg["room"]
sender = msg["sender"]
content = msg["content"]
sequence = self.room_sequences.get(room, 0) + 1
self.room_sequences[room] = sequence
message = Message(
id=self._generate_id(),
room=room,
sender=sender,
content=content,
sequence=sequence,
timestamp=int(time.time() * 1000),
)
# Stocker dans l'historique
history = self.message_history[room]
history.append(message)
self.message_history[room] = history[-100:] # Garder les 100 derniers
# Diffuser
await self.broadcast(room, {
"type": "message",
**asdict(message),
})
async def handle_get_history(self, room: str, ws: WebSocketServerProtocol):
history = self.message_history.get(room, [])
await ws.send(json.dumps({
"type": "history",
"room": room,
"messages": [asdict(m) for m in history],
}))
async def broadcast(self, room: str, payload: dict):
payload_str = json.dumps(payload)
tasks = []
for subscriber in self.subscribers.values():
if room in subscriber["rooms"]:
ws = subscriber["ws"]
if not ws.closed:
tasks.append(ws.send(payload_str))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def handle_disconnect(self, subscriber_id: str):
subscriber = self.subscribers.get(subscriber_id)
if subscriber:
# Notifier toutes les salles
for room in list(subscriber["rooms"]):
await self.broadcast(room, {
"type": "presence",
"userId": subscriber["userId"],
"action": "leave",
"timestamp": int(time.time() * 1000),
})
del self.subscribers[subscriber_id]
def _generate_id(self) -> str:
import random
import string
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
async def start(self):
print(f"Pub/Sub server running on port {self.port}")
async with websockets.serve(self.handle_connection, "", self.port):
await asyncio.Future() # Run forever
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", "8080"))
server = PubSubServer(port)
asyncio.run(server.start())
pubsub_client.py - Client avec tampon d'ordonnancement :
# src: examples/03-chat/py/pubsub_client.py
import asyncio
import json
import time
from typing import Dict, Optional
import websockets
from websockets.client import WebSocketClientProtocol
class PubSubClient:
def __init__(self, url: str, user_id: Optional[str] = None):
self.url = url
self.user_id = user_id or f"user-{int(time.time())}"
self.ws: Optional[WebSocketClientProtocol] = None
self.message_buffer: Dict[str, Dict[int, dict]] = {}
self.expected_sequence: Dict[str, int] = {}
self.reconnect_attempts = 0
self.max_reconnect_attempts = 5
async def connect(self):
try:
self.ws = await websockets.connect(self.url)
print(f"Connected as {self.user_id}")
self.reconnect_attempts = 0
asyncio.create_task(self.listen())
except Exception as e:
print(f"Connection failed: {e}")
await self.reconnect()
async def listen(self):
if not self.ws:
return
try:
async for message in self.ws:
data = json.loads(message)
await self.handle_message(data)
except websockets.exceptions.ConnectionClosed:
print("Disconnected. Attempting to reconnect...")
await self.reconnect()
async def handle_message(self, msg: dict):
msg_type = msg.get("type")
if msg_type == "subscribed":
room = msg["room"]
self.expected_sequence[room] = msg.get("sequence", 0) + 1
print(f"Subscribed to {room} at sequence {msg.get('sequence', 0)}")
elif msg_type == "message":
await self.handle_ordered_message(msg["room"], msg)
elif msg_type == "presence":
print(f"{msg['userId']} {msg['action']}ed")
elif msg_type == "history":
print(f"Received {len(msg['messages'])} historical messages")
for m in msg["messages"]:
self.display_message(m)
async def handle_ordered_message(self, room: str, msg: dict):
seq = msg["sequence"]
if room not in self.message_buffer:
self.message_buffer[room] = {}
buffer = self.message_buffer[room]
expected = self.expected_sequence.get(room, 1)
if seq == expected:
# Message attendu - livrer immédiatement
self.display_message(msg)
self.expected_sequence[room] = seq + 1
# Vérifier le tampon pour les messages suivants
await self.deliver_buffered_messages(room)
elif seq > expected:
# Message futur - le mettre en tampon
buffer[seq] = msg
print(f"Buffered message {seq} (expecting {expected})")
async def deliver_buffered_messages(self, room: str):
buffer = self.message_buffer.get(room, {})
expected = self.expected_sequence.get(room, 1)
while expected in buffer:
msg = buffer[expected]
self.display_message(msg)
del buffer[expected]
self.expected_sequence[room] = expected + 1
expected += 1
def display_message(self, msg: dict):
print(f"[{msg['sequence']}] {msg['sender']}: {msg['content']}")
async def subscribe(self, room: str):
await self.send({"type": "subscribe", "room": room, "userId": self.user_id})
async def unsubscribe(self, room: str):
await self.send({"type": "unsubscribe", "room": room})
async def publish(self, room: str, content: str):
await self.send({
"type": "publish",
"room": room,
"sender": self.user_id,
"content": content,
})
async def get_history(self, room: str):
await self.send({"type": "get_history", "room": room})
async def send(self, payload: dict):
if self.ws and not self.ws.closed:
await self.ws.send(json.dumps(payload))
else:
print("WebSocket not connected")
async def reconnect(self):
if self.reconnect_attempts < self.max_reconnect_attempts:
self.reconnect_attempts += 1
delay = min(1000 * (2 ** self.reconnect_attempts), 30000) / 1000
await asyncio.sleep(delay)
await self.connect()
else:
print("Max reconnection attempts reached")
async def main():
import sys
url = sys.argv[1] if len(sys.argv) > 1 else "ws://localhost:8080"
client = PubSubClient(url)
await client.connect()
# CLI simple
current_room = ""
print('Commands: /join <room>, /leave <room>, /history <room>, /quit')
while True:
try:
prompt = f"[{current_room}]> " if current_room else "(no room)> "
line = await asyncio.get_event_loop().run_in_executor(None, input, prompt)
if line == "/quit":
break
elif line.startswith("/join "):
current_room = line[6:]
await client.subscribe(current_room)
elif line.startswith("/leave "):
room = line[7:]
await client.unsubscribe(room)
if room == current_room:
current_room = ""
elif line.startswith("/history "):
room = line[9:]
await client.get_history(room)
elif line and current_room:
await client.publish(current_room, line)
except EOFError:
break
if client.ws:
await client.ws.close()
if __name__ == "__main__":
asyncio.run(main())
Exécution des exemples
Version TypeScript
cd distributed-systems-course/examples/03-chat/ts
# Installer les dépendances
npm install
# Démarrer le serveur
PORT=8080 npx ts-node pubsub-server.ts
# Dans un autre terminal, démarrer un client
npx ts-node pubsub-client.ts
Version Python
cd distributed-systems-course/examples/03-chat/py
# Installer les dépendances
pip install -r requirements.txt
# Démarrer le serveur
PORT=8080 python pubsub_server.py
# Dans un autre terminal, démarrer un client
python pubsub_client.py
Docker Compose
docker-compose.yml (TypeScript) :
services:
pubsub-server:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
docker-compose up
Test du système Pub/Sub
Test 1 : Pub/Sub de base
- Démarrer trois clients dans des terminaux séparés
- Client 1 :
/join general - Client 2 :
/join general - Client 1 :
Hello everyone! - Le client 2 devrait recevoir le message
- Client 3 :
/join general - Client 3 :
/history general- devrait voir les messages précédents
Test 2 : Salles multiples
- Client 1 :
/join sports - Client 2 :
/join news - Client 1 :
Game starting!(uniquement dans sports) - Client 2 :
Breaking news!(uniquement dans news) - Client 3 :
/join sportset/join news(reçoit les deux)
Test 3 : Ordonnancement des messages
- Démarrer un client et rejoindre une salle
- Envoyer des messages rapidement :
msg1,msg2,msg3 - Observer les numéros de séquence :
[1],[2],[3] - Noter que l'ordre est préservé
Test 4 : Suivi de présence
- Démarrer deux clients
- Les deux rejoignent la même salle
- Observer les notifications de présence (utilisateur rejoint/parti)
- Déconnecter un client (Ctrl+C)
- L'autre client reçoit la notification de départ
Exercices
Exercice 1 : Implémenter le cache des derniers messages
Ajouter une fonctionnalité pour stocker uniquement les derniers N messages par salle (déjà implémenté comme 100 dans le code).
Tâches :
- Rendre la taille de l'historique configurable via une variable d'environnement
- Ajouter une commande
/clear_historypour les administrateurs - Ajouter un TTL (time-to-live) pour les anciens messages
Exercice 2 : Implémenter les messages privés
Étendre le système pour prendre en charge les messages directs entre utilisateurs.
Exigences :
- Les messages privés ne doivent être livrés qu'au destinataire
- Utiliser un format de sujet spécial :
@username - Inclure l'authentification de l'expéditeur
Indice : Vous devrez modifier la méthode handlePublish pour vérifier le préfixe @.
Exercice 3 : Ajouter les accusés de réception de messages
Implémenter des accusés de réception pour garantir la livraison des messages.
Exigences :
- Les clients doivent ACK les messages reçus
- Le serveur suit les messages non accusés
- À la reconnexion, le serveur renvoie les messages non accusés
Indice : Ajoutez un type de message ack et suivez les messages en attente par subscriber.
Pièges courants
| Piège | Symptôme | Solution |
|---|---|---|
| Désynchronisation des numéros de séquence | Messages non affichés | Se réabonner pour réinitialiser la séquence |
| Fuite de mémoire de l'historique | Utilisation mémoire croissante | Implémenter des limites de taille d'historique |
| Mises à jour de présence manquantes | Statut en ligne obsolète | Ajouter des messages heartbeat/ping |
| Conditions de course | Messages perdus lors de la reconnexion | Mettre en tampon les messages pendant la déconnexion |
Exemples réels
| Système | Implémentation Pub/Sub | Stratégie d'ordonnancement |
|---|---|---|
| Redis Pub/Sub | Canaux basés sur des sujets | Aucune garantie d'ordonnancement |
| Apache Kafka | Sujets partitionnés | Ordonnancement par partition |
| Google Cloud Pub/Sub | Sujets avec abonnements | Livraison exactement une fois |
| AWS SNS | Diffusion basée sur des sujets | Ordonnancement au mieux (best-effort) |
| RabbitMQ | Liaison exchange/queue | FIFO dans la file |
Résumé
- Pub/Sub découple les publishers des subscribers via un broker intermédiaire
- Le routage basé sur les sujets est le modèle le plus simple et le plus courant
- Le suivi de présence permet le statut en ligne/hors ligne dans les systèmes temps réel
- L'ordonnancement des messages nécessite des numéros de séquence et la mise en tampon
- L'ordonnancement causal est réalisable avec une complexité modeste
- L'ordonnancement total est coûteux et souvent inutile
Suivant : Implémentation du système de chat →
🧠 Quiz du chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.
Implémentation du Système de Chat
Session 7 - Session complète (90 minutes)
Objectifs d'apprentissage
- Construire un système de chat en temps réel complet avec WebSockets
- Implémenter l'ordonnancement des messages avec numéros de séquence
- Gérer la gestion de présence (utilisateurs en ligne/hors ligne)
- Ajouter la persistance des messages pour l'historique
- Déployer plusieurs salles de chat avec Docker Compose
Architecture du système
Notre système de chat rassemble tous les concepts des sessions 6-7 :
graph TB
subgraph "Clients"
C1[Navigateur Utilisateur 1]
C2[Navigateur Utilisateur 2]
C3[Navigateur Utilisateur 3]
end
subgraph "Serveur de Chat"
WS[Gestionnaire WebSocket]
PS[Moteur Pub/Sub]
SM[Gestionnaire de Séquence]
PM[Gestionnaire de Présence]
MS[Stockage de Messages]
WS --> PS
WS --> SM
WS --> PM
PS --> SM
SM --> MS
end
C1 -->|WebSocket| WS
C2 -->|WebSocket| WS
C3 -->|WebSocket| WS
subgraph "Persistance"
DB[(Base de Messages)]
end
MS --> DB
style WS fill:#e3f2fd
style PS fill:#fff3e0
style SM fill:#f3e5f5
Composants clés
| Composant | Responsabilité |
|---|---|
| Gestionnaire WebSocket | Gère les connexions client, envoie/reçoit les messages |
| Moteur Pub/Sub | Achemine les messages vers les salles, gère les abonnements |
| Gestionnaire de Séquence | Attribue des numéros de séquence, assure l'ordonnancement |
| Gestionnaire de Présence | Suit le statut en ligne/hors ligne, heartbeat |
| Stockage de Messages | Persiste les messages pour l'historique et la relecture |
Flux des messages
sequenceDiagram
participant U1 as Utilisateur 1
participant WS as Gestionnaire WebSocket
participant PS as Pub/Sub
participant SM as Séquenceur
participant DB as Stockage de Messages
participant U2 as Utilisateur 2
U1->>WS: CONNECT("general")
WS->>PS: subscribe("general", U1)
WS->>PM: mark_online(U1)
PS->>U2: BROADCAST("Utilisateur 1 a rejoint")
Note over U1,U2: Envoi d'un message
U1->>WS: SEND("general", "Bonjour!")
WS->>PS: publish("general", msg)
PS->>SM: get_sequence(msg)
SM->>DB: save(msg, seq=1)
SM->>PS: return seq=1
PS->>U1: BROADCAST(msg, seq=1)
PS->>U2: BROADCAST(msg, seq=1)
Note over U1,U2: Utilisateur 2 se reconnecte
U2->>WS: CONNECT("general", last_seq=0)
WS->>DB: get_messages(since=0)
DB->>U2: REPLAY([msg1, msg2, ...])
Implémentation TypeScript
Structure du projet
chat-system/
├── package.json
├── tsconfig.json
├── src/
│ ├── types.ts # Définitions de type
│ ├── pub-sub.ts # Moteur Pub/Sub
│ ├── sequencer.ts # Gestionnaire de numéros de séquence
│ ├── presence.ts # Gestion de présence
│ ├── store.ts # Persistance des messages
│ ├── server.ts # Serveur WebSocket
│ └── index.ts # Point d'entrée
├── public/
│ └── client.html # Client de démo
├── Dockerfile
└── docker-compose.yml
1. Définitions de type
// src/types.ts
export interface Message {
id: string;
room: string;
user: string;
content: string;
sequence: number;
timestamp: number;
}
export interface Client {
id: string;
user: string;
rooms: Set<string>;
ws: WebSocket;
lastSeen: number;
}
export interface Presence {
user: string;
status: 'online' | 'offline' | 'away';
lastSeen: number;
}
export type MessageHandler = (client: Client, message: Message) => void;
2. Moteur Pub/Sub
// src/pub-sub.ts
import { Message, Client, MessageHandler } from './types';
export class PubSub {
private subscriptions: Map<string, Set<Client>> = new Map();
private handlers: Map<string, MessageHandler[]> = new Map();
subscribe(room: string, client: Client): void {
if (!this.subscriptions.has(room)) {
this.subscriptions.set(room, new Set());
}
this.subscriptions.get(room)!.add(client);
client.rooms.add(room);
}
unsubscribe(room: string, client: Client): void {
const subs = this.subscriptions.get(room);
if (subs) {
subs.delete(client);
if (subs.size === 0) {
this.subscriptions.delete(room);
}
}
client.rooms.delete(room);
}
publish(room: string, message: Message): void {
const subs = this.subscriptions.get(room);
if (subs) {
for (const client of subs) {
this.sendToClient(client, message);
}
}
this.emit('message', message);
}
on(event: string, handler: MessageHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler);
}
private emit(event: string, message: Message): void {
const handlers = this.handlers.get(event) || [];
handlers.forEach(h => h(null!, message));
}
private sendToClient(client: Client, message: Message): void {
if (client.ws.readyState === client.ws.OPEN) {
client.ws.send(JSON.stringify({
type: 'message',
data: message
}));
}
}
getSubscribers(room: string): Client[] {
return Array.from(this.subscriptions.get(room) || []);
}
getRooms(): string[] {
return Array.from(this.subscriptions.keys());
}
}
3. Gestionnaire de séquence
// src/sequencer.ts
import { Message } from './types';
export class Sequencer {
private sequences: Map<string, number> = new Map();
getNext(room: string): number {
const current = this.sequences.get(room) || 0;
const next = current + 1;
this.sequences.set(room, next);
return next;
}
setCurrent(room: string, sequence: number): void {
this.sequences.set(room, sequence);
}
getCurrent(room: string): number {
return this.sequences.get(room) || 0;
}
sequenceMessage(message: Message): Message {
const seq = this.getNext(message.room);
return { ...message, sequence: seq };
}
}
4. Gestionnaire de présence
// src/presence.ts
import { Client, Presence } from './types';
const HEARTBEAT_INTERVAL = 30000; // 30 secondes
const OFFLINE_TIMEOUT = 60000; // 60 secondes
export class PresenceManager {
private users: Map<string, Presence> = new Map();
private clients: Map<string, Client> = new Map();
private intervals: Map<string, NodeJS.Timeout> = new Map();
register(client: Client): void {
this.clients.set(client.id, client);
this.updatePresence(client.user, 'online');
this.startHeartbeat(client);
}
unregister(client: Client): void {
this.stopHeartbeat(client);
this.clients.delete(client.id);
this.updatePresence(client.user, 'offline');
}
updatePresence(user: string, status: 'online' | 'offline' | 'away'): void {
this.users.set(user, {
user,
status,
lastSeen: Date.now()
});
}
getPresence(user: string): Presence | undefined {
return this.users.get(user);
}
getOnlineUsers(): string[] {
const now = Date.now();
return Array.from(this.users.values())
.filter(p => p.status === 'online' && (now - p.lastSeen) < OFFLINE_TIMEOUT)
.map(p => p.user);
}
getPresenceInRoom(room: string): Presence[] {
const now = Date.now();
const usersInRoom = new Set<string>();
for (const client of this.clients.values()) {
if (client.rooms.has(room)) {
usersInRoom.add(client.user);
}
}
return Array.from(usersInRoom)
.map(user => this.users.get(user)!)
.filter(p => p && (now - p.lastSeen) < OFFLINE_TIMEOUT);
}
private startHeartbeat(client: Client): void {
const interval = setInterval(() => {
if (client.ws.readyState === client.ws.OPEN) {
client.ws.send(JSON.stringify({ type: 'heartbeat' }));
this.updatePresence(client.user, 'online');
}
}, HEARTBEAT_INTERVAL);
this.intervals.set(client.id, interval);
}
private stopHeartbeat(client: Client): void {
const interval = this.intervals.get(client.id);
if (interval) {
clearInterval(interval);
this.intervals.delete(client.id);
}
}
cleanup(): void {
for (const interval of this.intervals.values()) {
clearInterval(interval);
}
this.intervals.clear();
}
}
5. Stockage de messages
// src/store.ts
import { Message } from './types';
import fs from 'fs/promises';
import path from 'path';
export class MessageStore {
private basePath: string;
constructor(basePath: string = './data/messages') {
this.basePath = basePath;
}
async save(message: Message): Promise<void> {
const roomPath = path.join(this.basePath, message.room);
await fs.mkdir(roomPath, { recursive: true });
const filename = path.join(roomPath, `${message.sequence}.json`);
await fs.writeFile(filename, JSON.stringify(message, null, 2));
}
async getMessages(room: string, since: number = 0, limit: number = 100): Promise<Message[]> {
const roomPath = path.join(this.basePath, room);
const messages: Message[] = [];
try {
const files = await fs.readdir(roomPath);
const jsonFiles = files
.filter(f => f.endsWith('.json'))
.map(f => parseInt(f.replace('.json', '')))
.filter(seq => seq > since)
.sort((a, b) => a - b)
.slice(0, limit);
for (const seq of jsonFiles) {
const content = await fs.readFile(path.join(roomPath, `${seq}.json`), 'utf-8');
messages.push(JSON.parse(content));
}
} catch (err) {
// La salle n'existe pas encore
}
return messages;
}
async getLastSequence(room: string): Promise<number> {
const roomPath = path.join(this.basePath, room);
try {
const files = await fs.readdir(roomPath);
const sequences = files
.filter(f => f.endsWith('.json'))
.map(f => parseInt(f.replace('.json', '')));
return sequences.length > 0 ? Math.max(...sequences) : 0;
} catch {
return 0;
}
}
}
6. Serveur WebSocket
// src/server.ts
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import { v4 as uuidv4 } from 'uuid';
import { PubSub } from './pub-sub';
import { Sequencer } from './sequencer';
import { PresenceManager } from './presence';
import { MessageStore } from './store';
import { Client, Message } from './types';
const PORT = process.env.PORT || 8080;
export class ChatServer {
private wss: WebSocketServer;
private pubSub: PubSub;
private sequencer: Sequencer;
private presence: PresenceManager;
private store: MessageStore;
constructor() {
const server = createServer();
this.wss = new WebSocketServer({ server });
this.pubSub = new PubSub();
this.sequencer = new Sequencer();
this.presence = new PresenceManager();
this.store = new MessageStore();
this.setupHandlers();
}
private setupHandlers(): void {
this.wss.on('connection', (ws: WebSocket) => {
const clientId = uuidv4();
const client: Client = {
id: clientId,
user: `user_${clientId.slice(0, 8)}`,
rooms: new Set(),
ws,
lastSeen: Date.now()
};
console.log(`Client connected: ${client.id}`);
ws.on('message', async (data: string) => {
try {
const msg = JSON.parse(data);
await this.handleMessage(client, msg);
} catch (err) {
console.error('Error handling message:', err);
}
});
ws.on('close', () => {
console.log(`Client disconnected: ${client.id}`);
for (const room of client.rooms) {
this.pubSub.publish(room, {
id: uuidv4(),
room,
user: 'system',
content: `${client.user} left the room`,
sequence: this.sequencer.getCurrent(room),
timestamp: Date.now()
});
this.pubSub.unsubscribe(room, client);
}
this.presence.unregister(client);
});
// Envoyer un message de bienvenue
this.sendToClient(client, {
type: 'connected',
data: { clientId: client.id, user: client.user }
});
this.presence.register(client);
});
}
private async handleMessage(client: Client, msg: any): Promise<void> {
switch (msg.type) {
case 'join':
await this.handleJoin(client, msg.room);
break;
case 'leave':
this.handleLeave(client, msg.room);
break;
case 'message':
await this.handleChatMessage(client, msg.data);
break;
case 'presence':
this.handlePresenceRequest(client, msg.room);
break;
case 'history':
await this.handleHistoryRequest(client, msg.room, msg.since);
break;
default:
console.log('Unknown message type:', msg.type);
}
}
private async handleJoin(client: Client, room: string): Promise<void> {
console.log(`${client.user} joining room: ${room}`);
// S'abonner à la salle
this.pubSub.subscribe(room, client);
// Envoyer la présence actuelle
const presence = this.presence.getPresenceInRoom(room);
this.sendToClient(client, {
type: 'presence',
data: { room, users: presence }
});
// Annoncer le rejoindre
this.pubSub.publish(room, {
id: uuidv4(),
room,
user: 'system',
content: `${client.user} joined the room`,
sequence: this.sequencer.getCurrent(room),
timestamp: Date.now()
});
// Envoyer les messages récents
const history = await this.store.getMessages(room, 0, 50);
if (history.length > 0) {
this.sendToClient(client, {
type: 'history',
data: { room, messages: history }
});
}
}
private handleLeave(client: Client, room: string): void {
console.log(`${client.user} leaving room: ${room}`);
this.pubSub.unsubscribe(room, client);
this.pubSub.publish(room, {
id: uuidv4(),
room,
user: 'system',
content: `${client.user} left the room`,
sequence: this.sequencer.getCurrent(room),
timestamp: Date.now()
});
}
private async handleChatMessage(client: Client, data: any): Promise<void> {
const { room, content } = data;
if (!client.rooms.has(room)) {
this.sendError(client, 'Not subscribed to room');
return;
}
const message: Message = {
id: uuidv4(),
room,
user: client.user,
content,
sequence: 0, // Sera assigné
timestamp: Date.now()
};
// Assigner un numéro de séquence
const sequenced = this.sequencer.sequenceMessage(message);
// Sauvegarder dans le stockage
await this.store.save(sequenced);
// Publier à tous les subscribers
this.pubSub.publish(room, sequenced);
console.log(`[${room}] ${client.user}: ${content} (seq: ${sequenced.sequence})`);
}
private handlePresenceRequest(client: Client, room: string): void {
const presence = this.presence.getPresenceInRoom(room);
this.sendToClient(client, {
type: 'presence',
data: { room, users: presence }
});
}
private async handleHistoryRequest(client: Client, room: string, since: number = 0): Promise<void> {
const messages = await this.store.getMessages(room, since);
this.sendToClient(client, {
type: 'history',
data: { room, messages }
});
}
private sendToClient(client: Client, data: any): void {
if (client.ws.readyState === client.ws.OPEN) {
client.ws.send(JSON.stringify(data));
}
}
private sendError(client: Client, message: string): void {
this.sendToClient(client, {
type: 'error',
data: { message }
});
}
listen(): void {
const server = this.wss.server!;
server.listen(PORT, () => {
console.log(`Chat server listening on port ${PORT}`);
});
}
}
7. Point d'entrée
// src/index.ts
import { ChatServer } from './server';
const server = new ChatServer();
server.listen();
8. Package.json
{
"name": "chat-system",
"version": "1.0.0",
"description": "Real-time chat system with WebSockets",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"ws": "^8.18.0",
"uuid": "^11.0.3"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/ws": "^8.5.13",
"@types/uuid": "^10.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}
9. Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 8080
CMD ["npm", "start"]
10. Docker Compose
version: '3.8'
services:
chat:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- PORT=8080
restart: unless-stopped
Implémentation Python
Structure du projet
chat-system/
├── requirements.txt
├── src/
│ ├── __init__.py
│ ├── types.py
│ ├── pub_sub.py
│ ├── sequencer.py
│ ├── presence.py
│ ├── store.py
│ ├── server.py
│ └── main.py
├── public/
│ └── client.html
├── Dockerfile
└── docker-compose.yml
1. Définitions de type
# src/types.py
from dataclasses import dataclass, field
from typing import Set
import websockets.server
import datetime
@dataclass
class Message:
id: str
room: str
user: str
content: str
sequence: int
timestamp: float
@dataclass
class Client:
id: str
user: str
rooms: Set[str] = field(default_factory=set)
websocket: websockets.server.WebSocketServerProtocol = None
last_seen: float = field(default_factory=lambda: datetime.datetime.now().timestamp())
@dataclass
class Presence:
user: str
status: str # 'online', 'offline', 'away'
last_seen: float
2. Moteur Pub/Sub
# src/pub_sub.py
from typing import Dict, Set, List, Callable, Any
from .types import Message, Client
class PubSub:
def __init__(self):
self.subscriptions: Dict[str, Set[Client]] = {}
self.handlers: Dict[str, List[Callable]] = {}
def subscribe(self, room: str, client: Client) -> None:
if room not in self.subscriptions:
self.subscriptions[room] = set()
self.subscriptions[room].add(client)
client.rooms.add(room)
def unsubscribe(self, room: str, client: Client) -> None:
if room in self.subscriptions:
self.subscriptions[room].discard(client)
if not self.subscriptions[room]:
del self.subscriptions[room]
client.rooms.discard(room)
async def publish(self, room: str, message: Message) -> None:
if room in self.subscriptions:
for client in self.subscriptions[room]:
await self._send_to_client(client, message)
await self._emit('message', message)
async def _send_to_client(self, client: Client, message: Message) -> None:
if client.websocket and not client.websocket.closed:
import json
await client.websocket.send(json.dumps({
'type': 'message',
'data': message.__dict__
}))
async def _emit(self, event: str, message: Message) -> None:
handlers = self.handlers.get(event, [])
for handler in handlers:
await handler(None, message)
def get_subscribers(self, room: str) -> List[Client]:
return list(self.subscriptions.get(room, set()))
def get_rooms(self) -> List[str]:
return list(self.subscriptions.keys())
3. Gestionnaire de séquence
# src/sequencer.py
from typing import Dict
from .types import Message
class Sequencer:
def __init__(self):
self.sequences: Dict[str, int] = {}
def get_next(self, room: str) -> int:
current = self.sequences.get(room, 0)
next_seq = current + 1
self.sequences[room] = next_seq
return next_seq
def set_current(self, room: str, sequence: int) -> None:
self.sequences[room] = sequence
def get_current(self, room: str) -> int:
return self.sequences.get(room, 0)
def sequence_message(self, message: Message) -> Message:
seq = self.get_next(message.room)
message.sequence = seq
return message
4. Gestionnaire de présence
# src/presence.py
import asyncio
import datetime
from typing import Dict, List, Set
from .types import Client, Presence
HEARTBEAT_INTERVAL = 30 # secondes
OFFLINE_TIMEOUT = 60 # secondes
class PresenceManager:
def __init__(self):
self.users: Dict[str, Presence] = {}
self.clients: Dict[str, Client] = {}
self.tasks: Dict[str, asyncio.Task] = {}
def register(self, client: Client) -> None:
self.clients[client.id] = client
self.update_presence(client.user, 'online')
self.tasks[client.id] = asyncio.create_task(self._heartbeat(client))
def unregister(self, client: Client) -> None:
if client.id in self.tasks:
self.tasks[client.id].cancel()
del self.tasks[client.id]
if client.id in self.clients:
del self.clients[client.id]
self.update_presence(client.user, 'offline')
def update_presence(self, user: str, status: str) -> None:
self.users[user] = Presence(
user=user,
status=status,
last_seen=datetime.datetime.now().timestamp()
)
def get_presence(self, user: str) -> Presence | None:
return self.users.get(user)
def get_online_users(self) -> List[str]:
now = datetime.datetime.now().timestamp()
return [
p.user for p in self.users.values()
if p.status == 'online' and (now - p.last_seen) < OFFLINE_TIMEOUT
]
def get_presence_in_room(self, room: str) -> List[Presence]:
now = datetime.datetime.now().timestamp()
users_in_room = set()
for client in self.clients.values():
if room in client.rooms:
users_in_room.add(client.user)
return [
self.users.get(user)
for user in users_in_room
if user in self.users and (now - self.users[user].last_seen) < OFFLINE_TIMEOUT
]
async def _heartbeat(self, client: Client) -> None:
import json
while True:
try:
if client.websocket and not client.websocket.closed:
await client.websocket.send(json.dumps({'type': 'heartbeat'}))
self.update_presence(client.user, 'online')
except asyncio.CancelledError:
break
except Exception:
pass
await asyncio.sleep(HEARTBEAT_INTERVAL)
def cleanup(self) -> None:
for task in self.tasks.values():
task.cancel()
self.tasks.clear()
5. Stockage de messages
# src/store.py
import os
import json
import asyncio
from pathlib import Path
from typing import List
from .types import Message
class MessageStore:
def __init__(self, base_path: str = './data/messages'):
self.base_path = Path(base_path)
async def save(self, message: Message) -> None:
room_path = self.base_path / message.room
room_path.mkdir(parents=True, exist_ok=True)
filename = room_path / f'{message.sequence}.json'
with open(filename, 'w') as f:
json.dump(message.__dict__, f, indent=2)
async def get_messages(self, room: str, since: int = 0, limit: int = 100) -> List[Message]:
room_path = self.base_path / room
messages = []
if not room_path.exists():
return messages
try:
files = [f for f in os.listdir(room_path) if f.endswith('.json')]
sequences = sorted([
int(f.replace('.json', ''))
for f in files
if int(f.replace('.json', '')) > since
])[:limit]
for seq in sequences:
with open(room_path / f'{seq}.json', 'r') as f:
data = json.load(f)
messages.append(Message(**data))
except FileNotFoundError:
pass
return messages
async def get_last_sequence(self, room: str) -> int:
room_path = self.base_path / room
if not room_path.exists():
return 0
try:
files = [f for f in os.listdir(room_path) if f.endswith('.json')]
sequences = [int(f.replace('.json', '')) for f in files]
return max(sequences) if sequences else 0
except FileNotFoundError:
return 0
6. Serveur WebSocket
# src/server.py
import websockets
import json
import uuid
import asyncio
from typing import Any
from .pub_sub import PubSub
from .sequencer import Sequencer
from .presence import PresenceManager
from .store import MessageStore
from .types import Client, Message
PORT = int(os.getenv('PORT', 8080))
class ChatServer:
def __init__(self):
self.pub_sub = PubSub()
self.sequencer = Sequencer()
self.presence = PresenceManager()
self.store = MessageStore()
async def handle_client(self, websocket, path):
client_id = str(uuid.uuid4())
client = Client(
id=client_id,
user=f"user_{client_id[:8]}",
websocket=websocket,
rooms=set()
)
print(f"Client connected: {client.id}")
await self._send_to_client(client, {
'type': 'connected',
'data': {'clientId': client.id, 'user': client.user}
})
self.presence.register(client)
try:
async for message in websocket:
msg = json.loads(message)
await self.handle_message(client, msg)
except websockets.exceptions.ConnectionClosed:
print(f"Client disconnected: {client.id}")
finally:
for room in list(client.rooms):
await self.pub_sub.publish(room, Message(
id=str(uuid.uuid4()),
room=room,
user='system',
content=f"{client.user} left the room",
sequence=self.sequencer.get_current(room),
timestamp=asyncio.get_event_loop().time()
))
self.pub_sub.unsubscribe(room, client)
self.presence.unregister(client)
async def handle_message(self, client: Client, msg: Any) -> None:
handlers = {
'join': self.handle_join,
'leave': self.handle_leave,
'message': self.handle_chat_message,
'presence': self.handle_presence_request,
'history': self.handle_history_request
}
handler = handlers.get(msg.get('type'))
if handler:
await handler(client, msg)
else:
print(f"Unknown message type: {msg.get('type')}")
async def handle_join(self, client: Client, msg: Any) -> None:
room = msg.get('room')
print(f"{client.user} joining room: {room}")
self.pub_sub.subscribe(room, client)
presence = self.presence.get_presence_in_room(room)
await self._send_to_client(client, {
'type': 'presence',
'data': {'room': room, 'users': [p.__dict__ for p in presence]}
})
await self.pub_sub.publish(room, Message(
id=str(uuid.uuid4()),
room=room,
user='system',
content=f"{client.user} joined the room",
sequence=self.sequencer.get_current(room),
timestamp=asyncio.get_event_loop().time()
))
history = await self.store.get_messages(room, 0, 50)
if history:
await self._send_to_client(client, {
'type': 'history',
'data': {'room': room, 'messages': [m.__dict__ for m in history]}
})
def handle_leave(self, client: Client, msg: Any) -> None:
room = msg.get('room')
print(f"{client.user} leaving room: {room}")
self.pub_sub.unsubscribe(room, client)
async def handle_chat_message(self, client: Client, msg: Any) -> None:
data = msg.get('data', {})
room = data.get('room')
if room not in client.rooms:
await self._send_error(client, 'Not subscribed to room')
return
message = Message(
id=str(uuid.uuid4()),
room=room,
user=client.user,
content=data.get('content', ''),
sequence=0,
timestamp=asyncio.get_event_loop().time()
)
sequenced = self.sequencer.sequence_message(message)
await self.store.save(sequenced)
await self.pub_sub.publish(room, sequenced)
print(f"[{room}] {client.user}: {sequenced.content} (seq: {sequenced.sequence})")
async def handle_presence_request(self, client: Client, msg: Any) -> None:
room = msg.get('room')
presence = self.presence.get_presence_in_room(room)
await self._send_to_client(client, {
'type': 'presence',
'data': {'room': room, 'users': [p.__dict__ for p in presence]}
})
async def handle_history_request(self, client: Client, msg: Any) -> None:
room = msg.get('room')
since = msg.get('since', 0)
messages = await self.store.get_messages(room, since)
await self._send_to_client(client, {
'type': 'history',
'data': {'room': room, 'messages': [m.__dict__ for m in messages]}
})
async def _send_to_client(self, client: Client, data: Any) -> None:
if client.websocket and not client.websocket.closed:
await client.websocket.send(json.dumps(data))
async def _send_error(self, client: Client, message: str) -> None:
await self._send_to_client(client, {
'type': 'error',
'data': {'message': message}
})
async def start(self):
print(f"Chat server listening on port {PORT}")
async with websockets.serve(self.handle_client, "", PORT):
await asyncio.Future() # Run forever
7. Point d'entrée
# src/main.py
import asyncio
import os
from server import ChatServer
async def main():
server = ChatServer()
await server.start()
if __name__ == '__main__':
asyncio.run(main())
8. Configuration requise
websockets==13.1
aiofiles==24.1.0
9. Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "src/main.py"]
10. Docker Compose
version: '3.8'
services:
chat:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/app/data
environment:
- PORT=8080
restart: unless-stopped
Exécution du système de chat
TypeScript
# Installer les dépendances
npm install
# Compiler
npm run build
# Démarrer le serveur
npm start
# Avec Docker Compose
docker-compose up
Python
# Installer les dépendances
pip install -r requirements.txt
# Démarrer le serveur
python src/main.py
# Avec Docker Compose
docker-compose up
Exercices
Exercice 1 : Opérations de chat de base
- Démarrer le serveur de chat
- Connecter deux clients WebSocket
- Rejoindre la même salle
- Envoyer des messages et vérifier que les deux clients les reçoivent
- Quitter la salle et vérifier la diffusion
Exercice 2 : Ordonnancement des messages
- Envoyer plusieurs messages rapidement depuis différents clients
- Vérifier que tous les messages ont des numéros de séquence uniques et séquentiels
- Déconnecter et reconnecter un client
- Demander l'historique des messages et vérifier que l'ordonnancement est préservé
Exercice 3 : Gestion de la présence
- Connecter plusieurs clients à différentes salles
- Rejoindre une salle et vérifier les diffusions de présence
- Simuler une défaillance réseau (tuer un client sans partir correctement)
- Vérifier que la détection hors ligne intervient après le délai d'attente
Exercice 4 : Persistance des messages
- Envoyer des messages à une salle
- Arrêter le serveur
- Vérifier que les messages sont sauvegardés sur disque
- Redémarrer le serveur
- Connecter un nouveau client et vérifier qu'il reçoit l'historique des messages
Pièges courants
| Problème | Cause | Solution |
|---|---|---|
| Messages non ordonnés | Numéros de séquence manquants | Toujours séquencer avant de publier |
| Anciens messages non reçus | Pas demander l'historique lors de la jointure | Implémenter la relecture à la connexion |
| La présence affiche hors ligne | Heartbeat non envoyé | S'assurer que la boucle heartbeat fonctionne |
| Messages en double | Republication des messages sauvegardés | Publier uniquement les nouveaux messages, pas l'historique |
Points clés à retenir
- Pub/Sub permet la communication multi-salle extensible
- Les numéros de séquence garantissent l'ordonnancement des messages sur tous les clients
- La gestion de présence nécessite à la fois des heartbeats actifs et une détection de délai d'attente passive
- La persistance des messages permet aux clients de se reconnecter et de recevoir l'historique
- Docker Compose simplifie le déploiement et les tests du système complet
🧠 Quiz du chapitre
Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.
Qu'est-ce que le Consensus ?
Session 8 - Session complète
Objectifs d'Apprentissage
- Comprendre le problème du consensus dans les systèmes distribués
- Apprendre la différence entre les propriétés de sécurité et de vivacité
- Explorer le résultat d'impossibilité FLP
- Comprendre pourquoi les algorithmes de consensus sont nécessaires
- Comparer les approches Raft et Paxos
Le Problème du Consensus
Dans les systèmes distribués, le consensus (consensus) est le problème consistant à faire s'accorder plusieurs nœuds sur une seule valeur. Cela semble simple, mais c'est fondamental pour construire des systèmes distribués fiables.
Pourquoi avons-nous besoin du Consensus ?
Considérez ces scénarios :
- Élection de Leader (Leader Election) : Plusieurs nœuds doivent s'accorder sur qui est le leader
- Changements de Configuration : Tous les nœuds doivent s'accorder sur une nouvelle configuration
- Machines à États Répliquées : Tous les nœuds doivent appliquer les opérations dans le même ordre
- Transactions Distribuées : Tous les participants doivent s'accorder pour valider ou abandonner
Sans consensus, les systèmes distribués peuvent souffrir de :
- Scénarios de split-brain (multiple leaders)
- État incohérent entre les nœuds
- Corruption de données due à des écritures conflictuelles
- Systèmes indisponibles pendant les partitions réseau
graph LR
subgraph "Sans Consensus"
N1[Nœud A : valeur=1]
N2[Nœud B : valeur=2]
N3[Nœud C : valeur=3]
N1 --- N2 --- N3
Problem[Quelle valeur est correcte ?]
end
subgraph "Avec Consensus"
A1[Nœud A : valeur=2]
A2[Nœud B : valeur=2]
A3[Nœud C : valeur=2]
A1 --- A2 --- A3
Solved[Tous les nœuds s'accordent]
end
Définition Formelle
Le problème du consensus exige qu'un système satisfasse ces propriétés :
1. Accord (Sécurité)
Tous les nœuds corrects doivent s'accorder sur la même valeur.
Si le nœud A produit
vet le nœud B produitv', alorsv = v'
2. Validité
Si tous les nœuds corrects proposent la même valeur v, alors tous les nœuds corrects décident v.
La valeur décidée doit avoir été proposée par un certain nœud
3. Terminaison (Vivacité)
Tous les nœuds corrects décident finalement d'une certaine valeur.
L'algorithme doit progresser, pas tourner pour toujours
4. Intégrité
Chaque nœud décide au plus une fois.
Un nœud ne peut pas changer sa décision après avoir décidé
Sécurité vs Vivacité
Comprendre le compromis entre la sécurité (safety) et la vivacité (liveness) est crucial pour les systèmes distribués :
graph TB
subgraph "Propriétés de Sécurité"
S1[Accord]
S2[Validité]
S3[Intégrité]
style S1 fill:#90EE90
style S2 fill:#90EE90
style S3 fill:#90EE90
end
subgraph "Propriétés de Vivacité"
L1[Terminaison]
L2[Progrès]
style L1 fill:#FFB6C1
style L2 fill:#FFB6C1
end
Safety["Rien de mauvais n'arrive<br/>L'état est toujours cohérent"]
Liveness["Quelque chose de bon arrive<br/>Le système progresse"]
S1 & S2 & S3 --> Safety
L1 & L2 --> Liveness
Safety --> Tradeoff["Dans les réseaux,<br/>vous ne pouvez pas garantir les deux<br/>pendant les partitions"]
Liveness --> Tradeoff
| Sécurité (Safety) | Vivacité (Liveness) |
|---|---|
| "Rien de mauvais n'arrive" | "Quelque chose de bon arrive" |
| L'état est toujours valide | Le système progresse |
| Pas de corruption, pas de conflits | Les opérations se terminent finalement |
| Peut être maintenue pendant les partitions | Peut être sacrifiée pendant les partitions |
Exemple : Pendant une partition réseau (théorème CAP), un système CP maintient la sécurité (pas d'écritures incohérentes) mais sacrifie la vivacité (les écritures peuvent être rejetées). Un système AP maintient la vivacité (les écritures réussissent) mais peut sacrifier la sécurité (incohérences temporaires).
Pourquoi le Consensus est Difficile
Défi 1 : Pas d'Horloge Globale
Les nœuds ne partagent pas d'horloge synchronisée, ce qui rend difficile l'ordonnancement des événements :
sequenceDiagram
participant A as Nœud A (t=10:00:01)
participant B as Nœud B (t=10:00:05)
participant C as Nœud C (t=10:00:03)
Note over A: A propose valeur=1
A->>B: send(valeur=1)
Note over B: B reçoit à t=10:00:07
Note over C: C propose valeur=2
C->>B: send(valeur=2)
Note over B: B reçoit à t=10:00:08
Note over B: Quelle valeur est arrivée en premier ?
Défi 2 : Perte et Retards de Messages
Les messages peuvent être perdus, retardés ou réordonnés :
stateDiagram-v2
[*] --> Envoyé: Le nœud envoie un message
Envoyé --> Livré: Le message arrive
Envoyé --> Perdu: Message perdu
Envoyé --> Retardé: Réseau lent
Retardé --> Livré: Arrive finalement
Perdu --> Réessayer: Le nœud renvoie
Livré --> [*]
Défi 3 : Pannes de Nœuds
Les nœuds peuvent planter à tout moment, potentiellement en détenant des informations critiques :
graph TB
subgraph "État du Cluster"
N1[Nœud 1 : En vie]
N2[Nœud 2 : PLANTÉ<br/>Avait des données non validées]
N3[Nœud 3 : En vie]
N4[Nœud 4 : En vie]
N1 --- N2
N2 --- N3
N3 --- N4
end
Q[Que se passe-t-il pour<br/>les données du Nœud 2 ?]
Le Résultat d'Impossibilité FLP
En 1985, Fischer, Lynch et Paterson ont prouvé le Résultat d'Impossibilité FLP :
Dans un réseau asynchrone, même avec un seul nœud défectueux, aucun algorithme de consensus déterministe ne peut garantir la sécurité, la vivacité et la terminaison.
Ce que cela signifie
graph TB
A[Réseau Asynchrone] --> B[Pas d'hypothèses de synchronisation]
B --> C[Les messages peuvent prendre arbitrairement longtemps]
C --> D[Impossible de distinguer un nœud lent d'un nœud planté]
D --> E[Impossible de garantir la terminaison]
E --> F[FLP : Consensus impossible<br/>dans les systèmes purement asynchrones]
Comment nous contournons cela
Les systèmes réels gèrent FLP en relaxant certaines hypothèses :
- Synchronisme Partiel : Supposer que les réseaux sont finalement synchrones
- Randomisation : Utiliser des algorithmes randomisés (ex: délais d'élection randomisés)
- Détecteurs de Panne : Utiliser des détecteurs de panne non fiables
- Délais d'Attente : Supposer que les messages arrivent dans un certain délai
Aperçu Clé : Raft fonctionne dans des systèmes "partiellement synchrones" — les réseaux peuvent se comporter de manière asynchrone pendant un moment, mais deviennent finalement synchrones.
Scénarios Réels de Consensus
Scénario 1 : Configuration Distribuée
Tous les nœuds doivent s'accorder sur l'appartenance au cluster :
sequenceDiagram
autonumber
participant N1 as Nœud 1
participant N2 as Nœud 2
participant N3 as Nœud 3
participant N4 as Nouveau Nœud
N4->>N1: Demande de rejoindre
N1->>N2: Proposer d'ajouter le Nœud 4
N1->>N3: Proposer d'ajouter le Nœud 4
N2->>N1: Vote OUI
N3->>N1: Vote OUI
N1->>N2: Valider : ajouter le Nœud 4
N1->>N3: Valider : ajouter le Nœud 4
N1->>N4: Vous êtes dedans !
Note over N1,N4: Tous les nœuds s'accordent maintenant<br/>le cluster a 4 membres
Scénario 2 : Machine à États Répliquée
Toutes les répliques doivent appliquer les opérations dans le même ordre :
graph LR
C[Client] --> L[Leader]
subgraph "Journal Répliqué"
L1[Leader : SET x=1]
F1[Suiveur 1 : SET x=1]
F2[Suiveur 2 : SET x=1]
F3[Suiveur 3 : SET x=1]
L1 --- F1 --- F2 --- F3
end
subgraph "Machine à États"
S1[Leader : x=1]
S2[Suiveur 1 : x=1]
S3[Suiveur 2 : x=1]
S4[Suiveur 3 : x=1]
end
L --> L1
F1 --> S2
F2 --> S3
F3 --> S4
Algorithmes de Consensus : Raft vs Paxos
Paxos (1998)
Paxos fut le premier algorithme de consensus pratique, mais il est notoirement difficile à comprendre :
Phase 1a (Préparer) : Le proposant choisit le numéro de proposition n, envoie Prepare(n)
Phase 1b (Promesse) : L'accepteur promet de ne pas accepter les propositions < n
Phase 2a (Accepter) : Le proposant envoie Accept(n, valeur)
Phase 2b (Accepté) : L'accepteur accepte si aucune proposition supérieure vue
Avantages :
- Preuve de correction
- Gère n'importe quel nombre de pannes
- Complexité de message minimale
Inconvénients :
- Extrêmement difficile à comprendre
- Difficile à implémenter correctement
- Multi-Paxos ajoute de la complexité
- Pas de leader par défaut
Raft (2014)
Raft a été conçu spécifiquement pour la compréhension :
graph TB
subgraph "Composants Raft"
LE[Élection de Leader]
LR[Réplication de Journal]
SM[Machine à États]
Safety[Propriétés de Sécurité]
LE --> LR
LR --> SM
Safety --> LE
Safety --> LR
end
Avantages :
- Conçu pour la compréhension
- Séparation claire des préoccupations
- Leader fort simplifie la logique
- Guide d'implémentation pratique
- Large adoption
Inconvénients :
- Le leader peut être un goulot d'étranglement
- Pas aussi optimisé que les variantes Multi-Paxos
Quand avez-vous besoin du Consensus ?
Utilisez le consensus lorsque :
| Scénario | Besoin de Consensus ? | Raison |
|---|---|---|
| Base de données à nœud unique | Non | Pas d'état distribué |
| Réplication multi-maître | Oui | Doit s'accorder sur l'ordre des écritures |
| Élection de leader | Oui | Doit s'accorder sur qui est le leader |
| Gestion de configuration | Oui | Tous les nœuds ont besoin de la même config |
| Service de verrou distribué | Oui | Doit s'accorder sur le détenteur du verrou |
| État du répartiteur de charge | Non | Sans état, peut être reconstruit |
| Invalidation de cache | Parfois | Dépend des besoins de cohérence |
Quand vous N'avez PAS besoin du Consensus
- Systèmes en lecture seule : Pas d'état sur lequel s'accorder
- La cohérence éventuelle suffit : Last-write-wins suffit
- Types de données répliquées sans conflit (CRDT) : Résoudre mathématiquement les conflits
- Source unique de vérité : Autorité centralisée
Exemple Simple de Consensus
Examinons un scénario de consensus simplifié : s'accorder sur une valeur de compteur.
Exemple TypeScript
// Une simulation de consensus simple
interface Proposal {
value: number;
proposerId: string;
}
class ConsensusNode {
private proposals: Map<string, Proposal> = new Map();
private decidedValue?: number;
private nodeId: string;
constructor(nodeId: string) {
this.nodeId = nodeId;
}
// Proposer une valeur
propose(value: number): void {
const proposal: Proposal = {
value,
proposerId: this.nodeId
};
this.proposals.set(this.nodeId, proposal);
this.broadcastProposal(proposal);
}
// Recevoir une proposition d'un autre nœud
receiveProposal(proposal: Proposal): void {
this.proposals.set(proposal.proposerId, proposal);
this.checkConsensus();
}
// Vérifier si nous avons un consensus
private checkConsensus(): void {
if (this.decidedValue !== undefined) return;
const values = Array.from(this.proposals.values()).map(p => p.value);
const counts = new Map<number, number>();
for (const value of values) {
counts.set(value, (counts.get(value) || 0) + 1);
}
// Consensus à majorité simple
for (const [value, count] of counts.entries()) {
if (count > Math.floor(this.proposals.size / 2)) {
this.decidedValue = value;
console.log(`Nœud ${this.nodeId} a décidé de la valeur : ${value}`);
return;
}
}
}
private broadcastProposal(proposal: Proposal): void {
// Dans un système réel, cela enverrait aux autres nœuds
console.log(`Nœud ${this.nodeId} diffuse la proposition : ${proposal.value}`);
}
}
// Exemple d'utilisation
const node1 = new ConsensusNode('node1');
const node2 = new ConsensusNode('node2');
const node3 = new ConsensusNode('node3');
node1.propose(42);
node2.propose(42);
node3.propose(99); // Minorité, devrait perdre
Exemple Python
from dataclasses import dataclass
from typing import Optional, Dict
import random
@dataclass
class Proposal:
value: int
proposer_id: str
class ConsensusNode:
def __init__(self, node_id: str):
self.node_id = node_id
self.proposals: Dict[str, Proposal] = {}
self.decided_value: Optional[int] = None
def propose(self, value: int) -> None:
"""Proposer une valeur au groupe."""
proposal = Proposal(value, self.node_id)
self.proposals[self.node_id] = proposal
self._broadcast_proposal(proposal)
self._check_consensus()
def receive_proposal(self, proposal: Proposal) -> None:
"""Recevoir une proposition d'un autre nœud."""
self.proposals[proposal.proposer_id] = proposal
self._check_consensus()
def _check_consensus(self) -> None:
"""Vérifier si nous avons un consensus sur une valeur."""
if self.decided_value is not None:
return
if not self.proposals:
return
# Compter les occurrences de chaque valeur
counts = {}
for proposal in self.proposals.values():
counts[proposal.value] = counts.get(proposal.value, 0) + 1
# Consensus à majorité simple
total_nodes = len(self.proposals)
for value, count in counts.items():
if count > total_nodes // 2:
self.decided_value = value
print(f"Nœud {self.node_id} a décidé de la valeur : {value}")
return
def _broadcast_proposal(self, proposal: Proposal) -> None:
"""Diffuser la proposition aux autres nœuds."""
print(f"Nœud {self.node_id} diffuse la proposition : {proposal.value}")
# Exemple d'utilisation
if __name__ == "__main__":
node1 = ConsensusNode("node1")
node2 = ConsensusNode("node2")
node3 = ConsensusNode("node3")
node1.propose(42)
node2.propose(42)
node3.propose(99) # Minorité, devrait perdre
Pièges Courants
| Piège | Description | Solution |
|---|---|---|
| Split Brain | Plusieurs leaders pensent qu'ils sont en charge | Utiliser un vote à quorum |
| Lectures Stalées | Lire à partir de nœuds qui n'ont pas reçu les mises à jour | Lire à partir du leader ou utiliser des lectures à quorum |
| Gestion de Partition Réseau | Les nœuds ne peuvent pas communiquer mais continuent à fonctionner | Exiger un quorum pour les opérations |
| Pannes Partielles | Certains nœuds plantent, d'autres continuent | Concevoir pour la tolérance aux pannes |
| Dérive d'Horloge | Des horloges différentes causent des problèmes d'ordonnancement | Utiliser des horloges logiques (horodatages Lamport) |
Résumé
Points Clés à Retenir
- Le Consensus est le problème consistant à faire s'accorder plusieurs nœuds distribués sur une seule valeur
- La Sécurité (Safety) assure que rien de mauvais n'arrive (accord, validité, intégrité)
- La Vivacité (Liveness) assure que quelque chose de bon arrive (terminaison, progrès)
- L'Impossibilité FLP prouve que le consensus est impossible dans les systèmes purement asynchrones
- Les Systèmes réels contournent FLP en utilisant le synchronisme partiel et les délais d'attente
- Raft a été conçu pour la compréhension, contrairement à l'algorithme Paxos complexe
Prochaine Session
Dans la prochaine session, nous plongerons dans l'algorithme Raft lui-même :
- La philosophie de conception de Raft
- États des nœuds (Follower, Candidate, Leader)
- Le fonctionnement de l'élection de leader
- Comment la réplication de journal maintient la cohérence
Exercices
-
Sécurité vs Vivacité : Donnez un exemple d'un système qui privilégie la sécurité à la vivacité, et un qui fait l'inverse.
-
Scénario FLP : Décrivez un scénario où FLP causerait des problèmes dans un système distribué réel.
-
Besoin de Consensus : Pour chacun de ces systèmes, expliquez s'ils ont besoin de consensus et pourquoi :
- Un magasin clé-valeur distribué
- Un CDN (réseau de diffusion de contenu)
- Une file de tâches distribuée
- Un système blockchain
-
Consensus Simple : Étendez l'exemple de consensus simple pour gérer les pannes de nœuds (un nœud cesse de répondre).
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.
L'Algorithme Raft
Session 9, Partie 1 - 25 minutes
Objectifs d'Apprentissage
- Comprendre la philosophie de conception de Raft
- Apprendre les trois états d'un nœud Raft
- Explorer comment Raft gère le consensus à travers l'élection de leader et la réplication de journal
- Comprendre le concept des termes dans Raft
- Apprendre les propriétés de sécurité de Raft
Philosophie de Conception de Raft
Raft a été conçu par Diego Ongaro et John Ousterhout en 2014 avec un objectif spécifique : la compréhension. Contrairement à Paxos, qui était notoirement difficile à comprendre et à implémenter correctement, Raft sépare le problème du consensus en sous-problèmes clairs et gérables.
Principes de Conception Fondamentaux
- Leader Fort : Raft utilise une approche de leader fort — toutes les entrées de journal passent par le leader
- Complétude du Leader : Une fois qu'une entrée de journal est validée, elle reste dans le journal de tous les futurs leaders
- Décomposition : Diviser le consensus en trois sous-problèmes :
- Élection de leader
- Réplication de journal
- Sécurité
Pourquoi "Raft" ?
Le nom est une analogie : un radeau (l'algorithme) garde tous les nœuds (journaux) ensemble et se déplaçant dans la même direction, tout comme un radeau garde les gens ensemble sur l'eau.
Aperçu de Raft
graph TB
subgraph "Consensus Raft"
Client[Client]
subgraph "Cluster"
L[Leader]
F1[Suiveur 1]
F2[Suiveur 2]
F3[Suiveur 3]
L --> F1
L --> F2
L --> F3
end
Client -->|Demande d'écriture| L
L -->|AppendEntries| F1 & F2 & F3
F1 & F2 & F3 -->|Ack| L
L -->|Réponse| Client
end
Concepts Clés
| Concept | Description |
|---|---|
| Leader | Le seul nœud qui gère les demandes clientes et ajoute des entrées au journal |
| Suiveur (Follower) | Nœuds passifs qui répliquent le journal du leader |
| Candidat | Un nœud qui fait campagne pour devenir leader lors d'une élection |
| Terme | Une horloge logique divisée en termes de longueur arbitraire |
| Journal (Log) | Une séquence d'entrées contenant des commandes à appliquer à la machine à états |
États des Nœuds
Chaque nœud Raft peut être dans l'un des trois états :
stateDiagram-v2
[*] --> Suiveur: Le nœud démarre
Suiveur --> Candidat: Délai d'élection expire<br/>aucun RPC valide reçu
Candidat --> Leader: Reçoit les votes de la majorité
Candidat --> Suiveur: Découvre le leader actuel<br/>ou terme supérieur
Leader --> Suiveur: Découvre un terme supérieur
Suiveur --> Suiveur: Reçoit AppendEntries/RPC valide<br/>du leader ou candidat
note right of Suiveur
- Répond aux RPCs
- Pas de RPC sortants
- Délai d'élection en cours
end note
note right of Candidat
- Demander des votes
- Délai d'élection en cours
- Peut devenir leader ou suiveur
end note
note right of Leader
- Gère toutes les demandes clientes
- Envoie des battements de cœur aux suiveurs
- Pas de délai (actif)
end note
Descriptions d'États
Suiveur (Follower)
- État par défaut pour tous les nœuds
- Reçoit passivement les entrées du leader
- Répond aux RPCs (RequestVote, AppendEntries)
- Si aucune communication pendant le délai d'élection, devient candidat
Candidat
- Fait campagne pour devenir leader
- Incrémente le terme actuel
- Vote pour lui-même
- Envoie des RPCs RequestVote à tous les autres nœuds
- Devient leader s'il reçoit les votes de la majorité
- Retourne à l'état de suiveur s'il découvre le leader actuel ou un terme supérieur
Leader
- Gère toutes les demandes clientes
- Envoie des RPCs AppendEntries à tous les suiveurs (battements de cœur)
- Valide les entrées une fois répliquées sur la majorité
- Descend s'il découvre un terme supérieur
Termes
Un terme est le mécanisme de temps logique de Raft :
timeline
title Termes Raft
Terme 1 : Leader A élu
: Fonctionnement normal
: Le leader A plante
Terme 2 : L'élection commence
: Vote partagé !
: Délai, nouvelle élection
Terme 3 : Leader B élu
: Fonctionnement normal
Propriétés des Termes
- Croissance Monotone : Les termes augmentent toujours, ne diminuent jamais
- Terme Actuel : Chaque nœud stocke le numéro de terme actuel
- Transitions de Terme :
- Les nœuds incrémentent le terme lorsqu'ils deviennent candidats
- Les nœuds mettent à jour le terme lorsqu'ils reçoivent un message de terme supérieur
- Lorsque le terme change, le nœud devient suiveur
Terme dans les Messages
sequenceDiagram
participant C as Candidat
participant F1 as Suiveur (terme=3)
participant F2 as Suiveur (terme=4)
C->>F1: RequestVote(terme=5)
Note over F1: Voit un terme supérieur
F1-->>C: Vote OUI (passe à terme=5)
C->>F2: RequestVote(terme=5)
Note over F2: Déjà à un terme supérieur
F2-->>C: Vote NON (mon terme est supérieur)
Approche en Deux Phases de Raft
Raft atteint le consensus à travers deux phases principales :
Phase 1 : Élection de Leader
sequenceDiagram
autonumber
participant F1 as Suiveur 1
participant F2 as Suiveur 2
participant F3 as Suiveur 3
Note over F1,F3: Délai d'élection expire
F1->>F1: Devient Candidat (terme=1)
F1->>F2: RequestVote(terme=1)
F1->>F3: RequestVote(terme=1)
F2-->>F1: Accorder le vote (terme=1)
F3-->>F1: Accorder le vote (terme=1)
Note over F1: Majorité gagnée !
F1->>F1: Devient Leader
F1->>F2: AppendEntries (battement de cœur)
F1->>F3: AppendEntries (battement de cœur)
Phase 2 : Réplication de Journal
sequenceDiagram
autonumber
participant C as Client
participant L as Leader
participant F1 as Suiveur 1
participant F2 as Suiveur 2
C->>L: SET x=5
L->>L: Ajouter au journal (index=10, terme=1)
L->>F1: AppendEntries(entry: SET x=5)
L->>F2: AppendEntries(entry: SET x=5)
F1-->>L: Succès (répliqué)
F2-->>L: Succès (répliqué)
Note over L: Majorité répliquée !<br/>Valider l'entrée
L->>L: Appliquer à la machine à états : x=5
L-->>C: Réponse : OK
Propriétés de Sécurité
Raft garantit plusieurs propriétés de sécurité importantes :
1. Sécurité d'Élection
Au plus un leader peut être élu par terme.
Comment : Chaque nœud vote au plus une fois par terme, et un candidat a besoin de la majorité des votes.
graph TB
subgraph "Même Terme - Un Seul Leader"
T[Terme 5]
C1[Candidat A : 2 votes]
C2[Candidat B : 1 vote]
C1 -->|gagne la majorité| L[Leader A]
style L fill:#90EE90
end
2. Ajout-Seulement du Leader
Un leader ne jamais écrase ou supprime les entrées de son journal ; il ajoute seulement.
Comment : Les leaders ajoutent toujours de nouvelles entrées à la fin de leur journal.
3. Correspondance de Journal
Si deux journaux contiennent une entrée avec le même index et terme, alors toutes les entrées précédentes sont identiques.
graph LR
subgraph "Journal du Leader"
L1[index 1, terme 1: SET a=1]
L2[index 2, terme 1: SET b=2]
L3[index 3, terme 2: SET c=3]
L1 --> L2 --> L3
end
subgraph "Journal du Suiveur"
F1[index 1, terme 1: SET a=1]
F2[index 2, terme 1: SET b=2]
F3[index 3, terme 2: SET c=3]
F4[index 4, terme 2: SET d=4]
F1 --> F2 --> F3 --> F4
end
Match[Entrées 1-3 correspondent !<br/>Le suiveur peut avoir des entrées supplémentaires]
4. Complétude du Leader
Si une entrée de journal est validée dans un terme donné, elle sera présente dans les journaux de tous les leaders pour les termes supérieurs.
Comment : Un candidat doit avoir toutes les entrées validées avant de pouvoir gagner une élection.
5. Sécurité de la Machine à États
Si un serveur a appliqué une entrée de journal à un index donné à sa machine à états, aucun autre serveur n'appliquera jamais une entrée de journal différente pour le même index.
RPCs Raft
Raft utilise deux types principaux de RPC :
RPC RequestVote
interface RequestVoteArgs {
term: number; // Terme du candidat
candidateId: string; // Candidat demandant le vote
lastLogIndex: number; // Index de la dernière entrée de journal du candidat
lastLogTerm: number; // Terme de la dernière entrée de journal du candidat
}
interface RequestVoteReply {
term: number; // Terme actuel (pour que le candidat mette à jour)
voteGranted: boolean; // Vrai si le candidat a reçu le vote
}
Règles de Vote :
- Si
term < currentTerm: refuser le vote - Si
votedForest null oucandidateId: accorder le vote - Si le journal du candidat est au moins à jour : accorder le vote
RPC AppendEntries
interface AppendEntriesArgs {
term: number; // Terme du leader
leaderId: string; // Pour que le suiveur puisse rediriger les clients
prevLogIndex: number; // Index de l'entrée de journal précédant les nouvelles
prevLogTerm: number; // Terme de l'entrée prevLogIndex
entries: LogEntry[]; // Entrées de journal à stocker (vide pour battement de cœur)
leaderCommit: number; // Index de validation du leader
}
interface AppendEntriesReply {
term: number; // Terme actuel (pour que le leader mette à jour)
success: boolean; // Vrai si le suiveur avait l'entrée correspondant à prevLogIndex
}
Utilisé pour les deux :
- Réplication de journal : Envoyer de nouvelles entrées
- Battements de cœur : Entrées vides pour maintenir l'autorité
Propriété de Complétude de Journal
Lors du vote, les nœuds comparent la complétude du journal :
graph TB
subgraph "Comparaison des Journaux"
A[Journal du Candidat]
B[Journal du Suiveur]
A --> A1[Dernier index : 10, terme : 5]
B --> B1[Dernier index : 9, terme : 5]
Result[Le journal A est plus à jour<br/>car l'index 10 > 9]
end
subgraph "Règle de Décision"
C[Candidat : dernier terme=5]
D[Suiveur : dernier terme=6]
Result2[Le suiveur est plus à jour<br/>car le terme 6 > 5]
end
Comparaison de mise à jour :
- Comparer le terme des dernières entrées
- Si les termes diffèrent, le journal avec le terme le plus élevé est plus à jour
- Si les termes sont identiques, le journal avec la longueur la plus longue est plus à jour
Délais d'Élection Randomisés
Raft utilise des délais d'élection randomisés pour empêcher les votes partagés :
timeline
title Les Délais Randomisés Empêchent les Votes Partagés
Node1 : Délai de 150ms
Node2 : Délai de 300ms
Node3 : Délai de 200ms
Node1 : Délai ! Devient candidat
Node1 : Gagne l'élection avant Node2/3 délai
Node2 & Node3 : Reçoivent le battement de cœur, réinitialisent les délais
Sans randomisation : Tous les suiveurs atteignent le délai simultanément → tous deviennent candidats → vote partagé → aucun leader élu.
Avec randomisation : Un seul suiveur atteint le délai en premier → devient candidat → susceptible de gagner l'élection.
Structure d'Implémentation TypeScript
// Définitions de types pour Raft
type NodeState = 'follower' | 'candidate' | 'leader';
interface LogEntry {
index: number;
term: number;
command: { key: string; value: any };
}
interface RaftNode {
// État persistant
currentTerm: number;
votedFor: string | null;
log: LogEntry[];
// État volatil
commitIndex: number;
lastApplied: number;
state: NodeState;
// État volatil uniquement pour le leader
nextIndex: number[];
matchIndex: number[];
}
class RaftNodeImpl implements RaftNode {
currentTerm: number = 0;
votedFor: string | null = null;
log: LogEntry[] = [];
commitIndex: number = 0;
lastApplied: number = 0;
state: NodeState = 'follower';
nextIndex: number[] = [];
matchIndex: number[] = [];
// Gérer le RPC RequestVote
requestVote(args: RequestVoteArgs): RequestVoteReply {
if (args.term > this.currentTerm) {
this.currentTerm = args.term;
this.state = 'follower';
this.votedFor = null;
}
const logOk = this.isLogAtLeastAsUpToDate(args.lastLogIndex, args.lastLogTerm);
const voteOk = (this.votedFor === null || this.votedFor === args.candidateId);
if (args.term === this.currentTerm && voteOk && logOk) {
this.votedFor = args.candidateId;
return { term: this.currentTerm, voteGranted: true };
}
return { term: this.currentTerm, voteGranted: false };
}
// Gérer le RPC AppendEntries
appendEntries(args: AppendEntriesArgs): AppendEntriesReply {
if (args.term > this.currentTerm) {
this.currentTerm = args.term;
this.state = 'follower';
}
if (args.term !== this.currentTerm) {
return { term: this.currentTerm, success: false };
}
// Vérifier si le journal a une entrée à prevLogIndex avec prevLogTerm
if (this.log[args.prevLogIndex]?.term !== args.prevLogTerm) {
return { term: this.currentTerm, success: false };
}
// Ajouter de nouvelles entrées
for (const entry of args.entries) {
this.log[entry.index] = entry;
}
// Mettre à jour l'index de validation
if (args.leaderCommit > this.commitIndex) {
this.commitIndex = Math.min(args.leaderCommit, this.log.length - 1);
}
return { term: this.currentTerm, success: true };
}
private isLogAtLeastAsUpToDate(lastLogIndex: number, lastLogTerm: number): boolean {
const myLastEntry = this.log[this.log.length - 1];
const myLastTerm = myLastEntry?.term ?? 0;
const myLastIndex = this.log.length - 1;
if (lastLogTerm !== myLastTerm) {
return lastLogTerm > myLastTerm;
}
return lastLogIndex >= myLastIndex;
}
}
Structure d'Implémentation Python
from dataclasses import dataclass, field
from typing import Optional, List
from enum import Enum
class NodeState(Enum):
FOLLOWER = "follower"
CANDIDATE = "candidate"
LEADER = "leader"
@dataclass
class LogEntry:
index: int
term: int
command: dict
@dataclass
class RequestVoteArgs:
term: int
candidate_id: str
last_log_index: int
last_log_term: int
@dataclass
class RequestVoteReply:
term: int
vote_granted: bool
@dataclass
class AppendEntriesArgs:
term: int
leader_id: str
prev_log_index: int
prev_log_term: int
entries: List[LogEntry]
leader_commit: int
@dataclass
class AppendEntriesReply:
term: int
success: bool
class RaftNode:
def __init__(self, node_id: str, peers: List[str]):
# État persistant
self.current_term: int = 0
self.voted_for: Optional[str] = None
self.log: List[LogEntry] = []
# État volatil
self.commit_index: int = 0
self.last_applied: int = 0
self.state: NodeState = NodeState.FOLLOWER
# État uniquement pour le leader
self.next_index: dict[str, int] = {}
self.match_index: dict[str, int] = {}
self.node_id = node_id
self.peers = peers
def request_vote(self, args: RequestVoteArgs) -> RequestVoteReply:
"""Gérer le RPC RequestVote."""
if args.term > self.current_term:
self.current_term = args.term
self.state = NodeState.FOLLOWER
self.voted_for = None
log_ok = self._is_log_at_least_as_up_to_date(
args.last_log_index, args.last_log_term
)
vote_ok = (self.voted_for is None or self.voted_for == args.candidate_id)
if args.term == self.current_term and vote_ok and log_ok:
self.voted_for = args.candidate_id
return RequestVoteReply(self.current_term, True)
return RequestVoteReply(self.current_term, False)
def append_entries(self, args: AppendEntriesArgs) -> AppendEntriesReply:
"""Gérer le RPC AppendEntries."""
if args.term > self.current_term:
self.current_term = args.term
self.state = NodeState.FOLLOWER
if args.term != self.current_term:
return AppendEntriesReply(self.current_term, False)
# Vérifier si le journal a une entrée à prev_log_index avec prev_log_term
if len(self.log) <= args.prev_log_index:
return AppendEntriesReply(self.current_term, False)
if self.log[args.prev_log_index].term != args.prev_log_term:
return AppendEntriesReply(self.current_term, False)
# Ajouter de nouvelles entrées
for entry in args.entries:
if len(self.log) > entry.index:
if self.log[entry.index].term != entry.term:
# Conflit : supprimer à partir de ce point
self.log = self.log[:entry.index]
if len(self.log) <= entry.index:
self.log.append(entry)
# Mettre à jour l'index de validation
if args.leader_commit > self.commit_index:
self.commit_index = min(args.leader_commit, len(self.log) - 1)
return AppendEntriesReply(self.current_term, True)
def _is_log_at_least_as_up_to_date(self, last_index: int, last_term: int) -> bool:
"""Vérifier si le journal du candidat est au moins aussi à jour que le nôtre."""
if not self.log:
return True
my_last_entry = self.log[-1]
my_last_term = my_last_entry.term
my_last_index = len(self.log) - 1
if last_term != my_last_term:
return last_term > my_last_term
return last_index >= my_last_index
Résumé
Points Clés à Retenir
- Raft a été conçu pour la compréhension, séparant le consensus en sous-problèmes clairs
- Trois états de nœuds : Suiveur → Candidat → Leader
- Termes fournissent une horloge logique et empêchent les leaders obsolètes
- Deux RPCs principaux : RequestVote (élection) et AppendEntries (réplication + battement de cœur)
- Délais randomisés empêchent les votes partagés lors des élections
- Cinq propriétés de sécurité garantissent la correction : sécurité d'élection, ajout-seulement, correspondance de journal, complétude du leader et sécurité de la machine à états
Prochaine Session
Dans la prochaine session, nous plongerons dans l'Élection de Leader :
- Comment les élections sont déclenchées
- L'algorithme d'élection en détail
- Gérer les votes partagés
- Exemples d'élection de leader
Exercices
-
Transitions d'États : Dessinez le diagramme de transition d'états pour un nœud qui commence comme suiveur, devient candidat, gagne l'élection comme leader, puis découvre un terme supérieur.
-
Logique de Terme : Si un nœud reçoit un AppendEntries avec terme=7 mais son terme actuel est 9, que doit-il faire ?
-
Comparaison de Journal : Comparez ces deux journaux et déterminez lequel est le plus à jour :
- Journal A : dernier index=15, dernier terme=5
- Journal B : dernier index=12, dernier terme=7
-
Vote Partagé : Décrivez un scénario où un vote partagé pourrait se produire, et comment Raft empêche qu'il persiste.
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.
Élection de Leader Raft
Session 9, Partie 1 - 45 minutes
Objectifs d'Apprentissage
- Comprendre comment Raft élit un leader démocratiquement
- Implémenter le RPC RequestVote
- Gérer les délais d'élection et les intervalles randomisés
- Empêcher les votes partagés avec la sécurité d'élection
- Construire un système d'élection de leader fonctionnel
Concept : Élection Démocratique de Leader
Dans le chapitre précédent, nous avons appris la philosophie de conception de Raft. Maintenant, plongeons dans le mécanisme d'élection de leader — le processus démocratique par lequel les nœuds s'accordent sur qui doit diriger.
Pourquoi avons-nous besoin d'un Leader ?
Sans Leader :
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Nœud A │ │ Nœud B │ │ Nœud C │
│ │ │ │ │ │
│ "Je │ │ "Non, │ │ "Les │
│ suis │ │ moi ! │ │ deux │
│ leader!" │ │ │ │ tort !" │
└─────────┘ └─────────┘ └─────────┘
Chaos ! Split brain ! Confusion !
Avec Élection de Leader Raft :
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Nœud A │ │ Nœud B │ │ Nœud C │
│ │ │ │ │ │
│ "Je │ │ "Je │ │ "Je vote │
│ vote │---> │ vote │---> │ pour │
│ pour B" │ │ pour B" │ │ B" │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└───────────────┴───────────────┘
│
▼
┌──────────┐
│ Nœud B │
│ = LEADER │
└──────────┘
Aperçu Clé : Les nœuds votent les uns pour les autres. Le nœud avec la majorité des votes devient leader.
Transitions d'États Pendant l'Élection
Les nœuds Raft passent par trois états pendant l'élection de leader :
stateDiagram-v2
[*] --> Suiveur: Démarrage
Suiveur --> Candidat: Délai d'élection
Suiveur --> Suiveur: Recevoir AppendEntries valide
Suiveur --> Suiveur: Découvrir un terme supérieur
Candidat --> Leader: Recevoir la majorité des votes
Candidat --> Candidat: Vote partagé (délai)
Candidat --> Suiveur: Découvrir un terme supérieur
Candidat --> Suiveur: Recevoir AppendEntries valide
Leader --> Suiveur: Découvrir un terme supérieur
note right of Suiveur
- Vote pour au plus un candidat par terme
- Réinitialise le délai d'élection sur battement de cœur
end note
note right of Candidat
- Incrémente le terme actuel
- Vote pour lui-même
- Envoie RequestVote à tous les nœuds
- Le délai randomisé empêche l'interblocage
end note
note right of Leader
- Envoie des battements de cœur (AppendEntries vides)
- Gère les demandes clientes
- Réplique les entrées de journal
end note
L'Algorithme d'Élection Étape par Étape
Étape 1 : Délai du Suiveur
Lorsqu'un suiveur n'entend pas le leader dans le délai d'élection :
Temps ────────────────────────────────────────────────────────>
Nœud A : [en attente...] [en attente...] ⏱️ DÉLAI ! → Devenir Candidat
Nœud B : [en attente...] [en attente...] [en attente...]
Nœud C : [en attente...] [en attente...] [en attente...]
Étape 2 : Devenir Candidat
Le nœud passe à l'état de candidat :
sequenceDiagram
participant C as Candidat (Nœud A)
participant A as Tous les Nœuds
C->>C: Incrémenter le terme (ex: terme = 4)
C->>C: Voter pour soi-même
C->>A: Envoyer RequestVote(terme=4) à tous
Note over C: Attendre les votes...
par Chaque suiveur traite RequestVote
A->>A: Si terme < termeActuel : rejeter
A->>A: Si votéPour != null : rejeter
A->>A: Si le journal du candidat est à jour : accorder le vote
end
A-->>C: Envoyer la réponse de vote
alt Majorité de votes reçue
C->>C: Devenir LEADER
else Vote partagé
C->>C: Attendre le délai, puis réessayer
end
Étape 3 : RPC RequestVote
Le RequestVote RPC est le bulletin de vote dans l'élection de Raft :
graph LR
subgraph RequestVote RPC
C[term] --> D["Terme du candidat"]
E[candidateId] --> F["Nœud demandant le vote"]
G[lastLogIndex] --> H["Index de la dernière entrée de journal du candidat"]
I[lastLogTerm] --> J["Terme de la dernière entrée de journal du candidat"]
end
subgraph Réponse
K[term] --> L["Terme actuel (pour que le candidat mette à jour)"]
M[voteGranted] --> N["vrai si le suiveur a voté"]
end
Règle de Vote : Un suiveur accorde le vote si :
- Le terme du candidat > termeActuel du suiveur, OU
- Les termes sont égaux ET le suiveur n'a pas encore voté ET le journal du candidat est au moins à jour
Délais d'Élection Randomisés
Le Problème du Vote Partagé
Sans randomisation, les élections simultanées causent des interblocages :
Mauvais : Les délais fixes causent des votes partagés répétés
Nœud A : délai à T=100 → Candidat, obtient 1 vote
Nœud B : délai à T=100 → Candidat, obtient 1 vote
Nœud C : délai à T=100 → Candidat, obtient 1 vote
Résultat : Personne ne gagne ! Délai d'élection...
La même chose se répète pour toujours !
Solution : Intervalles Randomisés
Chaque nœud choisit un délai aléatoire dans une plage :
gantt
title Délais d'Élection (Randomisés : 150-300ms)
dateFormat X
axisFormat %L
Nœud A :a1, 0, 180
Nœud B :b1, 0, 220
Nœud C :c1, 0, 160
Nœud A devient Candidat :milestone, m1, 180, 0s
Nœud C devient Candidat :milestone, m2, 160, 0s
Le Nœud C atteint le délai en premier et commence l'élection. Les Nœuds A et B réinitialisent leurs délais lorsqu'ils reçoivent RequestVote, permettant au Nœud C de rassembler les votes.
Analyse de Probabilité : Pour un cluster de N nœuds avec une plage de délai [T, 2T] :
- Probabilité de délai simultané : ~1/N
- Avec 5 nœuds et une plage de 150-300ms : P < 5%
Implémentation TypeScript
Construisons un système d'élection de leader Raft fonctionnel :
Types Fondamentaux
// types/raft.ts
export type NodeState = 'follower' | 'candidate' | 'leader';
export interface LogEntry {
index: number;
term: number;
command: unknown;
}
export interface RaftNodeConfig {
id: string;
peers: string[]; // Liste des IDs de nœuds pairs
electionTimeoutMin: number; // Délai minimum en ms
electionTimeoutMax: number; // Délai maximum en ms
}
export interface RequestVoteArgs {
term: number;
candidateId: string;
lastLogIndex: number;
lastLogTerm: number;
}
export interface RequestVoteReply {
term: number;
voteGranted: boolean;
}
export interface AppendEntriesArgs {
term: number;
leaderId: string;
prevLogIndex: number;
prevLogTerm: number;
entries: LogEntry[];
leaderCommit: number;
}
export interface AppendEntriesReply {
term: number;
success: boolean;
}
Implémentation du Nœud Raft
// raft-node.ts
import { RaftNodeConfig, NodeState, LogEntry, RequestVoteArgs, RequestVoteReply } from './types';
export class RaftNode {
private state: NodeState = 'follower';
private currentTerm: number = 0;
private votedFor: string | null = null;
private log: LogEntry[] = [];
// Délai d'élection
private electionTimer: NodeJS.Timeout | null = null;
private lastHeartbeat: number = Date.now();
// État uniquement pour le leader
private leaderId: string | null = null;
constructor(private config: RaftNodeConfig) {
this.startElectionTimer();
}
// ========== API Publique ==========
getState(): NodeState {
return this.state;
}
getCurrentTerm(): number {
return this.currentTerm;
}
getLeader(): string | null {
return this.leaderId;
}
// ========== Gestionnaires RPC ==========
/**
* Invoqué par les candidats pour rassembler les votes
*/
requestVote(args: RequestVoteArgs): RequestVoteReply {
const reply: RequestVoteReply = {
term: this.currentTerm,
voteGranted: false
};
// Règle 1 : Si le terme du candidat est inférieur, rejeter
if (args.term < this.currentTerm) {
return reply;
}
// Règle 2 : Si le terme du candidat est supérieur, mettre à jour et devenir suiveur
if (args.term > this.currentTerm) {
this.becomeFollower(args.term);
reply.term = args.term;
}
// Règle 3 : Si nous avons déjà voté pour quelqu'un d'autre ce terme, rejeter
if (this.votedFor !== null && this.votedFor !== args.candidateId) {
return reply;
}
// Règle 4 : Vérifier si le journal du candidat est au moins à jour que le nôtre
const lastEntry = this.log.length > 0 ? this.log[this.log.length - 1] : null;
const lastLogIndex = lastEntry ? lastEntry.index : 0;
const lastLogTerm = lastEntry ? lastEntry.term : 0;
const logIsUpToDate =
(args.lastLogTerm > lastLogTerm) ||
(args.lastLogTerm === lastLogTerm && args.lastLogIndex >= lastLogIndex);
if (!logIsUpToDate) {
return reply;
}
// Accorder le vote
this.votedFor = args.candidateId;
reply.voteGranted = true;
this.resetElectionTimer();
console.log(`Nœud ${this.config.id} a voté pour ${args.candidateId} au terme ${args.term}`);
return reply;
}
/**
* Invoqué par le leader pour affirmer l'autorité (battement de cœur ou réplication de journal)
*/
receiveHeartbeat(term: number, leaderId: string): void {
if (term >= this.currentTerm) {
if (term > this.currentTerm) {
this.becomeFollower(term);
}
this.leaderId = leaderId;
this.resetElectionTimer();
}
}
// ========== Transitions d'États ==========
private becomeFollower(term: number): void {
this.state = 'follower';
this.currentTerm = term;
this.votedFor = null;
this.leaderId = null;
this.resetElectionTimer();
console.log(`Nœud ${this.config.id} est devenu suiveur au terme ${term}`);
}
private becomeCandidate(): void {
this.state = 'candidate';
this.currentTerm += 1;
this.votedFor = this.config.id;
this.leaderId = null;
console.log(`Nœud ${this.config.id} est devenu candidat au terme ${this.currentTerm}`);
// Démarrer l'élection
this.startElection();
}
private becomeLeader(): void {
this.state = 'leader';
this.leaderId = this.config.id;
console.log(`Nœud ${this.config.id} est devenu LEADER au terme ${this.currentTerm}`);
// Commencer à envoyer des battements de cœur
this.startHeartbeats();
}
// ========== Logique d'Élection ==========
private startElectionTimer(): void {
if (this.electionTimer) {
clearTimeout(this.electionTimer);
}
const timeout = this.getRandomElectionTimeout();
this.electionTimer = setTimeout(() => {
// Ne transitionner que si nous n'avons pas entendu d'un leader
if (this.state === 'follower') {
console.log(`Nœud ${this.config.id} délai d'élection`);
this.becomeCandidate();
}
}, timeout);
}
private resetElectionTimer(): void {
this.startElectionTimer();
}
private getRandomElectionTimeout(): number {
const { electionTimeoutMin, electionTimeoutMax } = this.config;
return Math.floor(
Math.random() * (electionTimeoutMax - electionTimeoutMin + 1)
) + electionTimeoutMin;
}
private async startElection(): Promise<void> {
const args: RequestVoteArgs = {
term: this.currentTerm,
candidateId: this.config.id,
lastLogIndex: this.log.length > 0 ? this.log[this.log.length - 1].index : 0,
lastLogTerm: this.log.length > 0 ? this.log[this.log.length - 1].term : 0
};
let votesReceived = 1; // Vote pour soi-même
const majority = Math.floor(this.config.peers.length / 2) + 1;
// Envoyer RequestVote à tous les pairs
const promises = this.config.peers.map(peerId =>
this.sendRequestVote(peerId, args)
);
const responses = await Promise.allSettled(promises);
// Compter les votes
for (const result of responses) {
if (result.status === 'fulfilled' && result.value.voteGranted) {
votesReceived++;
}
}
// Vérifier si nous avons gagné
if (votesReceived >= majority && this.state === 'candidate') {
this.becomeLeader();
}
}
// ========== Simulation Réseau ==========
private async sendRequestVote(
peerId: string,
args: RequestVoteArgs
): Promise<RequestVoteReply> {
// Dans une implémentation réelle, ce serait un appel HTTP/gRPC
// Pour cet exemple, nous simulons en appelant directement
// Dans l'exemple complet ci-dessous, nous utiliserons HTTP réel
return {
term: 0,
voteGranted: false
};
}
private startHeartbeats(): void {
// Le leader envoie des battements de cœur périodiques
// Implémentation dans l'exemple complet
}
stop(): void {
if (this.electionTimer) {
clearTimeout(this.electionTimer);
}
}
}
Serveur HTTP avec Raft
// server.ts
import express, { Request, Response } from 'express';
import { RaftNode } from './raft-node';
import { RequestVoteArgs, RequestVoteReply } from './types';
export class RaftServer {
private app: express.Application;
private node: RaftNode;
private server: any;
constructor(
private nodeId: string,
private port: number,
peers: string[]
) {
this.app = express();
this.app.use(express.json());
this.node = new RaftNode({
id: nodeId,
peers: peers,
electionTimeoutMin: 150,
electionTimeoutMax: 300
});
this.setupRoutes();
}
private setupRoutes(): void {
// Point de terminaison RPC RequestVote
this.app.post('/raft/request-vote', (req: Request, res: Response) => {
const args: RequestVoteArgs = req.body;
const reply: RequestVoteReply = this.node.requestVote(args);
res.json(reply);
});
// Point de terminaison de battement de cœur
this.app.post('/raft/heartbeat', (req: Request, res: Response) => {
const { term, leaderId } = req.body;
this.node.receiveHeartbeat(term, leaderId);
res.json({ success: true });
});
// Point de terminaison d'état
this.app.get('/status', (req: Request, res: Response) => {
res.json({
id: this.nodeId,
state: this.node.getState(),
term: this.node.getCurrentTerm(),
leader: this.node.getLeader()
});
});
}
async start(): Promise<void> {
this.server = this.app.listen(this.port, () => {
console.log(`Nœud ${this.nodeId} écoute sur le port ${this.port}`);
});
}
stop(): void {
this.node.stop();
if (this.server) {
this.server.close();
}
}
getNode(): RaftNode {
return this.node;
}
}
Implémentation Python
La même logique en Python :
# raft_node.py
import asyncio
import random
from dataclasses import dataclass
from enum import Enum
from typing import Optional, List
from datetime import datetime, timedelta
class NodeState(Enum):
FOLLOWER = "follower"
CANDIDATE = "candidate"
LEADER = "leader"
@dataclass
class LogEntry:
index: int
term: int
command: dict
@dataclass
class RequestVoteArgs:
term: int
candidate_id: str
last_log_index: int
last_log_term: int
@dataclass
class RequestVoteReply:
term: int
vote_granted: bool
class RaftNode:
def __init__(self, node_id: str, peers: List[str],
election_timeout_min: int = 150,
election_timeout_max: int = 300):
self.node_id = node_id
self.peers = peers
# État persistant
self.current_term = 0
self.voted_for: Optional[str] = None
self.log: List[LogEntry] = []
# État volatil
self.state = NodeState.FOLLOWER
self.leader_id: Optional[str] = None
# Délai d'élection
self.election_timeout_min = election_timeout_min
self.election_timeout_max = election_timeout_max
self.election_task: Optional[asyncio.Task] = None
self.heartbeat_task: Optional[asyncio.Task] = None
# Démarrer le timer d'élection
self.start_election_timer()
async def request_vote(self, args: RequestVoteArgs) -> RequestVoteReply:
"""Gérer le RPC RequestVote du candidat"""
reply = RequestVoteReply(
term=self.current_term,
vote_granted=False
)
# Règle 1 : Rejeter si le terme du candidat est inférieur
if args.term < self.current_term:
return reply
# Règle 2 : Mettre à jour au terme supérieur et devenir suiveur
if args.term > self.current_term:
await self.become_follower(args.term)
reply.term = args.term
# Règle 3 : Rejeter si déjà voté pour un autre candidat
if self.voted_for is not None and self.voted_for != args.candidate_id:
return reply
# Règle 4 : Vérifier si le journal du candidat est à jour
last_entry = self.log[-1] if self.log else None
last_log_index = last_entry.index if last_entry else 0
last_log_term = last_entry.term if last_entry else 0
log_is_up_to_date = (
args.last_log_term > last_log_term or
(args.last_log_term == last_log_term and
args.last_log_index >= last_log_index)
)
if not log_is_up_to_date:
return reply
# Accorder le vote
self.voted_for = args.candidate_id
reply.vote_granted = True
self.reset_election_timer()
print(f"Nœud {self.node_id} a voté pour {args.candidate_id} au terme {args.term}")
return reply
async def receive_heartbeat(self, term: int, leader_id: str):
"""Gérer le battement de cœur du leader"""
if term >= self.current_term:
if term > self.current_term:
await self.become_follower(term)
self.leader_id = leader_id
self.reset_election_timer()
async def become_follower(self, term: int):
"""Transitionner vers l'état de suiveur"""
self.state = NodeState.FOLLOWER
self.current_term = term
self.voted_for = None
self.leader_id = None
# Annuler la tâche de battement de cœur si en cours
if self.heartbeat_task:
self.heartbeat_task.cancel()
self.heartbeat_task = None
self.reset_election_timer()
print(f"Nœud {self.node_id} est devenu suiveur au terme {term}")
async def become_candidate(self):
"""Transitionner vers l'état de candidat et démarrer l'élection"""
self.state = NodeState.CANDIDATE
self.current_term += 1
self.voted_for = self.node_id
self.leader_id = None
print(f"Nœud {self.node_id} est devenu candidat au terme {self.current_term}")
await self.start_election()
async def become_leader(self):
"""Transitionner vers l'état de leader"""
self.state = NodeState.LEADER
self.leader_id = self.node_id
print(f"Nœud {self.node_id} est devenu LEADER au terme {self.current_term}")
self.start_heartbeats()
def start_election_timer(self):
"""Démarrer ou réinitialiser le timer de délai d'élection"""
if self.election_task:
self.election_task.cancel()
timeout = self.get_random_election_timeout()
self.election_task = asyncio.create_task(self.election_timeout(timeout))
def reset_election_timer(self):
"""Réinitialiser le timer de délai d'élection"""
self.start_election_timer()
def get_random_election_timeout(self) -> int:
"""Obtenir un délai aléatoire dans la plage configurée"""
return random.randint(
self.election_timeout_min,
self.election_timeout_max
)
async def election_timeout(self, timeout_ms: int):
"""Attendre le délai, puis démarrer l'élection si toujours suiveur"""
try:
await asyncio.sleep(timeout_ms / 1000)
if self.state == NodeState.FOLLOWER:
print(f"Nœud {self.node_id} délai d'élection")
await self.become_candidate()
except asyncio.CancelledError:
pass # Le timer a été réinitialisé
async def start_election(self):
"""Démarrer l'élection de leader en envoyant RequestVote à tous les pairs"""
args = RequestVoteArgs(
term=self.current_term,
candidate_id=self.node_id,
last_log_index=self.log[-1].index if self.log else 0,
last_log_term=self.log[-1].term if self.log else 0
)
votes_received = 1 # Vote pour soi-même
majority = len(self.peers) // 2 + 1
# Envoyer RequestVote à tous les pairs simultanément
tasks = [
self.send_request_vote(peer_id, args)
for peer_id in self.peers
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Compter les votes
for result in results:
if isinstance(result, RequestVoteReply) and result.vote_granted:
votes_received += 1
# Vérifier si nous avons gagné l'élection
if votes_received >= majority and self.state == NodeState.CANDIDATE:
await self.become_leader()
async def send_request_vote(self, peer_id: str, args: RequestVoteArgs) -> RequestVoteReply:
"""Envoyer le RPC RequestVote au pair (simulé)"""
# Dans une implémentation réelle, utiliser HTTP/aiohttp
# Pour cet exemple, retourner une réponse simulée
return RequestVoteReply(term=0, vote_granted=False)
def start_heartbeats(self):
"""Le leader envoie des battements de cœur périodiques"""
if self.heartbeat_task:
self.heartbeat_task.cancel()
self.heartbeat_task = asyncio.create_task(self.send_heartbeats())
async def send_heartbeats(self):
"""Envoyer des AppendEntries vides (battements de cœur) à tous les suiveurs"""
while self.state == NodeState.LEADER:
for peer_id in self.peers:
# Dans une implémentation réelle, envoyer HTTP POST
await asyncio.sleep(0.05) # Intervalle de battement de cœur : 50ms
def stop(self):
"""Arrêter le nœud"""
if self.election_task:
self.election_task.cancel()
if self.heartbeat_task:
self.heartbeat_task.cancel()
Serveur Flask avec Raft
# server.py
from flask import Flask, request, jsonify
from raft_node import RaftNode, RequestVoteArgs
import asyncio
app = Flask(__name__)
class RaftServer:
def __init__(self, node_id: str, port: int, peers: list):
self.node_id = node_id
self.port = port
self.node = RaftNode(node_id, peers)
self.app = app
self.setup_routes()
def setup_routes(self):
@self.app.route('/raft/request-vote', methods=['POST'])
def request_vote():
args = RequestVoteArgs(**request.json)
reply = asyncio.run(self.node.request_vote(args))
return jsonify({
'term': reply.term,
'voteGranted': reply.vote_granted
})
@self.app.route('/raft/heartbeat', methods=['POST'])
def heartbeat():
data = request.json
asyncio.run(self.node.receive_heartbeat(
data['term'], data['leaderId']
))
return jsonify({'success': True})
@self.app.route('/status', methods=['GET'])
def status():
return jsonify({
'id': self.node_id,
'state': self.node.state.value,
'term': self.node.current_term,
'leader': self.node.leader_id
})
def run(self):
self.app.run(port=self.port, debug=False)
Configuration Docker Compose
Déployons un cluster Raft à 3 nœuds :
# docker-compose.yml
version: '3.8'
services:
node1:
build:
context: ./examples/04-consensus
dockerfile: Dockerfile.typescript
container_name: raft-node1
environment:
- NODE_ID=node1
- PORT=3001
- PEERS=node2:3002,node3:3003
ports:
- "3001:3001"
networks:
- raft-network
node2:
build:
context: ./examples/04-consensus
dockerfile: Dockerfile.typescript
container_name: raft-node2
environment:
- NODE_ID=node2
- PORT=3002
- PEERS=node1:3001,node3:3003
ports:
- "3002:3002"
networks:
- raft-network
node3:
build:
context: ./examples/04-consensus
dockerfile: Dockerfile.typescript
container_name: raft-node3
environment:
- NODE_ID=node3
- PORT=3003
- PEERS=node1:3001,node2:3002
ports:
- "3003:3003"
networks:
- raft-network
networks:
raft-network:
driver: bridge
Exécution de l'Exemple
Version TypeScript
# 1. Construire et démarrer le cluster
cd distributed-systems-course/examples/04-consensus
docker-compose up
# 2. Observer l'élection se produire dans les journaux
# Vous verrez les nœuds transitionner de suiveur → candidat → leader
# 3. Vérifier l'état de chaque nœud
curl http://localhost:3001/status
curl http://localhost:3002/status
curl http://localhost:3003/status
# 4. Tuer le leader et observer la réélection
docker-compose stop node1 # Si node1 était leader
# Observer les journaux pour voir un nouveau leader élu !
# 5. Nettoyer
docker-compose down
Version Python
# 1. Construire et démarrer le cluster
cd distributed-systems-course/examples/04-consensus
docker-compose -f docker-compose.python.yml up
# 2-5. Identique à ci-dessus, utilisant les ports 4001-4003 pour les nœuds Python
Exercices
Exercice 1 : Observer la Sécurité d'Élection
Exécutez le cluster et répondez à ces questions :
- Combien de temps faut-il pour qu'un leader soit élu ?
- Que se passe-t-il si vous démarrez les nœuds à des moments différents ?
- Pouvez-vous observer un scénario de vote partagé ? (Indice : causez une partition réseau)
Exercice 2 : Implémenter le Pré-Vote
Le pré-vote est une optimisation qui empêche de perturber un leader stable :
- Renseignez-vous sur le mécanisme de pré-vote
- Modifiez le gestionnaire RequestVote pour vérifier d'abord si le leader est en vie
- Testez que le pré-vote empêche les élections inutiles
Exercice 3 : Réglage du Délai d'Élection
Expérimentez avec différentes plages de délai :
- Essayez 50-100ms : Que se passe-t-il ? (Indice : trop d'élections)
- Essayez 500-1000ms : Que se passe-t-il ? (Indice : basculement de leader lent)
- Trouvez la plage optimale pour un cluster à 3 nœuds
Exercice 4 : Simulation de Partition Réseau
Simulez une partition réseau :
- Démarrez le cluster et attendez l'élection du leader
- Isolez node1 du réseau (en utilisant l'isolement du réseau Docker)
- Observez : Est-ce que node1 pense toujours être leader ?
- Reconnectez : Est-ce que le cluster récupère correctement ?
Résumé
Dans ce chapitre, vous avez appris :
- Pourquoi l'élection de leader est importante : Empêche le split-brain et la confusion
- Le processus démocratique de Raft : Les nœuds votent les uns pour les autres
- Transitions d'états : Suiveur → Candidat → Leader
- RPC RequestVote : Le bulletin de vote des élections Raft
- Délais randomisés : Empêchent les votes partagés et les interblocages
- Sécurité d'élection : Au plus un leader par terme
Prochain Chapitre : Réplication de Journal — Une fois que nous avons un leader, comment répliquons-nous en toute sécurité les données à travers le cluster ?
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.
Réplication de Journal
Session 10, Partie 1 - 30 minutes
Objectifs d'Apprentissage
- Comprendre comment Raft réplique les journaux à travers les nœuds
- Apprendre la propriété de correspondance de journal qui assure la cohérence
- Implémenter le RPC AppendEntries
- Gérer les conflits de cohérence de journal
- Comprendre l'index de validation et l'application de la machine à états
Concept : Garder Tout le Monde Synchronisé
Une fois qu'un leader est élu, il doit répliquer les commandes clientes à tous les suiveurs. C'est la phase de réplication de journal de Raft.
Le Défi
Le Client envoie "SET x = 5" au Leader
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Leader │ │ Suiveur │ │ Suiveur │
│ │ │ A │ │ B │
└────┬─────┘ └──────────┘ └──────────┘
│
│ Comment nous assurer que TOUS les nœuds
│ ont le MÊME journal de commandes ?
│
│ Que se passe-t-il si le réseau échoue ?
│ Que se passe-t-il si le suiveur plante ?
▼
┌─────────────────────────────────────────┐
│ Protocole de Réplication de Journal │
└─────────────────────────────────────────┘
Structure du Journal
Chaque nœud maintient un journal de commandes. Une entrée de journal contient :
interface LogEntry {
index: number; // Position dans le journal (commence à 1)
term: number; // Terme quand l'entrée a été reçue
command: string; // La commande actuelle (ex: "SET x = 5")
}
@dataclass
class LogEntry:
index: int # Position dans le journal (commence à 1)
term: int # Terme quand l'entrée a été reçue
command: str # La commande actuelle (ex: "SET x = 5")
Représentation Visuelle du Journal
Nœud 1 (Leader) Nœud 2 (Suiveur) Nœud 3 (Suiveur)
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Index │ Terme │ Cmd│ │ Index │ Terme │ Cmd│ │ Index │ Terme │ Cmd│
├───────┼──────┼────┤ ├───────┼──────┼────┤ ├───────┼──────┼────┤
│ 1 │ 1 │SET │ │ 1 │ 1 │SET │ │ 1 │ 1 │SET │
│ 2 │ 2 │SET │ │ 2 │ 2 │SET │ │ 2 │ 2 │SET │
│ 3 │ 2 │SET │ │ 3 │ 2 │SET │ │ │ │ │
│ 4 │ 2 │SET │ │ │ │ │ │ │ │ │
└───────┴──────┴────┘ └───────┴──────┴────┘ └───────┴──────┴────┘
La Propriété de Correspondance de Journal
C'est la garantie de sécurité clé de Raft. Si deux journaux contiennent une entrée avec le même index et terme, alors toutes les entrées précédentes sont identiques et dans le même ordre.
Propriété de Correspondance de Journal
┌────────────────────────────────────────────────────────┐
│ │
│ Si journaux[i].terme == journaux[j].terme ET │
│ journaux[i].index == journaux[j].index │
│ │
│ ALORS : │
│ journaux[k] == journaux[k] pour tout k < i │
│ │
└────────────────────────────────────────────────────────┘
Exemple :
Nœud A : [1,1] [2,1] [3,2] [4,2] [5,2]
│
Nœud B : [1,1] [2,1] [3,2] [4,2] [5,3] [6,3]
│
└─ Même index 3, terme 2
Par conséquent les entrées 1-2 sont IDENTIQUES
Cette propriété permet à Raft de détecter et corriger efficacement les incohérences.
RPC AppendEntries
Le leader utilise AppendEntries pour répliquer les entrées de journal et aussi comme un battement de cœur.
Spécification RPC
interface AppendEntriesRequest {
term: number; // Terme du leader
leaderId: string; // Pour que le suiveur puisse rediriger les clients
prevLogIndex: number; // Index de l'entrée de journal précédant immédiatement les nouvelles
prevLogTerm: number; // Terme de l'entrée prevLogIndex
entries: LogEntry[]; // Entrées de journal à stocker (vide pour battement de cœur)
leaderCommit: number; // Index de validation du leader
}
interface AppendEntriesResponse {
term: number; // Terme actuel, pour que le leader se mette à jour
success: boolean; // Vrai si le suiveur avait l'entrée correspondant à prevLogIndex/terme
}
@dataclass
class AppendEntriesRequest:
term: int # Terme du leader
leader_id: str # Pour que le suiveur puisse rediriger les clients
prev_log_index: int # Index de l'entrée de journal précédant immédiatement les nouvelles
prev_log_term: int # Terme de l'entrée prevLogIndex
entries: List[LogEntry] # Entrées de journal à stocker (vide pour battement de cœur)
leader_commit: int # Index de validation du leader
@dataclass
class AppendEntriesResponse:
term: int # Terme actuel, pour que le leader se mette à jour
success: bool # Vrai si le suiveur avait l'entrée correspondant à prevLogIndex/terme
Flux de Réplication de Journal
sequenceDiagram
participant C as Client
participant L as Leader
participant F1 as Suiveur 1
participant F2 as Suiveur 2
participant F3 as Suiveur 3
C->>L: SET x = 5
L->>L: Ajouter au journal (non validé)
L->>F1: AppendEntries(entries=[SET x=5], prevLogIndex=2, prevLogTerm=1)
L->>F2: AppendEntries(entries=[SET x=5], prevLogIndex=2, prevLogTerm=1)
L->>F3: AppendEntries(entries=[SET x=5], prevLogIndex=2, prevLogTerm=1)
F1->>F1: Ajouter au journal, répondre succès
F2->>F2: Ajouter au journal, répondre succès
F3->>F3: Ajouter au journal, répondre succès
Note over L: Majorité reçue (2/3)
L->>L: Index de validation = 3
L->>L: Appliquer à la machine à états : x = 5
L->>C: Retourner succès (x = 5)
L->>F1: AppendEntries(entries=[], leaderCommit=3)
L->>F2: AppendEntries(entries=[], leaderCommit=3)
L->>F3: AppendEntries(entries=[], leaderCommit=3)
F1->>F1: Appliquer les entrées validées
F2->>F2: Appliquer les entrées validées
F3->>F3: Appliquer les entrées validées
Gestion des Conflits de Cohérence
Lorsque le journal d'un suiveur entre en conflit avec celui du leader, le leader le résout :
graph TD
A[Leader envoie AppendEntries] --> B{Suiveur vérifie<br/>prevLogIndex/terme}
B -->|Correspondance trouvée| C[Ajouter de nouvelles entrées<br/>Retourner success=true]
B -->|Pas de correspondance| D[Retourner success=false]
D --> E[Leader décrémente<br/>nextIndex pour le suiveur]
E --> F{Réessayer avec<br/>une position de journal antérieure ?}
F -->|Oui| A
F -->|Pas de correspondance à l'index 0| G[Ajouter le journal entier<br/>du leader]
C --> H[Suiveur met à jour<br/>l'index de validation si nécessaire]
H --> I[Appliquer les entrées validées<br/>à la machine à états]
Exemple de Conflit
Avant Résolution de Conflit :
Leader : [1,1] [2,2] [3,2]
Suiveur :[1,1] [2,1] [3,1] [4,3] ← Divergence à l'index 2 !
Étape 1 : Le leader envoie AppendEntries(prevLogIndex=2, prevLogTerm=2)
Suiveur : Pas de correspondance ! (a le terme 1, pas 2) → Retourner success=false
Étape 2 : Le leader décrémente nextIndex, envoie AppendEntries(prevLogIndex=1, prevLogTerm=1)
Suiveur : Correspondance ! → Retourner success=true
Étape 3 : Le leader envoie les entrées à partir de l'index 2
Le suiveur écrase [2,1] [3,1] [4,3] avec [2,2] [3,2]
Après Résolution de Conflit :
Leader : [1,1] [2,2] [3,2]
Suiveur :[1,1] [2,2] [3,2] ← Maintenant cohérent !
Index de Validation
L'index de validation suit quelles entrées de journal sont validées (durables et sûres à appliquer).
let commitIndex = 0; // Index de l'entrée validée la plus élevée
// Règle du leader : Une entrée du terme actuel est validée
// une fois stockée sur une majorité de serveurs
function updateCommitIndex(): void {
const N = this.log.length;
// Trouver le plus grand N tel que :
// 1. Une majorité de nœuds ont des entrées de journal jusqu'à N
// 2. log[N].term == currentTerm (règle de sécurité !)
for (let i = N; i > this.commitIndex; i--) {
if (this.log[i - 1].term === this.currentTerm && this.isMajorityReplicated(i)) {
this.commitIndex = i;
break;
}
}
}
commit_index: int = 0 # Index de l'entrée validée la plus élevée
# Règle du leader : Une entrée du terme actuel est validée
# une fois stockée sur une majorité de serveurs
def update_commit_index(self) -> None:
N = len(self.log)
# Trouver le plus grand N tel que :
# 1. Une majorité de nœuds ont des entrées de journal jusqu'à N
# 2. log[N].term == currentTerm (règle de sécurité !)
for i in range(N, self.commit_index, -1):
if self.log[i - 1].term == self.current_term and self.is_majority_replicated(i):
self.commit_index = i
break
Règle de Sécurité : Valider Seulement les Entrées du Terme Actuel
graph LR
A[Entrée du<br/>terme précédent] -->|Peut être<br/>validée| B[Quand une entrée du<br/>terme actuel existe]
C[Entrée du<br/>terme actuel] -->|Peut être<br/>validée| D[Quand répliquée<br/>à la majorité]
B --> E[Appliquée à<br/>la machine à états]
D --> E
style B fill:#f99
style D fill:#9f9
Pourquoi ? Empêche un leader de valider des entrées non validées de termes précédents qui pourraient être écrasées.
Implémentation TypeScript
Étendons notre implémentation Raft avec la réplication de journal :
// types.ts
export interface LogEntry {
index: number;
term: number;
command: string;
}
export interface AppendEntriesRequest {
term: number;
leaderId: string;
prevLogIndex: number;
prevLogTerm: number;
entries: LogEntry[];
leaderCommit: number;
}
export interface AppendEntriesResponse {
term: number;
success: boolean;
}
// raft-node.ts
export class RaftNode {
private log: LogEntry[] = [];
private commitIndex = 0;
private lastApplied = 0;
// Pour chaque suiveur, suivre le prochain index de journal à envoyer
private nextIndex: Map<string, number> = new Map();
private matchIndex: Map<string, number> = new Map();
// ... (code précédent de l'élection de leader)
/**
* Gérer le RPC AppendEntries du leader
*/
handleAppendEntries(req: AppendEntriesRequest): AppendEntriesResponse {
// Répondre faux si term < currentTerm
if (req.term < this.currentTerm) {
return { term: this.currentTerm, success: false };
}
// Mettre à jour le terme actuel si nécessaire
if (req.term > this.currentTerm) {
this.currentTerm = req.term;
this.state = NodeState.Follower;
this.votedFor = null;
}
// Réinitialiser le délai d'élection
this.resetElectionTimeout();
// Vérifier la cohérence du journal
if (req.prevLogIndex > 0) {
if (this.log.length < req.prevLogIndex) {
return { term: this.currentTerm, success: false };
}
const prevEntry = this.log[req.prevLogIndex - 1];
if (prevEntry.term !== req.prevLogTerm) {
return { term: this.currentTerm, success: false };
}
}
// Ajouter de nouvelles entrées
if (req.entries.length > 0) {
// Trouver la première entrée en conflit
let insertIndex = req.prevLogIndex;
for (const entry of req.entries) {
if (insertIndex < this.log.length) {
const existing = this.log[insertIndex];
if (existing.index === entry.index && existing.term === entry.term) {
// Correspond déjà, sauter
insertIndex++;
continue;
}
// Conflit ! Supprimer à partir d'ici et ajouter
this.log = this.log.slice(0, insertIndex);
}
this.log.push(entry);
insertIndex++;
}
}
// Mettre à jour l'index de validation
if (req.leaderCommit > this.commitIndex) {
this.commitIndex = Math.min(req.leaderCommit, this.log.length);
this.applyCommittedEntries();
}
return { term: this.currentTerm, success: true };
}
/**
* Appliquer les entrées validées à la machine à états
*/
private applyCommittedEntries(): void {
while (this.lastApplied < this.commitIndex) {
this.lastApplied++;
const entry = this.log[this.lastApplied - 1];
this.stateMachine.apply(entry);
console.log(`Nœud ${this.nodeId} appliqué : ${entry.command}`);
}
}
/**
* Leader : répliquer le journal aux suiveurs
*/
private replicateLog(): void {
if (this.state !== NodeState.Leader) return;
for (const followerId of this.clusterConfig.peerIds) {
const nextIdx = this.nextIndex.get(followerId) || 1;
const prevLogIndex = nextIdx - 1;
const prevLogTerm = nextIdx > 1 ? this.log[nextIdx - 2].term : 0;
const entries = this.log.slice(nextIdx - 1);
const req: AppendEntriesRequest = {
term: this.currentTerm,
leaderId: this.nodeId,
prevLogIndex,
prevLogTerm,
entries,
leaderCommit: this.commitIndex,
};
this.sendAppendEntries(followerId, req);
}
}
/**
* Leader : gérer la réponse AppendEntries
*/
private handleAppendEntriesResponse(
followerId: string,
resp: AppendEntriesResponse,
req: AppendEntriesRequest
): void {
if (this.state !== NodeState.Leader) return;
if (resp.term > this.currentTerm) {
// Le suiveur a un terme supérieur, descendre
this.currentTerm = resp.term;
this.state = NodeState.Follower;
this.votedFor = null;
return;
}
if (resp.success) {
// Mettre à jour l'index de correspondance et le prochain index
const lastIndex = req.prevLogIndex + req.entries.length;
this.matchIndex.set(followerId, lastIndex);
this.nextIndex.set(followerId, lastIndex + 1);
// Essayer de valider plus d'entrées
this.updateCommitIndex();
} else {
// Le journal du suiveur est incohérent, revenir en arrière
const currentNext = this.nextIndex.get(followerId) || 1;
this.nextIndex.set(followerId, Math.max(1, currentNext - 1));
// Réessayer immédiatement
setTimeout(() => this.replicateLog(), 50);
}
}
/**
* Leader : mettre à jour l'index de validation si la majorité a l'entrée
*/
private updateCommitIndex(): void {
if (this.state !== NodeState.Leader) return;
const N = this.log.length;
// Trouver le plus grand N tel qu'une majorité ait des entrées de journal jusqu'à N
for (let i = N; i > this.commitIndex; i--) {
if (this.log[i - 1].term !== this.currentTerm) {
continue;
}
let count = 1; // Le leader l'a
for (const matchIdx of this.matchIndex.values()) {
if (matchIdx >= i) count++;
}
const majority = Math.floor(this.clusterConfig.peerIds.length / 2) + 1;
if (count >= majority) {
this.commitIndex = i;
this.applyCommittedEntries();
break;
}
}
}
/**
* Client : soumettre une commande au cluster
*/
async submitCommand(command: string): Promise<void> {
if (this.state !== NodeState.Leader) {
throw new Error('Pas un leader. Rediriger vers le leader actuel.');
}
// Ajouter au journal local
const entry: LogEntry = {
index: this.log.length + 1,
term: this.currentTerm,
command,
};
this.log.push(entry);
// Répliquer aux suiveurs
this.replicateLog();
// Attendre la validation
await this.waitForCommit(entry.index);
}
private async waitForCommit(index: number): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (this.commitIndex >= index) {
resolve();
} else {
setTimeout(check, 50);
}
};
check();
});
}
}
Implémentation Python
# types.py
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class LogEntry:
index: int
term: int
command: str
@dataclass
class AppendEntriesRequest:
term: int
leader_id: str
prev_log_index: int
prev_log_term: int
entries: List[LogEntry]
leader_commit: int
@dataclass
class AppendEntriesResponse:
term: int
success: bool
# raft_node.py
import asyncio
from enum import Enum
from typing import List, Dict
class RaftNode:
def __init__(self, node_id: str, peer_ids: List[str]):
self.node_id = node_id
self.peer_ids = peer_ids
# État persistant
self.current_term = 0
self.voted_for: Optional[str] = None
self.log: List[LogEntry] = []
# État volatil
self.commit_index = 0
self.last_applied = 0
self.state = NodeState.FOLLOWER
# État du leader
self.next_index: Dict[str, int] = {}
self.match_index: Dict[str, int] = {}
# Machine à états
self.state_machine = StateMachine()
# Délai d'élection
self.election_timeout: Optional[asyncio.Task] = None
async def handle_append_entries(self, req: AppendEntriesRequest) -> AppendEntriesResponse:
"""Gérer le RPC AppendEntries du leader"""
# Répondre faux si term < currentTerm
if req.term < self.current_term:
return AppendEntriesResponse(term=self.current_term, success=False)
# Mettre à jour le terme actuel si nécessaire
if req.term > self.current_term:
self.current_term = req.term
self.state = NodeState.FOLLOWER
self.voted_for = None
# Réinitialiser le délai d'élection
self.reset_election_timeout()
# Vérifier la cohérence du journal
if req.prev_log_index > 0:
if len(self.log) < req.prev_log_index:
return AppendEntriesResponse(term=self.current_term, success=False)
prev_entry = self.log[req.prev_log_index - 1]
if prev_entry.term != req.prev_log_term:
return AppendEntriesResponse(term=self.current_term, success=False)
# Ajouter de nouvelles entrées
if req.entries:
# Trouver la première entrée en conflit
insert_index = req.prev_log_index
for entry in req.entries:
if insert_index < len(self.log):
existing = self.log[insert_index]
if existing.index == entry.index and existing.term == entry.term:
# Correspond déjà, sauter
insert_index += 1
continue
# Conflit ! Supprimer à partir d'ici et ajouter
self.log = self.log[:insert_index]
self.log.append(entry)
insert_index += 1
# Mettre à jour l'index de validation
if req.leader_commit > self.commit_index:
self.commit_index = min(req.leader_commit, len(self.log))
await self.apply_committed_entries()
return AppendEntriesResponse(term=self.current_term, success=True)
async def apply_committed_entries(self):
"""Appliquer les entrées validées à la machine à états"""
while self.last_applied < self.commit_index:
self.last_applied += 1
entry = self.log[self.last_applied - 1]
self.state_machine.apply(entry)
print(f"Nœud {self.node_id} appliqué : {entry.command}")
async def replicate_log(self):
"""Leader : répliquer le journal aux suiveurs"""
if self.state != NodeState.LEADER:
return
for follower_id in self.peer_ids:
next_idx = self.next_index.get(follower_id, 1)
prev_log_index = next_idx - 1
prev_log_term = self.log[prev_log_index - 1].term if prev_log_index > 0 else 0
entries = self.log[next_idx - 1:]
req = AppendEntriesRequest(
term=self.current_term,
leader_id=self.node_id,
prev_log_index=prev_log_index,
prev_log_term=prev_log_term,
entries=entries,
leader_commit=self.commit_index
)
await self.send_append_entries(follower_id, req)
async def handle_append_entries_response(
self,
follower_id: str,
resp: AppendEntriesResponse,
req: AppendEntriesRequest
):
"""Leader : gérer la réponse AppendEntries"""
if self.state != NodeState.LEADER:
return
if resp.term > self.current_term:
# Le suiveur a un terme supérieur, descendre
self.current_term = resp.term
self.state = NodeState.FOLLOWER
self.voted_for = None
return
if resp.success:
# Mettre à jour l'index de correspondance et le prochain index
last_index = req.prev_log_index + len(req.entries)
self.match_index[follower_id] = last_index
self.next_index[follower_id] = last_index + 1
# Essayer de valider plus d'entrées
await self.update_commit_index()
else:
# Le journal du suiveur est incohérent, revenir en arrière
current_next = self.next_index.get(follower_id, 1)
self.next_index[follower_id] = max(1, current_next - 1)
# Réessayer immédiatement
asyncio.create_task(self.replicate_log())
async def update_commit_index(self):
"""Leader : mettre à jour l'index de validation si la majorité a l'entrée"""
if self.state != NodeState.LEADER:
return
N = len(self.log)
# Trouver le plus grand N tel qu'une majorité ait des entrées de journal jusqu'à N
for i in range(N, self.commit_index, -1):
if self.log[i - 1].term != self.current_term:
# Ne valider que les entrées du terme actuel
continue
count = 1 # Le leader l'a
for match_idx in self.match_index.values():
if match_idx >= i:
count += 1
majority = len(self.peer_ids) // 2 + 1
if count >= majority:
self.commit_index = i
await self.apply_committed_entries()
break
async def submit_command(self, command: str) -> None:
"""Client : soumettre une commande au cluster"""
if self.state != NodeState.LEADER:
raise Exception("Pas un leader. Rediriger vers le leader actuel.")
# Ajouter au journal local
entry = LogEntry(
index=len(self.log) + 1,
term=self.current_term,
command=command
)
self.log.append(entry)
# Répliquer aux suiveurs
await self.replicate_log()
# Attendre la validation
await self._wait_for_commit(entry.index)
async def _wait_for_commit(self, index: int):
"""Attendre qu'une entrée soit validée"""
while self.commit_index < index:
await asyncio.sleep(0.05)
# state_machine.py
class StateMachine:
"""Machine à états de magasin clé-valeur simple"""
def __init__(self):
self.data: Dict[str, str] = {}
def apply(self, entry: LogEntry):
"""Appliquer une entrée de journal validée à la machine à états"""
parts = entry.command.split()
if parts[0] == "SET" and len(parts) == 3:
key, value = parts[1], parts[2]
self.data[key] = value
print(f"Appliqué : {key} = {value}")
elif parts[0] == "DELETE" and len(parts) == 2:
key = parts[1]
if key in self.data:
del self.data[key]
print(f"Supprimé : {key}")
Tests de Réplication de Journal
Test TypeScript
// test-log-replication.ts
async function testLogReplication() {
const nodes = [
new RaftNode('node1', ['node2', 'node3']),
new RaftNode('node2', ['node1', 'node3']),
new RaftNode('node3', ['node1', 'node2']),
];
// Simuler l'élection de leader (node1 gagne)
await nodes[0].becomeLeader();
// Soumettre une commande au leader
await nodes[0].submitCommand('SET x = 5');
// Vérifier que tous les nœuds ont l'entrée
for (const node of nodes) {
const entry = node.getLog()[0];
console.log(`${node.nodeId}: ${entry.command}`);
}
}
Test Python
# test_log_replication.py
import asyncio
async def test_log_replication():
nodes = [
RaftNode('node1', ['node2', 'node3']),
RaftNode('node2', ['node1', 'node3']),
RaftNode('node3', ['node1', 'node2']),
]
# Simuler l'élection de leader (node1 gagne)
await nodes[0].become_leader()
# Soumettre une commande au leader
await nodes[0].submit_command('SET x = 5')
# Vérifier que tous les nœuds ont l'entrée
for node in nodes:
entry = node.get_log()[0]
print(f"{node.node_id}: {entry.command}")
asyncio.run(test_log_replication())
Exercices
Exercice 1 : Réplication de Journal de Base
- Démarrer un cluster à 3 nœuds
- Élire un leader
- Soumettre
SET x = 10au leader - Vérifier que l'entrée est sur tous les nœuds
- Vérifier l'avancement de l'index de validation
Résultat Attendu : L'entrée apparaît sur tous les nœuds après validation.
Exercice 2 : Résolution de Conflit
- Démarrer un cluster à 3 nœuds
- Créer une divergence de journal (modifier manuellement les journaux des suiveurs)
- Faire répliquer de nouvelles entrées au leader
- Observer comment le journal du suiveur est corrigé
Résultat Attendu : Les entrées conflictuelles du suiveur sont écrasées.
Exercice 3 : Sécurité de l'Index de Validation
- Démarrer un cluster à 5 nœuds
- Partitionner le réseau (2 nœuds isolés)
- Soumettre des commandes au leader
- Vérifier que les entrées sont validées avec la majorité (3 nœuds)
- Guérir la partition
- Vérifier que les nœuds isolés rattrapent
Résultat Attendu : Les commandes sont validées avec 3 nœuds, les nœuds isolés rattrapent après guérison.
Exercice 4 : Application de la Machine à États
- Implémenter une machine à états de magasin clé-valeur
- Soumettre plusieurs commandes SET
- Vérifier que la machine à états les applique dans l'ordre
- Tuer et redémarrer un nœud
- Vérifier que la machine à états est reconstruite à partir du journal
Résultat Attendu : La machine à états reflète toutes les commandes validées, même après redémarrage.
Pièges Courants
| Piège | Symptôme | Solution |
|---|---|---|
| Valider les entrées du terme précédent | Les entrées sont perdues | Ne valider que les entrées du terme actuel |
| Ne pas appliquer les entrées dans l'ordre | État incohérent | Appliquer de lastApplied+1 à commitIndex |
| Boucle de résolution de conflit infinie | Pic de CPU | S'assurer que nextIndex ne descend pas en dessous de 1 |
| Appliquer des entrées non validées | Perte de données lors de la panne du leader | Ne jamais appliquer avant commitIndex |
Points Clés à Retenir
- La réplication de journal assure que tous les nœuds exécutent les mêmes commandes dans le même ordre
- Le RPC AppendEntries gère à la fois la réplication et les battements de cœur
- La propriété de correspondance de journal permet une résolution de conflit efficace
- L'index de validation suit quelles entrées sont répliquées en toute sécurité
- La machine à états applique les entrées validées de manière déterministe
Suite : Implémentation Complète du Système de Consensus →
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.
Implémentation du Système de Consensus
Session 10, Partie 2 - 60 minutes
Objectifs d'Apprentissage
- Construire un système de consensus complet basé sur Raft
- Implémenter une abstraction de machine à états (magasin clé-valeur)
- Créer des APIs clientes pour les opérations get/set
- Déployer et tester le système complet avec Docker Compose
- Vérifier les propriétés de sécurité et de vivacité
Aperçu : Tout Combiner
Dans les chapitres précédents, nous avons implémenté les deux composants principaux de Raft :
- Élection de Leader (Session 9) - Vote démocratique pour sélectionner un leader
- Réplication de Journal (Session 10, Partie 1) - Répliquer les commandes à travers les nœuds
Maintenant nous les combinons en un système de consensus complet - un magasin clé-valeur distribué qui fournit des garanties de forte cohérence.
┌────────────────────────────────────────────────────────────┐
│ Système de Consensus Raft │
├────────────────────────────────────────────────────────────┤
│ │
│ Client ──→ Leader ──→ Réplication de Journal ──→ Suiveurs │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ Élection de Leader (si nécessaire) │
│ │ │ │
│ ▼ ▼ ▼
│ Machine à États (tous les nœuds appliquent les mêmes commandes) │
│ │
└────────────────────────────────────────────────────────────┘
Architecture du Système
graph TB
subgraph "Couche Client"
C1[Client 1]
C2[Client 2]
end
subgraph "Cluster Raft"
N1[Nœud 1 : Leader]
N2[Nœud 2 : Suiveur]
N3[Nœud 3 : Suiveur]
end
subgraph "Couche Machine à États"
SM1[Magasin CV 1]
SM2[Magasin CV 2]
SM3[Magasin CV 3]
end
C1 -->|SET/GET| N1
C2 -->|SET/GET| N1
N1 <-->|AppendEntries RPC| N2
N1 <-->|AppendEntries RPC| N3
N2 <-->|RequestVote RPC| N3
N1 --> SM1
N2 --> SM2
N3 --> SM3
style N1 fill:#9f9
style N2 fill:#fc9
style N3 fill:#fc9
Implémentation Complète TypeScript
Structure du Projet
typescript-raft/
├── package.json
├── tsconfig.json
├── src/
│ ├── types.ts # Types partagés
│ ├── state-machine.ts # Machine à états de magasin CV
│ ├── raft-node.ts # Implémentation Raft complète
│ ├── server.ts # Serveur API HTTP
│ └── index.ts # Point d'entrée
└── docker-compose.yml
package.json
{
"name": "typescript-raft-kv-store",
"version": "1.0.0",
"description": "Magasin clé-valeur distribué utilisant le consensus Raft",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
types.ts
// États des nœuds
export enum NodeState {
FOLLOWER = 'follower',
CANDIDATE = 'candidate',
LEADER = 'leader'
}
// Entrée de journal
export interface LogEntry {
index: number;
term: number;
command: string;
}
// RPC RequestVote
export interface RequestVoteRequest {
term: number;
candidateId: string;
lastLogIndex: number;
lastLogTerm: number;
}
export interface RequestVoteResponse {
term: number;
voteGranted: boolean;
}
// RPC AppendEntries
export interface AppendEntriesRequest {
term: number;
leaderId: string;
prevLogIndex: number;
prevLogTerm: number;
entries: LogEntry[];
leaderCommit: number;
}
export interface AppendEntriesResponse {
term: number;
success: boolean;
}
// Commandes clientes
export interface SetCommand {
type: 'SET';
key: string;
value: string;
}
export interface GetCommand {
type: 'GET';
key: string;
}
export interface DeleteCommand {
type: 'DELETE';
key: string;
}
export type Command = SetCommand | GetCommand | DeleteCommand;
state-machine.ts
import { LogEntry } from './types';
/**
* Machine à États de Magasin Clé-Valeur
* Applique les entrées de journal validées pour construire un état cohérent
*/
export class KVStoreStateMachine {
private data: Map<string, string> = new Map();
/**
* Appliquer une entrée de journal validée à la machine à états
*/
apply(entry: LogEntry): void {
try {
const command = JSON.parse(entry.command);
switch (command.type) {
case 'SET':
this.data.set(command.key, command.value);
console.log(`[Machine à États] SET ${command.key} = ${command.value}`);
break;
case 'DELETE':
if (this.data.has(command.key)) {
this.data.delete(command.key);
console.log(`[Machine à États] DELETE ${command.key}`);
}
break;
case 'GET':
// Les commandes en lecture seule ne modifient pas l'état
break;
default:
console.warn(`[Machine à États] Type de commande inconnu : ${command.type}`);
}
} catch (error) {
console.error(`[Machine à États] Échec de l'application de l'entrée :`, error);
}
}
/**
* Obtenir une valeur de la machine à états
*/
get(key: string): string | undefined {
return this.data.get(key);
}
/**
* Obtenir toutes les paires clé-valeur
*/
getAll(): Record<string, string> {
return Object.fromEntries(this.data);
}
/**
* Effacer la machine à états (pour les tests)
*/
clear(): void {
this.data.clear();
}
}
raft-node.ts
import {
NodeState,
LogEntry,
RequestVoteRequest,
RequestVoteResponse,
AppendEntriesRequest,
AppendEntriesResponse,
Command
} from './types';
import { KVStoreStateMachine } from './state-machine';
import axios from 'axios';
interface ClusterConfig {
nodeId: string;
peerIds: string[];
electionTimeoutMin: number;
electionTimeoutMax: number;
heartbeatInterval: number;
}
export class RaftNode {
// Configuration
private config: ClusterConfig;
// État persistant (survit aux redémarrages)
private currentTerm: number = 0;
private votedFor: string | null = null;
private log: LogEntry[] = [];
// État volatil (réinitialisé au redémarrage)
private commitIndex: number = 0;
private lastApplied: number = 0;
private state: NodeState = NodeState.FOLLOWER;
// État du leader (réinitialisé à l'élection)
private nextIndex: Map<string, number> = new Map();
private matchIndex: Map<string, number> = new Map();
// Composants
private stateMachine: KVStoreStateMachine;
private leaderId: string | null = null;
// Timers
private electionTimer: NodeJS.Timeout | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
constructor(config: ClusterConfig) {
this.config = config;
this.stateMachine = new KVStoreStateMachine();
this.resetElectionTimeout();
}
// ========== API Publique ==========
/**
* Client : Soumettre une commande au cluster
*/
async submitCommand(command: Command): Promise<any> {
// Rediriger vers le leader si pas leader
if (this.state !== NodeState.LEADER) {
if (this.leaderId) {
throw new Error(`Pas un leader. Veuillez rediriger vers ${this.leaderId}`);
}
throw new Error('Aucun leader connu. Veuillez réessayer.');
}
// Gérer les commandes GET (lecture seule, pas de consensus nécessaire)
if (command.type === 'GET') {
return this.stateMachine.get(command.key);
}
// Ajouter au journal local
const entry: LogEntry = {
index: this.log.length + 1,
term: this.currentTerm,
command: JSON.stringify(command)
};
this.log.push(entry);
// Répliquer aux suiveurs
this.replicateLog();
// Attendre la validation
await this.waitForCommit(entry.index);
// Retourner le résultat
if (command.type === 'SET') {
return { key: command.key, value: command.value };
} else if (command.type === 'DELETE') {
return { key: command.key, deleted: true };
}
}
/**
* Démarrer le nœud (commencer le délai d'élection)
*/
start(): void {
this.resetElectionTimeout();
}
/**
* Arrêter le nœud (effacer les timers)
*/
stop(): void {
if (this.electionTimer) clearTimeout(this.electionTimer);
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
}
// ========== Gestionnaires RPC ==========
/**
* Gérer le RPC RequestVote
*/
handleRequestVote(req: RequestVoteRequest): RequestVoteResponse {
// Si term < currentTerm, rejeter
if (req.term < this.currentTerm) {
return { term: this.currentTerm, voteGranted: false };
}
// Si term > currentTerm, mettre à jour et devenir suiveur
if (req.term > this.currentTerm) {
this.currentTerm = req.term;
this.state = NodeState.FOLLOWER;
this.votedFor = null;
}
// Accorder le vote si :
// 1. Nous n'avons pas voté ce terme, OU
// 2. Nous avons voté pour ce candidat
// ET le journal du candidat est au moins aussi à jour que le nôtre
const logOk = req.lastLogTerm > this.getLastLogTerm() ||
(req.lastLogTerm === this.getLastLogTerm() && req.lastLogIndex >= this.log.length);
const canVote = this.votedFor === null || this.votedFor === req.candidateId;
if (canVote && logOk) {
this.votedFor = req.candidateId;
this.resetElectionTimeout();
return { term: this.currentTerm, voteGranted: true };
}
return { term: this.currentTerm, voteGranted: false };
}
/**
* Gérer le RPC AppendEntries
*/
handleAppendEntries(req: AppendEntriesRequest): AppendEntriesResponse {
// Si term < currentTerm, rejeter
if (req.term < this.currentTerm) {
return { term: this.currentTerm, success: false };
}
// Reconnaître le leader
this.leaderId = req.leaderId;
// Si term > currentTerm, mettre à jour et devenir suiveur
if (req.term > this.currentTerm) {
this.currentTerm = req.term;
this.state = NodeState.FOLLOWER;
this.votedFor = null;
}
// Réinitialiser le délai d'élection
this.resetElectionTimeout();
// Vérifier la cohérence du journal
if (req.prevLogIndex > 0) {
if (this.log.length < req.prevLogIndex) {
return { term: this.currentTerm, success: false };
}
const prevEntry = this.log[req.prevLogIndex - 1];
if (prevEntry.term !== req.prevLogTerm) {
return { term: this.currentTerm, success: false };
}
}
// Ajouter de nouvelles entrées
if (req.entries.length > 0) {
let insertIndex = req.prevLogIndex;
for (const entry of req.entries) {
if (insertIndex < this.log.length) {
const existing = this.log[insertIndex];
if (existing.index === entry.index && existing.term === entry.term) {
insertIndex++;
continue;
}
// Conflit ! Supprimer à partir d'ici
this.log = this.log.slice(0, insertIndex);
}
this.log.push(entry);
insertIndex++;
}
}
// Mettre à jour l'index de validation
if (req.leaderCommit > this.commitIndex) {
this.commitIndex = Math.min(req.leaderCommit, this.log.length);
this.applyCommittedEntries();
}
return { term: this.currentTerm, success: true };
}
// ========== Méthodes Privées ==========
/**
* Démarrer l'élection (convertir en candidat)
*/
private startElection(): void {
this.state = NodeState.CANDIDATE;
this.currentTerm++;
this.votedFor = this.config.nodeId;
this.leaderId = null;
console.log(`[Nœud ${this.config.nodeId}] Démarrage de l'élection pour le terme ${this.currentTerm}`);
// Demander les votes des pairs
const req: RequestVoteRequest = {
term: this.currentTerm,
candidateId: this.config.nodeId,
lastLogIndex: this.log.length,
lastLogTerm: this.getLastLogTerm()
};
let votesReceived = 1; // Vote pour soi-même
const majority = Math.floor(this.config.peerIds.length / 2) + 1;
for (const peerId of this.config.peerIds) {
this.sendRequestVote(peerId, req).then(resp => {
if (resp.voteGranted) {
votesReceived++;
if (votesReceived >= majority && this.state === NodeState.CANDIDATE) {
this.becomeLeader();
}
} else if (resp.term > this.currentTerm) {
this.currentTerm = resp.term;
this.state = NodeState.FOLLOWER;
this.votedFor = null;
}
}).catch(() => {
// Pair indisponible, ignorer
});
}
// Réinitialiser le délai d'élection pour le prochain tour
this.resetElectionTimeout();
}
/**
* Devenir leader après avoir gagné l'élection
*/
private becomeLeader(): void {
this.state = NodeState.LEADER;
this.leaderId = this.config.nodeId;
console.log(`[Nœud ${this.config.nodeId}] Devient leader pour le terme ${this.currentTerm}`);
// Initialiser l'état du leader
for (const peerId of this.config.peerIds) {
this.nextIndex.set(peerId, this.log.length + 1);
this.matchIndex.set(peerId, 0);
}
// Commencer à envoyer des battements de cœur
this.startHeartbeats();
}
/**
* Envoyer des battements de cœur à tous les suiveurs
*/
private startHeartbeats(): void {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = setInterval(() => {
if (this.state === NodeState.LEADER) {
this.replicateLog();
}
}, this.config.heartbeatInterval);
}
/**
* Répliquer le journal aux suiveurs (envoie aussi les battements de cœur)
*/
private replicateLog(): void {
if (this.state !== NodeState.LEADER) return;
for (const followerId of this.config.peerIds) {
const nextIdx = this.nextIndex.get(followerId) || 1;
const prevLogIndex = nextIdx - 1;
const prevLogTerm = prevLogIndex > 0 ? this.log[prevLogIndex - 1].term : 0;
const entries = this.log.slice(nextIdx - 1);
const req: AppendEntriesRequest = {
term: this.currentTerm,
leaderId: this.config.nodeId,
prevLogIndex,
prevLogTerm,
entries,
leaderCommit: this.commitIndex
};
this.sendAppendEntries(followerId, req).then(resp => {
if (this.state !== NodeState.LEADER) return;
if (resp.term > this.currentTerm) {
this.currentTerm = resp.term;
this.state = NodeState.FOLLOWER;
this.votedFor = null;
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
return;
}
if (resp.success) {
const lastIndex = prevLogIndex + entries.length;
this.matchIndex.set(followerId, lastIndex);
this.nextIndex.set(followerId, lastIndex + 1);
this.updateCommitIndex();
} else {
const currentNext = this.nextIndex.get(followerId) || 1;
this.nextIndex.set(followerId, Math.max(1, currentNext - 1));
}
}).catch(() => {
// Suiveur indisponible, réessayera
});
}
}
/**
* Mettre à jour l'index de validation si la majorité a l'entrée
*/
private updateCommitIndex(): void {
if (this.state !== NodeState.LEADER) return;
const N = this.log.length;
const majority = Math.floor(this.config.peerIds.length / 2) + 1;
for (let i = N; i > this.commitIndex; i--) {
if (this.log[i - 1].term !== this.currentTerm) continue;
let count = 1; // Le leader l'a
for (const matchIdx of this.matchIndex.values()) {
if (matchIdx >= i) count++;
}
if (count >= majority) {
this.commitIndex = i;
this.applyCommittedEntries();
break;
}
}
}
/**
* Appliquer les entrées validées à la machine à états
*/
private applyCommittedEntries(): void {
while (this.lastApplied < this.commitIndex) {
this.lastApplied++;
const entry = this.log[this.lastApplied - 1];
this.stateMachine.apply(entry);
}
}
/**
* Attendre qu'une entrée soit validée
*/
private async waitForCommit(index: number): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (this.commitIndex >= index) {
resolve();
} else {
setTimeout(check, 50);
}
};
check();
});
}
/**
* Réinitialiser le délai d'élection avec une valeur aléatoire
*/
private resetElectionTimeout(): void {
if (this.electionTimer) clearTimeout(this.electionTimer);
const timeout = this.randomTimeout();
this.electionTimer = setTimeout(() => {
if (this.state !== NodeState.LEADER) {
this.startElection();
}
}, timeout);
}
private randomTimeout(): number {
const min = this.config.electionTimeoutMin;
const max = this.config.electionTimeoutMax;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
private getLastLogTerm(): number {
if (this.log.length === 0) return 0;
return this.log[this.log.length - 1].term;
}
// ========== Couche Réseau (simplifiée) ==========
private async sendRequestVote(peerId: string, req: RequestVoteRequest): Promise<RequestVoteResponse> {
const url = `http://${peerId}/raft/request-vote`;
const response = await axios.post(url, req);
return response.data;
}
private async sendAppendEntries(peerId: string, req: AppendEntriesRequest): Promise<AppendEntriesResponse> {
const url = `http://${peerId}/raft/append-entries`;
const response = await axios.post(url, req);
return response.data;
}
// ========== Méthodes de Débogage ==========
getState() {
return {
nodeId: this.config.nodeId,
state: this.state,
term: this.currentTerm,
leaderId: this.leaderId,
logLength: this.log.length,
commitIndex: this.commitIndex,
stateMachine: this.stateMachine.getAll()
};
}
}
server.ts
import express from 'express';
import { RaftNode } from './raft-node';
import { Command } from './types';
export function createServer(node: RaftNode, port: number): express.Application {
const app = express();
app.use(express.json());
// Points de terminaison RPC Raft
app.post('/raft/request-vote', (req, res) => {
const response = node.handleRequestVote(req.body);
res.json(response);
});
app.post('/raft/append-entries', (req, res) => {
const response = node.handleAppendEntries(req.body);
res.json(response);
});
// Points de terminaison API Client
app.get('/kv/:key', (req, res) => {
const command: Command = { type: 'GET', key: req.params.key };
node.submitCommand(command)
.then(value => res.json({ key: req.params.key, value }))
.catch(err => res.status(500).json({ error: err.message }));
});
app.post('/kv', (req, res) => {
const command: Command = { type: 'SET', key: req.body.key, value: req.body.value };
node.submitCommand(command)
.then(result => res.json(result))
.catch(err => res.status(500).json({ error: err.message }));
});
app.delete('/kv/:key', (req, res) => {
const command: Command = { type: 'DELETE', key: req.params.key };
node.submitCommand(command)
.then(result => res.json(result))
.catch(err => res.status(500).json({ error: err.message }));
});
// Point de terminaison de débogage
app.get('/debug', (req, res) => {
res.json(node.getState());
});
return app;
}
index.ts
import { RaftNode } from './raft-node';
import { createServer } from './server';
const NODE_ID = process.env.NODE_ID || 'node1';
const PEER_IDS = process.env.PEER_IDS?.split(',') || [];
const PORT = parseInt(process.env.PORT || '3000');
const node = new RaftNode({
nodeId: NODE_ID,
peerIds: PEER_IDS,
electionTimeoutMin: 150,
electionTimeoutMax: 300,
heartbeatInterval: 50
});
node.start();
const app = createServer(node, PORT);
app.listen(PORT, () => {
console.log(`Nœud ${NODE_ID} écoute sur le port ${PORT}`);
console.log(`Pairs : ${PEER_IDS.join(', ')}`);
});
docker-compose.yml
version: '3.8'
services:
node1:
build: .
container_name: raft-node1
environment:
- NODE_ID=node1
- PEER_IDS=node2:3000,node3:3000
- PORT=3000
ports:
- "3001:3000"
node2:
build: .
container_name: raft-node2
environment:
- NODE_ID=node2
- PEER_IDS=node1:3000,node3:3000
- PORT=3000
ports:
- "3002:3000"
node3:
build: .
container_name: raft-node3
environment:
- NODE_ID=node3
- PEER_IDS=node1:3000,node2:3000
- PORT=3000
ports:
- "3003:3000"
Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Implémentation Complète Python
Structure du Projet
python-raft/
├── requirements.txt
├── src/
│ ├── types.py # Types partagés
│ ├── state_machine.py # Machine à états de magasin CV
│ ├── raft_node.py # Implémentation Raft complète
│ ├── server.py # Serveur API Flask
│ └── __init__.py
├── app.py # Point d'entrée
└── docker-compose.yml
requirements.txt
flask==3.0.0
requests==2.31.0
gunicorn==21.2.0
types.py
from enum import Enum
from dataclasses import dataclass
from typing import List, Optional, Union
class NodeState(Enum):
FOLLOWER = "follower"
CANDIDATE = "candidate"
LEADER = "leader"
@dataclass
class LogEntry:
index: int
term: int
command: str
@dataclass
class RequestVoteRequest:
term: int
candidate_id: str
last_log_index: int
last_log_term: int
@dataclass
class RequestVoteResponse:
term: int
vote_granted: bool
@dataclass
class AppendEntriesRequest:
term: int
leader_id: str
prev_log_index: int
prev_log_term: int
entries: List[LogEntry]
leader_commit: int
@dataclass
class AppendEntriesResponse:
term: int
success: bool
@dataclass
class SetCommand:
type: str = 'SET'
key: str = ''
value: str = ''
@dataclass
class GetCommand:
type: str = 'GET'
key: str = ''
@dataclass
class DeleteCommand:
type: str = 'DELETE'
key: str = ''
Command = Union[SetCommand, GetCommand, DeleteCommand]
state_machine.py
from typing import Dict, Optional
import json
from .types import LogEntry
class KVStoreStateMachine:
"""Machine à États de Magasin Clé-Valeur"""
def __init__(self):
self.data: Dict[str, str] = {}
def apply(self, entry: LogEntry) -> None:
"""Appliquer une entrée de journal validée à la machine à états"""
try:
command = json.loads(entry.command)
if command['type'] == 'SET':
self.data[command['key']] = command['value']
print(f"[Machine à États] SET {command['key']} = {command['value']}")
elif command['type'] == 'DELETE':
if command['key'] in self.data:
del self.data[command['key']]
print(f"[Machine à États] DELETE {command['key']}")
elif command['type'] == 'GET':
# Lecture seule, pas de changement d'état
pass
except Exception as e:
print(f"[Machine à États] Échec de l'application de l'entrée : {e}")
def get(self, key: str) -> Optional[str]:
"""Obtenir une valeur de la machine à états"""
return self.data.get(key)
def get_all(self) -> Dict[str, str]:
"""Obtenir toutes les paires clé-valeur"""
return dict(self.data)
def clear(self) -> None:
"""Effacer la machine à états (pour les tests)"""
self.data.clear()
raft_node.py
import asyncio
import random
import json
from typing import Dict, List, Optional
from .types import (
NodeState, LogEntry, RequestVoteRequest, RequestVoteResponse,
AppendEntriesRequest, AppendEntriesResponse, Command
)
from .state_machine import KVStoreStateMachine
import requests
class ClusterConfig:
nodeId: str
peer_ids: List[str]
election_timeout_min: int
election_timeout_max: int
heartbeat_interval: int
def __init__(self, node_id: str, peer_ids: List[str],
election_timeout_min: int = 150,
election_timeout_max: int = 300,
heartbeat_interval: int = 50):
self.nodeId = node_id
self.peer_ids = peer_ids
self.election_timeout_min = election_timeout_min
self.election_timeout_max = election_timeout_max
self.heartbeat_interval = heartbeat_interval
class RaftNode:
def __init__(self, config: ClusterConfig):
self.config = config
self.state_machine = KVStoreStateMachine()
# État persistant
self.current_term = 0
self.voted_for: Optional[str] = None
self.log: List[LogEntry] = []
# État volatil
self.commit_index = 0
self.last_applied = 0
self.state = NodeState.FOLLOWER
self.leader_id: Optional[str] = None
# État du leader
self.next_index: Dict[str, int] = {}
self.match_index: Dict[str, int] = {}
# Timers
self.election_task: Optional[asyncio.Task] = None
self.heartbeat_task: Optional[asyncio.Task] = None
# ========== API Publique ==========
async def submit_command(self, command: Command) -> any:
"""Client : Soumettre une commande au cluster"""
# Rediriger vers le leader si pas leader
if self.state != NodeState.LEADER:
if self.leader_id:
raise Exception(f"Pas un leader. Veuillez rediriger vers {self.leader_id}")
raise Exception("Aucun leader connu. Veuillez réessayer.")
# Gérer les commandes GET (lecture seule)
if command.type == 'GET':
return self.state_machine.get(command.key)
# Ajouter au journal local
entry = LogEntry(
index=len(self.log) + 1,
term=self.current_term,
command=json.dumps(command.__dict__)
)
self.log.append(entry)
# Répliquer aux suiveurs
await self.replicate_log()
# Attendre la validation
await self._wait_for_commit(entry.index)
# Retourner le résultat
if command.type == 'SET':
return {"key": command.key, "value": command.value}
elif command.type == 'DELETE':
return {"key": command.key, "deleted": True}
def start(self):
"""Démarrer le nœud"""
asyncio.create_task(self._election_loop())
def stop(self):
"""Arrêter le nœud"""
if self.election_task:
self.election_task.cancel()
if self.heartbeat_task:
self.heartbeat_task.cancel()
# ========== Gestionnaires RPC ==========
def handle_request_vote(self, req: RequestVoteRequest) -> RequestVoteResponse:
"""Gérer le RPC RequestVote"""
if req.term < self.current_term:
return RequestVoteResponse(term=self.current_term, vote_granted=False)
if req.term > self.current_term:
self.current_term = req.term
self.state = NodeState.FOLLOWER
self.voted_for = None
log_ok = (req.last_log_term > self._get_last_log_term() or
(req.last_log_term == self._get_last_log_term() and
req.last_log_index >= len(self.log)))
can_vote = self.voted_for is None or self.voted_for == req.candidate_id
if can_vote and log_ok:
self.voted_for = req.candidate_id
return RequestVoteResponse(term=self.current_term, vote_granted=True)
return RequestVoteResponse(term=self.current_term, vote_granted=False)
def handle_append_entries(self, req: AppendEntriesRequest) -> AppendEntriesResponse:
"""Gérer le RPC AppendEntries"""
if req.term < self.current_term:
return AppendEntriesResponse(term=self.current_term, success=False)
# Reconnaître le leader
self.leader_id = req.leader_id
if req.term > self.current_term:
self.current_term = req.term
self.state = NodeState.FOLLOWER
self.voted_for = None
# Vérifier la cohérence du journal
if req.prev_log_index > 0:
if len(self.log) < req.prev_log_index:
return AppendEntriesResponse(term=self.current_term, success=False)
prev_entry = self.log[req.prev_log_index - 1]
if prev_entry.term != req.prev_log_term:
return AppendEntriesResponse(term=self.current_term, success=False)
# Ajouter de nouvelles entrées
if req.entries:
insert_index = req.prev_log_index
for entry in req.entries:
if insert_index < len(self.log):
existing = self.log[insert_index]
if existing.index == entry.index and existing.term == entry.term:
insert_index += 1
continue
self.log = self.log[:insert_index]
self.log.append(entry)
insert_index += 1
# Mettre à jour l'index de validation
if req.leader_commit > self.commit_index:
self.commit_index = min(req.leader_commit, len(self.log))
self._apply_committed_entries()
return AppendEntriesResponse(term=self.current_term, success=True)
# ========== Méthodes Privées ==========
async def _election_loop(self):
"""Boucle de délai d'élection"""
while True:
timeout = self._random_timeout()
await asyncio.sleep(timeout / 1000)
if self.state != NodeState.LEADER:
await self._start_election()
async def _start_election(self):
"""Démarrer l'élection (convertir en candidat)"""
self.state = NodeState.CANDIDATE
self.current_term += 1
self.voted_for = self.config.nodeId
self.leader_id = None
print(f"[Nœud {self.config.nodeId}] Démarrage de l'élection pour le terme {self.current_term}")
req = RequestVoteRequest(
term=self.current_term,
candidate_id=self.config.nodeId,
last_log_index=len(self.log),
last_log_term=self._get_last_log_term()
)
votes_received = 1 # Vote pour soi-même
majority = len(self.config.peer_ids) // 2 + 1
tasks = []
for peer_id in self.config.peer_ids:
tasks.append(self._send_request_vote(peer_id, req))
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, RequestVoteResponse):
if result.vote_granted:
votes_received += 1
if votes_received >= majority and self.state == NodeState.CANDIDATE:
self._become_leader()
elif result.term > self.current_term:
self.current_term = result.term
self.state = NodeState.FOLLOWER
self.voted_for = None
def _become_leader(self):
"""Devenir leader après avoir gagné l'élection"""
self.state = NodeState.LEADER
self.leader_id = self.config.nodeId
print(f"[Nœud {self.config.nodeId}] Devient leader pour le terme {self.current_term}")
# Initialiser l'état du leader
for peer_id in self.config.peer_ids:
self.next_index[peer_id] = len(self.log) + 1
self.match_index[peer_id] = 0
# Démarrer les battements de cœur
asyncio.create_task(self._heartbeat_loop())
async def _heartbeat_loop(self):
"""Envoyer des battements de cœur aux suiveurs"""
while self.state == NodeState.LEADER:
await self.replicate_log()
await asyncio.sleep(self.config.heartbeat_interval / 1000)
async def replicate_log(self):
"""Répliquer le journal aux suiveurs"""
if self.state != NodeState.LEADER:
return
tasks = []
for follower_id in self.config.peer_ids:
next_idx = self.next_index.get(follower_id, 1)
prev_log_index = next_idx - 1
prev_log_term = self.log[prev_log_index - 1].term if prev_log_index > 0 else 0
entries = self.log[next_idx - 1:]
req = AppendEntriesRequest(
term=self.current_term,
leader_id=self.config.nodeId,
prev_log_index=prev_log_index,
prev_log_term=prev_log_term,
entries=entries,
leader_commit=self.commit_index
)
tasks.append(self._send_append_entries(follower_id, req))
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
follower_id = self.config.peer_ids[i]
if isinstance(result, AppendEntriesResponse):
if result.term > self.current_term:
self.current_term = result.term
self.state = NodeState.FOLLOWER
self.voted_for = None
return
if result.success:
last_index = len(self.log) if self.log else 0
self.match_index[follower_id] = last_index
self.next_index[follower_id] = last_index + 1
await self._update_commit_index()
else:
current_next = self.next_index.get(follower_id, 1)
self.next_index[follower_id] = max(1, current_next - 1)
async def _update_commit_index(self):
"""Mettre à jour l'index de validation si la majorité a l'entrée"""
if self.state != NodeState.LEADER:
return
N = len(self.log)
majority = len(self.config.peer_ids) // 2 + 1
for i in range(N, self.commit_index, -1):
if self.log[i - 1].term != self.current_term:
continue
count = 1 # Le leader l'a
for match_idx in self.match_index.values():
if match_idx >= i:
count += 1
if count >= majority:
self.commit_index = i
self._apply_committed_entries()
break
def _apply_committed_entries(self):
"""Appliquer les entrées validées à la machine à états"""
while self.last_applied < self.commit_index:
self.last_applied += 1
entry = self.log[self.last_applied - 1]
self.state_machine.apply(entry)
async def _wait_for_commit(self, index: int):
"""Attendre qu'une entrée soit validée"""
while self.commit_index < index:
await asyncio.sleep(0.05)
def _random_timeout(self) -> int:
"""Générer un délai d'élection aléatoire"""
return random.randint(
self.config.election_timeout_min,
self.config.election_timeout_max
)
def _get_last_log_term(self) -> int:
"""Obtenir le terme de la dernière entrée de journal"""
if not self.log:
return 0
return self.log[-1].term
# ========== Couche Réseau ==========
async def _send_request_vote(self, peer_id: str, req: RequestVoteRequest) -> RequestVoteResponse:
"""Envoyer le RPC RequestVote au pair"""
url = f"http://{peer_id}/raft/request-vote"
try:
response = requests.post(url, json=req.__dict__, timeout=1)
return RequestVoteResponse(**response.json())
except:
return RequestVoteResponse(term=self.current_term, vote_granted=False)
async def _send_append_entries(self, peer_id: str, req: AppendEntriesRequest) -> AppendEntriesResponse:
"""Envoyer le RPC AppendEntries au pair"""
url = f"http://{peer_id}/raft/append-entries"
try:
data = {
'term': req.term,
'leaderId': req.leader_id,
'prevLogIndex': req.prev_log_index,
'prevLogTerm': req.prev_log_term,
'entries': [e.__dict__ for e in req.entries],
'leaderCommit': req.leader_commit
}
response = requests.post(url, json=data, timeout=1)
return AppendEntriesResponse(**response.json())
except:
return AppendEntriesResponse(term=self.current_term, success=False)
# ========== Méthodes de Débogage ==========
def get_state(self) -> dict:
"""Obtenir l'état du nœud pour le débogage"""
return {
'nodeId': self.config.nodeId,
'state': self.state.value,
'term': self.current_term,
'leaderId': self.leader_id,
'logLength': len(self.log),
'commitIndex': self.commit_index,
'stateMachine': self.state_machine.get_all()
}
server.py
from flask import Flask, request, jsonify
from .raft_node import RaftNode, ClusterConfig
def create_server(node: RaftNode):
app = Flask(__name__)
# Points de terminaison RPC Raft
@app.route('/raft/request-vote', methods=['POST'])
def request_vote():
response = node.handle_request_vote(
RequestVoteResponse(**request.json)
)
return jsonify(response.__dict__)
@app.route('/raft/append-entries', methods=['POST'])
def append_entries():
# Convertir la requête au format approprié
data = request.json
entries = [LogEntry(**e) for e in data.get('entries', [])]
req = AppendEntriesRequest(
term=data['term'],
leader_id=data['leaderId'],
prev_log_index=data['prevLogIndex'],
prev_log_term=data['prevLogTerm'],
entries=entries,
leader_commit=data['leaderCommit']
)
response = node.handle_append_entries(req)
return jsonify(response.__dict__)
# Points de terminaison API Client
@app.route('/kv/<key>', methods=['GET'])
def get_key(key):
command = GetCommand(key=key)
try:
value = asyncio.run(node.submit_command(command))
return jsonify({'key': key, 'value': value})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/kv', methods=['POST'])
def set_key():
command = SetCommand(key=request.json['key'], value=request.json['value'])
try:
result = asyncio.run(node.submit_command(command))
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/kv/<key>', methods=['DELETE'])
def delete_key(key):
command = DeleteCommand(key=key)
try:
result = asyncio.run(node.submit_command(command))
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# Point de terminaison de débogage
@app.route('/debug', methods=['GET'])
def debug():
return jsonify(node.get_state())
return app
app.py
import os
from src.types import ClusterConfig
from src.raft_node import RaftNode
from src.server import create_server
NODE_ID = os.getenv('NODE_ID', 'node1')
PEER_IDS = os.getenv('PEER_IDS', '').split(',') if os.getenv('PEER_IDS') else []
PORT = int(os.getenv('PORT', '5000'))
config = ClusterConfig(
node_id=NODE_ID,
peer_ids=PEER_IDS
)
node = RaftNode(config)
node.start()
app = create_server(node)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=PORT)
docker-compose.yml (Python)
version: '3.8'
services:
node1:
build: .
container_name: python-raft-node1
environment:
- NODE_ID=node1
- PEER_IDS=node2:5000,node3:5000
- PORT=5000
ports:
- "5001:5000"
node2:
build: .
container_name: python-raft-node2
environment:
- NODE_ID=node2
- PEER_IDS=node1:5000,node3:5000
- PORT=5000
ports:
- "5002:5000"
node3:
build: .
container_name: python-raft-node3
environment:
- NODE_ID=node3
- PEER_IDS=node1:5000,node2:5000
- PORT=5000
ports:
- "5003:5000"
Dockerfile (Python)
FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]
Exécution du Système
TypeScript
# Construire
npm run build
# Exécuter avec Docker Compose
docker-compose up
# Tester le cluster
curl -X POST http://localhost:3001/kv -H "Content-Type: application/json" -d '{"key":"foo","value":"bar"}'
curl http://localhost:3001/kv/foo
curl http://localhost:3002/debug # Vérifier l'état du nœud
Python
# Exécuter avec Docker Compose
docker-compose up
# Tester le cluster
curl -X POST http://localhost:5001/kv -H "Content-Type: application/json" -d '{"key":"foo","value":"bar"}'
curl http://localhost:5001/kv/foo
curl http://localhost:5002/debug # Vérifier l'état du nœud
Exercices
Exercice 1 : Opérations de Base
- Démarrer le cluster à 3 nœuds
- Attendre l'élection du leader
- SET key=value sur le leader
- GET la clé de tous les nœuds
- Vérifier que tous les nœuds retournent la même valeur
Résultat Attendu : Tous les nœuds retournent la valeur validée.
Exercice 2 : Bascullement de Leader
- Démarrer le cluster et écrire des données
- Tuer le conteneur leader
- Observer un nouveau leader être élu
- Continuer à écrire des données
- Redémarrer l'ancien leader
- Vérifier qu'il rattrape
Résultat Attendu : Le système continue à fonctionner avec le nouveau leader, l'ancien leader rejoint en tant que suiveur.
Exercice 3 : Partition Réseau
- Démarrer un cluster à 5 nœuds
- Isoler 2 nœuds (simuler une partition)
- Vérifier que la majorité (3 nœuds) peut encore valider
- Guérir la partition
- Vérifier que les nœuds isolés rattrapent
Résultat Attendu : Le côté majorité continue, la minorité ne peut pas valider, la rejointe fonctionne.
Exercice 4 : Test de Persistance
- Écrire des données dans le cluster
- Arrêter tous les nœuds
- Redémarrer tous les nœuds
- Vérifier que les données sont récupérées
Résultat Attendu : Toutes les données survivent au redémarrage.
Pièges Courants
| Piège | Symptôme | Solution |
|---|---|---|
| Lire à partir des suiveurs | Lectures stalées | Toujours lire à partir du leader ou implémenter des lectures avec bail |
| Pas de battements de cœur | Élections inutiles | S'assurer que le timer de battement de cœur fonctionne continuellement |
| Délai d'attente du client | Écritures échouées | Attendre la validation, ne pas retourner immédiatement |
| Split brain | Leaders multiples | Les délais randomisés + les règles de vote empêchent cela |
Points Clés à Retenir
- Raft Complet combine l'élection de leader + la réplication de journal pour le consensus
- La machine à états applique les commandes validées de manière déterministe
- L'API client fournit un accès transparent au système distribué
- Le basculement est automatique - un nouveau leader est élu quand l'ancien échoue
- La sécurité garantit assure qu'il n'y a pas d'écritures conflictuelles
Félicitations ! Vous avez complété le Système de Consensus. Vous comprenez maintenant l'un des concepts les plus difficiles des systèmes distribués !
Suite : Matériels de Référence →
🧠 Quiz du Chapitre
Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.
Configuration Docker
Ce guide couvre l'installation de Docker et Docker Compose pour exécuter les exemples du cours.
Installation de Docker
Linux
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
macOS
Téléchargez Docker Desktop depuis docker.com
Windows
Téléchargez Docker Desktop depuis docker.com
Vérifier l'Installation
docker --version
docker-compose --version
Exécuter les Exemples du Cours
Chaque chapitre inclut un fichier Docker Compose :
cd examples/01-queue
docker-compose up
Commandes Courantes
# Démarrer les services
docker-compose up
# Démarrer en arrière-plan
docker-compose up -d
# Voir les journaux
docker-compose logs
# Arrêter les services
docker-compose down
# Reconstruire après des changements de code
docker-compose up --build
Dépannage
Voir Dépannage pour les problèmes courants.
Dépannage
Problèmes courants et solutions lors de l'utilisation des exemples du cours.
Problèmes Docker
Port Déjà Utilisé
Error: bind: address already in use
Solution : Changez le port dans docker-compose.yml ou arrêtez le service en conflit.
Permission Refusée
Error: permission denied while trying to connect to the Docker daemon
Solution : Ajoutez votre utilisateur au groupe docker :
sudo usermod -aG docker $USER
newgrp docker
Problèmes de Build
TypeScript : Module Non Trouvé
Solution : Installez les dépendances :
npm install
Python : Module Non Trouvé
Solution : Installez les dépendances :
pip install -r requirements.txt
Problèmes d'Exécution
Connexion Refusée
Solution : Vérifiez que tous les services sont en cours d'exécution :
docker-compose ps
Le Nœud ne Peut pas se Connecter aux Pairs
Solution : Vérifiez la configuration réseau dans docker-compose.yml. Assurez-vous que tous les nœuds sont sur le même réseau.
Obtenir de l'Aide
Si vous rencontrez des problèmes non couverts ici :
- Consultez les journaux Docker :
docker-compose logs - Vérifiez votre installation Docker :
docker --version - Voir Pour Aller Plus Loin pour des ressources supplémentaires
Pour Aller Plus Loin
Ressources pour approfondir votre compréhension des systèmes distribués.
Livres
| Titre | Auteur | Focus |
|---|---|---|
| Designing Data-Intensive Applications | Martin Kleppmann | Conception moderne de bases de données et systèmes distribués |
| Distributed Systems: Principles and Paradigms | Tanenbaum & van Steen | Fondements académiques |
| Introduction to Reliable Distributed Programming | Cachin, Guerraoui, Rodrigues | Fondements formels |
Articles
Fondamentaux
- Brewer, E. A. (2000). "Towards robust distributed systems"
- Gilbert, S. & Lynch, N. (2002). "Brewer's conjecture and the feasibility of consistent, available, partition-tolerant web services"
- Fischer, M. J., Lynch, N. A., & Paterson, M. S. (1985). "Impossibility of distributed consensus with one faulty process"
Consensus
- Ongaro, D. & Ousterhout, J. (2014). "In Search of an Understandable Consensus Algorithm (Raft)"
- Lamport, L. (2001). "Paxos Made Simple"
Ressources en Ligne
- The Raft Consensus Algorithm
- Jepsen: Distributed Systems Safety Analysis
- Distributed Systems Reading List
Cours Vidéo
- MIT 6.824: Distributed Systems
- Stanford CS247: Advanced Distributed Systems
Pratique
- Construisez votre propre système distribué à partir de zéro
- Contribuez à des bases de données distribuées open source
- Participez à des hackathons de systèmes distribués