Redis 분산 잠금 및 일반적인 문제에 대한 솔루션

Redis는 데이터베이스, 캐시, 메시지 브로커로 사용할 수 있는 인메모리 데이터 구조 스토리지 시스템입니다. 고성능과 유연한 데이터 구조로 인해 Redis는 분산 잠금 구현을 포함한 다양한 시나리오에서 널리 사용됩니다.

분산 잠금은 분산 시스템에서 상호 배타적인 액세스를 구현하는 기술입니다. 많은 실제 애플리케이션 시나리오에서는 공유 리소스 업데이트, 작업 대기열 처리 등과 같은 특정 작업이 동시에 하나의 노드에서만 실행될 수 있도록 보장해야 합니다. 이때 분산 잠금을 사용해야 합니다.

Redis는 분산 잠금을 구현하는 간단하고 효과적인 방법을 제공합니다. 기본 아이디어는 키가 존재하지 않을 때 값을 설정하고 키가 이미 존재할 경우 아무 작업도 수행하지 않는 Redis의 SETNX 명령을 사용하는 것입니다. 이 원자적 작업을 통해 여러 노드 간에 상호 배타적인 액세스를 달성할 수 있습니다.

그러나 Redis 분산 잠금의 구현은 비교적 간단하지만 잠금 시간 초과 및 갱신 문제, 잠금 공정성 문제, 네트워크 파티션 문제 등 실제 사용에서는 고려해야 할 많은 문제가 있습니다. 다음 기사에서는 이러한 문제와 해결 방법을 자세히 설명합니다.



1. Redis 분산 잠금 소개
1.1 분산 잠금 정보

분산 시스템에서 스레드가 데이터를 읽고 수정할 때 읽기, 업데이트 및 저장은 원자성 작업이 아니기 때문에 동시성 중에 동시성 문제가 발생하기 쉽고 이로 인해 잘못된 데이터가 발생합니다. 이 시나리오는 전자상거래 깜짝 세일 활동, 재고 수량 업데이트 등 매우 일반적입니다. 독립 실행형 애플리케이션인 경우 로컬 잠금을 직접 사용하여 피할 수 있습니다. 분산 애플리케이션인 경우 로컬 잠금은 유용하지 않으며 문제를 해결하려면 분산 잠금을 도입해야 합니다.

일반적으로 분산 잠금을 구현하는 방법은 다음과 같습니다.

  1. MySQL을 사용하는 방법: 데이터베이스에 고유한 인덱스 테이블을 생성한 후 데이터의 일부를 삽입하여 잠금을 획득하는 방법으로, 삽입에 성공하면 잠금 획득에 성공하고, 그렇지 않으면 잠금 획득에 실패합니다. 잠금을 해제하는 작업은 이 데이터 조각을 삭제하는 것입니다. 이 방식의 장점은 구현이 간단하다는 점이지만, 데이터베이스 작업을 포함하기 때문에 성능이 떨어진다는 단점이 있다.
  2. ZooKeeper 사용: ZooKeeper는 기본 분산 잠금 구현을 제공합니다. 기본 개념은 임시로 정렬된 노드를 생성한 후, 모든 자식 노드 중 가장 작은 시퀀스 번호를 가지고 있는지 판단하여, 그렇다면 잠금을 성공적으로 획득하고, 그렇지 않으면 자신보다 작은 시퀀스 번호를 가진 노드를 청취하고, 노드가 삭제되면 잠금을 다시 획득해 보십시오. 이 방식의 장점은 공정성을 확보할 수 있다는 점이지만 구현이 더 복잡하다는 단점이 있다.
  3. Redis 사용: 이 방법은 Redis의 SETNX 명령을 통해 구현되며, 이 명령은 키가 없을 때 값을 설정할 수 있고, 키가 이미 있을 경우에는 아무 작업도 수행하지 않습니다. 이 원자적 작업을 통해 여러 노드 간에 상호 배타적인 액세스를 달성할 수 있습니다. 이 방법의 장점은 고성능과 간단한 구현이지만, 단점은 잠금 시간 초과 및 갱신 문제를 처리해야 한다는 것입니다.
1.2 Redis 분산 잠금 개요

Redis에서는 SETNX명령을 사용하여 분산 잠금을 구현할 수 있습니다. 구체적인 단계는 다음과 같습니다.

  1. 잠금: 클라이언트는 SETNX key value명령을 사용하여 키를 설정하려고 합니다. 여기서 key는 잠금의 이름이고 value잠긴 클라이언트를 식별하는 데 사용되는 고유 식별자(예: UUID)입니다. 키가 없으면 SETNX명령은 키 값을 설정하고 잠금이 성공했음을 나타내는 1을 반환합니다. 키가 이미 있으면 명령은 SETNX키 값을 변경하지 않고 잠금에 성공했음을 나타내는 0을 반환합니다. 잠금에 실패했습니다.

이미지-20230916113717663

  1. 비즈니스 작업 수행: 잠금을 성공적으로 획득한 후 클라이언트는 보호가 필요한 비즈니스 작업을 수행할 수 있습니다.
  2. 잠금 해제: 비즈니스 작업을 완료한 후 클라이언트는 다른 클라이언트가 잠금을 획득할 수 있도록 잠금을 해제해야 합니다. 잠긴 클라이언트만 잠금을 해제할 수 있도록 하려면 클라이언트는 먼저 잠금 값(즉, 고유 식별자)을 얻은 다음 잠금 값을 자신의 고유 식별자와 비교하여 동일한지 확인해야 합니다. 동일할 경우 명령어를 이용하여 키를 삭제하여 잠금을 해제합니다 DEL key.

2. Redis 분산 잠금의 문제점과 해결 방법
2.1. 잠금 시간 초과 메커니즘

다음은 기본적인 Redis 분산 잠금 사용 프로세스입니다.

  1. 클라이언트 A는 SETNX lock.key 명령을 보내고, 1이 반환되면 클라이언트 A가 잠금을 획득합니다.
  2. 클라이언트 A는 실행이 완료된 후 DEL lock.key 명령을 통해 잠금을 해제합니다.

然而,这种最基本的锁存在一个问题,那就是如果客户端 A 在执行完毕后,因为某些原因(比如崩溃或网络问题)无法发送 DEL 命令来释放锁,那么其他客户端将永远无法获得锁。为了解决这个问题,我们需要引入锁的超时机制。

이미지-20230916130718524

下是一个带有超时机制的 Redis 分布式锁的使用流程:

  1. 客户端 A 发送一个 SETNX lock.key 命令,如果返回 1,那么客户端 A 获得锁。
  2. 客户端 A 通过 EXPIRE lock.key timeout 命令设置锁的超时时间。
  3. 客户端 A 执行完毕后,通过 DEL lock.key 命令释放锁。

这样,即使客户端 A 在执行完毕后无法释放锁,其他客户端也可以在锁超时后获得锁。

2.2、锁续期机制

然而,这种带有超时机制的锁还存在一个问题,那就是如果客户端 A 在锁即将超时时仍在执行,那么锁可能会被其他客户端获得,从而导致多个客户端同时持有锁。为了解决这个问题,我们需要引入锁的续期机制。

이미지-20230916130800910

以下是一个带有续期机制的 Redis 分布式锁的使用流程:

  1. 客户端 A 发送一个 SETNX lock.key 命令,如果返回 1,那么客户端 A 获得锁。
  2. 客户端 A 通过 EXPIRE lock.key timeout 命令设置锁的超时时间。
  3. 客户端 A 在执行过程中,定期通过 EXPIRE lock.key timeout 命令续期锁。
  4. 客户端 A 执行完毕后,通过 DEL lock.key 命令释放锁。

这样,即使客户端 A 的执行时间超过了最初的超时时间,也可以通过续期机制保证锁的互斥性。

2.3、误删锁问题

引入锁的续期机制可以解决锁提前过期的问题,但是并不能解决解锁时可能删除其他线程锁的问题。这是因为,即使有了续期机制,仍然存在这样一种情况:线程 A 在锁即将过期时仍在执行业务逻辑,此时锁过期,线程 B 获取到了锁,然后线程 A 执行完业务逻辑,尝试去删除锁,结果删除的是线程 B 的锁。

为了解决这个问题,我们可以使用 Redis 的 Lua 脚本功能,将这三个操作封装在一个 Lua 脚本中,然后使用 EVAL 命令执行这个 Lua 脚本。由于 Redis 会单线程顺序执行所有命令,因此 EVAL 命令可以保证 Lua 脚本中的操作是原子的。

以下是一个使用 Lua 脚本实现解锁的例子:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

이 Lua 스크립트에서는 먼저 get명령을 사용하여 잠금 값을 가져온 다음 잠금 값과 클라이언트의 고유 식별자를 비교하여 동일하면 명령을 사용하여 잠금을 삭제합니다 del.

클라이언트는 다음 명령을 사용하여 이 Lua 스크립트를 실행할 수 있습니다:

EVAL script 1 key value

그중에는 scriptLua 스크립트의 내용, key잠금 이름, value클라이언트의 고유 식별자가 있습니다.

2.4 분할 브레인 문제와 Redlock

Redis 클러스터에서 동기화 잠금이 슬레이브 노드로 전송되기 전에 마스터 노드가 죽으면 슬레이브 노드는 마스터 노드로 업그레이드된 후 잠금이 존재하지 않는다고 잘못 믿어 다른 클라이언트가 잠금을 얻을 수 있습니다. 동일한 잠금이 발생합니다.여러 클라이언트가 동시에 보유하는 문제.

이 문제를 해결하기 위해 RedLock 알고리즘을 사용할 수 있습니다. RedLock은 Redis에서 공식적으로 권장하는 분산 잠금 구현 알고리즘으로, 기본 개념은 여러 개의 독립적인 Redis 노드에 동시에 잠금을 획득하려고 시도하는 것이며, 대부분의 Redis 노드가 잠금을 성공적으로 획득한 경우에만 전체 작업이 성공한 것으로 간주됩니다.

다음은 RedLock 알고리즘의 기본 단계입니다.

  1. 현재 시간을 밀리초 단위로 가져옵니다.
  2. 각 시도에 대해 고정된 시간 제한을 두고 모든 Redis 노드에 대한 잠금을 순차적으로 획득하려는 시도가 이루어집니다. 잠금 획득에 실패하면 즉시 반환하고 더 이상 다른 노드를 시도하지 마세요.
  3. 대부분의 Redis 노드의 잠금이 성공적으로 획득되고 잠금을 획득하는 데 걸린 총 시간이 잠금의 유효 기간보다 짧으면 전체 작업이 성공한 것입니다.
  4. 잠금을 획득하는 데 걸린 총 시간이 잠금 유효 기간보다 길거나 대부분의 Redis 노드의 잠금이 성공적으로 획득되지 않은 경우 모든 Redis 노드에서 잠금이 해제됩니다.
  5. 전체 작업이 성공하면 잠금의 유효 기간은 원래 유효 기간에서 잠금을 획득하는 데 걸린 총 시간을 뺀 값입니다.

위의 내용은 RedLock 알고리즘의 기본 단계입니다. RedLock 알고리즘은 동시에 여러 개의 독립적인 Redis 노드에서 잠금을 획득하려고 시도함으로써 마스터 노드의 장애로 인해 발생하는 잠금 손실 문제를 어느 정도 해결할 수 있습니다. 그러나 RedLock 알고리즘은 잠금의 보안을 완전히 보장하지 않는다는 점에 유의해야 합니다. 네트워크 파티션 또는 노드 시간 동기화가 중단된 경우 동일한 잠금이 여러 클라이언트에 의해 동시에 유지될 수 있기 때문입니다. . 따라서 RedLock 알고리즘을 사용할 때에는 실제 상황에 따른 상세한 설계와 테스트가 필요하다.

2.5 공정성 문제

또한 Redis 분산 잠금 구현 시 잠금 공정성이 문제가 될 수 있습니다. 소위 공정성이란 여러 클라이언트가 동시에 잠금을 요청할 때 요청 순서대로 잠금을 할당해야 함을 의미합니다. 그러나 네트워크 지연 시간과 Redis의 단일 스레드 모델로 인해 Redis 분산 잠금은 공정성을 보장할 수 없습니다. 특히 여러 클라이언트가 동시에 잠금을 요청하는 경우 이러한 요청은 네트워크 지연으로 인해 서로 다른 시간에 Redis에 도착할 수 있으며 Redis는 요청이 도착하는 순서대로 잠금을 할당하며 이는 클라이언트의 요청 순서와 다를 수 있습니다. 또한 Redis에 여러 요청이 동시에 도착하더라도 Redis의 단일 스레드 모델로 인해 Redis는 이러한 요청을 순서대로만 처리할 수 있으며 처리 순서는 클라이언트의 요청 순서와 다를 수 있습니다.

따라서 애플리케이션에 공정한 분산 잠금이 필요한 경우 ZooKeeper 기반의 것과 같은 다른 분산 잠금 구현을 사용해야 할 수도 있습니다. ZooKeeper의 분산 잠금은 잠긴 노드 아래에 순차적인 임시 노드를 생성하고 자신의 노드가 가장 작은 노드인지 비교하여 잠금 획득 여부를 확인함으로써 잠금의 공정성을 보장합니다.


3. Java에서 Redis 분산 잠금 구현
3.1 제다이 구현

Java에서는 Jedis 또는 Lettuce와 같은 Redis 클라이언트 라이브러리를 사용하여 Redis 분산 잠금을 구현할 수 있습니다. 기본 구현 예는 다음과 같습니다.

import redis.clients.jedis.Jedis;

public class RedisLock {
    
    
    private Jedis jedis;
    private String lockKey;
    private String lockValue;
    private int expireTime;
    private boolean locked = false;

    public RedisLock(Jedis jedis, String lockKey, int expireTime) {
    
    
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.expireTime = expireTime;
        this.lockValue = Thread.currentThread().getId() + "-" + System.nanoTime();
    }

    public boolean lock() {
    
    
        long startTime = System.currentTimeMillis();
        while (true) {
    
    
            String result = jedis.set(lockKey, lockValue, "NX", "PX", expireTime);
            if ("OK".equals(result)) {
    
    
                locked = true;
                return true;
            }
            // 如果没有获取到锁,需要稍微等待一下再尝试
            try {
    
    
                Thread.sleep(100);
            } catch (InterruptedException e) {
    
    
                throw new RuntimeException(e);
            }
            // 如果尝试获取锁超过了expireTime,那么返回失败
            if (System.currentTimeMillis() - startTime > expireTime) {
    
    
                return false;
            }
        }
    }

    public void unlock() {
    
    
        if (!locked) {
    
    
            return;
        }
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(script, 1, lockKey, lockValue);
    }
}

set이 예에서는 명령의 NX및 옵션을 사용하여 PX잠금 획득 및 시간 초과 설정을 구현하고 Lua 스크립트를 사용하여 안전한 잠금 해제 작업을 구현합니다. 또한 잠금이 성공적으로 획득되거나 시도 시간이 만료될 때까지 잠금 획득을 계속 시도하기 위해 while 루프를 사용합니다.

그러나 이 예에서는 잠금 갱신 메커니즘을 구현하지 않습니다. 갱신 메커니즘을 구현하려면 다른 스레드에서 잠금의 남은 시간을 정기적으로 확인해야 하며, 남은 시간이 충분하지 않으면 명령을 사용하여 잠금 시간 초과를 재설정해야 합니다 expire. 이를 위해서는 Java의 ScheduledExecutorService를 사용하여 주기적으로 갱신 작업을 수행하는 등 구현하기 위한 더 복잡한 코드가 필요합니다.

3.2 스프링부트 구현

Spring Boot에서는 Redis 클라이언트 라이브러리인 Redisson을 사용하여 Redis 분산 잠금을 구현할 수 있습니다. Redisson은 분산 잠금, 분산 컬렉션, 분산 큐 등을 포함한 풍부한 분산 서비스 세트를 제공하며 Redisson에는 잠금 시간 초과 및 갱신 메커니즘이 내장되어 있으며 실수로 잠금을 삭제하는 문제를 해결합니다.

다음은 Redisson을 사용하여 Redis 분산 잠금을 구현하는 기본 예입니다.

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;

@Component
public class RedissonDistributedLocker {
    
    

    private RedissonClient redissonClient;

    @PostConstruct
    public void init() {
    
    
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        redissonClient = Redisson.create(config);
    }

    public void lock(String lockKey) {
    
    
        RLock lock = redissonClient.getLock(lockKey);
        // Wait for 100 seconds and automatically unlock it after 10 seconds
        lock.lock(10, TimeUnit.SECONDS);
    }

    public void unlock(String lockKey) {
    
    
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }
}

이 예에서는 먼저 init메서드에서 RedissonClient 인스턴스를 생성한 다음 lock메서드에서 RLock 개체를 획득하고 해당 lock메서드를 호출하여 잠금을 획득합니다. 이 메서드 에서는 unlockRLock 개체도 얻고 해당 unlock메서드를 호출하여 잠금을 해제합니다.

Redisson의 메소드는 자동으로 갱신되며, 잠금을 보유한 스레드가 계속 실행 중인 한 스레드가 종료되거나 메소드가 명시적으로 호출될 lock때까지 잠금이 갱신됩니다 . unlock따라서 갱신 메커니즘을 수동으로 구현할 필요가 없습니다. 또한 Redisson의 unlock방법은 현재 스레드가 잠금을 보유하고 있는지 확인하여 잠금을 보유하고 있는 스레드만이 잠금을 해제할 수 있으므로 실수로 잠금을 삭제하는 문제를 해결합니다.

Supongo que te gusta

Origin blog.csdn.net/weixin_45187434/article/details/132916333
Recomendado
Clasificación