ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Redis 를 사용한 Distributed Lock
    Spring-Boot 2024. 5. 31. 13:55
    test

    분산 환경에서 서로 다른 클라이언트가 공유 리소스를 사용하는 경우

    동시성 문제가 발생할 수 있으며

    Redis를 사용하여 원자성을 보장할 수 있다.

    분산 환경에서 다양한 클라이언트가 공유 리소스를 사용할 때 동시성 문제가 발생할 수 있으며

    이러한 문제를 해결하기 위해 Redis를 사용하여 원자성을 보장할 수 있다.

    Spin Lock

    Redis에서는 SET NX (SET if Not Exists)라는 명령을 통해 값이 존재하지 않을 때만 설정할 수 있다. 이를 활용하여 Lock 메커니즘을 구현할 수 있다.

    💡 NX의 명령 구성은 key, value, 유지시간(ms)으로 구성된다.

     

    스핀 락은 클라이언트가 값을 설정할 때까지 SET NX 명령을 반복 시도하여 락을 획득하는 방법이며 아래는 Spin Lock의 작동 방식이다.

     

    1. 클라이언트 A가 NX 명령을 사용하여 키를 설정한다.
    2. 클라이언트 B는 NX 명령을 사용하지만, 키가 이미 존재하므로 설정에 실패하고 반복 시도한다. 이때 너무 많은 요청을 방지하기 위해 적절한 주기로 sleep을 사용한다.
    3. 클라이언트 A가 작업을 마치면 키를 삭제한다.
    4. B가 NX 명령으로 키 설정에 성공하여 락을 획득한다.
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    
    @Service
    @RequiredArgsConstructor
    public class LockService {
    
         private final RedisTemplate<String, String> redisTemplate;
    
         public String service(String key) throws InterruptedException {
    
          while (!redisTemplate
                  .opsForValue()
                  .setIfAbsent(key, "lock", Duration.ofMillis(3_000))){
                  
              Thread.sleep(100);
          }
          try {
              return //todo feature
          } finally {
              redisTemplate.delete(key)
          }
    
      }
    }

    Spin Lock 방법은 Sleep이 있기에 연속적인 락 획득 및 해제가 되지 않아 비효율적 일 수 있다.

    또한 가장 큰 문제로 단일 Redis 인스턴스가 싱글 스레드로 동작하는 경우 동시성 문제가 발생하지 않는다는 전제로 구현한 것이기 때문에

    SPOF(단일 장애 지점)이 될 수도 있다.

     

    만약 단일 Redis 가 다운이 되어 장애로 이어지는 문제를 해결하기 위해

    Slave 복제본을 사용한다고 해도

    Redis의 복제는 비동기이기 때문에 아래의 상황의 경우 상호배제의 안전속성을 지키지 못하고 문제가 발생한다.

     

    1. 클라이언트 A는 Master에서 Lock를 획득
    2. Master의 내용이 Slave로 복제되기 전에 Master 다운
    3. Slave 가 Master로 승격
    4. 클라이언트 B는 Lock을 획득한다. 그렇지만 A 도 Lock 이 존재.(상호배제 안전속성 위반)

    RedLock

    Redlock는 redis 가 권장하는 lock 방법이다

    called Redlock, which implements a DLM which we believe to be safer than the vanilla single instance approach. We hope that the community will analyze it, provide feedback, and use it as a starting point for the implementations or more complex or alternative designs.

     

    위의 Spin Lock처럼 반복해서 Lock 획득을 시도하는 방식이 아닌

    Message Broker를 사용하여 Lock 획득을 위한 채널을 구독하고 있다가

    Lock이 해제되었다는 메시지를 받으면 Lock을 획득하는 방식이다.

     

    이 방식은 Redis 클라이언트인 Redisson를 사용해야 한다

    Redisson는 Spin Lock, Multi Lock, Fenced Lock 등 다양한 Lock 방식을 제공한다.

    (spring-data-redis는 Lettuce 클라이언트를 사용하고 있다)

    implementation 'org.redisson:redisson-spring-boot-starter:3.30.0'
    

     

    tryLock 메서드를 사용하여 Lock을 획득 시도를 하며 최대 대기시간, 유지 시간을 지정한다.

    유지시간을 넣어 Lock을 획득한 스레드에서 문제가 발생하더라도 Lock가 해제되도록 한다

    @Service
    @RequiredArgsConstructor
    public class LockService {
        private final RedissonClient redissonClient;
    		 
        public String service(String key) {
        
    	    RLock lock = redissonClient.getLock(key);
    
    	    try {
    	    
    	      if (!lock.tryLock(10, 1, TimeUnit.SECONDS)) {
    	        throw new RuntimeException("lock timeout");
    	      }
    	      
    	      return //todo feature
    	    } catch (InterruptedException e) {
            	throw new RuntimeException(e);
    	    } 
    	    finally {
    	      lock.unlock(code);
    	    }
    
      }
    }

    SpEL과 Annotation으로Distributed Lock 처리

    Distributed Lock 가 필요할 때마다 코드로 작성하는 것보단 어노테이션으로 처리하는 게 간편하다.

    어노테이션

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface DistributedLock {
    	String key();
    }
    

     

    key를 변수로 가지는 어노테이션을 만드는데

    key에 해당하는 값을 @DistributedLock(key = "key-id") 하드코딩 해서 사용한다면 상관이 없지만 리소스의 id를 가지고 동적으로 lock key를 사용해야 하는 경우를 처리하기 위해

    SpEL로 파싱 해서 사용하려 한다.

    SpEL 파서

    public class SpELParser {
     
        public static Object getValue(String[] parameterNames, Object[] parameterValues, String key) {
            
            StandardEvaluationContext context = new StandardEvaluationContext();
            for (int i = 0; i < parameterNames.length; i++) {
                context.setVariable(parameterNames[i], parameterValues[i]);
            }
            
            ExpressionParser parser = new SpelExpressionParser();
            return parser.parseExpression(key).getValue(context, Object.class);
        }
    }

    어노테이션 처리 AOP

    @Aspect
    @Component
    @RequiredArgsConstructor
    public class DistributedLockAspect {
        
        private final RedissonClient redissonClient;
    
        @Around("@annotation(DistributedLock)")
        public Object distributedLock(final ProceedingJoinPoint joinPoint) throws Throwable {
            
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            DistributedLock annotation = method.getAnnotation(DistributedLock.class);
            Object key = SpELParser.getValue(
                    signature.getParameterNames(),
                    joinPoint.getArgs(),
                    annotation.value()
            );
            
            RLock lock = redissonClient.getLock(key);
            if (!lock.tryLock(10, 1, TimeUnit.SECONDS)) {
                throw new RuntimeException();
            }
    
            try {
                return joinPoint.proceed();
            } finally {
                lock.unlock();
            }
        }
    }

    사용

    @DistributedLock(key = "#id")
    public void service(String id){}
    

    @Transactional와 Redis lock의 사용 주의

    락을 획득하는 메서드가 @transactional에 있는 경우 동시성 문제가 발생할 수 있다.

    이경우 Tx를 시작한 후에 락 획득을 하고 락을 반납한 후에 Tx를 종료하게 되기 때문에

    A 클라이언트와 B 클라이언트가 이미 Tx에 진입한 상태에서 공유 Lock에 대한 메커니즘이 동작하게 된다.

    따라서 @Transactional 아닌 transcational manager직접 사용하는 등 다른 방법이 필요하다.

     

     

    참고 및 출처

    https://redis.io/docs/latest/develop/use/patterns/distributed-locks/

    https://github.com/redisson/redisson?tab=readme-ov-file