Cache et coordination : Redis, Dragonfly, Valkey
Résumé. @ddd-ts-kit/redis implémente trois ports du noyau sur le protocole Redis : Cache,
RateLimiter et Lock, plus un signal de réveil pour le relais de l'outbox, un stockage des
jobs en arrière-plan et un bus d'événements sur Redis Streams
(brokers de messages). Il utilise un sous-ensemble commun de commandes,
si bien que le même code tourne sur Redis, Valkey et Dragonfly, et que le choix du serveur est
opérationnel et juridique (les trois ont des licences différentes), pas architectural. La partie
importante de cette page est ce que ces outils ne garantissent pas : un cache peut être périmé ou
absent, un limiteur de débit dépend d'horloges synchronisées, et le verrou est un outil
d'efficacité qui peut avoir deux détenteurs. Tout ce qui ne doit jamais arriver deux fois est
gardé par une contrainte de base de données.
Trois rôles différents
Section intitulée « Trois rôles différents »| Port | Question à laquelle il répond | Ce que coûte une mauvaise réponse | Dans le domaine fil rouge |
|---|---|---|---|
Cache |
« ai-je calculé cela récemment ? » | une lecture plus lente ou un peu périmée | availability met en cache les horaires de travail d'un praticien, lus par une gateway |
RateLimiter |
« cet appelant a-t-il fait cela trop souvent ? » | quelques appels de trop, ou un appel refusé | 120 requêtes HTTP par minute et par adresse devant l'API ; trois tentatives de réservation par minute et par client |
Lock |
« quelqu'un d'autre fait-il déjà ce travail ? » | le travail fait deux fois | une seule instance exécute le job notifications.send-reminders pour une occurrence donnée |
Aucun ne répond à « ce créneau est-il encore libre ? ». C'est l'invariant « pas de double
réservation » d'appointments, et il est tenu par l'agrégat et par une contrainte de base de
données (invariants, permissions et validation).
Les cas d'usage dépendent des ports du noyau ; l'adaptateur est choisi dans src/platform/ et
lié à la racine de composition. @ddd-ts-kit/redis est refusé dans domain/ et application/
même quand une liste d'autorisation le nomme (layer/domain-purity, layer/application-inward
dans ARCHITECTURE.md). Sans serveur, kernelModule() lie des
implémentations en mémoire, correctes pour un processus et pour les tests.
Passer d'une instance à plusieurs change la plateforme et rien d'autre. L'application d'exemple
lie les trois adaptateurs Redis sur une seule connexion, chacun sous son propre espace de noms,
comme le fait src/platform/redis/open-redis.ts :
import { kernelModule } from '@ddd-ts-kit/di';import { Metrics } from '@ddd-ts-kit/kernel';import { RedisCache, RedisLock, RedisRateLimiter, connectRedis } from '@ddd-ts-kit/redis';
declare const redisUrl: string;
// redis://..., la même pour Redis, Valkey et Dragonfly. Les commandes expirent après deux secondes.const redis = connectRedis(redisUrl);
// src/platform/ : les implémentations en mémoire de kernelModule() remplacées par des adaptateurs que toutes les instances partagent.export const kernel = kernelModule({ cache: (resolve) => new RedisCache(redis.client, { namespace: 'scheduling', metrics: resolve(Metrics) }), // Un serveur absent laisse passer les requêtes plutôt que de toutes les refuser. rateLimiter: new RedisRateLimiter(redis.client, { namespace: 'scheduling:rate', onError: 'allow' }), lock: new RedisLock(redis.client, { namespace: 'scheduling:lock' }),});Les trois serveurs
Section intitulée « Les trois serveurs »Vérifié le 2026-10-10 sur le dépôt GitHub de chaque projet (dernière version publiée, fichier de licence).
| Redis | Valkey | Dragonfly | |
|---|---|---|---|
| Dernière version publiée | 8.10.2 (2026-09-17) | 9.1.2 (2026-09-01) | 2.0.2 (2026-10-07) |
| Licence | depuis Redis 8 : au choix RSALv2, SSPLv1 ou AGPLv3 ; la 7.2 et les précédentes étaient sous BSD 3-clause | BSD 3-clause | Business Source License 1.1 ; devient Apache 2.0 à la date de changement écrite dans le fichier de licence (le 1er novembre 2030 le jour de la vérification) |
| Ce que la licence restreint | RSALv2 et SSPLv1 restreignent l'offre en service géré ; AGPLv3 est un copyleft qui s'étend au réseau | rien au-delà de l'attribution | l'usage au sein de votre propre produit est accordé ; l'offrir comme service de stockage de données en mémoire ne l'est pas |
| Origine | le projet d'origine | fork de Redis 7.2 sous la Linux Foundation (2024) | implémentation indépendante du protocole, multithread |
| Lua embarqué | 5.1 | 5.1 | 5.4 |
| Persistance | fichier en ajout seul (append-only) et instantanés | fichier en ajout seul et instantanés | instantanés |
| Compatibilité du protocole, telle que le kit l'a mesurée | référence | mêmes résultats que Redis sur la suite du kit | mêmes résultats sur la suite du kit ; deux différences de réponse absorbées par le bus d'événements ; refuse un script qui touche une clé non déclarée |
Les licences sont résumées, pas interprétées : faites lire les termes par la personne qui en est responsable dans votre organisation, surtout si vous revendez de l'hébergement. Cette page n'a pas mesuré les performances des serveurs et n'affirme rien à leur sujet.
Critères de choix
Section intitulée « Critères de choix »- Adéquation de la licence. Pour la plupart des services internes, les trois sont utilisables ; un hébergeur, ou une entreprise dont la politique écarte les logiciels à source disponible ou sous AGPL, réduit vite la liste.
- Ce que propose votre plateforme. Un Redis ou un Valkey géré par votre cloud est une chose de moins à faire tourner.
- Besoins de persistance. Un cache n'en a aucun. Un verrou et un limiteur de débit tolèrent un redémarrage. Redis Streams comme bus d'événements, c'est une autre affaire (brokers de messages).
- Un serveur ou deux. L'éviction convient à un cache et nuit aux verrous, aux clés de limitation de débit et aux streams.
- Avez-vous seulement besoin d'un serveur ? Une seule instance n'a besoin de rien de tout cela : les ports en mémoire suffisent.
Ce que ce kit prend en charge et a vérifié
Section intitulée « Ce que ce kit prend en charge et a vérifié »D'après packages/redis/README.md et
packages/kernel/README.md :
-
Testé contre Dragonfly 2.0.2, Valkey 9.1.2 et Redis 8.10.2 avec ioredis 6, la même suite avec les mêmes résultats. ioredis 5 est accepté en peer dependency ; rien n'a été exécuté dessus. La suite a besoin d'un serveur et est sautée quand aucune URL n'est donnée.
-
Suites de contrat (
cacheContract,lockContract,rateLimiterContract) que l'implémentation en mémoire et l'adaptateur passent tous deux ; un adaptateur à vous y est tenu. -
Choix de portabilité : des scripts envoyés par
EVALavec chaque clé déclarée ; aucune arithmétique en Lua (les deux versions de Lua diffèrent sur les nombres) ; niKEYS, ni modules, ni fonctions, ni notifications d'espace de clés. -
Connexion : un délai d'expiration (timeout) de deux secondes par commande et une nouvelle tentative (retry) par requête par défaut, si bien qu'un serveur absent échoue vite au lieu de bloquer chaque requête ;
redisHealthpour une sonde de disponibilité (readiness). -
Généré par la CLI :
init --cache redisécritsrc/platform/redis/open-redis.ts(une connexion derrièreREDIS_URL) et y relie lesCache,RateLimiteretLockdu noyau dans le module de la plateforme, avec le signal du relais pour l'outbox ; le fichier compose fait tourner Dragonfly 2.0.2 pour le développement local. Sans l'option, les trois restent dans la mémoire du processus.init --queue redislieRedisJobStorepour les jobs en arrière-plan, etinit --broker redisle bus d'événements sur Redis Streams. -
Jobs en arrière-plan :
RedisJobStoreest leJobStorede@ddd-ts-kit/schedulingsur les mêmes serveurs. Redis ne peut pas rejoindre la transaction de la base de données : un job ajouté dans une unité de travail est écrit une fois celle-ci validée, et un processus qui meurt entre les deux perd cette écriture sans que rien ne le signale. Une écriture que Redis refuse après la validation est passée àonLostJob, quereportLostJobs(logger, errors)journalise et signale. Avec une base de données, la table des jobs du package de l'ORM est le stockage le plus sûr (jobs en arrière-plan).
Cache. Des clés typées, un espace de noms par contexte ou par application, une durée de vie
en millisecondes. Un serveur en panne vaut un défaut de cache à la lecture et une écriture
abandonnée par défaut : l'application tourne comme si elle n'avait pas de cache, et
cache_errors_total est le signal à surveiller. getOrLoad protège contre une ruée (stampede) :
les appelants d'un même processus partagent un calcul, et avec un verrou un seul processus calcule
pendant que les autres attendent le verrou jusqu'à waitMs, puis renvoient la valeur qu'il a
écrite, ou la calculent eux-mêmes quand elle manque encore. Il n'échoue jamais à cause du verrou.
L'aspect cached refuse d'envelopper un repository ou un cas d'usage sauf autorisation
explicite : un agrégat servi depuis un cache masque qu'il est périmé, et la commande suivante
déciderait sur un état ancien.
Quoi mettre en cache, et où. Au lieu de mettre en cache ce sur quoi une commande décide :
// AspectNotAllowedError dès que le repository est enveloppébuilder.bind(AppointmentRepository).toClass(KnexAppointmentRepository).as('repository') .with(cached({ key: (method, [id]) => `appointment:${String(id)}`, ttlMs: 60_000 }));mettez en cache ce qui est lu ailleurs et peut être un peu ancien, depuis la racine de composition, si bien que la gateway elle-même n'en sait rien :
import { cached } from '@ddd-ts-kit/aspects';import type { Cache } from '@ddd-ts-kit/kernel';
interface WorkingHours { readonly practitionerId: string; readonly opensAt: string; readonly closesAt: string;}
// contexts/availability/application/ports/calendar.gateway.tsexport abstract class CalendarGateway { abstract workingHoursOf(practitionerId: string): Promise<WorkingHours>;}
declare const httpCalendars: CalendarGateway; // l'adaptateur qui appelle le contexte calendarsdeclare const cache: Cache; // RedisCache avec plusieurs instances, InMemoryCache avec une seule
// src/platform/ : cinq minutes de retard sont acceptables pour des horaires de travail.export const calendars = cached({ key: (method, [practitionerId]) => (method === 'workingHoursOf' ? `working-hours:${String(practitionerId)}` : undefined), ttlMs: 5 * 60_000, cache,})(httpCalendars, { kind: 'gateway', context: 'availability' });Des appels identiques et simultanés partagent un seul appel au contexte calendars ; un appel en
échec n'est pas mis en cache ; un cache en panne vaut un défaut de cache. Avec un conteneur, le
même aspect se pose sur la liaison : .as('gateway').with(cached({ ... })).
Limiteur de débit. Une fenêtre glissante exacte : un membre d'ensemble trié par appel
autorisé, décidé dans un seul script, si bien que des instances concurrentes ne peuvent pas
prendre toutes deux la dernière place. Un appel refusé n'est pas compté. onError choisit entre
throw (le défaut), allow (habituel devant une API) et deny (pour les limites qui sont un
contrôle de sécurité).
Une limite qui est une règle métier (« trois tentatives de réservation par minute et par client ») appartient au cas d'usage, qui dépend du port et refuse avec un code qui lui est propre :
import { ConceptError, RateLimiter } from '@ddd-ts-kit/kernel';
export class BookingError extends ConceptError { static tooManyAttempts(retryAfterMs: number) { return BookingError.refusal('TOO_MANY_BOOKING_ATTEMPTS', 'Too many booking attempts, try again later.', { retryAfterMs, }); }}
// contexts/appointments/application/ : appelé par le gestionnaire de BookAppointment avant toute autre chose.export class BookingAttempts { constructor(private readonly limiter: RateLimiter) {}
async admit(clientId: string): Promise<void> { const decision = await this.limiter.consume(`booking:${clientId}`, { limit: 3, windowMs: 60_000 });
if (!decision.allowed) { throw BookingError.tooManyAttempts(decision.retryAfterMs); } }}Dans une spec, InMemoryRateLimiter sur une FixedClock est son fake : avancez l'horloge
d'une minute et la quatrième tentative est admise.
Verrou. L'algorithme à instance unique de la documentation de Redis : SET NX PX avec un
propriétaire aléatoire, la libération par comparaison puis suppression, un jeton de clôture
(fencing token) incrémenté dans le même script.
Signal du relais. La publication et l'abonnement (publish/subscribe) servent d'indice pour réveiller le relais de l'outbox ; une notification perdue ne fait que retarder la publication jusqu'à la scrutation suivante.
Ce que le verrou garantit, et ce qu'il ne garantit pas
Section intitulée « Ce que le verrou garantit, et ce qu'il ne garantit pas »Il garantit au plus un détenteur à la fois à condition que le serveur garde ses données et que
le détenteur termine dans les ttlMs. Ce n'est pas un verrou à consensus. Le README liste les cas
où deux détenteurs existent en même temps :
- le détenteur est mis en pause (ramasse-miettes, disque bloqué, conteneur suspendu) plus longtemps que la durée de vie et reprend son travail ;
- le serveur bascule sur un réplica qui n'avait pas reçu la clé ;
- le serveur redémarre sans persistance, ou évince la clé sous la pression mémoire ;
- les horloges côté serveur font un saut.
Le jeton de clôture réduit ce risque pour les ressources capables de comparer : un stockage
qui retient le plus grand jeton qu'il a vu refuse l'écriture portant 41 après avoir vu 42. Ce
n'est pas une preuve non plus : le compteur vit dans le même serveur et repart de zéro si ce
serveur perd ses données. Le verrou n'est par ailleurs ni réentrant ni renouvelé : choisissez
un ttlMs au-dessus du pire cas du travail, ou découpez le travail. Un serveur absent fait
rejeter acquire, puisque personne ne peut savoir si la clé est prise.
Un stockage compare les jetons par une condition sur son écriture. Ici, la ligne d'un export garde le jeton du détenteur qui l'a écrite en dernier, et un détenteur dont le verrou a expiré ne change rien :
import type { LockHandle } from '@ddd-ts-kit/kernel';
interface Database { execute(sql: string, values: readonly unknown[]): Promise<{ readonly rowCount: number }>;}
/** Renvoie false quand un détenteur plus récent du verrou a déjà écrit : celui-ci a été mis en pause au-delà de son ttlMs. */export async function writeExport(database: Database, holder: LockHandle, content: string): Promise<boolean> { const { rowCount } = await database.execute( 'update exports set content = $1, fencing_token = $2 where id = $3 and fencing_token <= $2', [content, holder.fencingToken, 'nightly'], );
return rowCount === 1;}D'où la règle que le noyau énonce pour Lock lui-même : un outil d'efficacité, jamais la garde
d'un invariant. Au lieu de garder avec lui l'invariant « pas de double réservation » :
// une pause de plus de cinq secondes, une bascule, une éviction : et deux réservations d'un même créneau passentconst handle = await lock.acquire(`slot:${practitionerId}:${startsAt}`, { ttlMs: 5_000 });if (handle === undefined) { throw AppointmentError.slotTaken(practitionerId, startsAt);}await appointments.save(Appointment.book(practitionerId, startsAt));laissez l'invariant à l'agrégat et à une contrainte de la base de données (la contrainte d'exclusion de la page sur la persistance), et gardez le verrou pour un travail dont la double exécution ne coûte que du travail, comme l'envoi des rappels de ce matin par une commande qui saute ce qui a déjà été envoyé :
import type { Lock } from '@ddd-ts-kit/kernel';
declare function sendTodaysReminders(): Promise<void>; // idempotent : un rappel déjà envoyé est sauté
export async function sendRemindersOnce(lock: Lock, day: string): Promise<void> { const handle = await lock.acquire(`notifications.send-reminders:${day}`, { ttlMs: 15 * 60_000 });
if (handle === undefined) { return; // une autre instance les envoie, ou les a envoyés }
// Pas libéré à la fin : une instance dont le minuteur se déclenche un peu plus tard trouve la clé // encore prise et n'envoie rien. Le verrou se libère de lui-même après ttlMs. await sendTodaysReminders();}Un job planifié déclaré singleInstance obtient cela du planificateur de
@ddd-ts-kit/scheduling, qui prend le verrou sous job:<name>:<scheduled instant> avant chaque
occurrence ; les autres enregistrent skipped-locked
(jobs planifiés).
| Besoin | Le bon outil | Pourquoi |
|---|---|---|
| une seule instance envoie les rappels de ce matin | Lock, et une commande idempotente |
une rare double exécution gaspille du travail ; la commande envoie de toute façon chaque rappel une fois |
| recalculer une seule fois une entrée de cache coûteuse | Lock par getOrLoad |
le pire cas est le calcul sans protection |
| un praticien n'est jamais réservé deux fois | une contrainte d'exclusion ou d'unicité dans le schéma appointments, plus l'agrégat |
tient quoi que croie n'importe quel processus au sujet d'un verrou |
| un rendez-vous n'est pas reprogrammé par deux requêtes HTTP à la fois | la concurrence optimiste sur la version de l'agrégat |
le second écrivain reçoit un conflit |
| un événement d'intégration est traité une fois par consommateur | le registre des événements traités dans la transaction du consommateur | une marque dans Redis et une modification dans PostgreSQL sont deux systèmes |
| une occurrence de job s'exécute exactement une fois et survit à un plantage | pas disponible : une ligne dans une table traitée par un job répétable | jobs planifiés |
Limites, et quand ne pas l'utiliser
Section intitulée « Limites, et quand ne pas l'utiliser »Énoncées par le package :
- Un serveur, ou un primaire avec des réplicas. Un Redis Cluster fonctionne pour le cache et
le verrou ;
deleteByPrefixne parcourt pas chaque nœud. Pas testé sur un cluster. - L'éviction s'applique à tout. Sous
allkeys-lru, un verrou ou une clé de limitation de débit peut être évincé comme une entrée de cache. Utilisez une politiquevolatile-*, ou un second serveur pour ce qui n'est pas un cache. - Pas de cache côté client, pas de pipelining des lectures : chaque
getest un aller-retour. - Les valeurs sont en JSON par défaut :
Date,Map,bigintet les instances de classe ne survivent pas. Mettez en cache des données simples. deleteByPrefixn'est pas atomique et c'est un outil d'administration ; préférez des durées de vie courtes à l'invalidation.- La mémoire du limiteur de débit croît avec la limite (un membre par appel autorisé) : cela va jusqu'à des milliers par fenêtre. Son horloge est celle de l'appelant : les instances doivent être synchronisées, et un décalage déplace la fenêtre d'autant.
- Pas de registre des événements traités sur Redis, délibérément, et pas d'outbox : ce qui doit être atomique avec les données métier vit dans la base de données.
Quand ne pas utiliser de cache : devant un repository d'agrégats ; pour masquer une requête lente qu'un index ou un modèle de lecture corrigerait ; quand la donnée doit être exacte à l'instant d'une décision. Quand ne pas utiliser le verrou : chaque fois que la phrase qui décrit le besoin contient « jamais ».
Recommandation selon la situation
Section intitulée « Recommandation selon la situation »| Situation | Choix |
|---|---|
| Une seule instance | aucun serveur : les ports en mémoire de kernelModule() |
| Plusieurs instances, un cache et des jobs, aucune contrainte de licence | n'importe lequel des trois ; le fichier compose généré fait tourner Dragonfly pour le développement local |
| Une politique qui exige une licence open source permissive | Valkey |
| Un service géré de votre fournisseur cloud | ce qu'il propose qui parle le protocole ; faites tourner les suites de contrat contre lui une fois |
| Un cache plus Redis Streams comme bus d'événements | deux serveurs ou deux politiques : le bus a besoin de noeviction et de persistance |
| Une exigence stricte d'exclusion mutuelle | pas ce package : une contrainte de base de données, ou un système à consensus que vous exploitez en connaissance de cause |
- Documentation de Redis, verrous distribués (le patron à instance unique et ses limites) : https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
- Martin Kleppmann, « How to do distributed locking » (2016), sur les pauses et les jetons de clôture : https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- Licence de Redis : https://github.com/redis/redis/blob/unstable/LICENSE.txt ; Valkey : https://github.com/valkey-io/valkey ; licence de Dragonfly : https://github.com/dragonflydb/dragonfly/blob/main/LICENSE.md (tous lus le 2026-10-10)
- Politiques d'éviction de Redis : https://redis.io/docs/latest/develop/reference/eviction/
- Michael Nygard, Release It!, 2e édition, Pragmatic Bookshelf, 2018 (le cache comme risque pour la stabilité).
- Martin Kleppmann, Designing Data-Intensive Applications, O'Reilly, 2017, chapitre 8.
- Non vérifié : les modes de persistance de Dragonfly au-delà de ce qu'en dit le README du kit (instantanés) ; le comportement de chacun des trois en mode cluster.
Voir aussi
Section intitulée « Voir aussi »- Résilience
- Jobs planifiés
- Agrégats
- Livraison et idempotence
- Brokers de messages
- Persistance
- Caches HTTP : le cache partagé devant l'API, Varnish et les CDN