콘텐츠로 이동

2025 07 08

2025-07-08

Redisson MultiLock

참고: https://www.javadoc.io/doc/org.redisson/redisson/3.14.0/org/redisson/RedissonMultiLock.html
참고: https://www.javadoc.io/doc/org.redisson/redisson/3.14.0/org/redisson/api/RedissonClient.html

  • 개요
    • RedissonMultiLock은 여러 노드에 대한 락을 조합하여 사용하는 RedLock 구현체
    • 몇가지의 독립적인 락을 하나의 락 처럼 그룹화하여 다룸
  • 구현
    • RedissonClient: getMultiLock(RLock... locks)
      • 정의된 lock에 대한 MultiLock 인스턴스 반환
    • RedissonMultiLock: tryLockAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId)
      • threadId로 명시한 thread와 leaseTime으로 락을 획득하려고 함
      • lock을 획득할 때 까지 waitTime 만큼 기다림
      • leaseTime 인터벌 이후에 lock은 자동으로 해지함
      • 락 획득 시도할 때, multiLock에 정의된 전체 내부의 모든 락을 다 획득해야 함
        • newRLock 성공, oldRLock 실패 시, 모든 락에 대해 즉시 해제 명령 날림
  • 오버헤드
    • 락 획득/락 해제에 상당한 오버헤드가 발생하더라... 따라서 매우 작은 임계영역에서만 사용할 것!
    • 하나의 연산 = (락 획득 시간 + 실제 작업 시간 + 락 해제 시간)
      1. 락 획득 시간
        • Network Round-Trip Time: 두개의 Redis 서버 각각에게 락을 잡아달라는 명령. 최소 2번의 NRT
        • 락 경합 및 대기: 두 번째 작업부터는 앞선 작업이 락 해제해줄 때 까지 기다림, Pub/Sub을 통한 락 해제 알림 대기
      2. 실제 작업 시간
      3. 락 해제 시간
        • multiLock.unlockAsync() 역시 두개의 서버 각각에게 락 해제하는 명령
  • 테스트 코드
    • countDownLatch를 사용하여 모든 작업이 완료될 때 까지 쓰레드 기다리게 할 수 있음
      "멀티락 테스트" should {
          "멀티락 10개를 동시에 수행했을 때 정상적으로 동작해야 한다." in {
              val taskCount = 10
              val count = new AtomicInteger(0)
              val latch = new CountDownLatch(taskCount) // 10개의 작업 완료를 기다릴 Latch
      
              def addKey(uuid: String) = Future {
                  try {
                      Thread.sleep(100)
                      println(s"addKey function called, uuid: $uuid")
                      count.incrementAndGet()
                      println(s"key after increment: ${count.get()}")
                  } finally {
                      latch.countDown() // 작업이 성공하든 실패하든 Latch 카운트를 줄임
                  }
              }
      
              for (_ <- 1 to taskCount) {
                  val uuid = System.nanoTime()
                  // Try로 감싸서 redisLockClient 호출 자체의 예외 발생에 대비
                  Try(redisLockClient.multiLockAsync(testRedisKey, uuid)(addKey(uuid.toString)))
                    .recover { case _ => latch.countDown() } // 락 요청 자체 실패 시에도 카운트 감소
              }
      
              // Latch가 0이 될 때까지 최대 30초간 대기
              val allTasksCompleted = latch.await(30, TimeUnit.SECONDS)
      
              println(s"Final count: ${count.get()}")
      
              allTasksCompleted should be(true) // 타임아웃이 발생하지 않았는지 확인
              count.get() should be(taskCount)
          }
      }
      
  • 데드락 걸릴 가능성?

    • 프로세스 B가 진행되기 위해, 프로세스 A가 점유하고 있는 리소스를 대기하는 상황... (여기선 Lock)
    • 1차 배포: Old -> Old & New
      • 시나리오 1.
        • 프로세스 A Old 잡음
        • 프로세스 B Old&New 잡기 위해 대기 중
        • 프로세스 A Old 해제
        • 프로세스 B Old&New 잡음
      • 시나리오 2.
        • 프로세스 A Old&New 잡음
        • 프로세스 B Old 잡기 위해 대기 중
        • 프로세스 A Old&New 해제
        • 프로세스 B Old 잡음
    • 2차 배포: Old&New -> New
      • 시나리오 1.
        • 프로세스 A Old&New 잡음
        • 프로세스 B New 잡기 위해 대기 중
        • 프로세스 A Old&New 해제
        • 프로세스 B New 잡음
      • 시나리오 2.
        • 프로세스 A New 잡음
        • 프로세스 B Old&New 잡기 위해 대기 중
        • 프로세스 A New 해제
        • 프로세스 B Old&New 잡음
    • 유일하게 락 걸릴 가능성 (둘 다 Old&New 락 잡을 때)
      • 프로세스 A Old&New 잡을라고 함 -- 프로세스 B Old&New 잡을라고 함
      • 프로세스 A는 Old 부터 잡았고, 프로세스 B는 New 부터 잡았음
      • 이러면 서로가 서로 잡은거 놔줘야 한명이라도 잡는데, 못잡는 교착상태 발생
      • 이러려면 항상 동일한 순서로 Old&New를 잡는 보장이 있어야 함.
        • 다행히 RedissonMultiLock은 명확한 순서로 잡음
        • 참고: https://github.com/redisson/redisson/blob/master/redisson/src/main/java/org/redisson/RedissonMultiLock.java#L82
          • 현재 코드베이스 버전은 3.14
            // Redisson.java - val multiLock = oldClient.getMultiLock(oldRLock, newRLock)
            @Override
            public RLock getMultiLock(RLock... locks) {
                return new RedissonMultiLock(locks);
            }
            
            // RedissonMultiLock.java
            public RedissonMultiLock(RLock... locks) {
                if (locks.length == 0) {
                    throw new IllegalArgumentException("Lock objects are not defined");
                }
                this.locks.addAll(Arrays.asList(locks)); // 락 순서 리스트에 저장
            }
            
            @Override
            public RFuture<Boolean> tryLockAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
                RPromise<Boolean> result = new RedissonPromise<Boolean>();
                LockState state = new LockState(waitTime, leaseTime, unit, threadId);
                state.tryAcquireLockAsync(locks.listIterator(), result);
                return result;
            }
            
            void tryAcquireLockAsync(ListIterator<RLock> iterator, RPromise<Boolean> result) {
                if (!iterator.hasNext()) {
                    checkLeaseTimeAsync(result);
                    return;
                }
            
                RLock lock = iterator.next();
                RPromise<Boolean> lockAcquiredFuture = new RedissonPromise<Boolean>();
                if (waitTime == -1 && leaseTime == -1) {
                    lock.tryLockAsync(threadId)
                            .onComplete(new TransferListener<Boolean>(lockAcquiredFuture));
                } else {
                    long awaitTime = Math.min(lockWaitTime, remainTime);
                    lock.tryLockAsync(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS, threadId)
                            .onComplete(new TransferListener<Boolean>(lockAcquiredFuture));
                }
                // ...
            }