phantasmicmeans 기술 블로그

Spring Data Redis - Lua Script 본문

Web Programming/spring

Spring Data Redis - Lua Script

phantasmicmeans 2019. 8. 10. 22:53

오랜만에 글을 쓴다.. 몇달간 죈종일 코딩만 하다보니 여유가 없었다.. 앞으로는 틈틈히 다시 정리하는 차원에서 글을 써보려 한다.

 

Multi Thread 환경에서 작업하다보니 공유 자원에 대한 atomic한 flow가 항상 필요했고, 모든 상황에 대해 Application 단에서 각 thread 별로 공유자원에 접근하는 flow를 sync하게 처리할 순 없다. 또한 사용자의 request를 multi-thread로 처리하는 경우 공유자원에 대한 ACID가 지켜지지 않을 수 있다.

 

자주 사용하는 인 메모리 데이터 스토어인 Redis를 활용 함에 있어서도 마찬가지이다. single thread로 동작하는것은 다들 아실테니..

 

평소 RTT를 줄이기 위해 Pipeline을 자주 사용한다. 많은 양의 데이터를 write 하는 로직에는 pipeline이 최고다. Round Trip이 단 한번이기 때문.. 

 

그러나 무언가 write하고, 그 값을 Application이 받아와 다른 플로우를 거친 뒤, 다시 write해야하는 상황에서는 pipeline이 적합하지 않다..

 

그리하여 Lua Script를 활용하게 되었다. redis 2.6 이상이면 이를 지원하고, script는 EVAL, EVALSHA command에 의해 실행된다. EVAL의 첫번째 arg는 Lua 5.1 script이고, 2번째 args는 Key 리스트, 3번째 args는 인자로 넘길 데이터들이다. 뭐 쉽게 말해 EVAL을 통해 script와 key list, 추가 args list를 넘겨준다.

 

실행된 script는 redis script cache로 저장되며, sha값으로 저장된다. 추후 같은 내용의 스크립트 실행시 EVALSHA에 의해 cache된 script를 로드한다. 재사용한다는 얘기이다. 

local current = redis.call('zrangebyscore', KEYS[1], ARGV[1], ARGV[2], 'LIMIT', ARGV[3], ARGV[4])
if (current == nil or current == '') then
    return "failed"
else
    for i, mem in pairs(current) do
        redis.call('zincrby', KEYS[1], 1, mem)
        return current
    end
end

script는 위와 같고, 인자로 KEYS, ARGV[1 ~ 4]를 받아 실행한다.  예를 들어 ZRANGEBYSCORE("key", 0, 5, 0, 1) 일때,

첫번째 arg는 key아고, 두번째 args 순서는 min, max, offset, count이다. 순서대로 (최소값, 최대값, offset, 추출 원소 개수) 이해하면 된다. (참고로 인덱스 0은 없다.)

 

즉 sorted set으로부터 min ~ max 사이의 value를 가진 list들 중, offset을 기준으로 count 수 만큼 추출하게 된다. 그리고 그 값이 존재하면 ZINCRYBY ("keys", 1, member) 를 실행한다.

 

ZINCRBY("keys, 1, member)는 한눈에 유추 가능할 것이다.. 위 zrangebyscore로 부터 추출한 원소의 value에 1을 더하는 셈이다. 즉 value = value + 1이다. 어쨌든 lua script는 atomic하게 실행되고, 해당 script가 실행되는중에는 다른 operation은 대기상태이다. (고려해서 사용하면 된다.)

 

간단히 lua script를 활용하면 RTT도 줄이며.. application의 thread들을 sync하게 처리하지 않고, 빠르게 처리할 수 있다.  redis script를 사용하기 위한 spring boot configuration은 아래와 같고, spring boot 2.x 이다. 

 

configuration에 다음을 Bean으로 추가하고

    @SuppressWarnings("unchecked")
    @Bean
    public RedisScript<Object> script() throws IOException {
        String location = "/app/redis/";
        try {
            Path rootLocation = Paths.get(location);
            Path file = rootLocation.resolve("script.lua");
            Resource resource = new UrlResource(file.toUri());
            ScriptSource scriptSource = new ResourceScriptSource(resource);
            return RedisScript.of(scriptSource.getScriptAsString(), Object.class);
        } catch (MalformedURLException e) {
            log.error("[CONFIGURATION] REDIS LUA SCRIPT SET FAILED => " + e.getMessage());
            throw new FileNotFoundException();
        }
    }

참고로 Spring Data Redis 공식 문서에 따르면 

The scriptresultTypeshould be one ofLong,Boolean,List, or a deserialized value type. It can also benullif the script returns a throw-away status (specifically,OK). 라고 나와있으니 참고하자..

 

실제 사용은 이런식으로 .. 

@Slf4j
@Service
public class RedisStorageService {
    @Autowired
    RedisScript<Object> script;
    
    @SuppressWarnings("unchecked")
    public String luaRedis() {
        List<String> spares = null;
        try {
            spares = (ArrayList <String>) redisTemplate.execute(script, Collections.singletonList("key"), "0", "4", "0", "1"); //min, max, offset, count
            return Optional.ofNullable(spares)
                           .orElseThrow(RuntimeException::new) // if spares null -> exception
                           .stream()
                           .findFirst()
                           .orElseThrow(RuntimeException::new); // if findFirst(isEmpty) null -> exception
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }
   
   }

exception은 일부러 RuntimeException으로 뒀으니.. 알아서 고쳐 사용하면 된다. 결론적으로 어렵지 않다. 또한 script를 string으로 입력할 수도 있다고 한다.. 

Comments