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 SETNX seguido de EXPIRE em 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 comando SET key token NX PX ttl. O resultado true significa que você adquiriu o lock.
  • tryLock retorna o token se bem-sucedido. Você deve guardar esse token para liberar o lock depois.
  • unlock executa 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.
  • unlock sempre fica no finally, 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 retorna false imediatamente.
  • 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) com leaseTime específico, o watchdog é desativado. O lock expirará exatamente após lease, independentemente se o trabalho terminou ou não.
  • RLock está ligado à thread e é reentrant. Se chamar unlock() de uma thread diferente, você receberá IllegalMonitorStateException. Isso é especialmente importante ao usar com CompletableFuture ou 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:

  • @DataRedisTest carrega 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 o StringRedisTemplate para 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). Verifique isHeldByCurrentThread() 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 UPDATE ou 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.

redisdistributed-lockspring-bootredissonconcurrency