devcken.io

Thoughts, stories and ideas.

libketama: Consistent Hashing library for memcached clients

우리는 memcached 클라이언트가 키를 서버에 매핑하는 방법을 바꾸기 위해 Ketama를 만들었습니다. 이전에는, 클라이언트가 키를 다음과 같이 서버에 매핑했습니다:

server = serverlist[hash(key) % serverlist.length];  

즉, 풀에 서버를 추가하거나 제거할 때마다, 모든 것이 다른 서버에 해시되어, 전체 캐시를 효과적으로 삭제했습니다.

자주 memcached 풀에 서버를 추가(그리고 때로는 제거)하는데, 이 점이 이 라이브러리 작성의 이유가 됩니다 - memcached 풀이 결코 변경되지 않는다면, 이 글을 읽지 않아도 될 겁니다 :)

Ketama는 일치성 해싱 알고리즘의 구현으로, 모든 키에 대한 전체적인 리매핑을 일으키지 않고 memcached 풀에 서버를 추가하거나 제거할 수 있다는 것을 의미합니다.

동작 방법

  • 서버 목록을 받습니다(예: 1.2.3.4:11211, 5.6.7.8:11211, 9.8.7.6:11211).
  • 각 서버 문자열을 각각의 unsigned int으로 해싱합니다.
  • 개념 상, 이 숫자들은 컨티늄(continum)이라고 하는 원에 놓입니다(0에서 2^32까지의 시계를 상상해보세요).
  • 각각의 숫자는 그것이 해시된 서버에 연결되고, 해시된 숫자에 따라 컨티늄 상의 몇몇 지점에 서버가 놓입니다.
  • 키를 서버에 매핑하기 위해, 키를 단일 unsigned int로 해싱하고, 컨티늄 상에서 가장 큰 숫자를 찾습니다. 그 숫자와 연결된 서버가 그 키에 대한 올바른 서버입니다.

리스트에서 서버를 추가하거나 제거하는 경우, 적은 비율의 키만 다른 서버에 매핑됩니다.

코드

코드의 대부분은 C 라이브러리 (libketama)와 그것을 감싸는 php4 확장입니다. 또한 자바 클라이언트의 클래스도 포함시켰습니다(자바 컬렉션이 라이브러리를 더 쉽게 만듭니다). 네이티브 php 클래스로 래핑된 단일 서버의 memcached 클라이언트를 사용해 다중 서버를 사용할 수 있게 만들어, 해싱 메서드를 ketamafindserver 호출로 대체했습니다(필요하다면 이것을 libmemcache에 쉽게 끼워 넣을 수 있어야 합니다).

libketama on github

현재 10일 가까이 Last.fm의 모든 php 설치본과 자바 서비스의 상용에서 사용하고 있습니다. 데이터 센터 내 웹 서버의 부하를 원활하게 이동시킬 수 있도록 이 라이브러리를 제때에 시간에 맞추어 배포했습니다.

더 읽어볼 거리

Consistent Hashing, a guide & Go libarary

본문: https://medium.com/@sent0hil/consistent-hashing-a-guide-go-implementation-fe3421ac3e8f

Consistent Hashing은 믿을 수 없을 만큼 간단하면서도 매우 강력하지만, 앉아서 직접 해보기 전까지는 그것이 무엇인지 잘 이해하지 못했습니다. Consistent Hashing에 대해 이야기하기 전에, 해결하려고 하는 문제가 무엇인지를 이해해야 합니다:

분산 네트워크 내에서 키를 저장하고 조회할 서버가 어떤 것인지 어떻게 결정해야 할까요? 요구사항은 다음과 같습니다. 모든 노드가 상대적으로 동일한 수의 키를 가져오고, 최소한의 키가 움직이도록 노드를 추가하거나 제거할 수 있어야 합니다.

몇 가지를 가정해보죠: 네트워크 내에는 Chico, Harpo, Groucho 그리고 Zeppo라는 네 개의 서버가 존재합니다. 네 개 서버 모두 동일하지만, 서버로에 대해서 알지 못합니다.

이 예제를 간단하게 하기 위해, 키를 증가하는 정수라고 하겠습니다. 보통 여러분은 체크섬에 대해 키를 실행하여 숫자를 반환합니다. 이 숫자를 얻으면, 노드 수에 맞춰 그 숫자의 모듈로를 취할 수 있습니다. 이는 네트워크가 안정적인 경우(즉, 네트워크를 떠나거나 들어오는 노드가 없는 경우) 놀라울 만큼 잘 동작합니다.

하지만, 만약 하나의 노드, 즉 Harpor가 중지되는 일이 발생하면 어떨까요? 그러면 큰 문제가 발생합니다. 동일한 Hash 함수를 사용하는 경우, 동일한 결과를 갖지만, 모듈로 연산을 적용하면 노드의 개수가 한 개 줄어듦으로 이전과 다른 결과를 얻게 됩니다.

모든 노드의 모든 키 역시 대부분 다시 매핑되어야 하는지 확인해야 합니다. 이는 합리적이지 않습니다. 제대로 동작하고 있는 서버의 키를 다시 매핑해야 하나요? 제 의견에 찬성하시나요? 이제 consistent hashing에 대한 요구 사항에 도달했습니다.

Consistent hashing은 해시 테이블의 크기가 변경되고 consistent hasing이 사용되는 경우, 오직 K/n 개의 키만이 평균적으로 다시 매핑되는 특별한 종류의 hashing입니다. 여기서 K란 키의 개수이며 n은 슬롯의 개수입니다.

출처: https://en.wikipedia.org/wiki/Consistent_hashing

위에서 consistent hashing을 사용했더라면, Harpo의 키만 옮겨지면 됐을 겁니다. 보통 대부분의 포스트에서 이를 단위 서클의 그림을 이용해 그점에 대해서 설명합니다. 그렇게 해보죠:

노드가 단위 서클에 배치되는지는 당분간 제쳐두도록 하겠습니다. 키의 해시 상에 모듈로 함수를 적용하는 대신, 해시를 단위 서클 상에 매핑하겠습니다(말 뿐인 것 같지만 곧 구현하게 될 겁니다). 키가 매핑되는 노드를 결정하기 위해, 노드를 찾을 때까지 단순히 시계 방향으로 진행합니다. 그러므로 1번 키는 Harpo에 저장되고 그로부터 조회되어야 합니다.

만약 Harpo가 중지된다면 어떨까요? 다른 노드로부터 1번 키를 얻거나 조회해야 할 겁니다. 하지만 나머지 키의 매핑이 변하지 않았는지를 유의해야 합니다. 변경된 유일한 키는 Harpo 노드에서 사용되던 것들입니다. 이는 새로운 노드를 추가하는 경우에도 동작합니다. 네트워크에 Gummo를 추가했다고 해보죠. 기존 키의 위치를 변경할 필요가 없습니다.

또한 Consistent hashing은 노드의 크기가 다른 상황도 커버합니다. 여러분이 해야할 것은 가상 노드를 단위 서클에 생성하는 것입니다. 여러분이 선택한 해시 함수에 따라, 가상 노드는 단위 서클 상에 무작위로 위치하도록 만들어질 수 있습니다. 노드가 좀 더 많은 용량을 갖도록 하려면, 더 많은 가상 노드들을 추가해야 합니다. 이런 방법으로 노드가 중지된 경우, 키가 단순히 다음 노드가 아닌 다른 노드에 고르게 분산됩니다.

구현

여기서 좀 더 진행해 Go로 consistent hash 라이브러리를 구현해보겠습니다. 좋은 구현을 찾고 모든 곳에 print 구문을 넣고 코드를 변경할 때까지 그것을 이해하지 못했습니다. 이 구현은 stathat의 구현에 상당히 영감을 받았습니다. 원래의 논문은 구현을 위해 트리를 사용해 호출하지만, 저는 stathat이 한 방법을 더 선호합니다.

인터페이스를 정의해보죠:

// Initializes new distribute network of nodes or a ring.
func NewRing() *Ring  
// Adds node to the ring.
func (r *Ring) AddNode(id string)  
// Removes node from the ring if it exists, else returns 
// ErrNodeNotFound.
func (r *Ring) RemoveNode(id string) error  
// Gets node which is mapped to the key. Return value is identifer
// of the node given in `AddNode`.
func (r *Ring) Get(key string) string  

crc32를 사용해 키의 체크섬을 생성할 겁니다. crc32가 하는 일과 그 방법을 설명하는 것은 이 포스트의 범주를 벗어납니다. 입력이 주어지면, 32 uint가 반환된다고 알고 있으면 됩니다. 이 경우에 입력은 노드의 IP 주소입니다.

그것의 요점은 노드 아이디의 체크섬 결과를 배열에 둔다는 것입니다. 각 키에 대해 체크섬을 실행하고 키가 추가되어야 하는 위치를 정하고 그 위치와 가장 가까운 노드를 반환합니다. 배열의 범위를 넘으면, 첫번째 노드를 반환합니다.

먼저, Ring을 정의해보죠. 이는 Node의 컬렉션입니다.

package consistenthash  
// Ring is a network of distributed nodes.
type Ring struct {  
  Nodes Nodes
}
// Nodes is an array of nodes.
type Nodes []Node  
// Node is a single entity in a ring.
type Node struct {  
 Id     string
 HashId uint32
}

다음으로, RingNode를 위한 초기화 함수를 작성해보죠:

package consistenthash  
func NewRing() *Ring {  
  return &Ring{Nodes : Nodes{}}
}
func NewNode(id string) *Node{  
  return &Node{
    Id        : id,
    hashedKey : crc32.Checksum([]byte(id)),
  }
}

이제 드디어 AddNode를 채워넣을 준비가 됐습니다:

func (r *Ring) AddNode(id string) {  
  node := NewNode(id)  
  r.Nodes = append(r.Nodes, node)
  sort.Sort(r.Nodes)
}

sort.Sort를 실행할까요? 단위 서클로 돌아가서 봐야 합니다. 정확하게 단위 서클을 어떻게 구현할까요? 한 가지 방법은 배열의 엔트리 내에 첫번째 아이템을 가리키는 마지막 아이템을 두는 것입니다. 이를 위해 연결 리스트를 사용할 수 있지만, 그것이 불필요한 이유에 대해서 곧 충분히 알게 될 겁니다.

지금까지 한 것을 실행하면, Nodesort.Sort 인터페이스를 구현하지 않았기 때문에 Go 컴파일러가 여러분에게 무언가를 던질 겁니다. 쉽게 해결할 수 있습니다:

package consistenthash  
func (n Nodes) Len() int           { return len(n) }  
func (n Nodes) Less(i, j int) bool { return n[i].HashId < n[j].HashId }  
func (n Nodes) Swap(i, j int)      { n[i], n[j] = n[j], n[i] }  

이 모든 것의 요점인 Get을 구현해보겠습니다:

func (r *Ring) Get(key string) string {  
  searchfn := func(i int) bool {
    return r.Nodes[i].HashId >= crc32.Checksum([]byte(key))
  }
  i := sort.Search(r.Nodes.Len(), searchfn)
  if i >= r.Nodes.Len() {
    i = 0
  }
  return r.Nodes[i].Id
}

sort.Search는 바이너리 검색을 사용해 배열 내에서 기존의 노드를 찾아냅니다. 존재하지 않는 경우 노드가 추가되어야 할 위치를 반환합니다. 노드 체크섬이 마지막 노드보다 크다면, 첫번째 노드에 그것을 추가합니다. 그리고 그게 답니다.

코드의 나머지 부분을 보려면 여기에 약간의 테스트와 함께 오픈 소스화되어 있습니다.

처음에 consistent hashing이 믿을 수 없을 만큼 간단하고도 강력하다고 말한 것을 기억하나요? 아직 저를 믿나요? consistent hashing이 분산 시스템에 관해 좀 아는 Akamai의 논문에서 처음 도입되었다는 것을 알아둬야 합니다. consistent hashing의 향상된 버전은 분산 해시 테이블인 Chord 알고리즘에서 사용됩니다(이전 버전에서는 Chord가 Amazon DynamoDB보다 못하다고 하는데, 이는 잘못된 것입니다).

저는 여전히 chord를 읽고 이해하는 과정에 있으며, 구현하는 것은 말할 것도 없고, 일단 완료되면 포스트할 겁니다. 저는 포스트를 쓰는 것은 물론이거니와, consistent hashing에 대해 배우고, 구현하는 것이 매우 재미있습니다. 여러분은 그것을 배우거나 구현하기를 바랍니다. 오류나 더 좋다고 말할 수 있는 무언가를 찾는다면, @sent-hil로 트윗해주시기 바랍니다.

Redis Keys in RAM

원문: https://redislabs.com/blog/redis-keys-in-ram/

Dr. Seuss의 "Green Eggs and Ham"을 각색. 텍스트에 대한 링크. 그림은 Dr. Seuss에게 저작권이 있음.

I am San. 나는 San이야.
I am San. 나는 San이야.
San I am. San I am.

That San-I-am! 바로 San-I-am이라고!
That San-I-am! 바로 San-I-am이라고!
I do not like that San-I-am! 난 San-I-am을 좋아하지 않아!

Do you like Redis keys in RAM? 너는 RAM 안에 있는 Redis 키들을 좋아하니?

I do not like them San-I-am 난 그것을 좋아하지 않아, San-I-am
I do not like Redis keys in RAM. 나는 RAM 안에 있는 Redis 키들을 좋아하지 않아

Would you like them large [1] or small? 그들이 큰게 좋아 작은게 좋아?

I would not like them large or small. 그들이 크거나 작은 걸 좋아하지 않아.
I would not like them not at all. 난 그들을 전혀 좋아하지 않을거야.
I do not like Redis keys in RAM. 나는 RAM 안에 있는 Redis 키들을 좋아하지 않아.
I do not like them San-I-am. 나는 그들을 좋아하지 않아 San-I-am.

Would you like them as a String? 그것들이 문자열이길 바래?
Would you serialize everything? 모든 것을 직렬화하길 바래?

I do not like them as a String. 문자열을 좋아하지 않아.
I do not like to serialize things. 직렬화하는 것을 좋아하지 않아.
I do not like them large or small. 그들이 크든 작든 좋아하지 않아.
I do not like them not at all. 나는 그들을 전혀 좋아하지 않아.
I do not like Redis keys in RAM. 나는 RAM 안에 있는 Redis 키들을 좋아하지 않아.
I do not like them San-I-am. 나는 그들을 좋아하지 않아 San-I-am.

Would you like them in a Hash? 그들이 Hash 안에 있길 바라니?
Would you like a Hash as cache? cache 같은 Hash를 바라니?

Not in a Hash. Not as a cache. Hash 안에 있는 걸 바라지 않아. Cache도 바라지 않아.
Not as a String. No serialized, no anything. 문자열 같은 것도. 직렬화도, 어떤 것도 원치 않아.
I do not want them large or small. 난 그들이 크거나 작길 바라지 않아.
I do not want them, not at all. 난 그들을 전혀 원하지 않아.
I do not want Redis keys in RAM. 난 RAM 안에 있는 Redis 키를 원하지 않아.
I do not want them, San-I-am. 난 그들을 원하지 않아, San-I-am.

Would you want them as a List instead? 그러면 대신 List이길 바라니?
Do you want to access tail, body and head? 테일, 본문 그리고 헤드에 액세스하길 원하니?

Not as a List. Not as a Hash. 리스트를 원하지 않아. Hash를 원하지 않아.
Not as a String. Not as a cache. 문자열을 원하지 않아. cache를 원하지 않아.
Small or large I will have naught. 작든 크든 0을 가질 거야.
Goodbye San-I-am and thanks a lot. 안녕 San-I-am 그리고 고마워.

Would you? Could you? As a Set? Set는 어때? 해볼래? 해볼테야?
Get the difference! Store a union! Or just intersect… 차를 구해봐! 합집합을 구해봐! 아니면 교집합을...

I would not, could not, as a Set. 아니 해보고 싶지 않아

You may like them. 그들을 좋아할 수도 있어
You'll see for sure. 확실히 보게 될 거야.
You may like Sorted Sets by score? 스코어에 의한 Sorted Sets를 좋아할지도 몰라.

I would not, could not by a score. 스코어로 하고 싶지도, 할 수도 없어.
No more Sets! I say no more! 더 이상의 Sets는 없어! 더 이상 말하고 싶지 않아!
I do not like them as a List 난 List 같은 것도 좋아하지 않아
Stop this now – I do insist. 이제 그만둬. 분명히 말할게.
I do not like them as String or Hash 문자열이든 Hash든 좋아하지 않아
I do not like an in-memory database or cache. in-memory 데이터베이스든 cache든 좋아하지 않아.
I do not want Redis keys in RAM. RAM 안에 있는 Redis 키들을 좋아하지 않아.
I do not want them, San-I-am. 그들을 좋아하지 않아, San-I-am.

You do not like them. So you say. 넌 그들을 좋아하지 않는구나. 너가 말한대로.
http://try.redis.io! Try them! And you may. http://try.redis.io! 한 번 해봐! 좋아하질도 모른다.
Try them and you may, I say. 한 번 해봐, 내가 말하는데로 좋아할거야.

San! If you will let me be, San! 너가 날 놓아준다고 약속하다면,
I will try them. You will see. 한 번 해볼게. 두고봐.

Say! I like Redis keys in RAM! 우와! 난 RAM 안에 있는 Redis 키들이 좋아!
I do! I like them, San-I-am! 정말이야! 그들이 좋아, San-I-am!
So I will have them as a String. 그러니까 문자열을 가질거야.
And as a Hash, a List or anything. 그리고 Hash, List 어떤 것이든지.
And as a Set – both unordered and an ordered one. 그리고 Set, 정렬되지 않은 것이든 정렬된 것이든.
Say! Data structures are so much FUN! 우와! 데이터 구조는 정말 즐거워!

I do so like Redis keys in RAM 난 RAM 안에 Redis 키들이 정말 좋아
Thank you! Grazie, San-I-am 고마워! 고마워요, San-I-am!


후기 노트

저는 암스테르담에서 열린 Percona Live Europe 2015의 "Use Redis in Odd and Unusual Ways"라는 주제의 일부분으로 위와 같은 내용을 발표했습니다. 주요 주제는 MySQL로 이루어진 반면, 컨퍼런스의 프로그램에 Redis에 관한 내용은 4개 세션 밖에 없었습니다. 제 발표를 제외하고 말이죠. Redis 옹호자가 되는 것, 그것이 아마도 내가 바라는 최선의 방법일 것이나, 발표자로써 그것은 도전이었습니다. Redis에 대한 경험을 지닌 청중 뿐만 아니라 NoSQL에 대한 경험을 지닌 청중과 관련된 대화를 어떻게 준비해야 할까?

그래서 결론은 Redis와 대한 기초 주제와 고급 주제를 섞는 것이었고 Redis 베테랑 뿐만 아니라 Redis가 처음인 사람에게 도움이 되길 바랬습니다. DR.ediseuss라는 모티브는 약간의 배경에 가치를 둘 만 합니다. 나 이전의 많은 사람들이 그랬듯이, 내가 빨간 벽돌이 깔린 길에 첫발을 내디뎠을 때처럼, 무언가 API의 네이밍 스키마에 현혹되었습니다. [2] 모든 것을 흡수하면서, 나는 그 모든 것에 대한 첫 단계였던 작은 "시"를 생각해냈습니다. 다음은 여러분의 즐거운을 위한, "Dr. Seuss Reads Redis" 책의 첫번째 (그리고 유일한) 페이지입니다:

This is my friend
His name is ZADD
ZADD's a lad
Who's always SADD

It's really bad
that ZADD is SADD
I don't know why
And that makes me SCARD

I hope that ZADD
Will be someday glad
And that he'll get over
This stupid PFADD

문제가 있나요? 열렬히 환호해 주실건가요? 이메일이나 트윗하세요. 언제나 환영합니다 🙂


FN#1 Redis의 키는 512MB까지 가능하며 바이너리에 안전합니다. Simple string 값은 512MB까지 가능하며 역시 바이너리에 안전합니다. 다른 데이터 구조체들은 2^32개의 요소, 512MB까지 가능합니다.

FN#2 시작하는 이들을 위한 격려의 메모 - 금방 완벽하게 자리를 잡을 것이고 처음에 무엇때문에 그렇게 현혹되었는지를 염려하게 될 것입니다 🙂

Redis RAM Ramifications - Part 1

원문: https://redislabs.com/blog/redis-ram-ramifications-part-i/

... 우리가 알고 있듯, 알고 있다고 아는 것이 있다. 우리가 안다고 아는 것이 있다는 것이다. 우리는 또한 모른다고 알고 있는 것이 있다. 즉, 우리가 모르는 무언가가 있다고 알고 있다고 하는 것이다. 그러나 모르는 것을 모르는 것도 있다. 우리가 알지 못하는 것을 알지 못하고 있음을 말한다"

미국 국방 장관 도널드 럼스펠드, 2002년

Redis는 얼마나 많은 RAM을 필요로 할까?

Redis에 관해 가장 자주 반복되는 질문이 있다면, 이것일 겁니다. 이에 대한 대답은 너무도 많은 요인들 때문에 정확히 대답하기 가장 힘든 대답 중 하나입니다. 이 포스트에서 (그리고 내가 대충 얘기하고 중얼거리면서 장황하게 이야기하는 경향이 있기에 앞으로 쓸 글들에서) Redis의 RAM 소비에 대해 알아볼 것입니다. 모든 것에 대한 굉장한 답변은 보장하지는 못하지만, 다음과 같은 것들에 도움이 되길 바랍니다:

  • 알려진 것에 대해 알고 있다는 것(KKs)을 확인하기 위해 반복합니다
  • 가능하면 알고 있지 못하는 것을 아는 것으로 바꾸거나, 적어도 대충 이해하는 정도(KRUU)로 바꾸고자 합니다
  • 우리가 모르고 있는 것을 모르는 것에 대해 희망을 가지고 어떻게든 알아보면서, 모르고 있다는 사실을 아는 것, 대충이나마 이해하는 것, 혹은 알고 있는 것으로 바꿉니다

사이드 노트: RSS 피드를 구독해 이 시리즈에 대한 진행 과정을 추적할 수 있습니다. 아니면, Redis Watch에 가입해 메일함에 주간 업데이트가 오도록 할 수 있습니다. 그러나 그것만 보고 있다면, 실시간 정보를 위해 트위터를 팔로우하세요.

아무튼, Redis는 소프트웨어 중 하나이고 동작하는데 RAM을 요구합니다. 그러나, Redis는 그냥 보통의 소프트웨어가 아니라, in-memory 데이터베이스이며, 이는 Redis가 관리하는 모든 데이터 조각들 역시 RAM 안에 유지된다는 것을 뜻합니다. Redis가 운영에 필요로 하는 RAM을 Opernational RAM이라고 하고, 데이터 스토리지에 사용되는 RAM을 User Data RAM이라고 해보죠.

짐을 싣지 않은 제비의 비행 속도는 얼마인가?

Redis의 Operational RAM에 대해 얼른 알아보죠.

그것은 Redis가 수행하는 많은 목적과 작업을 위해 사용되며, RAM의 청크를 사용자의 데이터가 아닌 모든 것을 위해 Redis가 사용하는 전체 메모리와 같다고 생각하는 방법 중 하나입니다(나는 아마도 이후에 이것 때문에 천천히 진행할 것 같다). Redis의 RAM 사용 공간은 다음을 포함하는, 무수히 많은 배치 요인들에 영향을 받습니다:

  • 서버 프로세서의 아키텍처
  • 운영 시스템
  • Redis의 버전과 구성 정보
  • 수많은 아는 것, 무엇인지는 알지만 잘은 모르는 것, 아예 모르는 것

하지만, 전형적인 서버의 유휴 상태에서 Redis를 테스트하여 Redis의 operational RAM 요구 사항에 대한 기준을 쉽게 정할 수 있습니다. 예를 들어, 가상화된 우분투 14 64비트 서버의 upladen African swallow V3.0.0 인스턴스의 메모리 사용 공간은 7995392 바이트(또는 약 7.6MB)다. Redis 인스턴스가 얼마나 많은 RAM을 할당하는지 ps의 RSS 컬럼 혹은 Redis의 INFO 명령어를 사용해 빠르게 알아낼 수 있습니다:

foo@bar:~$ uname -a  
Linux bar 3.13.0-49-generic #81-Ubuntu SMP Tue Mar 24 19:29:48 UTC 2015 x86_64 x86_64 x86_64 GNU/Linux  
foo@bar:~$ ps aux | grep redis-server  
foo 20139 0.0 0.1 42304 7808 pts/1 Sl+ 19:18 0:00 ./redis-server *:6379  
foo 20143 0.0 0.0 15940 944 pts/9 S+ 19:18 0:00 grep --color=auto redis-server  
foo@bar:~$ redis-cli INFO memory | grep used_memory_rss  
used_memory_rss:7995392  

사이드 노트: 위 두 메서드와 다른 것들의 결과가 항상 동일한 것은 아닙니다. 또한, 앞으로 보게 되겠지만 usedmemoryrss가 Redis의 used_memory와 상당히 차이가 난다는 점을 알아두시기 바랍니다.

이것이 최근에 초기화된 Redis 인스턴스라는 점 덕분에, 이 수치가 운영 상의 공정한 기준이라는 것을 추정할 수 있습니다. Redis의 operational RAM은 증가할 수 있으며, 심지어는 엄청 늘수도 있지만, 이제부터는 그 점에 대해 걱정할 필요가 없습니다.

뭐라고? 제비가 코코넛을 나른다고?

Redis는 아름답지만, 그저 아름다운 면 때문에 사용하고 있지는 않겠죠? 아닐 겁니다. 오늘 날 지구 상에서 가장 빠른 NoSQL를 필요로 하기 때문에 Redis를 관리하고 있을 겁니다. 당신은 Redis가 당신의 코코넛을 나르도록 하며 (거의) 매 초당 43번의 날개 짓을 하도록 합니다. 그래서 사용자 데이터가 차지하는 RAM은 얼마나 될까요? 그것은 코코넛에 달려있습니다 🙂

Redis의 schemaless schema*는 Key-Value 모델에 기반합니다. Redis가 관리하는 모든 사용자 데이터는 기본적으로 KV 쌍입니다. 키와 값이 더 길거나 클수록, Redis가 더 많은 RAM을 필요로 한다는 것을 이해하는 것은 대단한 일이 아닙니다. 그러나 Redis는 데이터를 간결하고 조직적으로 유지하기 위해 설계된 몇 가지 독창적인 트릭을 가지고 있습니다.

** 스키마가 없는 데이터베이스 같은 것은 없습니다. 대부분은 암시적인 데이터베이스만 가질 수 있죠.

가장 간단한 Redis 데이터 타입과 관련된 예제로 그것을 알아보죠:

127.0.0.1:6379> SET swallow coconut  
OK  

위에서 얼마만큼의 RAM을 사용했을까요? 모든 데이터가 키에 의해 구성되므로, 키의 이름이 우리가 알아내야 할 첫번째 요소입니다. Redis 키 이름은 바이너리에 안전한 문자열로 512MB까지 가능합니다. 좋아요. Redis에서 문자열 값 또한 바이너리에 안전하며 0.5GB까지 가능합니다. 그러므로 위 예제에서 "swallow"에 대해 7 바이트, "coconut"에 대해서 또 7바이트 라고 추정할 수 있는데... 틀렸습니다. 적어도 부분적으로는 말이죠. 다음을 보겠습니다:

127.0.0.1:6379> STRLEN swallow  
(integer) 7
127.0.0.1:6379> DEBUG SDSLEN swallow  
key_sds_len:7, key_sds_avail:0, val_sds_len:7, val_sds_avail:0  

STRLEN을 이용해 본대로, Redis는 "swallow"라는 키에 저장한 값("coconut")의 길이가 7바이트라고 합니다. 더욱이, "비밀의" DEBUG SDSLEN 명령어 역시 동일한 주장을 하고 있지만, 둘 다 데이터의 오버헤드에 대해서는 언급하고 있지 않은데, 모든 Redis 데이터 구조는 자신의 수화물을 가지고 있습니다. 즉, 실제 문자열 ("swallow"와 "coconut") 외에도 Redis는 그것을 관리하기 위한 약간의 RAM을 더 필요로 합니다.

Redis의 모든 key-value 튜플은 내부적인 장부를 위해 추가적인 RAM을 사용하며, 해당 RAM의 양은 데이터 구조와 데이터 자체에 달려 있으며, 나는 메타데이터가 아닌 이 오버헤드가 사용자 데이터의 일부라고 생각합니다. 달리 말하자면, 모든 X 바이트의 문자열에 대해 Redis는 X + Y 바이트를 요구하며, 그렇기에 분명히 Y는 잘 모르는 것에서 잘 아는 것이 되어야 할 겁니다.

문자열 이론

Redis의 문자열은 대부분은 Salvatore Sanfilippo @ antirez의 서브 프로젝트 중 하나인 sds (혹은 Simple Dynamic Strings 라이브러리)에 의해 구현됩니다. sds 문자열이 Redis 내부적으로 사용 상의 상당한 힘과 용이함을 가져다 주지만, sds 문자열이 만들어져 약간의 오버헤드가 발생합니다:

+--------+-------------------------------+-----------+
| Header | Binary safe C alike string... | Null term |
+--------+-------------------------------+-----------+
         |
         `-> Pointer returned to the user.

(안티레즈의 다이어그램 설명, https://github.com/antirez/sds/blob/master/README.md)

sds 문자열 헤더의 크기는 (당장은) 8바이트이며 추가 바이트로 null 문자가 들어가는데, 이는 문자열 당 총 9바이트의 오버헤드입니다. 7바이트의 "swallow"가 갑자기 그 두 배 이상인 16바이트 RAM으로 Redis에 저장되었습니다!

사이드 노트: 실제로는 더 사용합니다. 모든 키에 대한 참조가 Redis의 keyspace 해시 테이블에 저장되는데, 이는 더 많은 RAM을 요구합니다... 그리고 Redis가 LRU와 같은 일부 데이터들을 조성하기 위한 robj "객체" 또한 존재합니다... 그러나 그건 잘은 모르지만 아는 것으로, 키의 관리 RAM 오버헤드로 두고 당장은 operational RAM으로 던져두도록 하죠 🙂

실제로 한 라인에 코코넛이 있다면...

제비와 코코넛으로 돌아가보겠습니다. 현재 우리는 Redis가 위 예제에서 키와 값을 구성하는 두 개의 문자열을 저장하기 위해 36바이트를 사용한다는 것을 알게 되었습니다. 근데 그게 전부일까요? 코코넛에 대해 좀 더 알아보도록 하죠:

127.0.0.1:6379> OBJECT ENCODING swallow  
"embstr"

점점 더 복잡해지고 있다(또는 어쩌면 코코넛의 털이 자라고 있는걸지도). 이 암호같은 응답은 설명을 필요로 한다. 그런데 모든 코코넛이 동일하게 인코딩되는 걸까? 다른 모양의 문자열 코코넛들에 대해 살펴보자:

127.0.0.1:6379> SET swallow:0 "0"  
OK  
127.0.0.1:6379> SET swallow:1 "An oversized and thickly-haired coconut"  
OK  
127.0.0.1:6379> SET swallow:2 "Ok, this is the mother of all coconuts - it is something that would make Donkey Kong run back to his mama in tears"  
OK  
127.0.0.1:6379> OBJECT ENCODING swallow:0  
"int"
127.0.0.1:6379> OBJECT ENCODING swallow:1  
"embstr"
127.0.0.1:6379> OBJECT ENCODING swallow:2  
"raw"

각각의 코코넛이 모두 다릅니다. Redis가 서로 다른 인코딩을 사용했습니다. "int" 인코딩은 LONGMIN과 LONGMAX 사이의 정수 값을 효율적으로 저장하기 위해 사용되며 shared.integers 구조를 활용해 데이터 중복을 막습니다. 따라서 약간의 공간을 더 사용합니다. 39 바이트보다 긴 문자열은 "raw"로 저장되는 반면, 짧은 것은 "embstr" 인코딩을 사용합니다(이 마법의 숫자는 redis.h의 REDIS_ENCODING_EMBSTR_SIZE_LIMIT에 정의되어 있습니다).

Going (coco)nuts

다른 코코넛 구조들은 어떨까요? 문자열은 Redis가 제공하는 가장 간단한 데이터 구조이며 내부적으로 다른, 좀 더 진보된 구조를 만드는데 사용됩니다. Hash는 각 엔트리가 하나의 linked list인, 딕셔너리 데이터 구조와 함께 추가된 문자열 뭉치(필드와 값)로 구성됩니다... 그러나 완전히 ziplist로 인코딩될 수도 있습니다. 그리고 리스트에 대해 말하자면, Sets와 Sorted Sets에 의해 사용되는 linked list와 ziplist(그리고 아마 Matt Stancliff @ mattsta의 quicklist)가 있습니다.

Redis의 데이터 인코딩에 대한 미묘한 복잡함과 그들이 메모리 소비에 어떠한 영향을 주는지 계속해서 알아볼 수 있지만, 우리 모두를 지루하게 만들어 조만간 곧 죽게 만들 것 같아 두렵군요. 대신, 소스 코드를 체크아웃하여 계속 읽어볼 수 있습니다. 누군가는 그렇게 하리라는 것을 알지만, 어느 정도의 프로그래밍 스킬이 필요할 것입니다.

아직 알지 못하는, Redis가 필요로 하는 RAM은 얼마나 되는가라는 질문이 아직 남아 있습니다. 그리고 그보다는 작은 궁금증인 코코넛이 필요로 하는 RAM은 얼마나 되는가라는 질문도. 그러나 뜻이 있는 곳에 길이 있습니다. 저와 사용 가능한 채널을 통해 얼마든지 연락할 수 있습니다. 언제나 환영합니다 🙂

사이드 노트: 이어서 계속

Why should Java 8's Optional not be used in arguments

Java 8부터 java.util.Optional<T>이라는 클래스가 지원되기 시작했습니다. Scala에서 Option이라는 이름으로 지원되는 클래스와 거의 동일한 역할을 합니다.

자바의 공식 문서를 보면 다음과 같이 설명하고 있습니다.

null이 아닌 값을 포함하지 않거나 포함하는 컨테이너 객체

즉, null과 관련된 처리를 처리하기 위한 클래스로 보입니다.

Null Island is One of the Most Visited Places on Earth. Too Bad It Doesn’t Exist

Null 참조의 아버지(?)인 Tony Hoare는 2009년 한 컨퍼런스에서 Null 참조를 발명한 것에 대해서 사과했습니다.

저는 그걸 10억 달러짜리 실수라고 부릅니다. 1965년에 null 참조를 발명한 것 말이죠. 그 시대에, 저는 객체 지향 언어(ALGOL W)의 참조를 위한 첫번째 내포 타입 시스템을 설계하고 있었죠. 저의 목표는 모든 참조 사용이 절대적으로 안전하다는 것을 보장하는 것이었는데, 컴파일러에 의해 자동으로 수행되는 검사와 함께 말이죠. 그런데, null 참조를 부여하는 유혹을 견뎌내지 못했습니다. 단순히 구현하기 쉬웠기 때문이죠. 이는 무수한 오류, 취약점 그리고 시스템 충돌들을 야기시켰습니다. 필시 이것이 최근 40년 동안의 10억 달러 만큼의 고통과 상처를 안겨주었습니다.

그만큼 Null 참조는 문제가 많습니다. Null 참조로 인한 잠재적인 버그들이 개발자들을 괴롭혀왔고 여전히 그렇습니다.

이러한 고통과 상처를 해결하기 위한 것이 바로 Optional입니다.

제가 구현 중인 제품의 일부 코드를 예로 들어보겠습니다.

...
return Optional.ofNullable(Longs.tryParse(identifier))  
       .map(this::findOne)
       .orElse(this.repository.findOne(identifier));
...

보시면 아시겠지만, 저장소에서 특정 인스턴스를 가져오는 코드입니다. Optional.ofNullable을 사용했습니다. 그 이유는 identifier라는 변수가 String 타입이고 이를 Guava의 Longs.tryParse 함수를 사용해 구문 분석한 후 그 결과가 null이면 저장소의 findOne 메서드에 identifier 변수를 그대로 넘기고 Long 타입으로 잘 캐스팅되었다면 캐스팅된 값을 넘기도록 한 것입니다.

위 코드를 null을 사용해서 구현하면 다음과 같이 구현해야 할 겁니다.

Long x = Longs.tryParse(identifier)

return x == null ?  
  this.repository.findOne(identifier) :
  this.repository.findOne(x);

뭐 굉장히 평범한 코드이긴 합니다. null을 직접 언급한다는 것이 깨림직한 정도죠. Null 참조가 왜 나쁜가에 대한 논의는 여기서 직접하지 않겠습니다.

제가 이 포스트를 통해 알아보고자 하는 것은 사실 따로 있습니다.

저는 위 예제에서 Optional을 메서드의 반환 타입으로 사용했습니다. 이것이 바로 Optional의 존재 이유죠. 아, 참고로 findOne 함수 또한 Optional을 반환합니다. 만약 findOne의 반환 타입이 Optional이 아니고 Optional의 타입 파라메터 자체가 반환 타입이었다면 null이 반환될 가능성이 존재합니다. 요청된 식별자에 대한 값이 저장소 내에 없을 수도 있기 때문이죠.

만약 그럴 경우, 위 코드의 메서드를 실행하는 호출자는 null에 대한 대비를 해야 합니다. if (x == null) {...이나 x == null ? ... 류의 코드 말이죠.

음, 저는 이런 생각이 들었습니다. 메서드의 파라메터로 Optional 타입을 받으면 어떨까?

T doSomething(Optional<T> tOptional) {  
  tOptional.ifPresent(...);
  ...
}

뭐 대략 이런 식의 코드가 가능해질 겁니다.

저는 IDE로 IntelliJ를 사용 중인데요, 위와 같은 코드를 작성하면 IntelliJOptional<T>에 노란색 박스를 그립니다. 그리고는 아래와 같이 경고합니다.

Reports any uses of java.util.Optional, java.util.OptionalDouble, java.util.OptionalInt, java.util.OptionalLong or com.google.common.base.Optional as the type for a field or a parameter. Optional was designed to provide a limited mechanism for library method return types where there needed to be a clear way to represent "no result". Using a field with type java.util.Optional is also problematic if the class needs to be Serializable, which java.util.Optional is not.

말인즉슨, Optional 류의 타입을 파라메터에 적용하지 말라는 겁니다. 원래 그 용도가 반환 값으로 null이 반환될 가능성이 있는 경우, 이를 위한 명확한 방법을 제시하기 위한 것이고, 그것을 파라메터에 적용할 경우 대상 클래스가 Serializable해야 하는 경우 문제를 일으킬 수 있다는 것입니다.

즉, 객체를 직렬화할 때 Optional 자체가 문제가 될 수도 있다는 뜻입니다.

그 이유에 대해서 좀 더 찾아보던 중 StackOverflow에서 도움이 될 만한 질문과 대답을 찾았습니다. 이 포스트의 제목이 바로 그 질문의 제목입니다.

대답의 내용을 보면,

  1. (+) 어떤 의미론적 분석도 없이 Option 결과를 다른 메서드를 전달할 수 있다; 해당 메서드에 그것을 남기는 것(전달)은 꽤 괜찮다.
  2. (-) Optional을 파라메터로 사용하면, 메서드 내부의 조건부 로직이 일어나 비 생산적이다.
  3. (-) 인자를 Optional로 패키징해야 할 필요가 있는데, 이는 컴파일러에 대해 차선책이며, 불필요한 랩핑을 수행하게 됩니다.
  4. (-) null이 가능한 파라메터와의 비교에서 Optional이 좀 더 비용이 듭니다.

일반적으로: Optional은 해결해야 할 두 가지 상태를 통합한다. 따라서 데이터 흐름의 복잡도를 위해 입력보다는 결과(반환 값)에 더 잘 맞는다.

그 외에도 댓글들을 읽어보면 좀 더 도움이 될 것 같습니다. 그리고 채택되지는 않았지만 두번째 대답도 상당히 좋은 인사이트를 제공합니다.