Distributed lock avec Redis dans Spring Boot
1. Tổng quan
Quand l’application s’exécute sur plusieurs instances, synchronized ou ReentrantLock ne suffisent plus. Ces locks ne fonctionnent que dans une JVM. Cet article vous guide pour construire un distributed lock avec Redis de deux façons : l’implémenter vous-même avec Spring Data Redis, ou utiliser la bibliothèque Redisson. À la fin, vous saurez comment acquérir et libérer un lock en toute sécurité, comment écrire des tests pour le lock, et les cas où un lock Redis n’offre pas une sécurité suffisante.
2. Vì sao cần distributed lock
Supposez que vous avez un OrderService s’exécutant sur 3 pods. Deux clients commandent le dernier article du stock. Les deux requêtes arrivent sur deux pods différents. Les deux lisent stock = 1, réduisent tous les deux le stock, et le résultat est une surcharge de stock.
Un autre exemple est un job @Scheduled qui génère un rapport à 2h du matin. S’il y a 3 instances, le job s’exécute 3 fois, et les clients reçoivent 3 emails identiques.
Dans les deux cas, vous avez besoin d’un lock visible par toutes les instances. Redis est bien adapté à ce rôle pour trois raisons :
- Redis traite les commandes de façon séquentielle sur un thread, donc chaque commande est atomique.
- Les clés peuvent avoir un TTL, donc le lock s’auto-expire si le processus qui le détient s’arrête brutalement.
- Latence faible et la plupart des systèmes ont déjà Redis.
3. Nguyên lý: lệnh SET NX PX
Tout le mécanisme repose sur une seule commande :
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
Signification de chaque partie :
NX: écrit uniquement si la clé n’existe pas. C’est l’étape d’acquisition du lock.PX 10000: la clé expire automatiquement après 10 secondes. De cette façon, si le processus s’arrête brutalement, le lock ne reste pas bloqué indéfiniment."c1f9-token": une valeur aléatoire, unique pour chaque acquisition du lock. Cette valeur est utilisée pour identifier qui possède le lock.
Exécutez cette commande deux fois de suite, vous verrez :
OK
(nil)
La première fois retourne OK, ce qui signifie que vous avez acquis le lock. La deuxième fois retourne (nil) car la clé existe déjà.
Note : N’utilisez pas
SETNXpuis appelezEXPIREdans une commande séparée. Si le processus s’arrête entre les deux commandes, la clé n’expirera jamais et vous serez en deadlock.
4. Tự cài đặt với Spring Data Redis
4.1. Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configurez la connexion dans application.properties :
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. Acquérir et libérer un lock
@Component
public class RedisLock {
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<String> tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
}
public boolean unlock(String key, String token) {
Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
return deleted != null && deleted == 1L;
}
}
Explication :
setIfAbsent(key, token, ttl)génère exactement la commandeSET key token NX PX ttl. Un résultattruesignifie que vous avez acquis le lock.tryLockretourne le token en cas de succès. Vous devez conserver ce token pour libérer le lock plus tard.unlockexécute un script Lua. Le script vérifie que la valeur de la clé correspond à votre token, et ne supprime la clé que si c’est le cas.
Pourquoi ne pas appeler directement DEL ? Considérez ce scénario. L’instance A acquiert le lock avec un TTL de 10 secondes mais le traitement prend 12 secondes. À la 10ème seconde, le lock expire et l’instance B l’acquiert. À la 12ème seconde, A appelle DEL et supprime le lock de B. Maintenant, l’instance C peut s’exécuter, et B avec C s’exécutent en parallèle.
Si vous séparez en GET puis DEL, il y a toujours un écart entre les deux commandes. Le script Lua est exécuté atomiquement par Redis, donc cet écart n’existe plus.
4.3. Utilisation dans le service
public record Order(long productId, int quantity) {}
@Service
public class OrderService {
private final RedisLock redisLock;
private final InventoryRepository inventory;
public OrderService(RedisLock redisLock, InventoryRepository inventory) {
this.redisLock = redisLock;
this.inventory = inventory;
}
public void placeOrder(Order order) {
String key = "lock:product:" + order.productId();
String token = redisLock.tryLock(key, Duration.ofSeconds(10))
.orElseThrow(() -> new IllegalStateException("Le produit est en cours de traitement, veuillez réessayer"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
Il y a trois points à noter :
- La clé lock doit être spécifique à la ressource, ici
lock:product:{id}. Si vous utilisez un seul lock pour tous les produits, toutes les commandes seront mises en file d’attente de manière séquentielle. unlockdoit toujours être dans unfinally, pour que le lock soit libéré même en cas d’exception.- Cet exemple échoue rapidement si le lock ne peut pas être acquis. Si vous voulez attendre, vous pouvez réessayer plusieurs fois avec un backoff court. N’utilisez pas une boucle
while(true)sans limite.
InventoryRepository est n’importe quel repository de votre projet, par exemple un Spring Data JPA repository.
5. Dùng Redisson
L’implémenter vous-même vous aide à comprendre le mécanisme, mais manque de quelques fonctionnalités : renouveler le lock pendant un long traitement, reentrant, et attendre le lock via pub/sub au lieu du polling. Redisson fournit déjà ces fonctionnalités.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Vous devez vérifier la dernière version compatible avec la version de Spring Boot que votre projet utilise. Ce starter lit la configuration spring.data.redis.* et crée d’office le bean RedissonClient.
@Service
public class DailyReportJob {
private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
private final RedissonClient redisson;
public DailyReportJob(RedissonClient redisson) {
this.redisson = redisson;
}
@Scheduled(cron = "0 0 2 * * *")
public void generate() throws InterruptedException {
RLock lock = redisson.getLock("lock:daily-report");
if (!lock.tryLock(0, TimeUnit.SECONDS)) {
log.info("Une autre instance exécute le rapport, passage");
return;
}
try {
log.info("Début de la génération du rapport quotidien");
// interroger les données, exporter le fichier, envoyer un email...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Quand le job s’exécute sur 3 instances, le log ressemble à :
[pod-1] Début de la génération du rapport quotidien
[pod-2] Une autre instance exécute le rapport, passage
[pod-3] Une autre instance exécute le rapport, passage
Points importants :
tryLock(0, TimeUnit.SECONDS)n’attend pas. Si le lock est détenu, la fonction retournefalseimmédiatement.- Quand vous ne passez pas
leaseTime, Redisson active le watchdog. Le lock a un TTL par défaut de 30 secondes (lockWatchdogTimeout) et est renouvelé environ toutes les 10 secondes, tant que le thread le détient toujours. Si la JVM s’arrête, le renouvellement s’arrête et le lock expire automatiquement. - Quand vous appelez
tryLock(wait, lease, unit)avec uneleaseTimespécifique, le watchdog est désactivé. Le lock expirera exactement aprèslease, que le travail soit terminé ou non. RLockest lié au thread et est reentrant. Si vous appelezunlock()à partir d’un thread différent, vous recevrezIllegalMonitorStateException. Ceci nécessite une attention particulière quand vous utilisezCompletableFutureou du code réactif.
Note : Puisque
RLockest lié au thread, vous devez être prudent quand vous l’utilisez avec des virtual threads et les frameworks qui changent de thread au milieu. Le lock doit être acquis et libéré sur le même thread.
6. Viết test với Testcontainers
Vous devriez tester le lock sur un Redis réel, ne pas utiliser mock. Ajoutez les dépendances de test suivantes (les versions gérées par Spring Boot) :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
RedisLock redisLock;
@Autowired
StringRedisTemplate template;
@Test
void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
.<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
.toList();
try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
long acquired = executor.invokeAll(tasks).stream()
.map(Future::resultNow)
.filter(Optional::isPresent)
.count();
assertThat(acquired).isEqualTo(1);
}
}
@Test
void givenWrongToken_whenUnlock_thenLockIsKept() {
String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();
assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
assertThat(template.hasKey("lock:owner")).isTrue();
assertThat(redisLock.unlock("lock:owner", token)).isTrue();
assertThat(template.hasKey("lock:owner")).isFalse();
}
}
Explication :
@DataRedisTestne charge que la configuration Redis, donc les tests s’exécutent rapidement.@Import(RedisLock.class)place le bean à tester dans le contexte.@ServiceConnection(name = "redis")pointe automatiquementStringRedisTemplatevers le container que vous venez de démarrer. Vous n’avez pas besoin de déclarer l’hôte et le port manuellement.- Le premier test laisse 20 threads se battre pour un lock et affirme que seul 1 thread gagne.
- Le deuxième test confirme que le script Lua fonctionne correctement : un mauvais token ne supprime pas le lock, le bon token le supprime.
7. Lỗi hay gặp và giới hạn
7.1. TTL inférieur au temps de traitement
C’est l’erreur la plus courante. Le lock expire pendant que le travail est toujours en cours, et une autre instance s’interpose. Les causes ne se limitent pas à du code lent. Les pauses GC, le réseau lent ou l’attente du pool de connexions peuvent aussi vous faire dépasser le TTL.
Il y a deux façons de gérer cela. La première est d’utiliser le watchdog de Redisson. La deuxième est de définir un TTL suffisamment large, par exemple plusieurs fois le temps de traitement p99.
7.2. Le lock n’offre pas une garantie absolue
La réplication Redis est asynchrone. Supposez que le maître reçoive le lock mais s’arrête avant de pouvoir le copier vers le replica. Le replica est promu en nouveau maître, et il n’y a pas de clé lock dessus. À ce moment, un autre client peut acquérir le même lock.
L’algorithme Redlock (acquérir le lock sur la majorité de N nœuds indépendants) réduit ce risque. Cependant, Redlock est toujours controversé car il dépend d’hypothèses sur l’horloge et la latence. Dans Redisson, RedissonRedLock est maintenant marqué comme deprecated.
Si des données incorrectes causeraient des conséquences graves, par exemple débiter deux fois, ne vous fiez pas uniquement au lock. Combinez avec un fencing token : chaque fois que vous acquérez le lock, vous recevez un nombre croissant (par exemple via INCR lock:product:42:fence). La couche de stockage rejettera les écritures portant un ancien token :
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
Si l’instruction de mise à jour modifie 0 lignes, cela signifie qu’un processus avec un token plus récent a écrit en premier. L’ancienne requête sera rejetée.
7.3. Autres erreurs
- Utiliser la même clé pour toutes les ressources, ce qui entraîne une chute drastique du débit.
- Oublier le
finally, donc le lock n’est libéré que quand le TTL expire. - Appeler
unlock()de Redisson quand vous ne tenez plus le lock (car il a expiré). VérifiezisHeldByCurrentThread()avant d’appeler. - Réessayer continuellement d’acquérir le lock sans backoff, ce qui inonde Redis de requêtes.
7.4. Quand NE PAS l’utiliser
- L’application n’a qu’une seule instance :
ReentrantLocksuffit et est bien plus simple. - Les données sont dans une base de données relationnelle : optimistic locking (
@Version),SELECT ... FOR UPDATE, ou les unique constraints sont généralement plus sûrs. Ils se trouvent dans la même transaction que les données. - Vous avez besoin d’une garantie absolue : envisagez un système de consensus comme ZooKeeper ou etcd, combiné avec un fencing token.
- Vous avez juste besoin que chaque message soit traité une seule fois : une idempotency key ou un consumer group du message broker convient généralement mieux qu’un lock.
8. Kết luận
Un distributed lock avec Redis repose sur trois principes : acquérir le lock via une commande atomique SET NX PX, joindre un token unique à chaque acquisition du lock, et libérer le lock via un script Lua avec vérification du token. Vous pouvez l’implémenter vous-même en quelques dizaines de lignes avec StringRedisTemplate, ou utiliser Redisson pour avoir en plus le watchdog, la réentrance et un mécanisme d’attente efficace.
N’oubliez pas qu’un lock Redis aide à éviter le traitement en double dans la plupart des cas, mais n’est pas une garantie absolue. Pour les opérations critiques, vous devriez combiner le lock avec un fencing token ou des contraintes au niveau de la base de données. En outre, testez toujours le lock sur un Redis réel en utilisant Testcontainers.