티스토리 뷰
1. Lettuce를 사용하는 경우에 MGET 동작 방식과 해시태그(HashTag)
[ Redis MGET 명령어와 CrossSlot 에러 ]
MGET 이란 Multi-Get의 줄임말로, 여러 개의 key에 대하여 GET 요청을 보내는 방식을 의미한다. 그러면 레디스는 각각의 GET 요청에 대한 응답을 목록으로 반환하여 제공해준다.
MGET key1 key2 key3 key4
1) "value1"
2) "value2"
3) "value3"
4) "value4"
MGET 요청을 보낼 때는 주의할 점이 하나 있는데, 레디스 클러스터 환경에서는 MGET 요청으로 전달되는 key들이 항상 동일한 슬롯을 향해야 한다는 것이다. 레디스 클러스터 환경에는 키를 특정 노드에 배분하는 단위인 슬롯(Slot)이라는 개념이 존재한다. 기본적으로 16384개의 슬롯으로 구성되며, 각각의 클러스터가 슬롯을 다음과 같이 나눠 갖는다.
레디스는 요청 키를 CRC 16 해싱한 값으로 슬롯 번호를 구하고, 해당 슬롯이 존재하는 레디스 클러스터에 요청을 보내게 된다. 따라서 기본적인 레디스 구현에 따르면, MGET 요청이 서로 다른 슬롯을 향하게 될 경우, 다음과 같은 에러를 만나게 된다.
mangkyu@mangkyu-1 ~ % redis-cli mget key1 key2 key3 key4 key5
(error) CROSSSLOT Keys in request don't hash to the same slot
[ Lettuce 클라이언트를 사용하는 경우의 MGET 동작 방식 ]
자바 진영에서는 레디스와 통신하기 위해 레디스 클라이언트(Redis Client) 중 하나인 Lettuce가 많이 사용된다. 그렇다면 Lettuce는 MGET 동작을 어떻게 처리하는지 살펴보도록 하자.
다음과 같이 {0}MangKyu0, {1}MangKyu1, … {98}MangKyu98, {99}MangKyu99 과 같이 문자열을 만들고, Lettuce를 사용하는 redisTemplate을 통해 MGET 요청을 보낸다고 하자. 이러한 요청을 Lettuce는 어떻게 처리할까?
fun mget(): List<Long> {
val set: MutableSet<String> = HashSet()
for (i in 0 until 100) {
val tmp = "{${i}}" + "MangKyu${i}"
set.add(tmp)
}
return redisTemplate.opsForValue().multiGet(set) ?: emptyList<>()<>()
}
Wireshark 도구를 이용해 TCP 패킷을 분석해보면, 다음과 같이 슬롯 단위로 N번 연산을 보내게 됨을 확인할 수 있다.
Redis CLI에서는 CROSSSLOT 에러가 발생한 반면 Lettuce에서는 이러한 에러가 발생하지 않기에, 해당 처리에 대한 부분을 Lettuce가 내부적으로 지원하고 있음을 추측할 수 있다. 실제로 Lettuce의 MGET 구현을 살펴보면, 다음과 같이 요청하는 키들을 슬롯 단위로 구분하고, 레디스에 MGET 요청을 전송하도록 해두었음을 확인할 수 있다.
[ Lettuce 클라이언트에서 MGET 사용 시의 주의 사항 ]
앞서 살펴보았듯 Lettuce 클라이언트는 MGET 요청을 보내는 경우에 슬롯 단위로 MGET을 구분하여 레디스로 요청함을 확인할 수 있었다. 따라서 Lettuce에서 MGET 요청을 보낼 때, 슬롯 관련 처리를 적절하게 하지 않으면 예상치 못한 과도한 요청이 발생할 수 있다.
레디스에는 해시태그(hashtag)라는 것이 존재하는데, 요청을 보내는 키에 다음과 같이 {} 중괄호로 요청을 처리할 해시 슬롯을 계산할 값을 지정해줄 수 있다. 참고로 여러 개의 {} 문자가 포함된 키의 경우 가장 처음의 {부터 가장 처음의 } 사이의 값들이 해싱된다.
- {user1000}.followers → user1000
- user{}id → user{}id
- user{{name}}id → {name
- user{name}{id} → name
이제 위의 코드를 바탕으로 MOD 10 연산을 통해 해시태그를 적용하면 다음과 같이 적용됨을 확인할 수 있다.
fun mget(): List<Long> {
val set: MutableSet<String> = HashSet()
for (i in 0 until 100) {
val tmp = "{${i % 10}}" + "MangKyu${i}"
set.add(tmp)
}
return redisTemplate.opsForValue().multiGet(set) ?: emptyList<>()
}
MGET 요청의 키들이 지나치게 별도의 해시 슬롯에 배치되지 않도록 해시태그를 위와 같이 적절히 연산해주면, 다음과 같이 레디스의 MGET 요청을 어느 정도 제어할 수 있다. 이를 통해 특정 레디스 슬롯으로 MGET 연산의 범위를 좁혀 효율적인 처리 방식으로 구현 할 수 있는 것이다.
하지만 여기서 주의할 점이 있는데, 현재 MOD 10 연산으로 저장된 값이 해시 슬롯에 매핑되는 것이 아니라는 점이다. 예를 들어 위에서 해시 태그가 9라고 적힌 부분은 일괄적으로 동일한 슬롯으로 요청이 나가지만, 10번 슬롯으로의 요청은 아닌 것이다. 실제로 MGET 요청이 나가는 슬롯 부분을 살펴보면 다음과 같음을 확인할 수 잇다.
레디스는 Key-Vale 형태로 데이터를 저장하는데, 다음의 레디스 코드에서 확인할 수 있듯이 요청을 보낼 때 Key 역시 직렬화가 진행되어 바이트 기반으로 변형된다.
그리고 변형된 키를 인코딩하는 과정을 거치는 것을 확인할 수 있다.
그리고 실제 슬롯을 계산할 때 다음과 같이 내부적으로 CRC 알고리즘으로 처리되고 있음을 확인할 수 있다.
해시 슬롯이 지정된 경우에는 해당 위치의 값을 바탕으로 slot을 구하는 것이고 없다면 해당 키 자체를 바탕으로 구하게 된다.
예를 들어 우리가 {1}과 같이 해시태그를 지정했다면 문자열 1이 바이트로 인코딩된 후 해당 값을 통해 해시 슬롯 계산이 되기 때문에 1번 해시슬롯으로 요청이 가는 것이 아니다. 이러한 부분을 간략하게 정리하면 다음과 같다.
@Test
fun getHashSlot() {
val value = "1"
val bytes: ByteArray = value.toByteArray()
// CRC16 계산
val crc = CRC16.crc16(bytes) % SlotHash.SLOT_COUNT
println("CRC16 of 1L: " + crc) // 9842
}
따라서 이러한 부분에 주의해서 레디스를 사용해주도록 하자.