Home EVAL, EVALSHA 명령어의 차이점과 레디스 내부 코드 살펴보기
Post
Cancel

EVAL, EVALSHA 명령어의 차이점과 레디스 내부 코드 살펴보기

글을 작성하게 된 계기


EVAL과 EVALSHA 명령어의 차이점레디스 소스 코드를 살펴보며 알게된 내용 을 정리하기 위해 글을 작성하게 되었습니다.





1. EVAL, EVALSHA


EVAL는 서버 측에서 루아 스크립트를 실행 하는 명령어입니다. 클라이언트가 스크립트를 레디스 서버로 보내면, 서버는 이를 실행합니다. 이는 스크립트를 즉시 실행 하며, 스크립트를 매번 전달해야 합니다.

Invoke the execution of a server-side Lua script.




다음과 같이 스크립트를 레디스에 보내 명령어를 실행하는 것이죠.

1
2
3
4
127.0.0.1:6379> EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 key value
OK
127.0.0.1:6379> GET key
"value"





EVALSHA는 EVAL과 거의 동일한 방식으로 동작하지만, 스크립트를 전달하지 않고 SHA1 해시 를 사용해 스크립트를 실행하는 명령어입니다. 이는 SCRIPT LOAD 명령어를 사용해 미리 스크립트를 캐시할 수 있으며, EVAL 명령어를 사용해도 스크립트가 캐시 됩니다. 이후 SHA1 해시를 통해 EVALSHA 명령어로 캐시된 스크립트를 빠르게 실행할 수 있습니다.

Evaluate a script from the server’s cache by its SHA1 digest. The server caches scripts by using the SCRIPT LOAD command. The command is otherwise identical to EVAL.




즉, EVAL을 사용할 때는 매번 스크립트 전체를 서버로 보내지만, EVALSHA는 이미 서버에 저장된 스크립트를 해시값으로 참조하기 때문에 성능네트워크 효율성 을 높일 수 있습니다.

1
2
3
127.0.0.1:6379> SCRIPT LOAD "return redis.call('set', KEYS[1], ARGV[1])"
"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"
127.0.0.1:6379> EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff8db 1 key value  # 해시 값으로 실행







2. 코드 살펴보기


EVAL과 EVALSHA 명령어가 레디스 코드에 어떻게 구현돼 있는지 한 번 살펴보겠습니다. 이를 위해서는 먼저 레디스 내부에서 어떻게 루아 스크립트가 관리 되는지를 이해하고 있어야 합니다.

  1. 루아 스크립트 로드
  2. Lua 함수로 변환
  3. 함수 이름으로 참조





레디스는 루아 스크립트를 한 번 로드 하면 이를 Lua 함수로 변환 하여 메모리에 저장 합니다. 이후 스크립트는 해당 함수 이름을 통해 참조/호출 됩니다. 함수 이름은 funcname 이라는 변수에 저장되며, 이는 스크립트의 고유한 SHA1 해시값 을 기반으로 생성됩니다. 즉, 레디스는 funcname 을 통해 스크립트를 식별 하고 실행하는 것이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
void evalGenericCommand(redisClient *c, int evalsha) {

    ......

    if (!evalsha) {
        sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr));
    } else {
        int j;
        char *sha = c->argv[1]->ptr;

        // funcname에 저장
        for (j = 0; j < 40; j++)
            funcname[j+2] = (sha[j] >= 'A' && sha[j] <= 'Z') ?
                sha[j]+('a'-'A') : sha[j];
        funcname[42] = '\0';
    }

    ......
    
}

이 해시 값이 위에서 봤던 EVALSHA에서 사용되는 해시 값 입니다.






2-1. EVAL

두 명령어는 같은 evalGenericCommand 함수를 사용하지만, EVAL 명령어는 스크립트를 직접 전달하므로 인자로 0을, EVALSHA 명령어는 해시값을 전달하므로 인자로 1을 넘겨 분기 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void evalCommand(redisClient *c) {
    evalGenericCommand(c,0);
}

void evalShaCommand(redisClient *c) {
    if (sdslen(c->argv[1]->ptr) != 40) {
        /* We know that a match is not possible if the provided SHA is
         * not the right length. So we return an error ASAP, this way
         * evalGenericCommand() can be implemented without string length
         * sanity check */
        addReply(c, shared.noscripterr);
        return;
    }
    evalGenericCommand(c,1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
void evalGenericCommand(redisClient *c, int evalsha) {

    ......

    // EVAL 명령어인 경우
    if (!evalsha) {
        sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr));
    } else {
        // EVALSHA 명령어인 경우
        int j;
        char *sha = c->argv[1]->ptr;

        for (j = 0; j < 40; j++)
            funcname[j+2] = (sha[j] >= 'A' && sha[j] <= 'Z') ?
                sha[j]+('a'-'A') : sha[j];
        funcname[42] = '\0';
    }

    ......
    
}





위에서 EVAL 명령어는 직접 스크립트를 전달한다고 했죠? 코드를 보면 스크립트 내용을 SHA1 해시로 변환 해 funcname에 저장하는 것을 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void sha1hex(char *digest, char *script, size_t len) {
    SHA1_CTX ctx;
    unsigned char hash[20];
    char *cset = "0123456789abcdef";
    int j;

    SHA1Init(&ctx);
    SHA1Update(&ctx,(unsigned char*)script,len);
    SHA1Final(hash,&ctx);

    for (j = 0; j < 20; j++) {
        digest[j*2] = cset[((hash[j]&0xF0)>>4)];
        digest[j*2+1] = cset[(hash[j]&0xF)];
    }
    digest[40] = '\0';
}





2-2. EVALSHA

반면 EVALSHA 명령어는 이미 해시된 스크립트를 찾기 때문에, 해시값을 직접 받아서 funcname에 저장합니다. 별도의 변환 과정이 없죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
void evalGenericCommand(redisClient *c, int evalsha) {

    ......

    if (!evalsha) {
        sha1hex(funcname+2,c->argv[1]->ptr,sdslen(c->argv[1]->ptr));
    } else {
        // EVALSHA 명령어인 경우 해시 값을 funcname에 저장
        int j;
        char *sha = c->argv[1]->ptr;

        for (j = 0; j < 40; j++)
            funcname[j+2] = (sha[j] >= 'A' && sha[j] <= 'Z') ?
                sha[j]+('a'-'A') : sha[j];
        funcname[42] = '\0';
    }
    
    ......






위에서 SCRIPT LOAD 명령어를 통해 해시값을 반환하고, 이 해시값을 넘겨 EVALSHA를 실행하는데, 이 동작과 일치하죠. EVAL과 EVALSHA 모두 위에서 본 개념과 똑같이 동작하죠?

1
2
3
127.0.0.1:6379> SCRIPT LOAD "return redis.call('set', KEYS[1], ARGV[1])"
"e0e1f9fabfc9d4800c877a703b823ac0578ff8db"
127.0.0.1:6379> EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff8db 1 key value  # 해시 값으로 실행







3. EVAL 명령어와 스크립트 캐시


여기서 재미있는 부분을 하나 발견했는데요, EVAL 명령어를 실행해도 스크립트가 캐시 된다는 점입니다. 물론 이를 사용하기 위해서는 SCRIPT LOAD 명령어로 스크립트를 로드해 해시값 을 얻어야 하지만요.

Executed scripts are guaranteed to be in the script cache of a given execution of a Redis instance forever. This means that if an EVAL is performed against a Redis instance all the subsequent EVALSHA calls will succeed.





이를 코드 레벨에서 살펴보면, 먼저 lua_isnil 함수를 통해 루아 스크립트가 존재하는지를 검증합니다. 즉, 스크립트를 최초로 사용할 때죠. 만약 처음 함수를 호출한다면 이 분기문으로 진입해 luaCreateFunction 로 스크립트를 등록합니다.

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
32
33
34
35
#include <stdio.h>
void evalGenericCommand(redisClient *c, int evalsha) {
    lua_State *lua = server.lua;
    char funcname[43];
    long long numkeys;
    int delhook = 0, err;

    ......

    lua_getglobal(lua, "__redis__err__handler");
    lua_getglobal(lua, funcname);
    
    // 1. LuaScript가 존재하지 않는 경우
    if (lua_isnil(lua,-1)) {
        lua_pop(lua,1);
        if (evalsha) {
            lua_pop(lua,1); /* remove the error handler from the stack. */
            addReply(c, shared.noscripterr);
            return;
        }

        // 2. 새로운 Lua 스크립트를 함수로 등록
        if (luaCreateFunction(c,lua,funcname,c->argv[1]) == REDIS_ERR) {
            lua_pop(lua,1); 
            return;
        }
        
        // 새로운 함수가 Lua 상태에 추가
        lua_getglobal(lua, funcname);
        redisAssert(!lua_isnil(lua,-1));
    }
    
    ......
    
}





luaCreateFunction 내부를 보면 luaL_loadbuffer 함수를 통해 스크립트 코드를 메모리에 로드해 컴파일합니다. 에러가 없다면 lua_pcall 함수로 스크립트를 실행하며, 이를 레디스 스크립트 캐시에 저장한 후 해당 함수를 종료합니다. 즉, EVAL 명령어를 사용해도 EVALSHA와 같은 로직을 실행 하기 때문에 스크립트가 캐싱 되는 것이죠.

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
32
33
34
35
36
37
int luaCreateFunction(redisClient *c, lua_State *lua, char *funcname, robj *body) {
    // 새로운 스크립트 내용을 함수에 추가
    sds funcdef = sdsempty();
    funcdef = sdscat(funcdef,"function ");
    funcdef = sdscatlen(funcdef,funcname,42);
    funcdef = sdscatlen(funcdef,"() ",3);
    funcdef = sdscatlen(funcdef,body->ptr,sdslen(body->ptr));
    funcdef = sdscatlen(funcdef," end",4);
    
    // 3. Lua 스크립트 코드를 메모리에 로드하여 컴파일
    if (luaL_loadbuffer(lua,funcdef,sdslen(funcdef),"@user_script")) {
        addReplyErrorFormat(c,"Error compiling script (new function): %s\n",
            lua_tostring(lua,-1));
        lua_pop(lua,1);
        sdsfree(funcdef);
        return REDIS_ERR;
    }
    sdsfree(funcdef);
    
    // 4. 로드된 Lua 스크립트를 실행
    if (lua_pcall(lua,0,0,0)) {
        addReplyErrorFormat(c,"Error running script (new function): %s\n",
            lua_tostring(lua,-1));
        lua_pop(lua,1);
        return REDIS_ERR;
    }

    {
        // 5. 함수 등록
        int retval = dictAdd(server.lua_scripts,
                             sdsnewlen(funcname+2,40),body);
        redisAssertWithInfo(c,NULL,retval == DICT_OK);
        incrRefCount(body);
    }

    return REDIS_OK;
}







4. 정리


EVAL, EVALSHA 두 명령어가 레디스 내부에서 어떻게 동작하는지 간단하게 살펴보았습니다. EVAL도 EVALSHA와 같은 로직을 사용하기 때문에 내부적으로 스크립트를 캐싱합니다. 비슷하지만 미묘하게 다른데, 이를 인지하고 사용합시다. 참고로 클러스터 환경에서는 다른 이슈가 존재하는데 이는 다음 포스팅에서 살펴보도록 하겠습니다.


This post is licensed under CC BY 4.0 by the author.