Distributed lock con Redis en Spring Boot
1. Resumen
Cuando tu aplicación se ejecuta en múltiples instancias, synchronized o ReentrantLock no son suficientes. Estos locks solo funcionan dentro de una única JVM. Este artículo te guía en la construcción de un distributed lock con Redis de dos formas: implementarlo por tu cuenta con Spring Data Redis, y usar la librería Redisson. Después de leerlo, sabrás cómo adquirir y liberar un lock de forma segura, cómo escribir tests para el lock, y qué casos hacen que Redis no sea completamente seguro.
2. Por qué necesitas distributed lock
Supongamos que tu OrderService se ejecuta en 3 pods. Dos clientes piden el último producto del inventario. Dos requests caen en dos pods diferentes. Ambos leen stock = 1, ambos restan del inventario, y el resultado es vender más de lo disponible.
Otro ejemplo es un job @Scheduled que genera un reporte a las 2 de la mañana. Si tienes 3 instancias, el job se ejecuta 3 veces, y los clientes reciben 3 emails idénticos.
En ambos casos, necesitas un lock que todas las instancias puedan ver. Redis es perfecto para este rol por tres razones:
- Redis procesa comandos secuencialmente en un único thread, así que cada comando es atómico.
- Las keys pueden tener un TTL, por lo que el lock expirará automáticamente si el proceso que lo sostiene se cuelga.
- Baja latencia y la mayoría de sistemas ya tienen Redis.
3. Principio: comando SET NX PX
Todo el mecanismo se basa en un único comando:
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
Significado de cada parte:
NX: solo escribe si la key no existe. Este es el paso de “adquirir el lock”.PX 10000: la key expira automáticamente después de 10 segundos. Gracias a esto, si el proceso muere en medio, el lock no se queda atrapado para siempre."c1f9-token": un valor aleatorio, único para cada adquisición de lock. Este valor se usa para identificar quién es el propietario del lock.
Si ejecutas el comando anterior dos veces seguidas, verás:
OK
(nil)
La primera vez devuelve OK, lo que significa que has adquirido el lock. La segunda vez devuelve (nil) porque la key ya existe.
Nota: No uses
SETNXseguido deEXPIREen comandos separados. Si el proceso muere entre los dos comandos, la key nunca expirará y te quedarás en deadlock.
4. Implementación propia con Spring Data Redis
4.1. Dependencia
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configura la conexión en application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. Adquirir y liberar el 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;
}
}
Explicación:
setIfAbsent(key, token, ttl)genera exactamente el comandoSET key token NX PX ttl. El resultadotruesignifica que has adquirido el lock.tryLockdevuelve el token si tiene éxito. Debes guardar este token para liberar el lock después.unlockejecuta un script Lua. El script verifica que el valor de la key sea exactamente tu token, y solo elimina la key si es correcto.
¿Por qué no llamar a DEL directamente? Considera este escenario. La instancia A adquiere el lock con TTL de 10 segundos pero el procesamiento toma 12 segundos. En el segundo 10, el lock expira y la instancia B lo adquiere. En el segundo 12, A llama a DEL y elimina el lock de B. Ahora C puede entrar, y B y C corren en paralelo.
Si separas GET y luego DEL, todavía hay una brecha entre los dos comandos. El script Lua se ejecuta de forma atómica en Redis, por lo que no hay brecha.
4.3. Uso en el servicio
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("El producto está siendo procesado, por favor intenta de nuevo"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
Hay tres puntos a tener en cuenta:
- La key del lock debe ser específica para cada recurso, aquí es
lock:product:{id}. Si usas un lock único para todos los productos, todos los pedidos se procesarán secuencialmente. unlocksiempre está en el bloquefinally, para que el lock se libere incluso si hay excepciones.- Este ejemplo usa fail-fast cuando no puede adquirir el lock. Si quieres esperar, puedes reintentar varias veces con un backoff corto. No uses un bucle
while(true)sin límite.
InventoryRepository es cualquier repository en tu proyecto, por ejemplo un Spring Data JPA repository.
5. Usando Redisson
La implementación propia te ayuda a entender el mecanismo, pero le faltan algunas características: renovar el lock cuando el procesamiento es lento, reentrancia, y esperar el lock usando pub/sub en lugar de polling. Redisson proporciona estas características.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Debes verificar la versión más reciente compatible con Spring Boot que usa tu proyecto. Este starter lee la configuración spring.data.redis.* y crea automáticamente el 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("Otra instancia está ejecutando el reporte, omitiendo");
return;
}
try {
log.info("Iniciando generación del reporte diario");
// consultar datos, exportar archivo, enviar email...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Cuando el job se ejecuta en 3 instancias, el log se verá así:
[pod-1] Iniciando generación del reporte diario
[pod-2] Otra instancia está ejecutando el reporte, omitiendo
[pod-3] Otra instancia está ejecutando el reporte, omitiendo
Puntos importantes:
tryLock(0, TimeUnit.SECONDS)no espera. Si el lock está siendo usado, la función devuelvefalseinmediatamente.- Cuando no pasas
leaseTime, Redisson activa el watchdog. El lock tiene un TTL por defecto de 30 segundos (lockWatchdogTimeout) y se renueva aproximadamente cada 10 segundos, mientras el thread siga sosteniendo el lock. Si la JVM muere, la renovación se detiene y el lock expira automáticamente. - Cuando llamas a
tryLock(wait, lease, unit)con unleaseTimeespecífico, el watchdog se desactiva. El lock expirará exactamente después delease, completado el trabajo o no. RLockestá vinculado al thread y es reentrante. Si llamas aunlock()desde otro thread, recibirásIllegalMonitorStateException. Debes prestar especial atención al usar conCompletableFutureo código reactivo.
Nota: Dado que
RLockestá vinculado al thread, sé cuidadoso al usar con virtual threads y frameworks que cambian de thread en el medio. El lock debe ser adquirido y liberado en el mismo thread.
6. Escribir tests con Testcontainers
Debes probar el lock en Redis real, no uses mocks. Añade estas dependencias de test (versiones gestionadas por 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();
}
}
Explicación:
@DataRedisTestsolo carga la configuración de Redis, por lo que los tests se ejecutan rápido.@Import(RedisLock.class)añade el bean a probar al contexto.@ServiceConnection(name = "redis")automáticamente apuntaStringRedisTemplateal contenedor recién iniciado. No necesitas declarar host y puerto manualmente.- El primer test hace que 20 threads compitan por un lock y verifica que solo 1 gana.
- El segundo test verifica que el script Lua funcione correctamente: un token incorrecto no elimina el lock, un token correcto sí.
7. Errores comunes y limitaciones
7.1. TTL más corto que el tiempo de procesamiento
Este es el error más común. El lock expira mientras el trabajo aún se está ejecutando, y otra instancia entra. La causa no es solo código lento. Pausas de GC, red lenta, o esperar a que se libere una conexión también pueden hacerte exceder el TTL.
Hay dos formas de manejarlo. La primera es usar el watchdog de Redisson. La segunda es establecer un TTL generoso, por ejemplo varios veces el percentil p99 del tiempo de procesamiento.
7.2. El lock no garantiza seguridad absoluta
La replicación de Redis es asincrónica. Supongamos que el master recibe un lock pero se cuelga antes de replicarlo a la replica. La replica se promueve a master nuevo, y en ella no existe la key del lock. Ahora otro cliente puede adquirir el mismo lock.
El algoritmo Redlock (adquirir lock en la mayoría de N nodos independientes) reduce este riesgo. Sin embargo, Redlock sigue siendo controvertido porque depende de suposiciones sobre relojes y latencia. En Redisson, RedissonRedLock ahora está marcado como deprecated.
Si los datos incorrectos causan consecuencias graves, por ejemplo un cargo duplicado, no confíes solo en el lock. Combínalo con un fencing token: cada vez que adquieres un lock, recibes un número que incrementa (por ejemplo de INCR lock:product:42:fence). La capa de almacenamiento rechazará escrituras con tokens antiguos:
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
Si la sentencia actualiza 0 filas, significa que un proceso con un token más nuevo ya escribió antes. La request antigua será rechazada.
7.3. Otros errores
- Usar la misma key para todos los recursos, causando una caída masiva de throughput.
- Olvidar
finally, por lo que el lock solo se libera cuando expira el TTL. - Llamar a
unlock()de Redisson cuando ya no sostienes el lock (porque expiró). VerificaisHeldByCurrentThread()antes de llamar. - Reintentar adquirir el lock continuamente sin backoff, lo que abruma Redis con requests.
7.4. Cuándo NO usar
- Aplicación con una única instancia:
ReentrantLockes suficiente y mucho más simple. - Datos en una base de datos relacional: optimistic locking (
@Version),SELECT ... FOR UPDATE, o unique constraints suelen ser más seguros. Están en la misma transacción que los datos. - Necesitas garantía absoluta: considera sistemas con consenso como ZooKeeper o etcd, combinados con fencing tokens.
- Solo necesitas procesar cada mensaje una vez: idempotency keys o consumer groups de message brokers suelen ser mejores que locks.
8. Conclusión
El distributed lock con Redis se basa en tres principios: adquirir el lock con un comando atómico SET NX PX, asignar un token único a cada adquisición, y liberar el lock con un script Lua que verifica el token. Puedes implementarlo por tu cuenta en unas pocas decenas de líneas usando StringRedisTemplate, o usar Redisson para tener watchdog, reentrancia y un mecanismo de espera eficiente.
Recuerda que el lock de Redis ayuda a evitar procesamiento duplicado en la mayoría de casos, pero no es una garantía absoluta. Para operaciones críticas, combina el lock con un fencing token o restricciones en la capa de base de datos. Además, siempre prueba el lock en Redis real usando Testcontainers.