Distributed lock com Redis no Spring Boot
1. Visão geral
Quando sua aplicação executa em múltiplas instâncias, synchronized ou ReentrantLock não são mais suficientes. Esses locks funcionam apenas dentro de uma JVM. Este artigo te mostra como construir um distributed lock com Redis de duas formas: implementando do zero com Spring Data Redis, e usando a biblioteca Redisson. Ao final, você saberá como adquirir e liberar um lock com segurança, escrever testes para locks e entender quando um lock Redis não é suficientemente seguro.
2. Por que precisamos de distributed lock
Suponha que você tenha um OrderService executando em 3 pods. Dois clientes pedem o último item do estoque ao mesmo tempo. Os dois requests caem em pods diferentes. Ambos leem stock = 1, ambos decrementam o estoque, e o resultado é uma venda acima da quantidade disponível.
Outro exemplo é um job @Scheduled que gera relatório às 2 da manhã. Se houver 3 instâncias, o job executa 3 vezes, e clientes recebem 3 emails idênticos.
Em ambos os casos, você precisa de um lock que todas as instâncias consigam enxergar. Redis é perfeito para esse papel por três razões:
- Redis processa comandos sequencialmente em uma única thread, então cada comando é atomic.
- As chaves podem ter TTL, então o lock expira automaticamente se o processo que o detém fizer crash.
- Baixa latência e a maioria dos sistemas já têm Redis.
3. Princípio: comando SET NX PX
Todo o mecanismo se baseia em um único comando:
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
O significado de cada parte:
NX: escreve apenas se a chave não existe ainda. Este é o passo de “adquirir lock”.PX 10000: a chave expira automaticamente em 10 segundos. Dessa forma, se o processo morrer no meio do caminho, o lock não fica travado para sempre."c1f9-token": um valor aleatório, único para cada aquisição de lock. Esse valor é usado para identificar quem possui o lock.
Chamando o comando acima duas vezes seguidas, você verá:
OK
(nil)
A primeira chamada retorna OK, significando que você adquiriu o lock. A segunda retorna (nil) porque a chave já existe.
Nota: Não use
SETNXseguido deEXPIREem comandos separados. Se o processo morrer entre os dois comandos, a chave nunca expirará e você terá deadlock.
4. Implementação manual com Spring Data Redis
4.1. Dependência
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configure a conexão em application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. Adquirir e liberar 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;
}
}
Explicação:
setIfAbsent(key, token, ttl)gera exatamente o comandoSET key token NX PX ttl. O resultadotruesignifica que você adquiriu o lock.tryLockretorna o token se bem-sucedido. Você deve guardar esse token para liberar o lock depois.unlockexecuta um script Lua. O script verifica se o valor da chave é exatamente seu token e só deleta a chave se for.
Por que não chamar DEL diretamente? Considere este cenário. A instância A adquire o lock com TTL de 10 segundos, mas o processamento leva 12 segundos. No décimo segundo, o lock expira e a instância B adquire o lock. No décimo segundo, A chama DEL e deleta o lock de B. Agora a instância C pode entrar, e B com C executam em paralelo.
Se você separar GET e depois DEL, ainda há um espaço de tempo entre os dois comandos. Um script Lua é executado de forma atomic pelo Redis, então esse espaço desaparece.
4.3. Usar no serviço
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("Produto está sendo processado, tente novamente"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
Há três pontos importantes:
- A chave do lock deve ser específica para o recurso, aqui é
lock:product:{id}. Se usar um lock genérico para todos os produtos, todos os pedidos ficarão na fila sequencial. unlocksempre fica nofinally, para garantir que o lock é liberado mesmo que haja exceção.- Este exemplo falha rápido quando não consegue adquirir o lock. Se quiser esperar, pode fazer retry algumas vezes com backoff curto. Não use um loop
while(true)sem limite.
InventoryRepository é qualquer repositório em seu projeto, por exemplo um Spring Data JPA repository.
5. Usar Redisson
Implementar manualmente ajuda você entender o mecanismo, mas faltam alguns recursos: renovação do lock durante processamento longo, reentrant, e esperar o lock com pub/sub em vez de polling. Redisson fornece tudo isso pronto.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Você deve verificar a versão mais recente compatível com sua versão do Spring Boot. Este starter lê a configuração spring.data.redis.* e cria um bean RedissonClient automaticamente.
@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("Outra instância está gerando o relatório, pulando");
return;
}
try {
log.info("Iniciando geração de relatório do dia");
// consultar dados, exportar arquivo, enviar email...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Quando o job executa em 3 instâncias, o log ficará assim:
[pod-1] Iniciando geração de relatório do dia
[pod-2] Outra instância está gerando o relatório, pulando
[pod-3] Outra instância está gerando o relatório, pulando
Pontos importantes:
tryLock(0, TimeUnit.SECONDS)não espera. Se o lock estiver sendo detido, a função retornafalseimediatamente.- Quando você não passa
leaseTime, Redisson ativa o watchdog. O lock tem um TTL padrão de 30 segundos (lockWatchdogTimeout) e é renovado a cada ~10 segundos, enquanto a thread mantiver o lock. Se a JVM morrer, a renovação para e o lock expira naturalmente. - Quando você chama
tryLock(wait, lease, unit)comleaseTimeespecífico, o watchdog é desativado. O lock expirará exatamente apóslease, independentemente se o trabalho terminou ou não. RLockestá ligado à thread e é reentrant. Se chamarunlock()de uma thread diferente, você receberáIllegalMonitorStateException. Isso é especialmente importante ao usar comCompletableFutureou código reativo.
Nota: Como
RLocké ligado à thread, tenha cuidado ao usar com virtual threads e frameworks que trocam de thread no meio. O lock deve ser adquirido e liberado na mesma thread.
6. Escrever testes com Testcontainers
Você deve testar o lock em um Redis real, não com mock. Adicione essas dependências de teste (versões gerenciadas pelo 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();
}
}
Explicação:
@DataRedisTestcarrega apenas a configuração do Redis, então o teste é rápido.@Import(RedisLock.class)adiciona o bean a testar ao contexto.@ServiceConnection(name = "redis")aponta automaticamente oStringRedisTemplatepara o container que acabou de iniciar. Você não precisa declarar host e porta manualmente.- O primeiro teste coloca 20 threads competindo por um lock e afirma que exatamente 1 vence.
- O segundo teste valida que o script Lua funciona corretamente: token errado não deleta o lock, token correto sim.
7. Erros comuns e limitações
7.1. TTL menor que o tempo de processamento
Este é o erro mais comum. O lock expira enquanto o trabalho ainda está rodando, e outra instância entra. A causa não é apenas código lento. Pausa de GC, rede lenta ou espera por connection pool também podem fazer você exceder o TTL.
Há duas formas de lidar. A primeira é usar o watchdog do Redisson. A segunda é usar um TTL generoso, por exemplo várias vezes o p99 do tempo de processamento.
7.2. O lock não é absolutamente garantido
A replicação no Redis é assíncrona. Suponha que o master recebe o lock mas falha antes de copiar para as réplicas. A réplica é promovida a novo master, e nele não existe a chave de lock. Agora um cliente pode adquirir o mesmo lock.
O algoritmo Redlock (adquirir lock na maioria em N nós independentes) reduz esse risco. Porém, Redlock ainda é controverso pois depende de hipóteses sobre relógios e latência. No Redisson, RedissonRedLock já está marcado como deprecated.
Se dados incorretos causarem consequências graves, como debitar duas vezes, não confie apenas no lock. Combine com um fencing token: cada vez que você adquire o lock, recebe um número crescente (por exemplo de INCR lock:product:42:fence). A camada de armazenamento rejeita escritas com token antigo:
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
Se a atualização afeta 0 linhas, significa que um processo com token mais novo já escreveu. A requisição antiga será descartada.
7.3. Outros erros
- Usar a mesma chave para todos os recursos, fazendo o throughput despencar.
- Esquecer
finally, então o lock só é liberado quando o TTL expira. - Chamar
unlock()do Redisson quando não está mais segurando o lock (porque expirou). VerifiqueisHeldByCurrentThread()antes. - Fazer retry contínuo para adquirir lock sem backoff, bombardeando Redis.
7.4. Quando NÃO usar
- Aplicação com uma única instância:
ReentrantLocké suficiente e muito mais simples. - Dados estão em um banco de dados relacional: optimistic locking (
@Version),SELECT ... FOR UPDATEou unique constraint geralmente são mais seguros. Eles fazem parte da mesma transação dos dados. - Precisa de garantia absoluta: considere sistemas com consenso como ZooKeeper ou etcd, combinados com fencing token.
- Apenas processar cada mensagem uma vez: idempotency key ou consumer group de message broker geralmente são melhores que lock.
8. Conclusão
Distributed lock com Redis se baseia em três princípios: adquirir o lock com um comando atomic SET NX PX, associar um token único a cada aquisição, e liberar o lock com script Lua validando o token. Você pode implementar em poucas linhas com StringRedisTemplate, ou usar Redisson para ter watchdog, reentrant e mecanismo de espera eficiente.
Lembre-se que o lock Redis ajuda evitar processamento duplicado na maioria dos casos, mas não é garantia absoluta. Com operações críticas, combine o lock com fencing token ou restrições na camada de banco de dados. E sempre teste o lock em um Redis real usando Testcontainers.