Redis - 레디스 클라이언트와 분산락, 루아스크립트

June·2023년 4월 17일
1

Redis

목록 보기
3/4

레디스 클라이언트

레디스 클라이언트는 레디스 서버에 연결하여 레디스 데이터 구조를 조작할 수 있게 해준다. 서버 개발자는 cli가 아닌 코드 상에서 레디스에 접근해서 데이터를 다룰 수 있어야 한다.

Java용 Redis 클라이언트 라이브러리로는 Lettuce, Redisson, Jedis 등이 있다.

  • Lettuce: 비동기 및 논블로킹 IO를 지원한다. 레디스 클러스터 지원.
  • Redisson: 비동기 및 논블로킹 IO를 지원한다. 분산락, 분산 집합, 분산 맵 등 다양한 분산 기능을 제공한다. 자체적으로 클러스터링 및 마스터-슬레이브 구성 지원.

참고로 Jedis는 멀티 쓰레드 불안정, 스레드 풀 문제 등으로 잘 사용되지 않는 추세라고 한다. Spring Boot 2.0부터 기본 클라이언트가 lettuce로 바뀌었다.

분산락

1. 필요성

실무에서 서비스의 로그들을 보면서 느끼는건데 '따닥' 요청은 생각보다 많이 들어온다. 사용자가 실수로 그렇게 한걸 수도 있고, 어뷰징 목적으로 그렇게 한 것일 수도 있다. 이때 만약 어떤 버튼을 눌렀을때 돈이 나가는 api를 친다면, 이런 따닥은 막아야한다. 물론 프론트에서도 막아야하지만 서버에서도 막아야 한다.

물론 이러면 자바 단에서 synchronized나 db에서 락을 걸면되는거 아니냐고 할 수 있어서 간단히 정리한다.

1. java - synchronized

자바의 synchronized는 하나의 프로세스 내에서만 보장이 된다. 서버가 두대 이상일 경우 메서드에 synchronized를 걸었다해도 그 메서드를 두 곳 이상에서 실행할 수 있는거다.

2. db - optimistic lock

lock을 걸지 않고 version column을 만들어준다. 데이터를 읽고 업데이트를 할 때 버전이 맞는지 확인하며 업데이트를 진행하는 방식이다.

이건 업데이트가 실패했을때 재시도 로직을 개발자가 직접 짜워야하고, 충돌이 자주 일어난다면 뒤에 나오는 pessimistic lock이 나을 수 있다.

3. db - pessimistic lock

다른 트랜잭션이 특정 row의 lock을 획득하는 것을 방지하는거다. 이런 경우 서버1이 락을 걸면 서버 2는 서버1이 락을 해제하기 전까지 데이터를 변경할 수 없다.

하지만 데드락이 발생할 수 있고, 락을 거는 것은 성능 저하를 유발한다.

2. 분산락 예시

redis-cli를 사용하여 Redis 서버에 접속하고, SETNX 명령어를 사용하여 락을 시도한다. SETNX 명령어는 해당 키가 존재하지 않는 경우에만 키를 생성하고, 락을 획득할 수 있다. SETNX 명령어는 락을 시도한 클라이언트에게 1(True)을 반환하며, 다른 클라이언트에 의해 락이 이미 획득된 경우 0(False)을 반환한다.

1. lettuce

SETNX 명령어 (SET if Not eXists)를 이용한다. 기존의 값이 없을때 set 하는 방식이다.

RedisLockRepository

@Component
public class RedisLockRepository {

    private RedisTemplate<String, String> redisTemplate;

    public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}

LettuceLockStockFacade

@Component
public class LettuceLockStockFacade {

    private RedisLockRepository redisLockRepository;

    private StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository repository, StockService stockService) {
        this.redisLockRepository = repository;
        this.stockService = stockService;
    }

    public void decrease(Long key, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(key)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(key, quantity);
        } finally {
            redisLockRepository.unlock(key);
        }
    }
}

lettuce는 코드에서 보면 알듯이 spin lock 방식이다. retry 로직을 개발자가 작성해야 하는데, 락을 획득할 수 있는지 반복적으로 확인하는 것이다. 이는 자원의 낭비가 되기 쉽다. 하지만 spring data redis를 사용할 경우 기본이기도해서 재시도가 필요하지 않으면 쓰기 좋다.

2. Redisson

redisson에서는 pub-sub 방식으로 lock 구현을 제공한다. 즉 공통 채널을 하나 만들고, 락을 점유중이던 스레드가 끝나면 채널에 알려주고, 다른 스레드가 락 획득을 시도하는 방식인 것이다. 스핀락이 아니기 때문에 부하가 훨씬 덜하다.

@Component
public class RedissonLockStockFacade {

    private RedissonClient redissonClient;

    private StockService stockService;

    public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
        this.redissonClient = redissonClient;
        this.stockService = stockService;
    }

    public void decrease(Long key, Long quantity) {
        RLock lock = redissonClient.getLock(key.toString());

        try {
            boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);

            if (!available) {
                System.out.println("lock 획득 실패");
                return;
            }

            stockService.decrease(key, quantity);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

RedissonClient 인터페이스를 보면 Rlock을 반환하는데,

RedissonClient

스레드의 대기 순서를 고려하지 않는다.

RLock

코드에서 사용했던 tryLock을 보면 waitTime까지 기다려 락을 획득하려는 것을 알 수 있다. leaseTime이 지나면 락은 풀린다.

RedissonLock은 RedissonBaseLock을 확장했고, RedissonBaseLock은 RLock을 구현했다.

루아 스크립트

RedissonClient를 구현한 Redisson에서 getLock을 하면 RedissonLock을 반환한다.

이 RedissonLock의 코드를 보면 아래와 같이 문자열이 하드코딩된 것을 볼 수 있다.

이게 루아 스크립트인데, 레디스에서 루아 엔진이 있기 때문에 이 스크립트를 실행할 수 있는 것이다.

local value1 = redis.call('get', KEYS[1])
local value2 = redis.call('get', KEYS[2])
return tonumber(value1) + tonumber(value2)

위는 레디스에 저장된 두 개의 값을 가져와서 더한 값을 반환하는 스크립트다.

@Service
class RedisService(private val redisTemplate: RedisTemplate<String, Any>) {

    fun executeScript(): Int {
        // 레디스 Lua 스크립트를 작성합니다.
        val script = """
            local value1 = redis.call('get', KEYS[1])
            local value2 = redis.call('get', KEYS[2])
            return tonumber(value1) + tonumber(value2)
        """.trimIndent()

        // 레디스 Lua 스크립트를 실행합니다.
        val result = redisTemplate.execute(
            RedisScript.of(script, Int::class.java),
            listOf("key1", "key2"),
            "value1",
            "value2"
        )

        return result ?: 0
    }

}

RedisTemplate을 사용해서 lua 스크립트를 실행하는 코드다.

루아스크립트를 쓰면 여러개의 Redis 명령어를 원자적으로 실행 가능하고, 또 스프링 서버와 레디스 서버간의 네트워크 비용을 줄일 수 있다. 또 동적으로 레디스에서 스크립트 실행 중 데이터를 처리할 수 있는 것도 장점이라고 한다.

하지만 레디스의 메인 스레드는 싱글스레드로 동작하기 때문에, 당연히 하나의 작업이 지나치게 길어지지 않게 주의해야 한다.

아직까지 실무에서 루아스크립트를 써서 문제를 해결해본 적이 없어서 이 부분은 생기면 추가하겠다.

참고

https://docs.spring.io/spring-data/redis/docs/2.3.3.RELEASE/reference/html/#tx.spring

http://redisgate.kr/redis/clients/redisson_intro.php

https://engineering.linecorp.com/ko/blog/atomic-cache-stampede-redis-lua-script

0개의 댓글