Une application de chat est la question de conception de système qui cache le plus d'état. Chaque utilisateur garde une connexion active vers un serveur précis, chaque message doit arriver une seule fois et dans l'ordre, et le même message peut devoir atteindre une personne ou dix mille. Un diagramme « client, serveur, base de données » ne répond à rien de tout cela.
Cet article parcourt un backend de chat une décision à la fois : comment les clients se connectent, comment un message prend sa place dans une conversation, comment il atteint tous ceux qui doivent le voir et comment il est stocké. Il se termine par un diagramme d'architecture que vous pouvez prendre comme point de départ.
Exigences
- Conversations à deux et en groupe, avec du texte et des médias.
- Les messages arrivent dans le même ordre pour chaque membre d'une conversation.
- Aucun doublon quand un client renvoie un message.
- Les utilisateurs en ligne reçoivent les messages en moins d'une seconde ; les utilisateurs hors ligne reçoivent une notification push et rattrapent leur retard à la reconnexion.
- Accusés de réception et de lecture, et un indicateur de présence en ligne.
Connexions : pourquoi des WebSockets, et pourquoi des passerelles
Interroger le serveur pour savoir s'il y a de nouveaux messages gaspille des requêtes et ajoute du délai. Une connexion WebSocket reste ouverte, le serveur peut donc pousser un message dès qu'il existe. Chaque appareil garde une connexion vers une passerelle de chat.
Les passerelles sont la partie à état du système. Une passerelle sait quels utilisateurs lui sont connectés, et rien d'autre. Gardez-les légères : authentifier la connexion une fois, recevoir les messages et les confier à un service sans état, pousser les messages sortants dans le bon socket. Quand vous déployez une nouvelle version d'une passerelle, les connexions tombent et les clients se reconnectent : ils doivent donc se reconnecter avec backoff et jitter, et reprendre à partir du dernier message vu.
Placez devant un répartiteur de charge de couche 4, ou un répartiteur qui comprend les montées de version WebSocket, et attendez-vous à ce que les connexions de longue durée déséquilibrent la charge. Les nouvelles connexions doivent privilégier la passerelle la moins chargée.
Envoyer un message : idempotence et ordre
Le client génère un client_msg_id (un UUID) avant d'envoyer. Si la connexion tombe avant l'accusé de réception, le client renvoie avec le même ID, et le service de messages écarte le doublon. Sans lui, les réseaux mobiles instables produisent des messages en double.
L'ordre se définit par conversation, pas globalement. Le service de messages attribue à chaque message le numéro de séquence suivant de sa conversation et le stocke. Les clients trient selon ce numéro, jamais selon leur propre horloge. Deux façons pratiques de l'attribuer :
- Un compteur par conversation, incrémenté dans la même écriture que celle qui stocke le message.
- Un ID triable dans le temps, comme un ID façon Snowflake généré par le serveur, si vous acceptez que « l'ordre » soit « l'ordre vu par le serveur ».
Le numéro de séquence rend aussi le rattrapage peu coûteux. Un client qui se reconnecte dit « j'ai la conversation 42 jusqu'à 1871 » et reçoit tout ce qui suit.
Fan-out : en ligne, hors ligne et grands groupes
Une fois le message stocké, le service publie un événement. Les workers de fan-out prennent le relais :
- Retrouver les membres de la conversation.
- Retrouver quels membres sont en ligne et quelle passerelle tient leur connexion, dans un registre de présence et de sessions comme Redis.
- Envoyer le message à ces passerelles, qui le poussent dans les sockets.
- Envoyer des notifications push via APNs et FCM aux membres hors ligne.
Écrire le message une seule fois et répartir les lectures garde un stockage proportionnel au nombre de messages, pas au nombre de membres. Les grands groupes demandent de l'attention : un canal de 100 000 membres ne doit pas déclencher 100 000 notifications push par message. Ne notifiez que les membres qui l'ont demandé, ou regroupez et résumez.
Présence
La présence est un battement de cœur avec un TTL. La passerelle renouvelle « l'utilisateur 7 est connecté à la passerelle 3 » toutes les 30 secondes environ ; quand la clé expire, l'utilisateur est hors ligne. Ne diffusez pas chaque changement de présence à chaque contact en temps réel. C'est l'une des fonctionnalités les plus coûteuses d'un chat, alors que les utilisateurs remarquent à peine ses retards. Récupérez la présence à l'ouverture d'une conversation et rafraîchissez-la périodiquement. Les clés qui expirent fonctionnent comme des entrées de cache ; l'article sur les stratégies de cache couvre les TTL et ce qui se passe quand ils expirent tous en même temps.
Accusés et chiffrement de bout en bout
Les accusés de réception et de lecture sont aussi des messages. Quand un appareil reçoit le message 1871, il envoie un petit événement « reçu jusqu'à 1871 » ; quand l'utilisateur ouvre la conversation, il envoie « lu jusqu'à 1871 ». Stockez le numéro le plus élevé par membre et par conversation plutôt qu'un accusé par message, et le coût reste stable dans les groupes actifs.
Le chiffrement de bout en bout change ce que le serveur peut voir. Avec un protocole comme celui de Signal, le serveur stocke et relaie du texte chiffré : la recherche, la modération et les aperçus de liens passent côté clients. Décidez tôt, car cela change à la fois le stockage et le fan-out.
Stockage
Les messages sont écrits une fois, surtout lus quand ils sont récents, et presque toujours lus par conversation. Cela oriente vers un stockage partitionné par conversation et trié par numéro de séquence. Discord, par exemple, partitionne les messages par canal plus une tranche de temps fixe, pour qu'un canal très actif ne devienne pas une partition énorme ; son article sur la façon dont il stocke des billions de messages décrit le passage de ce modèle de Cassandra à ScyllaDB.
Gardez les médias hors du stockage des messages. Les clients envoient images et vidéos directement vers un stockage objet avec une URL présignée, et le message porte une référence. Le service de messages ne manipule jamais les octets.
Le diagramme d'architecture
Contenu du diagramme
- Users: mobile and web
- Load balancer
- Chat gateway
- Message service
- Message store
- Message events
- Fan-out workers
- Presence and sessions
- APNs and FCM
- Media bucket
- wss
- send
- append, next seq
- event
- who is online, which gateway
- deliver
- offline
- heartbeat
- pre-signed upload
Comment le lire :
- À gauche : les utilisateurs se connectent en WebSocket (wss), via le répartiteur de charge, à une passerelle de chat. Les envois de médias vont directement dans le bucket.
- Au milieu : la passerelle confie chaque envoi au service de messages, qui écarte les doublons grâce à
client_msg_id, attribue le numéro de séquence suivant et ajoute le message au stockage. - À droite : le service publie un événement. Les workers de fan-out vérifient la présence et les sessions, livrent par la bonne passerelle et envoient une notification push aux membres hors ligne.
- Le battement de cœur de la passerelle vers la présence est en pointillé, car il suit une minuterie et non chaque message.
- Le bloc Markdown montre l'enveloppe du message, pour que tout le monde s'accorde sur les champs.
Ce qu'il faut étiqueter
- Les composants qui gardent un état. Les passerelles gardent des connexions ; le service de messages ne garde rien. Cela décide comment vous déployez et faites évoluer chacun.
- La règle d'ordre. « Séquence par conversation, attribuée par le serveur » a sa place sur le diagramme.
- La clé de dédoublonnage. Nommez
client_msg_idsur le chemin d'envoi. - Les chemins en ligne et hors ligne. Dessinez les deux ; la plupart des bugs vivent dans le second.
L'article sur l'architecture événementielle détaille le côté événements et consommateurs, et l'exemple de diagramme de séquence est un bon moyen de documenter pas à pas l'échange entre envoi et accusé de réception.
Le construire dans Archboard
- Connectez-vous avec Google ou GitHub et créez un diagramme.
- Ajoutez une Personne du pack Modèle C4 pour les utilisateurs et un Répartiteur de charge cloud du pack Cloud.
- Ajoutez trois nœuds Conteneur du pack Modèle C4 : la passerelle de chat, le service de messages et les workers de fan-out. Indiquez sur chacun s'il garde un état.
- Ajoutez une Base de données du pack Modèle C4 pour le stockage des messages, Redis et Kafka du pack Données pour la présence et les événements, un nœud Stockage objet du pack Cloud pour les médias et un Système externe pour APNs et FCM.
- Reliez-les, dessinez le battement de cœur et l'envoi de médias en arêtes tiretées ou pointillées, et ajoutez un bloc Markdown du pack Base avec l'enveloppe du message.
L'exemple utilise 11 nœuds de diagramme et reste dans les limites de l'offre Free.
FAQ
Faut-il des WebSockets pour une application de chat ? Pour une livraison en temps réel, oui, ou un canal push similaire comme les server-sent events, avec HTTP pour les envois. Le long polling convient aux petites applications, mais coûte plus de requêtes et ajoute du délai.
Comment garder les messages dans l'ordre ? Laissez le serveur attribuer un numéro de séquence par conversation au moment de stocker le message, et faites trier les clients selon ce numéro. Les horloges des clients divergent et ne sont pas fiables pour ordonner.
Comment éviter les messages en double ? Le client génère un ID de message avant l'envoi et le réutilise lors des nouvelles tentatives. Le serveur stocke l'ID et ignore un second message portant le même.
Quelle base de données pour stocker les messages de chat ? Une base qui lit vite par conversation et trie par séquence, comme un stockage orienté colonnes larges partitionné par conversation et par tranche de temps. Pour des applications plus petites, Postgres fonctionne bien avec un index sur la conversation et la séquence.
Comment les messages sont-ils livrés aux utilisateurs hors ligne ? Ils sont stockés comme n'importe quel autre message. L'étape de fan-out envoie une notification push, et quand l'application se reconnecte, elle demande tout ce qui suit son dernier numéro de séquence.