redis網絡通信模塊源碼分析(下)

這裏註冊可寫事件AE_WRITABLE的回調函數是sendReplyToClient。也就是說,當下一次某個觸發可寫事件時,調用的就是sendReplyToClient函數了。可以猜想,sendReplyToClient發送數據的邏輯和上面的writeToClient函數一模一樣,不信請看(位於文件networking.c文件中):

/* Write event handler. Just send data to the client. */
void sendReplyToClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    UNUSED(el);
    UNUSED(mask);
    writeToClient(fd,privdata,1);
}

至此,redis-server發送數據的邏輯也理清楚了。這裏簡單做個總結:

如果有數據要發送給某個client,不需要專門註冊可寫事件,等觸發可寫事件再發送。通常的做法是,在應答數據產生的地方直接發送,如果是因爲對端Tcp窗口太小引起的發送不完,則將剩餘的數據存儲至某個緩衝區並註冊監聽可寫事件,等下次觸發可寫事件後再嘗試發送,一直到數據全部發送完畢後移除可寫事件。

redis-server數據的發送邏輯與這個稍微有點差別,就是將數據發送的時機放到了EventLoop的某個時間點上(這裏是在ProcessEvents之前),其他的與上面完全一樣。

之所以不註冊監聽可寫事件,等可寫事件觸發再發送數據,原因是通常情況下,網絡通信的兩端數據一般都是正常收發的,一般不會出現某一端由於Tcp窗口太小而使另外一端發不出去的情況。如果註冊監聽可寫事件,那麼這個事件會頻繁觸發,而觸發時不一定有數據需要發送,這樣不僅浪費系統資源,同時也浪費服務器程序寶貴的CPU時間片。

定時器邏輯

一個網絡通信模塊是離不開定時器的,前面我們也介紹了在事件處理函數的中如何去除最早到期的定時器對象,這裏我們接着這個問題繼續討論。在aeProcessEvents函數(位於文件ae.c中)的結尾處有這樣一段代碼:

/* Check time events */ if (flags & AE_TIME_EVENTS) processed += processTimeEvents(eventLoop);

如果存在定時器事件,則調用processTimeEvents函數(位於文件ae.c中)進行處理。

/* Process time events */
static int processTimeEvents(aeEventLoop *eventLoop) {
    int processed = 0;
    aeTimeEvent *te, *prev;
    long long maxId;
    time_t now = time(NULL);

    /* If the system clock is moved to the future, and then set back to the
     * right value, time events may be delayed in a random way. Often this
     * means that scheduled operations will not be performed soon enough.
     *
     * Here we try to detect system clock skews, and force all the time
     * events to be processed ASAP when this happens: the idea is that
     * processing events earlier is less dangerous than delaying them
     * indefinitely, and practice suggests it is. */
    if (now < eventLoop->lastTime) {
        te = eventLoop->timeEventHead;
        while(te) {
            te->when_sec = 0;
            te = te->next;
        }
    }
    eventLoop->lastTime = now;

    prev = NULL;
    te = eventLoop->timeEventHead;
    maxId = eventLoop->timeEventNextId-1;
    while(te) {
        long now_sec, now_ms;
        long long id;

        /* Remove events scheduled for deletion. */
        if (te->id == AE_DELETED_EVENT_ID) {
            aeTimeEvent *next = te->next;
            if (prev == NULL)
                eventLoop->timeEventHead = te->next;
            else
                prev->next = te->next;
            if (te->finalizerProc)
                te->finalizerProc(eventLoop, te->clientData);
            zfree(te);
            te = next;
            continue;
        }

        /* Make sure we don't process time events created by time events in
         * this iteration. Note that this check is currently useless: we always
         * add new timers on the head, however if we change the implementation
         * detail, this check may be useful again: we keep it here for future
         * defense. */
        if (te->id > maxId) {
            te = te->next;
            continue;
        }
        aeGetTime(&now_sec, &now_ms);
        if (now_sec > te->when_sec ||
            (now_sec == te->when_sec && now_ms >= te->when_ms))
        {
            int retval;

            id = te->id;
            retval = te->timeProc(eventLoop, id, te->clientData);
            processed++;
            if (retval != AE_NOMORE) {
                aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);
            } else {
                te->id = AE_DELETED_EVENT_ID;
            }
        }
        prev = te;
        te = te->next;
    }
    return processed;
}

這段代碼核心邏輯就是通過eventLoop->timeEventHead中記錄的定時器對象鏈表遍歷每個定時器對象的時間,然後與當前時間比較,如果定時器已經到期,則調用定時器對象設置的回調函數timeProc進行處理。這段代碼,沒有什麼特別需要注意的地方。但是代碼中作者考慮到了一種特殊場景,就是假設有人將當前的計算機時間調到了未來某個時刻,然後再調回來。這樣就會出現now(當前時間)小於eventLoop->lastTime(記錄在aeEventLoop中的上一次時間)。出現這種情況怎麼辦呢?redis的作者,遍歷該定時器對象鏈表,將這個鏈表中的所有定時器對象的時間設置成0。這樣,這些定時器就會立即得到處理了。這也就是作者在代碼註釋中說的:

force all the time events to be processed ASAP

ASAP應該是英文As Soon As Possible(儘快)的縮寫吧。

那麼redis-server中到底哪些地方使用了定時器呢?我們可以在redis源碼中搜索創建定時器的函數aeCreateTimeEvent,在initServer函數中有這麼一行(位於server.c文件中):

if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
        serverPanic("Can't create event loop timers.");
        exit(1);
 }

上述代碼前面的章節我們也提到過,原來定時器的用途是用於redis的Cron任務。這個任務具體做些什麼工作,就不是本章節的內容了,有興趣的讀者可以閱讀下serverCron函數源碼(位於server.c中)。

aftersleep鉤子

通常情形下,在一個EventLoop中除了有定時器、IO Multiplexing和IO事件處理邏輯外,可以根據需求自定義一些函數,這類函數我們稱之爲“鉤子函數”。鉤子函數可以位於Loop的任何位置,前面我們介紹的beforesleep函數就是在事件處理之前的自定義鉤子函數(位於定時器時間檢測邏輯之前)。

在redis-server中,在IO Multiplexing調用與IO事件處理邏輯之間也有一個自定義鉤子函數叫aftersleep。

int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
    //無關代碼省略...
    numevents = aeApiPoll(eventLoop, tvp);

    /* After sleep callback. */
    if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
        eventLoop->aftersleep(eventLoop);

    for (j = 0; j < numevents; j++) {
        //無關代碼省略...
    }    
}

這個函數在main函數中設置:

int main(int argc, char **argv) {
    //無關代碼省略...
    aeSetBeforeSleepProc(server.el,beforeSleep);
    aeSetAfterSleepProc(server.el,afterSleep);

     return 0;
}

由於afterSleep函數的具體作用與我們的網絡通信無關,這裏也就不再介紹了。

redis-server端網絡通信模塊小結

通過前面的講解,我們用一張圖來概括一下redis-server端的網絡通信模型。

如上圖所示,這就是典型的利用one loop one thread 思想實現的reactor網絡通信模型,也是目前最主流的網絡通信架構。而且由於redis-server的網絡通信中所有的客戶端fd和偵聽fd都集中在一個EventLoop中,所以通常也說redis的網絡通信模型是單線程的。

探究redis-cli端的網絡通信模型

我們接着探究一下redis源碼自帶的客戶端redis-cli的網絡通信模塊。

我們使用gdb把redis-cli跑起來以後,原來打算按Ctrl + C讓gdb中斷下來查看一下redis-cli跑起來有幾個線程,但是實驗之後發現,這樣並不能讓gdb中斷下來,反而會導致redis-cli這個進程退出。

我們換個思路:直接把redis-cli跑起來,然後使用linux pstack + 進程id來查看下redis-cli的線程數量。

[root@localhost ~]# ps -ef | grep redis-cli
root     35454 12877  0 14:51 pts/1    00:00:00 ./redis-cli
root     35468 33548  0 14:51 pts/5    00:00:00 grep --color=auto redis-cli
[root@localhost ~]# pstack 35454
#0  0x00007f011c2186f0 in __read_nocancel () from /lib64/libpthread.so.0
#1  0x000000000041bc5c in linenoiseEdit (stdin_fd=0, stdout_fd=1, buflen=4096, prompt=<optimized out>, buf=0x7ffea3c20410 "") at linenoise.c:800
#2  linenoiseRaw (buflen=4096, prompt=<optimized out>, buf=0x7ffea3c20410 "") at linenoise.c:991
#3  linenoise (prompt=<optimized out>) at linenoise.c:1059
#4  0x00000000004116ac in repl () at redis-cli.c:1398
#5  0x000000000040aa4e in main (argc=0, argv=0x7ffea3c216b0) at redis-cli.c:2950

通過上面的輸出,我們發現redis-cli只有一個主線程。既然只有一個主線程,那麼我們可以斷定redis-cli中的發給redis-server的命令肯定都是同步的,這裏同步的意思是發送命令後一直等待服務器應答或者應答超時。

在redis-cli的main函數(位於文件redis-cli.c中)有這樣一段代碼:

/* Start interactive mode when no command is provided */
if (argc == 0 && !config.eval) {
    /* Ignore SIGPIPE in interactive mode to force a reconnect */
    signal(SIGPIPE, SIG_IGN);

    /* Note that in repl mode we don't abort on connection error.
    * A new attempt will be performed for every command send. */
    cliConnect(0);
    repl();
}

其中cliConnect(0)調用代碼(位於redis-cli.c文件中)如下:

static int cliConnect(int force) {
    if (context == NULL || force) {
        if (context != NULL) {
            redisFree(context);
        }

        if (config.hostsocket == NULL) {
            context = redisConnect(config.hostip,config.hostport);
        } else {
            context = redisConnectUnix(config.hostsocket);
        }

        if (context->err) {
            fprintf(stderr,"Could not connect to Redis at ");
            if (config.hostsocket == NULL)
                fprintf(stderr,"%s:%d: %s\n",config.hostip,config.hostport,context->errstr);
            else
                fprintf(stderr,"%s: %s\n",config.hostsocket,context->errstr);
            redisFree(context);
            context = NULL;
            return REDIS_ERR;
        }

        /* Set aggressive KEEP_ALIVE socket option in the Redis context socket
         * in order to prevent timeouts caused by the execution of long
         * commands. At the same time this improves the detection of real
         * errors. */
        anetKeepAlive(NULL, context->fd, REDIS_CLI_KEEPALIVE_INTERVAL);

        /* Do AUTH and select the right DB. */
        if (cliAuth() != REDIS_OK)
            return REDIS_ERR;
        if (cliSelect() != REDIS_OK)
            return REDIS_ERR;
    }
    return REDIS_OK;
}

這個函數做的工作可以分爲三步:

    context = redisConnect(config.hostip,config.hostport);

    cliAuth()

    cliSelect()

我們先來看第一步redisConnect函數,這個函數實際又調用redisContextConnectTcp函數,後者又調用_redisContextConnectTcp函數。_redisContextConnectTcp函數是實際連接redis-server的地方,先調用API getaddrinfo解析傳入進來的ip地址和端口號(筆者這裏是127.0.0.1和6379),然後創建socket,並將socket設置成非阻塞模式,接着調用API connect函數,由於socket是非阻塞模式,connect函數會立即返回-1。接着調用redisContextWaitReady函數,該函數中調用API poll檢測連接的socket是否可寫(POLLOUT),如果可寫則表示連接redis-server成功。由於_redisContextConnectTcp代碼較多,我們去掉一些無關的代碼,整理出關鍵邏輯的僞碼如下(位於net.c文件中):

static int _redisContextConnectTcp(redisContext *c, const char *addr, int port,
                                   const struct timeval *timeout,
                                   const char *source_addr) {
    //省略部分無關代碼...    

    rv = getaddrinfo(c->tcp.host,_port,&hints,&servinfo)) != 0

    s = socket(p->ai_family,p->ai_socktype,p->ai_protocol)) == -1

    redisSetBlocking(c,0) != REDIS_OK

    connect(s,p->ai_addr,p->ai_addrlen)

    redisContextWaitReady(c,timeout_msec) != REDIS_OK

    return rv;  // Need to return REDIS_OK if alright
}

redisContextWaitReady函數的代碼(位於net.c文件中)如下:

static int redisContextWaitReady(redisContext *c, long msec) {
    struct pollfd   wfd[1];

    wfd[0].fd     = c->fd;
    wfd[0].events = POLLOUT;

    if (errno == EINPROGRESS) {
        int res;

        if ((res = poll(wfd, 1, msec)) == -1) {
            __redisSetErrorFromErrno(c, REDIS_ERR_IO, "poll(2)");
            redisContextCloseFd(c);
            return REDIS_ERR;
        } else if (res == 0) {
            errno = ETIMEDOUT;
            __redisSetErrorFromErrno(c,REDIS_ERR_IO,NULL);
            redisContextCloseFd(c);
            return REDIS_ERR;
        }

        if (redisCheckSocketError(c) != REDIS_OK)
            return REDIS_ERR;

        return REDIS_OK;
    }

    __redisSetErrorFromErrno(c,REDIS_ERR_IO,NULL);
    redisContextCloseFd(c);
    return REDIS_ERR;
}

這裏貼一下此時的調用堆棧:

(gdb) bt
#0  redisContextWaitReady (c=c@entry=0x66f050, msec=msec@entry=-1) at net.c:213
#1  0x000000000041a4dd in _redisContextConnectTcp (c=c@entry=0x66f050, addr=addr@entry=0x66f011 "127.0.0.1", port=port@entry=6379, timeout=timeout@entry=0x0, 
    source_addr=source_addr@entry=0x0) at net.c:391
#2  0x000000000041a948 in redisContextConnectTcp (c=c@entry=0x66f050, addr=addr@entry=0x66f011 "127.0.0.1", port=port@entry=6379, timeout=timeout@entry=0x0)
    at net.c:420
#3  0x0000000000414ec9 in redisConnect (ip=0x66f011 "127.0.0.1", port=6379) at hiredis.c:682
#4  0x000000000040f6b2 in cliConnect (force=<optimized out>) at redis-cli.c:606
#5  0x000000000040aa49 in main (argc=0, argv=0x7fffffffe680) at redis-cli.c:2949

連接redis-server成功以後,會接着調用上文中提到的cliAuth和cliSelect函數,這兩個函數分別根據是否配置了config.auth和config.dbnum來給redis-server發送相關命令。由於我們這裏沒配置,所以這兩個函數實際什麼也不做。

583     static int cliSelect(void) {
(gdb) n
585         if (config.dbnum == 0) return REDIS_OK;
(gdb) p config.dbnum
$11 = 0

接着調用repl函數,在這個函數中是一個while循環,不斷從命令行中獲取用戶輸入:

//位於redis-cli.c文件中
static void repl(void) {
    //...省略無關代碼...
    while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
        if (line[0] != '\0') {
            argv = cliSplitArgs(line,&argc);
            if (history) linenoiseHistoryAdd(line);
            if (historyfile) linenoiseHistorySave(historyfile);

            if (argv == NULL) {
                printf("Invalid argument(s)\n");
                linenoiseFree(line);
                continue;
            } else if (argc > 0) {
                if (strcasecmp(argv[0],"quit") == 0 ||
                    strcasecmp(argv[0],"exit") == 0)
                {
                    exit(0);
                } else if (argv[0][0] == ':') {
                    cliSetPreferences(argv,argc,1);
                    continue;
                } else if (strcasecmp(argv[0],"restart") == 0) {
                    if (config.eval) {
                        config.eval_ldb = 1;
                        config.output = OUTPUT_RAW;
                        return; /* Return to evalMode to restart the session. */
                    } else {
                        printf("Use 'restart' only in Lua debugging mode.");
                    }
                } else if (argc == 3 && !strcasecmp(argv[0],"connect")) {
                    sdsfree(config.hostip);
                    config.hostip = sdsnew(argv[1]);
                    config.hostport = atoi(argv[2]);
                    cliRefreshPrompt();
                    cliConnect(1);
                } else if (argc == 1 && !strcasecmp(argv[0],"clear")) {
                    linenoiseClearScreen();
                } else {
                    long long start_time = mstime(), elapsed;
                    int repeat, skipargs = 0;
                    char *endptr;

                    repeat = strtol(argv[0], &endptr, 10);
                    if (argc > 1 && *endptr == '\0' && repeat) {
                        skipargs = 1;
                    } else {
                        repeat = 1;
                    }

                    issueCommandRepeat(argc-skipargs, argv+skipargs, repeat);

                    /* If our debugging session ended, show the EVAL final
                     * reply. */
                    if (config.eval_ldb_end) {
                        config.eval_ldb_end = 0;
                        cliReadReply(0);
                        printf("\n(Lua debugging session ended%s)\n\n",
                            config.eval_ldb_sync ? "" :
                            " -- dataset changes rolled back");
                    }

                    elapsed = mstime()-start_time;
                    if (elapsed >= 500 &&
                        config.output == OUTPUT_STANDARD)
                    {
                        printf("(%.2fs)\n",(double)elapsed/1000);
                    }
                }
            }
            /* Free the argument vector */
            sdsfreesplitres(argv,argc);
        }
        /* linenoise() returns malloc-ed lines like readline() */
        linenoiseFree(line);
    }
    exit(0);
}

得到用戶輸入的一行命令後,先保存到歷史記錄中(以便下一次按鍵盤上的上下箭頭鍵再次輸入),然後校驗命令的合法性,如果是本地命令(不需要發送給服務器的命令,如quit、exit)則直接執行,如果是遠端命令,則調用issueCommandRepeat函數發送給服務器端:

//位於文件redis-cli.c中
static int issueCommandRepeat(int argc, char **argv, long repeat) {
    while (1) {
        config.cluster_reissue_command = 0;
        if (cliSendCommand(argc,argv,repeat) != REDIS_OK) {
            cliConnect(1);

            /* If we still cannot send the command print error.
             * We'll try to reconnect the next time. */
            if (cliSendCommand(argc,argv,repeat) != REDIS_OK) {
                cliPrintContextError();
                return REDIS_ERR;
            }
         }
         /* Issue the command again if we got redirected in cluster mode */
         if (config.cluster_mode && config.cluster_reissue_command) {
            cliConnect(1);
         } else {
             break;
        }
    }
    return REDIS_OK;
}

實際發送命令的函數是cliSendCommand,在cliSendCommand函數中又調用cliReadReply函數,後者又調用redisGetReply函數,在redisGetReply函數中又調用redisBufferWrite函數,在redisBufferWrite函數中最終調用系統API write將我們輸入的命令發出去:

//位於hiredis.c文件中
int redisBufferWrite(redisContext *c, int *done) {
    int nwritten;

    /* Return early when the context has seen an error. */
    if (c->err)
        return REDIS_ERR;

    if (sdslen(c->obuf) > 0) {
        nwritten = write(c->fd,c->obuf,sdslen(c->obuf));
        if (nwritten == -1) {
            if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
                /* Try again later */
            } else {
                __redisSetError(c,REDIS_ERR_IO,NULL);
                return REDIS_ERR;
            }
        } else if (nwritten > 0) {
            if (nwritten == (signed)sdslen(c->obuf)) {
                sdsfree(c->obuf);
                c->obuf = sdsempty();
            } else {
                sdsrange(c->obuf,nwritten,-1);
            }
        }
    }
    if (done != NULL) *done = (sdslen(c->obuf) == 0);
    return REDIS_OK;
}

在redis-cli中輸入set hello world這一個簡單的指令後,發送數據的調用堆棧如下:

(gdb) c
Continuing.
127.0.0.1:6379> set hello world

Breakpoint 7, redisBufferWrite (c=c@entry=0x66f050, done=done@entry=0x7fffffffe310) at hiredis.c:831
831     int redisBufferWrite(redisContext *c, int *done) {
(gdb) bt
#0  redisBufferWrite (c=c@entry=0x66f050, done=done@entry=0x7fffffffe310) at hiredis.c:831
#1  0x0000000000415942 in redisGetReply (c=0x66f050, reply=reply@entry=0x7fffffffe368) at hiredis.c:882
#2  0x00000000004102a0 in cliReadReply (output_raw_strings=output_raw_strings@entry=0) at redis-cli.c:846
#3  0x0000000000410e58 in cliSendCommand (argc=argc@entry=3, argv=argv@entry=0x693ed0, repeat=0, repeat@entry=1) at redis-cli.c:1006
#4  0x0000000000411445 in issueCommandRepeat (argc=3, argv=0x693ed0, repeat=<optimized out>) at redis-cli.c:1282
#5  0x00000000004117fa in repl () at redis-cli.c:1444
#6  0x000000000040aa4e in main (argc=0, argv=0x7fffffffe680) at redis-cli.c:2950

當然,待發送的數據需要存儲在一個全局靜態變量context中,這是一個結構體,定義在hiredis.h文件中。

/* Context for a connection to Redis */
typedef struct redisContext {
    int err; /* Error flags, 0 when there is no error */
    char errstr[128]; /* String representation of error when applicable */
    int fd;
    int flags;
    char *obuf; /* Write buffer */
    redisReader *reader; /* Protocol reader */

    enum redisConnectionType connection_type;
    struct timeval *timeout;

    struct {
        char *host;
        char *source_addr;
        int port;
    } tcp;

    struct {
        char *path;
    } unix_sock;

} redisContext;

其中字段obuf指向的是一個sds類型的對象,這個對象用來存儲當前需要發送的命令。這也同時解決了命令一次發不完需要暫時緩存下來的問題。

在redisGetReply函數中發完數據後立馬調用redisBufferRead去收取服務器的應答。

int redisGetReply(redisContext *c, void **reply) {
    int wdone = 0;
    void *aux = NULL;

    /* Try to read pending replies */
    if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
        return REDIS_ERR;

    /* For the blocking context, flush output buffer and read reply */
    if (aux == NULL && c->flags & REDIS_BLOCK) {
        /* Write until done */
        do {
            if (redisBufferWrite(c,&wdone) == REDIS_ERR)
                return REDIS_ERR;
        } while (!wdone);

        /* Read until there is a reply */
        do {
            if (redisBufferRead(c) == REDIS_ERR)
                return REDIS_ERR;
            if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
                return REDIS_ERR;
        } while (aux == NULL);
    }

    /* Set reply object */
    if (reply != NULL) *reply = aux;
    return REDIS_OK;
}

拿到應答後就可以解析並顯示在終端了。

總結起來,redis-cli是一個實實在在的網絡同步通信方式,只不過通信的socket仍然設置成非阻塞模式,這樣有如下三個好處:

使用connect連接服務器時,connect函數不會阻塞,可以立即返回,之後調用poll檢測socket是否可寫來判斷是否連接成功。

在發數據時,如果因爲對端tcp窗口太小發不出去,write函數也會立即返回,不會阻塞,此時可以將未發送的數據暫存,下次繼續發送。

在收數據時,如果當前沒有數據可讀,則read函數也不會阻塞,程序也可以立即返回,繼續響應用戶的輸入。

redis的通信協議格式

redis客戶端與服務器通信使用的是純文本協議,以\r\n來作爲協議或者命令或參數之間的分隔符。

我們接着通過redis-cli給redis-server發送“set hello world”命令。

127.0.0.1:6379> set hello world

此時服務器端收到的數據格式如下:

*3\r\n3\r\nset\r\n5\r\nhello\r\n$5\r\nworld\r\n

其中第一個3是redis命令的標誌信息,標誌以星號()開始,數字3是請求類型,不同的命令數字可能不一樣,接着\r\n分割,後面就是統一的格式:

A指令字符長度\r\n指令A\r\nB指令或key字符長度\r\nB指令\r\nC內容長度\r\nC內容\r\n

不同的指令長度不一樣,攜帶的key和value也不一樣,服務器端會根據命令的不同來進一步解析。

總結

至此,我們將redis的服務端和客戶端的網絡通信模塊分析完了,redis的通信模型是非常常見的網絡通信模型,也是非常值得學習和模仿的,建議想提高自己網絡編程水平的讀者可以好好研讀一下。同時,在redis源碼中,有許多網絡通信API的使用小技巧,這也是非常值得我們學習的。

同時,redis中的用到的數據結構(字符串、鏈表、有序集合等)都有自己的高效實現,因此redis源碼也是我們學習數據結構知識非常好的材料。

最後,redis也是目前業界用的最多的內存數據庫,它不僅開源,而且源碼量也不大。如果您想成爲一名合格的服務器端開發人員,您應該去學習它、用好它。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章