[입 개발] Redis Pipeline and Transaction(Multi/Exec)

해당 블로그는 KT Olleh UCloudBiz의 지원을 받고 있습니다.

Redis Pipeline에 대한 내용을 보고 나면, 다음과 같은 질문이 꼭 나오게 됩니다. Pipeline을 이용하면 성능은 좋아지는데, Redis는 Single Thread 라, Pipeline으로 처리하는 동안에는 다른 클라이언트의 작업을 처리못하게 되는거 아닌가요? 라는 질문입니다.

먼저 결론부터 내리자면, Pipeline 형태로 데이터를 전달하더라도 중간에 명령을 수행할 수 있습니다.(진짜루?)

그럼 다음과 같이 간단하게 테스트를 해보도록 하겠습니다. 테스트에는 KT Olleh UcloudBiz 의 8vCore, 16GB 머신을 이용했습니다.

일단 지난번의 테스트 코드를 다시 사용합니다. 다만 테스트를 위해서 프로세스는 하나만 띄웁니다.

from multiprocessing import Process
import redis
import random
import string

def id_generator(size=6, chars=string.ascii_uppercase + string.digits):
    return ''.join(random.choice(chars) for x in range(size))

data = id_generator(512, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()")

def f(idx):
    print 'PR', idx
    r = redis.StrictRedis(host='172.27.217.193', port=6379, db=0)
    pipeline = r.pipeline()
    for i in xrange(idx * 1024 * 1024 * 2, (idx+1)*1024*1024 * 2):
        key = 'PR%s'%(i)
        pipeline.get(key)
        if (i != idx * 0124 * 1024 and i%1024==0):
            pipeline.execute();

if __name__ == '__main__':
    print data
    pids = []
    for i in xrange(1):
        p = Process(target=f, args=(i,))
        pids.append(p)
        p.start()

    for pid in pids:
        pid.join()

이제 이 코드를 실행하고 모니터링을 해보면… 이상한 결과가 보여집니다.

redis-pipeline-with-transaction

분명히 문제 없다고 해놓고, 시간이 뻥뛰고 있습니다. redis-stat이라는 모니터링 툴 역시 내부적으로 info 명령을 주기적으로 전달하는 건데, 저렇게 시간이 확 지난거면… 중간에 명령이 실행이 안되는거지 않느냐!!! 라는 생각이 부글부글 드실껍니다.

여기에 비밀이 하나 있습니다. 제가 사용한 Python Redis Clinet 인 redis-py 가 Redis 의 다른 특성을 함께 사용하고 있어서 그렇습니다. 여기서 설명할 것이 Redis Transaction Multi/Exec 입니다. Transaction 이라는 것은 여러 개의 명령을 하나의 성공/실패의 그룹으로 묶어서 수행하는 것을 말합니다. 예를 들어 다음과 같은 명령들이 있다고 합니다.

set A 123
get A

당연히 기대하는 값은 “123” 이겠지만, set A 123 명령 다음에 set A 23이라는 명령을 다른 클라이언트에 의해서 실행이 되면 기대했던 결과를 얻을 수가 없습니다. 그래서 redis 2.6 부터는 MULTI/EXEC 라는 Transaction을 지원하기 위한 명령을 지원하고 있습니다. MULTI/EXEC 의 특징은 그 안의 명령을 모두 한꺼번에 실행시켜준다라는 특성이 있습니다. 즉

MULTI
set A 123
get A
EXEC

의 마지막 결과는 항상 123 입니다. 이것의 구현은 어떻게 되는 것일까요? 아주 간단합니다. MULTI가 나오면 Redis 가 해당 Client로 할당된 큐에 EXEC가 나오기 전까지 모든 명령을 담아두고 있다가 EXEC 명령이 들어오면, 그냥 모두 한꺼번에 수행시켜줍니다. 그래서 이 안에 blocking 관련 BLPOP등의 명령은 데이터가 없으면 바로 실패하게 됩니다. 그리고 MUTLI/EXEC 사이에 명령이 많으면, 이걸 수행하기 위해서, 그 동안에는 다른 클라이언트의 명령을 처리하지 못합니다.(Single Thread 라…)

왜 이 설명을 했냐 하면, Python Redis Client 는 기본적으로 pipeline을 실행할 때, MULTI/EXEC를 사용합니다.(이 특성 때문에 redis-py 의 pipeline 기능을 twemproxy와 함께 사용하려고 하면 지원하지 않는 명령이라 제대로 실행이 되지 않습니다.)

그럼 어떻게 해야 하는가? 호출시에 transaction=False 를 넣어주면 됩니다. 다음과 같습니다.

def f(idx):
    print 'PR', idx
    r = redis.StrictRedis(host='172.27.217.193', port=6379, db=0)
    pipeline = r.pipeline(transaction=False)
    for i in xrange(idx * 1024 * 1024 * 2, (idx+1)*1024*1024 * 2):
        key = 'PR%s'%(i)
        pipeline.get(key)
        if (i != idx * 0124 * 1024 and i%1024==0):
            pipeline.execute();

자 이렇게 바꾸고 다시 실행 후 모니터링 해보도록 하겠습니다.

redis-pipeline-without-transaction

자, 이제 2초마다 제대로 모니터링 되는 걸 알 수 있습니다. 즉 제대로 중간에 명령이 끼어들 수 있다는 겁니다.
(제가 거짓말 한게 아닙니다.)

정리하면 다음과 같습니다.
1. Redis Pipeline은 중간에 명령을 허용한다. 즉 이로 인해 뭔가 데이터가 바뀔 수도 있다.(다른 클라이언트가 변경할 수 있으니…)
2. Multi/Exec를 쓰면, 그 사이의 명령이 전부 한꺼번에 수행된다. 이 안의 작업이 길면 다른 클라이언트들의 명령이 늦게 처리된다.
3. 사용시에 라이브러리를 잘 확인하자. 위와 같은 이유로 안될 수 있다.

[입 개발] Redis Pipeline

해당 블로그는 KT Olleh UCloudBiz의 지원을 받고 있습니다.

가끔씩 Redis의 성능을 좀 더 극대화 시키고 싶어하시는 분들이 있으시기 때문에, 오늘은 이에 가장 많이 사용되는 Pipeline에 대해서 설명을 할려고 합니다.

먼저 재미난 사실을 알려드리자면, Redis pipeline이라는 것이 성능 향상을 위해서 유리하다라는 것은 많은 분들이 알고계시지만, 사실, 서버에는 Redis pipeline 라는 기능이 없다는 것입니다.

다음과 같은 명령을 Redis 소스에서 확인해보면, redis-benchmark.c 와 redis-cli.c를 빼고는 아무데도 pipe라는 글자자체가 없다는 것을 알 수 있습니다.

grep pipe src/* -Rn

그렇다면 Pipeline의 정체는 무엇일까요? 먼저 Pipeline을 적용한 테스트와 Pipeline을 적용하지 않은 테스트 결과를
보여드리도록 하겠습니다.

언어는 python 에 KT Olleh UCloudBiz 의 8vcore 16GB 메모리 모델을 사용했습니다.
두 대를 사용해서 한대는 redis-server unstable 버전이 설치되어 있고, 다른 서버에서 접속한 버전입니다.

from multiprocessing import Process
import redis
import random
import string

def id_generator(size=6, chars=string.ascii_uppercase + string.digits):
    return ''.join(random.choice(chars) for x in range(size))

data = id_generator(512, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()")

def f(idx):
    print 'PR', idx
    r = redis.StrictRedis(host='172.27.217.193', port=6379, db=0)
    for i in xrange(idx * 1024 * 1024, (idx+1)*1024*1024):
        key = 'PR%s'%(i)
        r.get(key)

if __name__ == '__main__':
    print data
    pids = []
    for i in xrange(80):
        p = Process(target=f, args=(i,))
        pids.append(p)
        p.start()

    for pid in pids:
        pid.join()

redis-not-pipeline위의 그림을 보면 55k 에서 65k 수준의 cmd/s 를 볼 수 있습니다.

이제 Pipeline을 적용한 테스트를 한번 보도록 하겠습니다. 다른 부분은 동일하고 함수 f()만 조금 수정되었습니다.

def f(idx):
    print 'PR', idx
    r = redis.StrictRedis(host='172.27.217.193', port=6379, db=0)
    pipeline = r.pipeline()
    for i in xrange(idx * 1024 * 1024, (idx+1)*1024*1024):
        key = 'PR%s'%(i)
        pipeline.get(key)
        if (i != idx * 0124 * 1024 and i%1024==0):
            pipeline.execute();

redis-pipeline

물론 거의 200k 전후로 cmd/s 가 나오는 것을 볼 수 있습니다. 도대체 pipeline은 무엇이길래, 이런 속도차를 보여주는 것일까요?

사실 pipeline을 이용해서 가지게 되는 이점은 다음과 같습니다.
1. 명령들이 뭉쳐서 전달되기 때문에, 클라이언트 입장에서는 함수 콜이 줄어들고, 서버 입장에서도 I/O가 적게 발생합니다.
2. 보통 하나의 명령을 보내면 결과를 확인하는 시점이 있는데, 이미 서버에서는 다음 명령이 실행되고 있기 때문에 이런 시간이 줄어듭니다.

실제로 memcached 나 redis를 쓸 때, 한 서버와 통신할 때 mget등을 이용해서 명령어 전송 수를 줄이는 것도 일종의 튜닝 방법입니다. 사실 서버입장에서는 명령어가 뭉쳐서 들어와서 시스템 콜이 줄어드는 이점 이외에는 아무런 차이가 없지만… 심할때는 위와 같은 차이가 나는 것입니다.

[입 개발] 한 서버에 하나의 Redis를 띄우시나요? 아니면 여러 대를 띄우시나요?

해당 블로그는 KT Olleh UCloud Biz의 지원을 받고 있습니다.

여러분은 하나의 서버에 Redis 인스턴스를 하나만 실행하시나요? 아니면 CPU Core 수나 메모리 양에 따라서 여러 개의 Redis 인스턴스를 실행시키시나요?

아마도 일반적으로는 하나의 서버에 하나의 Redis 인스턴스를 실행하는 경우가 많을 것 같지만, 아마도 다 각자의 기준이 있거나 또는, 그냥 실행시키지 않을까 싶기도 합니다.

오늘은 바로 이 이야기에 대해서 해볼려고 합니다.

일단 먼저, 어떤 방식을 선택해야 한다라는 정답은 없습니다. 다 잘쓰면 되지요.(퍽퍽퍽, 이 따위 소릴 할려고 소중한 내 시간을 뺐느냐? 라는 주먹들이 보이시는군요.) 그런데, 일단 끝까지 헛소리를 들으시고 그 뒤에 절(퍽퍽퍽… 들을 가치도 없어보인다는 말씀들이 덜덜덜)

일단 두 가지 경우의 장단점을 간단히 따져보면,

하나의 서버에 Redis 인스턴스를 하나만 실행하는 경우는 관리가 싶습니다. 그리고 행여나 다른 이슈로 인해서 해당 Redis 인스턴스가 영향을 받을 경우도 덜합니다.

하나의 서버에 여러개의 Redis 인스턴스를 실행하는 것은, 일단, Redis가 싱글스레드이기 때문에, 그래도 성능이 더 잘 나올 수 있습니다. 그리고, 여러 대의 서버를 써야 한다면, 하나의 서버 여러개를 관리하나, 여러 대의 서버에서 여러 개의 인스턴스를 사용하나 비슷할 가능성이 높습니다.

그럼, 일단 너는 어떤걸 권장하냐? 라고 물어보신다면, 적절히 잘 관리하면 어떤 방법이든 상관없지만, 개인적으로는 하나의 서버에 여러 대의 Redis 인스턴스를 사용하는 것을 추천합니다.

자 일단 예를 들어보겠습니다. 다음과 같은 서버 사양이 있다고 합니다.(KT UCloud 에서 해당 서버 목록을 가져왔습니다.)

*2vCore, 4G memory
*4vCore, 8G memory
*8vCore, 16G memory

위와 같은 사양에서 만약 한 서버에 하나의 Redis 인스턴스를 실행한다면, 어느정도 메모리를 사용할 경우, 메모리를 많이 사용한다고 할 수 있을가요?

그냥 제 맘대로 생각했을때, 3G, 6G, 13~14G 정도 사용하면 거의 맥시멈으로 사용한다라고 생각하지 않을까요? 이게 그냥 일반적인 생각일 것입니다. 그런데 만약 우리가 마스터/슬레이브 형태로 Redis 를 사용한다면, 여기서 문제가 하나 생길 수 있습니다. 슬레이브 노드가 마스터에 연결될 때, 마스터가 죽을 수도(도를 강조) 있다는 것입니다.

“왜” 라는 질문이 나오는 것이 정상일 것입니다. 그리고 죽을 수 있는 이유는 RDB 때문입니다. 여기서 “아!!!” 하시는 분과 “엥???” 하시는 분이 계실껍니다. 그럼 RDB 꺼두면 되는거 아니예요? 라고 물어보시는 분도 생길껍니다. 그런데… RDB 설정을 켜두든, 꺼두든… 이 문제는 발생할 수 있습니다.

이유를 살펴보면, Redis의 마스터/슬레이브 연결시에는 RDB 설정 여부에 상관없이 무조건 RDB를 생성하게 됩니다. 그리고 이 RDB 파일을 seed로 전송하고 그 뒤의 차이를 버퍼에 저장한 후에 이를 보내서 sync를 맞추게 되는데, 이 때, write가 많은 서버라면 메모리를 많이 사용해서 서버가 죽을 수도 있습니다. 처음 서비스 시작시에 슬레이브를 설정하면, 당연히 메모리 사용량이 얼마 안되니, 큰 문제가 없지만, 서비스로 인해서 메모리가 거의 풀로 찬 상황에서 슬레이브가 붙는다면, 문제의 소지가 있는 것이죠.

이 때문에, Core 수나 메모리에 따라서 적절히 Redis 인스턴스를 여러 개 띄워주는게 유리합니다. 즉, N개의 인스턴스를 실행한다면 (Memory-(운영체제필요메모리))/(N+1) 정도의 규칙으로 적절히 나누면(물론 Core 수도 중요합니다.) 하나의 인스턴스가 순간적으로 메모리를 많이 사용하더라도, 안정적으로 넘어갈 수 있습니다.(물론 이 때도 관리를 잘 해야합니다. 관리를 잘못하면… 한대든 여러대든 똑같은 이슈가…)

즉, 위와 같은 문제를 해결하기 위해서 하나의 서버에서 하나의 인스턴스만 실행하면, 16G 메모리라면, 최악을 대비해서 7G 정도만 사용해야 하지만… 여러 개의 인스턴스를 실행한다면 4G*3 개 정도의 인스턴스를 운영할 수 있습니다. 어차피 여러 대 관리해야 하면, 관리 이슈도 비슷하게 들테니까요.

여담이지만, Redis 나 이런 서버종류를 단순히 8G 서버 4대를 32G 서버 한대로 변경할 수 있는 것은 아닙니다. 컨넥션 수의 관리 때문에, 무조건 일정 대수 이상은 있어야 할 경우도 충분히 있습니다. 즉, 어느 것이 답이라는게 아니라, 어떤 것을 쓰든, 내부 구조를 잘 알아야 한다는게 오늘의 이야기입니다.

[입 개발] Redis Sentinel을 이용하면서 겪게 되는 문제 하나와 해결책

해당 포스트는 NIPA의 클라우드 지원센터의 도움으로 작성되었습니다.

Redis Sentinel을 이용하다보면, 흔한 상황은 아니지만, 문제가 생기는 상황이 있습니다. 어떤 상황이냐 하면, “모든 Redis 노드가 장애가 나고, 다시 한 대가 살아났을 때, 최초 Master가 아니면, Master 로 전환되지 않는 문제” 입니다.

즉 다음과 같이 Redis Master/Slave 각각 한대와 Sentinel 이 한대 있다고 가정하겠습니다.(여기서 Sentinel 과 Slave의 개수는 상관없습니다.)

sentinel1

그런데 여기서 Master가 장애나면 당연히 Slave가 Master로 승격이 되게 됩니다. 당연히 기대한 동작입니다.

sentinel2

sentinel3

자 이제 사고로 마지막 노드까지 장애가 나면 전혀 서비스 할 수 있는 서버가 없어지게 됩니다.

sentinel4

여기서 두 가지 가정이 있습니다.

  • 최초의 master 가 살아난다.
  • 마지막의 master 가 다시 살아난다.

첫번째 경우에는 전혀 문제가 없습니다. 즉 정상적으로 master가 인식이 됩니다.

sentinel5

그런데 두번째 케이스 원래 최후의 master나 다른 slave가 살아나네 되면… sentinel은 해당
노드 자체를 인식하지 못하게 되고, 계속 해당 노드는 Slave로 남아있습니다. 당연히 master가 되기를 바라고 있는데 말이죠.

sentinel6

그렇다면 왜 그런걸까요? 어떤 문제가 있는건가요?

사실, 실제로 Redis 두 대가 모두 죽어있을 때도 Sentinel 은 해당 두 노드에 대한 연결을 모두 가지고 있고 이를 지속적으로 체크하게 됩니다. 해당 작업은 sentinelHandleDictOfRedisInstances 에서 이루어집니다.

그런데 후자의 경우, Redis 노드가 다시 살아나면서!!!, 이전의 설정을 읽어드립니다. 즉, 자신이 slave라고 인식을 하게 되고, Sentinel 이 INFO 명령을 줬을 때, 자신이 slave 라고 전달하게 됩니다. 이 때, Sentinel은 slave가 연결되었을 때, master를 인식하고자 하고, 현재의 연결 정보를 날리고, Master 에 대한 연결만 가지게 됩니다. 정상적인 상황에서는 Master에 질의해서 Slave들의 정보를 가져올 수 있기 때문입니다.(sentinelRefreshInstanceInfo)

    /* ---------------------------- Acting half -----------------------------
     * Some things will not happen if sentinel.tilt is true, but some will
     * still be processed. */

    /* When what we believe is our master, turned into a slave, the wiser
     * thing we can do is to follow the events and redirect to the new
     * master, always. */
    if ((ri->flags & SRI_MASTER) && role == SRI_SLAVE && ri->slave_master_host)
    {
        sentinelEvent(REDIS_WARNING,"+redirect-to-master",ri,
            "%s %s %d %s %d",
            ri->name, ri->addr->ip, ri->addr->port,
            ri->slave_master_host, ri->slave_master_port);
        sentinelResetMasterAndChangeAddress(ri,ri->slave_master_host,
                                               ri->slave_master_port);
        return; /* Don't process anything after this event. */
    }

즉 Sentinel은 위의 작업 때문에 최초의 master에 대한 정보만 가지고, 계속 이전 아직 살아나지 않은 노드만 체크하게 되는겁니다. 그래서 정상적인 master가 살아날때는 문제가 없이 인식이 되는거죠.

사실 서버의 관리상 한대가 죽더라도 이에 대한 관리를 즉각 해야하기 때문에, 이런 일이 발생하는 일이 많지는 않지만, 서버가 급격히 죽을 경우는 문제가 될수도 있습니다.

그럼 이에 대한 해결책이 있는가? 라는 질문을 받게 된다면… 크게 두 가지 방법이 있습니다.
전자는 완벽한 해결책은 아니고, 장애가 난 마지막 master 노드가 살아났을 때에 대한 해결책입니다. redis에는 config rewrite 라는 새로운 명령이 들어가 있고, master 변환이 생길때마다 해당 명령을 던져주면 master로 동작하게 됩니다.

config rewrite

그러나 이것은 모든 경우에 대한 해결책은 아닙니다. 그렇다면, 어떻게 해결을 해야 하는가? 라는 질문이 생깁니다. 후자는 sentinel을 수정하는 방법입니다. 이건 다음 번에…(실제로 해당 수정을 통해서 위의 경우에 sentinel이 master로 인식이 되도록 하게 했습니다.) 그럼 60초 후에(?) 뵙겠습니다.

[입 개발: Redis 장애] twilio 의 Redis 장애의 원인과 해결책(?)

몇일전에 twilio에 빌링 관련해서 장애가 발생했다고 합니다. 주 내용은 http://www.twilio.com/blog/2013/07/billing-incident-post-mortem.html 에서 보실 수 있습니다. 그리고 이 글이 해커 뉴스(https://news.ycombinator.com/item?id=6093954) 에 올라 왔고, 이에 대해 다시 @antirez가 답변을 블로그에 올리기도 했습니다.(http://antirez.com/news/60)

그럼 왜 이런 현상이 일어났을까요?

먼저 twilio의 발표에는 네트웍 단절로 인해서 slave 노드들이 모두 연결이 실패하고 이에 따라서 master 노드에 여러대의 slave가 동시에 sync 과정을 거치면서 master의 부하가 높아져서, 실제 처리해야 할 리퀘스트들을 처리하지 못하게 되어서 장애가 생긴걸로 설명하고 있습니다.(밑에 덧글도 읽어보면 싸우기 시작하는군요. 재미있습니다.)

먼저 현재 2.6.x 대의 Redis는 master와의 연결이 끊어지면 무조건 sync를 다시 하게 됩니다. sync 과정은 master가 rdb를 백그라운드로 생성하게 되고(무조건!!!) 이를 client에 전달하고, 그 사이에 쌓인 버퍼를 전달해서 sync를 맞추게 됩니다. 이 때, 여러 개의 client가 동시에 sync를 요청해도 백그라운드로 rdb는 하나만 생성되고, 현재 쌓인 버퍼만 복사해서 전달하게 됩니다.
syncCommand에 자세히 설명되어 있습니다. 코드를 보면 child_pid != -1이면 현재 child_pid 생성중이면, 현재 sync를 기다리는 자식 노드가 있으면 sync를 위한 추가 명령 버퍼만 복사하고, 리스트에 추가합니다. slave가 아무것도 없으면, 지금 rdb 저장이 마무리 되었다고 생각하고, 다음 rdb 생성이 일어나길 기다리게 됩니다. 반대로 child_pid == -1이면 현재 아무런 sync 요청이 없으므로 rdb를 백그라운드로 생성합니다.

void syncCommand(redisClient *c) {
    /* ignore SYNC if already slave or in monitor mode */
    if (c->flags & REDIS_SLAVE) return;

    /* Refuse SYNC requests if we are a slave but the link with our master
     * is not ok... */
    if (server.masterhost && server.repl_state != REDIS_REPL_CONNECTED) {
        addReplyError(c,"Can't SYNC while not connected with my master");
        return;
    }

    /* SYNC can't be issued when the server has pending data to send to
     * the client about already issued commands. We need a fresh reply
     * buffer registering the differences between the BGSAVE and the current
     * dataset, so that we can copy to other slaves if needed. */
    if (listLength(c->reply) != 0) {
        addReplyError(c,"SYNC is invalid with pending input");
        return;
    }
    redisLog(REDIS_NOTICE,"Slave ask for synchronization");
    /* Here we need to check if there is a background saving operation
     * in progress, or if it is required to start one */
    if (server.rdb_child_pid != -1) {
        /* Ok a background save is in progress. Let's check if it is a good
         * one for replication, i.e. if there is another slave that is
         * registering differences since the server forked to save */
        redisClient *slave;
        listNode *ln;
        listIter li;

        listRewind(server.slaves,&li);
        while((ln = listNext(&li))) {
            slave = ln->value;
            if (slave->replstate == REDIS_REPL_WAIT_BGSAVE_END) break;
        }
        if (ln) {
            /* Perfect, the server is already registering differences for
             * another slave. Set the right state, and copy the buffer. */
            copyClientOutputBuffer(c,slave);
            c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
            redisLog(REDIS_NOTICE,"Waiting for end of BGSAVE for SYNC");
        } else {
            /* No way, we need to wait for the next BGSAVE in order to
             * register differences */
            c->replstate = REDIS_REPL_WAIT_BGSAVE_START;
            redisLog(REDIS_NOTICE,"Waiting for next BGSAVE for SYNC");
        }
    } else {
        /* Ok we don't have a BGSAVE in progress, let's start one */
        redisLog(REDIS_NOTICE,"Starting BGSAVE for SYNC");
        if (rdbSaveBackground(server.rdb_filename) != REDIS_OK) {
            redisLog(REDIS_NOTICE,"Replication failed, can't BGSAVE");
            addReplyError(c,"Unable to perform background save");
            return;
        }
        c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
    }

    if (server.repl_disable_tcp_nodelay)
        anetDisableTcpNoDelay(NULL, c->fd); /* Non critical if it fails. */
    c->repldbfd = -1;
    c->flags |= REDIS_SLAVE;
    c->slaveseldb = 0;
    listAddNodeTail(server.slaves,c);
    return;
}

그런데, 사실 이것 자체가 큰 문제는 아니었는데, 로드가 높다고 생각하고, twilio 운영팀은 Redis 재시작을 결정합니다. 그리고 master를 재시작 했는데, 기본 설정이 잘못되어 있었던 것입니다. 즉 RDB 파일을 읽을 것이라고 생각했는데 AppendOnlyFile을 읽어버린 거죠. 왜냐하면 AOF와 RDB 중에 AOF는 바로 직전 까지의 값을 가지고 있으므로 AOF 활성화 옵션이 있으면 rdb 대신에 AOF를 읽습니다. 아래의 코드를 보면 aof 설정이 켜져 있으면 aof를 읽는 것을 볼 수 있습니다.

/* Function called at startup to load RDB or AOF file in memory. */
void loadDataFromDisk(void) {
    long long start = ustime();
    if (server.aof_state == REDIS_AOF_ON) {
        if (loadAppendOnlyFile(server.aof_filename) == REDIS_OK)
            redisLog(REDIS_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
    } else {
        if (rdbLoad(server.rdb_filename) == REDIS_OK) {
            redisLog(REDIS_NOTICE,"DB loaded from disk: %.3f seconds",
                (float)(ustime()-start)/1000000);
        } else if (errno != ENOENT) {
            redisLog(REDIS_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
            exit(1);
        }
    }
}

그런데 여기서 twilio는 aof를 사용하지 않아서 빈 데이터를 로딩하게 되고, 이를 slave가 리플리케이션 받게 되어서 데이터가 날라가게 된 것입니다.

그렇다면, 이런 문제에 대해서 어떻게 대처를 해야할까요?

1] Full resync에 대해서는 2.8 부터는 partial sync라는 기능이 들어가서, 만약 이전 마스터와 동일한 runid를 가지고 있고, 현재 저장된 버퍼 안까지만의 차이라면, 그냥 이 차이값만 받아서 업데이트 하게 됩니다. 아직 2.6.x는 못썻으니…

2] 설정에 대한 검증을 강화해야 합니다. 처음부터 aof를 안쓴느데, aof 관련 설정이 켜져있었다는 것은 뭔가 실수가 있었다는 것입니다. 이런것도 검증하는 서비스가 필요하지 않을가 싶네요.

* 핵심정리 중 하나: slave 가 sync 해야할때의 rdb는 옵션을 끄더라도 무조건 발생합니다. 이것이 메모리를 적당히 나눠서 rdb 관련 이슈를 줄여야 하는 이유중에 하나입니다.

[입 개발] 왜 Redis는 마스터/마스터 리플리케이션을 지원하지 않을까?

입으로만 개발하고 손으로는 개발하지 않는 입개발 전문가로써 쿨럭… 지금 무슨 얘기를… 다시 원래의 이야기로 돌아가서… 가끔씩 외국도 그렇고 국내도 그렇고 사람들은 다 똑같은가 봅니다. 왜 레디스는 마스터/마스터 리플리케이션을 지원하지 않는가에 대해서 물어봅니다. 뭐, 클러스터만 되면 어느정도 해결이 될것 같기도 하지만… 레디스의 경우 마스터/슬레이브에 대한 리플리케이션이 존재하는 지라… 이것 조금만 고치면 되지 않겠냐라고 생각하시는 분들도 많은 것 같습니다.(저만 그렇게 생각할지도…)

그런데 마스터/마스터 리플리케이션 자체가 그렇게 쉬운 기술이 아닙니다.(저한테만!!! 어려울지도…) 그리고 현재 레디스의 마스터/슬레이브 리플리케이션 구조가 마스터/마스터를 지원하기 힘든 아키텍처로 구성이 되어 있습니다. 그렇다면 먼저 레디스의 리플리케이션 구조를 살펴보는게 우선일듯 합니다.

간단하게 설명하면, 자신이 요청 받은 커맨드가 update 관련(insert/update/delete) 라면 그냥 그대로 자신의 슬레이브 노드들에게 다시 전달해 버립니다. 뭐, 그게 어째서 라고 말씀하시는 분들은 DB의 Bin 로그 같은 것들도 비슷한 방식이지 않냐라고 항변 하시면 할 말이 없습니다만… 여기서 그게 가능할려면 전제조건이 하나 붙어야 합니다. 즉, 자신에게 다시 해당 패킷이 돌아왔을 때, 이게 발신자가 자신이면 거기서 멈춰야 하는 것이죠. 그런데 레디스에서는 이런 정보를 붙일 부분이 없어서, 그렇게 구현을 해버리면 자신에게 해당 패킷이 오면, 계속 다시 무한 반복해서 돌아오게 될것입니다. 즉, 기존의 리플리케이션 구조가 바뀌지 않는 한은 아직은 어렵다라는 거죠.

실제로 Hbase 같은 경우는 클러스터간 마스터/마스터 리플리케이션을 위해서, 패킷에 누가 최초에 명령을 수행해서 리플리케이션 되는 것인지 정보가 추가로 붙어서 전달되게 됩니다. HBase Replication(http://blog.cloudera.com/blog/2012/07/hbase-replication-overview-2/)을 보면 ClusterID를 설정해서 보낸다고 되어 있습니다. 레디스는 현재 이런 작업이 안되어 있는 겁니다. 사이클이 생겼을 때는 또 어떻게 처리할 것인지 등등등 고민거리가 여기에도 꽤 많이 있습니다.

redis.c 의 call() 함수를 보면 propagate() 를 사용하는데, 여기서 AOF와 Replication을 실제로 호출하게 됩니다.

void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc,
               int flags)
{
    if (server.aof_state != REDIS_AOF_OFF && flags & REDIS_PROPAGATE_AOF)
        feedAppendOnlyFile(cmd,dbid,argv,argc);
    if (flags & REDIS_PROPAGATE_REPL)
        replicationFeedSlaves(server.slaves,dbid,argv,argc);
}

그리고 replication.c 의 replicationFeedSlaves()를 살펴봅니다. 사실 여기서 제 뻥을 하나 눈치채셔야 합니다. 그대로 보낸다라고 했지만, 실제로는 여기서 못보낼 상황이라면 계속 버퍼를 모으게 됩니다. 보낼 수 있다면 바로보냅니다. 여기서도 최종적으로 데이터 변경이 되는 경우에만 리플리케이션이 됩니다. 예를 들어서 이미 지워진 데이터에 대해서 계속 del을 해도 해당 명령은 전달이 되지 않습니다. 이를 확인하는 것은 telnet 으로 레디스에 붙은 뒤에 sync 라는 명령을 주고 실제 레디스 서버에 set 이나 del을 해보시면 됩니다.

SELECT
$1
0
*3
$3
set
$1
a
$3
123
*2
$3
del
$1
a
*1
$4
PING
*1
$4
PING

[입 개발] Sentinel은 어떻게 노드를 찾아낼까?

점점, 내용이 없어지는 입개발입니다. 최근에는 백수가 열심히 먹고 살아볼려고 하니, 좋은 내용이 없네요. 죄송합니다. 요새는 Redis HA쪽에 관심이 있다보니, 점점 Sentinel을 자주보게 되는데요. 근원적인 질문을 하나 던집니다. Sentinel은 어떻게 감시해야할 노드를 찾아낼까?

사실 어떻게 보면 간단합니다. 우리는 sentinel.conf에 감시해야할 서버의 주소를 미리적어두니까요. 그런데 슬레이브 노드 중에 마스터로 승격을 시키게 되는데, 과연 이 슬레이브 노드들을 어떻게 찾는 것일까요?

핵심은 “INFO” 명령에 있습니다. 아무런 슬레이브가 없을 때 Redis 서버에 “INFO” 명령을 주면 다음과 같습니다.

# Replication
role:master
connected_slaves:0
master_repl_offset:0
repl_backlog_active:0
repl_backlog_size:1048576
repl_backlog_first_byte_offset:0
repl_backlog_histlen:0

이제 하나의 슬레이브가 있을 때는 다음과 같습니다.

# Replication
role:master
connected_slaves:1
slave0:127.0.0.1,6380,online,1
master_repl_offset:1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:0

중간에 slave0: 127.0.0.1,6380,online,1 이런 정보가 보이죠. 그럼 슬레이브가 한 100개까지 추가되면 어떻게 될까요? 네 아마도 slave99 이런식으로 정보가 생길것입니다. sentinel도 마찬가지 입니다. 접속해야할 노드에 INFO명령을 통해서 위의 주소를 분석해 내게 됩니다.

얼마전에 unstable에 sentinel에서 전혀 슬레이브 노드를 찾지 못해서 sentinel이 동작하지 않는 버그가 있었는데 최신 unstable에서 해당 내용이 수정되었기 때문입니다. 최신 버전에 대해서 INFO를 날려보면 다음과 같습니다.

# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6380,state=online,offset=1,lag=0
master_repl_offset:1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:0

보시면 기존에는 단순히 ‘,’ 로 분리만 되던것에 이름 정보가 붙었습니다. 현재 버전의 sentinel은 이전 형식과 최신 형식 두 가지 모두를 지원하고 있습니다. sentinel.c 의 sentinelRefreshInstanceInfo 함수를 보면 됩니다.
아주, 단순하게 구현되어 있습니다.

        if ((ri->flags & SRI_MASTER) &&
            sdslen(l) >= 7 &&
            !memcmp(l,"slave",5) && isdigit(l[5]))
        {
            char *ip, *port, *end;

            if (strstr(l,"ip=") == NULL) {
                /* Old format. */
                ip = strchr(l,':'); if (!ip) continue;
                ip++; /* Now ip points to start of ip address. */
                port = strchr(ip,','); if (!port) continue;
                *port = '\0'; /* nul term for easy access. */
                port++; /* Now port points to start of port number. */
                end = strchr(port,','); if (!end) continue;
                *end = '\0'; /* nul term for easy access. */
            } else {
                /* New format. */
                ip = strstr(l,"ip="); if (!ip) continue;
                ip += 3; /* Now ip points to start of ip address. */
                port = strstr(l,"port="); if (!port) continue;
                port += 5; /* Now port points to start of port number. */
                /* Nul term both fields for easy access. */
                end = strchr(ip,','); if (end) *end = '\0';
                end = strchr(port,','); if (end) *end = '\0';
            }

또한 여기서 행여나 sentinel.conf 에 마스터가 아닌 슬레이브 주소가 있을 때 처리하는 부분도 들어가 있습니다. 먼저 슬레이브 노드에 INFO 명령을 주면 다음과 같은 결과가 나옵니다.

# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:1
master_sync_in_progress:0
slave_repl_offset:435
slave_priority:100
slave_read_only:1
connected_slaves:0
master_repl_offset:0
repl_backlog_active:0
repl_backlog_size:1048576
repl_backlog_first_byte_offset:0
repl_backlog_histlen:0

보시면 내용에 role: slave 이고, master_host, master_port 가 있는 것을 알 수 있습니다. 즉, 접속한 노드가 슬레이브라면, 해당 정보를 이용해서 마스터를 찾을 수 있습니다.(다만, 딱 자신의 마스터만 찾을 수 있습니다. 멀티 체인 형태로 리플리케이션을 하고 있다면, 최상위 부모는 찾을 수가 없습니다.)

그럼 이제 Sentinel에서 마스터와 슬레이브 노드를 찾아내는 것은 알아냈습니다. 그런데 sentinel을 여러대 동작시키면, sentinel 끼리도 동작하게 됩니다. 여기서는 도대체 어떻게 서로 알아내는 것일까요? 여기서는 복합적으로 정보를 주고 받게 됩니다. 앞에 내용으로 Sentinel은 INFO 명령을 통해서 마스터와 슬레이브를 구분할 수 있다고 했습니다. 그리고, Sentinel은 마스터에 pub/sub을 이용해서 SENTINEL_HELLO_CHANNEL (__sentinel__:hello) 값을 이용해서 데이터를 주고 받게 됩니다. 여기서 서로간의 heartbeat 를 이루게 됩니다. 주고 받게 되는 Hello 메시지는 다음과 같습니다. ip:port:runid 입니다. 마지막 값은 failover 할지 말지를 설정하는 값입니다.

*4
$8
pmessage
$1
*
$18
__sentinel__:hello
$58
127.0.0.1:26379:7d717f945afde99e6f82f825de052f17cab7e6f3:1

해당 코드는 sentinelReconnectInstance 에서 호출됩니다.

            int retval;

            ri->pc_conn_time = mstime();
            ri->pc->data = ri;
            redisAeAttach(server.el,ri->pc);
            redisAsyncSetConnectCallback(ri->pc,
                                            sentinelLinkEstablishedCallback);
            redisAsyncSetDisconnectCallback(ri->pc,
                                            sentinelDisconnectCallback);
            sentinelSendAuthIfNeeded(ri,ri->pc);
            /* Now we subscribe to the Sentinels "Hello" channel. */
            retval = redisAsyncCommand(ri->pc,
                sentinelReceiveHelloMessages, NULL, "SUBSCRIBE %s",
                    SENTINEL_HELLO_CHANNEL);
            if (retval != REDIS_OK) {
                /* If we can't subscribe, the Pub/Sub connection is useless
                 * and we can simply disconnect it and try again. */
                sentinelKillLink(ri,ri->pc);
                return;
            }

그리고 위의 Hello Pub/Sub을 이용해서 받은 주소가 현재 자신이 가지고 있지 않은 값이면 Sentinel로 등록하게 됩니다. sentinelReceiveHelloMessages 를 보시면 됩니다.

            port = atoi(token[1]);
            canfailover = atoi(token[3]);
            sentinel = getSentinelRedisInstanceByAddrAndRunID(
                            ri->sentinels,token[0],port,token[2]);

            if (!sentinel) {
                /* If not, remove all the sentinels that have the same runid
                 * OR the same ip/port, because it's either a restart or a
                 * network topology change. */
                removed = removeMatchingSentinelsFromMaster(ri,token[0],port,
                                token[2]);
                if (removed) {
                    sentinelEvent(REDIS_NOTICE,"-dup-sentinel",ri,
                        "%@ #duplicate of %s:%d or %s",
                        token[0],port,token[2]);
                }

                /* Add the new sentinel. */
                sentinel = createSentinelRedisInstance(NULL,SRI_SENTINEL,
                                token[0],port,ri->quorum,ri);
                if (sentinel) {
                    sentinelEvent(REDIS_NOTICE,"+sentinel",sentinel,"%@");
                    /* The runid is NULL after a new instance creation and
                     * for Sentinels we don't have a later chance to fill it,
                     * so do it now. */
                    sentinel->runid = sdsnew(token[2]);
                }
            }

사실 http://redis.io/topics/sentinel 여기를 보시면 더 잘 나와있습니다. 쿨럭…

[입 개발] Redis Sentinel의 동작 방식에 대하여…

Redis Sentinel은 Redis에서 Simple Failover를 위해서 제공해주는 솔루션 입니다. 나름 복잡하지만, 다른 솔루션에 비해서 아주 심플하게 구현되어 있기 때문에, 아주 간단한 구조에서는 사용하기에 편리합니다.

저 같은 경우는 부끄럽게도, Sentinel의 중요한 동작 중에 하나를 신경을 못쓰고 잘 알지 못하다가 이번에 제대로 이해를(소스를 안봤던 부분과 실제로 테스트를 못한 부분) 못한 부분에 대해서 다시 공유할려고 합니다.

먼저 Sentinel의 기본 동작에 대해서 정리를 하자면 Sentinel의 동작은 다음과 같습니다.

  1. 주기적으로 지시된 레디스 서버가 동작하는 체크
  2. 해당 레디스 서버가 죽었다고 판단되면, 슬레이브 노드 중에 하나를 마스터로 승격
  3. Sentinel에 pub/sub으로 연결된 노드에 마스터 변경 통지

이렇게 아주 단순한 기능 밖에 없습니다. 다만, 장애가 났던 마스터가 들어오면 해당 주소를 기억하고 있다가 자동으로 슬레이브로 변경시켜주는 기능이 추가되어 있습니다.

자, 그럼 이제 제가 잘못 알고 있던 부분에 대해서 설명하고자 합니다. 일반적인 Sentinel을 이용한 Failover 시나리오를 보면, 위와 동일합니다. 그런데 위의 케이스에서, 점검을 위해 모든 서비스를 종료한다고 가정합니다. 그러면, 현재 마스터와 슬레이브 사이의 설정이 변경되어 있어야 합니다. 이를 Redis 의 경우는 “config rewrite” 라는 명령을 통해서 현재 메모리에 존재하는 설정을 디스크에 저장할 수 있습니다. 이 부분이 저를 혼란에 빠트렸습니다. 아, Redis도 이렇게 설정을 저장하는 것 처럼, Sentinel도 설정이 저장되지 않으면 문제가 생기겠구나라는 생각을 잘못 가지게 된거죠.

왜냐하면 Sentinel 설정은 다음과 같습니다.

port 26379

sentinel monitor mymaster 127.0.0.1 6379 1
sentinel down-after-milliseconds mymaster 30000
sentinel can-failover mymaster yes
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 900000

여기에 아이피와 포트번호가 들어가게 되는데, 당연히 장애로 인해서 Failover를 해주면 ip와 포트가 다른 슬레이브로 바뀌게 되는 것이죠. 저는 그래서 config rewrite가 없는 sentinel을 위하여(-_-) config rewrite 패치를 제출하기도 했습니다.(부끄럽습니다. T.T)

그런데 코드를 실제로 살펴보면 이럴 필요가 없습니다. 즉 만약 해당 주소가 slave 의 주소라면, Sentinel이 마스터의 주소를 찾아서 감시해야할 대상을 마스터로 자동으로 변경합니다. 그리고 다음과 같은 로그를 출력합니다.

[626] 27 Jun 09:32:24.377 # +redirect-to-master mymaster 127.0.0.1 6380 127.0.0.1 6379

소스를 보면 다음과 같습니다. sentinel.c 의 sentinelRefreshInstanceInfo 함수에서 자신이 slave 면 role이 SRI_SLAVE 로 셋팅되고 여기서 master_host 의 ip와 port를 가져오게 됩니다.

        if (role == SRI_SLAVE) {
            /* master_host:<host> */
            if (sdslen(l) >= 12 && !memcmp(l,"master_host:",12)) {
                sdsfree(ri->slave_master_host);
                ri->slave_master_host = sdsnew(l+12);
            }

            /* master_port:<port> */
            if (sdslen(l) >= 12 && !memcmp(l,"master_port:",12))
                ri->slave_master_port = atoi(l+12);

            /* master_link_status:<status> */
            if (sdslen(l) >= 19 && !memcmp(l,"master_link_status:",19)) {
                ri->slave_master_link_status =
                    (strcasecmp(l+19,"up") == 0) ?
                    SENTINEL_MASTER_LINK_STATUS_UP :
                    SENTINEL_MASTER_LINK_STATUS_DOWN;
            }

            /* slave_priority:<priority> */
            if (sdslen(l) >= 15 && !memcmp(l,"slave_priority:",15))
                ri->slave_priority = atoi(l+15);
        }

위의 코드를 보면 info 에 slave면 다음과 같은 정보가 있는 것을 이용하게 되는 것입니다.

# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:1
master_sync_in_progress:0
slave_repl_offset:5417

그래서 sentinel은 그냥 올렸다 내려도 문제가 없습니다.(이걸 몰라서 지금까지 고생을 T.T) 그런데… 소스를 보시면 아시겠지만, 한계도 존재합니다.

레디스는 체인형 마스터/슬레이브 설정이 가능합니다.(내부적으로는 그냥 딱 부모와 자식간만의 연결이지만… 부모가 또 다른 노드의 슬레이브가 될 수가 있습니다.) 그래서, 만약 해당 슬레이브의 부모가 또 다른 부모를 가지고 있다면, 딱 자신의 부모로 스위칭이 되는 것입니다. 재귀적으로 최종 부모를 찾을 수도 있겠지만, 중간에 slave 노드 자체가 write 가 될 가능성도 있고(이것도 확인은 가능합니다만…) 이런 이유로, 이런 부분을 알고 쓰시면 간단한 Failover 시스템을 만들때는 꽤 도움이 될 것 같습니다.

[입 개발] Redis 데이터 모델링시에 주의할점

Redis를 사용하는 큰 이유중에 하나가 여러가지 자료구조를 제공해준다는 점입니다. set, list, sorted set, hash 등 그래서, 실제로 유저별 데이터를 prefix:id 또는 id:data_prefix 형태로 데이터를 set/get을 이용하거나 hset/hget/hgetall 등을 이용해서 데이터를 가져옵니다. 사실 이것은 적합한 형태는 서비스의 모델마다 틀리고, 어떤 목적으로 이용할 것인가에 따라서 틀리므로, 서비스에 맞게 설계하고, 사용해보면 됩니다.

예를 들어, 트위터와 비슷한 서비스를 만들때, 팔로워 정보를 list 나 set, sorted set에 넣어둘 수도 있고, hash를 이용할수도 있습니다. 즉 뭘해도 크게 상관이 없다는 것입니다. 바로 찾을 필요가 있다면 hash에 넣을것이고, 전체 순회만 한다면 list를 써도 되는거죠. 그런데, 여기서 놓치기 쉬운 문제가 하나 숨어있습니다.

자 list 나 set 등의 collection 에 친구목록을 넣는다고 합니다. 어떤 유저는 친구가 너무 많아서 백만명이고, 어떤 유저는 몇백명 또는 몇명일 수 있습니다. 그런데 이제 이 사용자가 탈퇴를 해야해서 데이터를 지우려고 합니다. 어떻게 해야할까요?

 del [key]

물론, 위의 형태로 간단하게 삭제가 가능합니다. 그러면 어떤 문제가 발생할까요? 레디스의 철학은 짧은 시간에 지울 수 있는 일만 하자입니다. 그래서 단순히 백만개 정도의 데이터를 지운다고 하면 1초 정도의 시간이 걸립니다. 자 그럼 백만개의 아이템이 들어있는 set을 지우는데는 얼마의 시간이 걸릴까요? 넵 당연히 1초 정도가 걸릴겁니다. 그러면, 이 시간동안 레디스는 다른 작업을 처리할 수 가 없습니다.

그래서 이런 삭제 문제가 사실 레디스에서 처리하기 어려운 문제입니다. 그렇다면 이런 문제는 어떻게 해결해야 할까요? 일단 다음과 같은 룰을 지켜야 합니다. 데이터 콜레션에는 데이터를 일정이상 넘기지 않는다. 많아도 몇천개 정도로 한정합니다. 그리고 이 몇천개의 데이터를 가지는 key 목록에 대해서 다시 한번 목록을 유지합니다.

즉 다음과 같은 형태가 됩니다.
목록(set1, set2, set3)
|
|
set1 —— set2 ——– set3

이걸 특정시간에 다 지우지 않고 한번에 한 set들만 지우는 것입니다. 이래야만 실제로, 하나의 거대한 작업을 쪼개서 처리할 수 있게 됩니다. 이런 테크닉은 사실, 싱글 스레드에서 대량의 데이터를 다뤄야 하는 경우에는, 다른 곳에서도 많이 응용되고 있습니다. 긴 작업은 여러 개의 작은 작업으로 나눠서 단계별로 처리하게 하는것이죠.

[입 개발] Redis 에서 Key의 Expire는 어떻게 처리되는가?

Redis를 사용하다보면 Key 의 Expire가 어떻게 처리되는지 궁금할 때가 있습니다. 결론부터 말하자면, Redis에서는 Key가 두 가지 방법으로 Expire가 될 수 가 있습니다.

  1. memcached 처럼 key에 대한 접근이 발생할 때
  2. activeExpireCycle 에 의한 삭제
  3. command 처리 전에 memory가 부족할 때 메모리 정책에 따라서 삭제

memcached의 경우 expire 된 key의 경우 그 시점에 지워지지 않고 실제로 해당 key에 접근이 되는 시점에 없다라고 돌려주게 됩니다. Redis도 기본적으로 get등의 operation이 발생할 때 key의 expire가 되었는지를 체크하게됩니다. 이 때 getExpire라는 함수를 이용해서 expire time을 가져오게 됩니다.

long long getExpire(redisDb *db, robj *key) {
    dictEntry *de;

    /* No expire? return ASAP */
    if (dictSize(db->expires) == 0 ||
       (de = dictFind(db->expires,key->ptr)) == NULL) return -1;

    /* The entry was found in the expire dict, this means it should also
     * be present in the main dict (safety check). */
    redisAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
    return dictGetSignedIntegerVal(de);
}

그런데 dictFind를 보면 db->expires에서 key를 찾습니다. 그렇다는 얘기는 expire되는 key들을 따로 관리한다는 의미가 될것 같습니다. 그럼 어디서 이것을 변경하게 될까요? getExpire대신에 setExpire라는 함수가 있습니다.

void setExpire(redisDb *db, robj *key, long long when) {
    dictEntry *kde, *de;

    /* Reuse the sds from the main dict in the expire dict */
    kde = dictFind(db->dict,key->ptr);
    redisAssertWithInfo(NULL,key,kde != NULL);
    de = dictReplaceRaw(db->expires,dictGetKey(kde));
    dictSetSignedIntegerVal(de,when);
}

실제로 모든 key는 db->dict에 저장되고 db->expire에서 expire값을 가진 key만 추가로 관리합니다. 그래서 실제로 dbDelete에서는 두 군데서 해당 포인터를 제거합니다.

그런데 이런 작업 이외에도 expire 된 key가 지워집니다. aof rewrite시에 실제로 보고된 적은 없지만, expire된 key가 타이밍에 따라서 지워지지 않고 aof에 저장될 수 있는 버그를 수정하면서, 중간중간 계속 key가 먼저 지워져서 고민했던 적이 있는데 이 부분이 바로 activeExpireCycle 때문입니다.

100ms 마다 actvieExpireCycle이 database들을 돌면서 랜덤하게 expire된 데이터를 삭제합니다. timeout이 있고, 이 시간마다 다음 작업할 DB를 기억하고 있다가 완료될 때 까지 작업을 하게 됩니다. timelimit은 다음과 같이 정해집니다.

timelimit = 1000000*REDIS_EXPIRELOOKUPS_TIME_PERC/server.hz/100;

마지막으로 memory정책에 따라서 command 처리전에 메모리 확보를 위해서 key를 지우게 됩니다.
이 작업은 freeMemoryIfNeeded 에서 진행하게 됩니다.