티스토리 뷰

Server

[Server] Redis와 LuaScript(레디스와 루아스크립트)를 활용한 슬라이딩 윈도우 구현하기

망나니개발자 2024. 5. 7. 10:00
반응형

 

 

1. Redis와 LuaScript(레디스와 루아스크립트)를 활용한
슬라이딩 윈도우 구현하기


분산 환경에서 5분 동안 특정 요청의 성공률이 50% 미만으로 떨어진 경우, fallback 로직을 수행해야 하는 요구사항이 있다고 하자. 이를 위한 다양한 구현 방식이 존재하는데, 레디스 기반으로 슬라이딩 윈도우를 구현해 해결할 수 있다.

 

 

 

[ 요구사항 구현하기 ]

먼저 현재 시간을 기준으로, 5분 동안의 성공률을 갱신해야 한다. 이를 위해 슬라이딩 윈도우 알고리즘을 사용할 수 있다.

 

 

슬라이딩 윈도우 알고리즘을 구현하는 다양한 방법이 있는데, 레디스의 Sorted Set을 사용하면 손쉽게 구현할 수 있다. 이는 다음과 같은 내용들을 바탕으로 구현하면 된다.

  1. 성공과 실패를 저장하는 Sorted Set을 관리함
  2. Sorted Set에는 현재 시간 데이터가 저장됨
  3. Sorted Set에 존재하는 원소의 개수가 성공/실패 횟수에 해당함

 

 

Sorted Set에 데이터를 저장하고 개수를 바탕으로 성공률을 계산하는 작업은 하나의 원자성 있는 작업으로 보장되어야 하는데, 이를 위해 루아스크립트(LuaScript)를 활용할 수 있다. 루아스크립트란 Lua 프로그래밍 언어로 작성된 사용자 지정 스크립트로, 복잡한 작업을 레디스 내에서 Atomic 하게 수행해준다.

다음과 같은 로직을 스크립트로 작성할 수 있다.

  1. 성공/실패 Sorted Set에 현재 시간 데이터를 추가함
  2. 추가한 데이터의 만료 시간을 설정함
  3. 성공/실패 Sorted Set에 존재하는 원소의 개수로 성공률을 계산하여 반환함

 

 

private fun updateSuccessRateScript(): DefaultRedisScript<Long> {
    val luaScript = """
        local currentMs = tonumber(ARGV[1])
        local maxScoreMs = tonumber(ARGV[2])
        local windowInSecond = tonumber(ARGV[3])
        local resultKey = KEYS[1]
        local successKey = KEYS[2]
        local failKey = KEYS[3]

        -- 키에 0 ~ (현재 시간 - 슬라이딩 윈도우 초)에 해당하는 요소들을 모두 제거함
        redis.call('ZREMRANGEBYSCORE', resultKey, '-inf', maxScoreMs)
        
        -- 키에 현재 시간 요소를 추가함
        redis.call('ZADD', resultKey, currentMs, currentMs .. "-" .. math.random())
        
        -- 키에 만료 시간을 설정함
        redis.call('EXPIRE', resultKey, windowInSecond)

        -- 성공 횟수와 실패 횟수를 가지고 비율을 계산하여 성공률을 반환함
        local successCount = redis.call('ZCARD', successKey)
        local failCount = redis.call('ZCARD', failKey)
        
        local totalCards = successCount + failCount
        local successRate = 0
        if totalCards > 0 then
            successRate = successCount / totalCards
        end
        
        return successRate * 100
    """.trimIndent()

    return createScript(luaScript)
}

private fun createScript(luaScript: String): DefaultRedisScript<Long> {
    val script = DefaultRedisScript<Long>()
    script.setScriptText(luaScript)
    script.resultType = Long::class.java
    return script
}

 

 

해당 스크립트의 호출은 redisTemplate을 통해 가능하다. 여기서 주의할 점은 루아스크립트의 인자로 전달할 key를 생성하는 부분인데, key에 따라 동일한 slot으로 요청이 전달되도록 해야 한다. 루아스크립트는 slot을 지정하는 hashkey를 ${}로 지정해줄 수 있다. 만약 하나의 요청이 동일한 slot으로 전달되지 않는다면 예외가 발생할 것이므로 key를 반드시 지정해주어야 한다.

fun updateSuccessRate(
    key: TargetServer,
    currentTime: Long,
    boolean isSuccess,
    windowInSecond: Int = WINDOWS_IN_SECOND,
): Long {
    // LuaScript에서 동일한 slot으로 요청이 가도록 해야 함
    // slot을 지정하는 hashkey를 ${}로 지정해줄 수 있어서 설정해줌
    val successKey = "{${key.code}}:success"
    val failKey = "{${key.code}}:fail"
    val maxScoreMs: Long = currentTime - (windowInSecond * 1000)

    return redisTemplate.execute(
        updateSuccessRateScript(),
        createKeys(isSuccess, successKey, failKey),
        currentTime.toString(),
        maxScoreMs.toString(),
        windowInSecond.toString()
    )
}

private fun createKeys(isSuccess: Boolean, successKey: String, failKey: String): List<String> {
    return when (isSuccess) {
        true -> listOf(successKey, successKey, failKey)
        false -> listOf(failKey, successKey, failKey)
    }
}

 

 

 

위의 스크립트는 여러 복잡한 상황들을 가정하지 않았다. 대표적으로 서로 다른 서버에서 동일한 currentTime으로 요청이 들어온 경우, 하나의 데이터로 취급된다. 이를 위해서는 현재 시간 + 요청온 사용자의 ID를 해싱한 값을 저장하는 방식으로 처리하여 개선할 수 있다.

위의 코드를 큰 배경으로 삼고, 필요한 케이스에 맞게 확장시키면 될 것이다.

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함