走近原始碼:Redis的啟動過程

面向Google程式設計發表於2019-01-07

當我們對不斷加深對某一項技術的瞭解時,一定會在一個特定的時間對它的實現方式產生興趣。沒錯,這就是我現在的狀態,所以,多年沒有讀/寫C語言的我,決定要啃一下Redis的原始碼。

Redis大體上可以分為兩部分:伺服器和客戶端(讀者吐槽:你這分的也太大體了吧)。在使用時,我們先啟動伺服器,然後再啟動客戶端。由客戶端向伺服器傳送命令,伺服器處理後將結果返回給客戶端。我們從“頭”開始,一起來了解一下Redis伺服器在啟動的時候都做了哪些事情。

對於C語言來說,main函式是一個程式的的入口,Redis也不例外。Redis的main函式寫在server.c檔案中。由於redis啟動過程相當複雜,需要判斷許多條件,例如是否在叢集中,或者是否是哨兵模式等等,因此我們只介紹單機redis啟動過程中一些比較重要的步驟。

初始化全域性伺服器狀態

如果redis-server命令啟動時使用了test引數,那麼就會先進行指定的測試。接下來呼叫了initServerConfig()函式,這個函式初始化了一個型別為redisServer的全域性變數server。redisServer這個結構包含了非常多的欄位,由於篇幅限制,我們不在這裡列出,如果按類別劃分的話,可以分為以下類別:

  • General
  • Modules
  • Networking
  • RDB / AOF loading information
  • Fast pointers to often looked up command
  • Fields used only for stats
  • Configuration
  • AOF / RDB persistence
  • Logging
  • Replication
  • Synchronous replication
  • Limits
  • Blocked clients
  • Sort parameters
  • Zip structure config
  • time cache
  • Pubsub
  • Cluster
  • Scripting
  • Lazy free
  • Latency monitor
  • Assert & bug reporting
  • System hardware info

如果用一句話來概括initServerConfig()函式作用,它就是用來給可以在配置檔案(通常命名為redis.conf)中配置的變數初始化一個預設值。比較常用的變數有伺服器埠號、日誌等級等等。

設定commend table

在initServerConfig()函式中,會呼叫populateCommandTable()函式來設定伺服器的命令表,命令表的結構如下。

struct redisCommand redisCommandTable[] = {
    {"module",moduleCommand,-2,"as",0,NULL,0,0,0,0,0},
    {"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
    {"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
    {"setnx",setnxCommand,3,"wmF",0,NULL,1,1,1,0,0},
    ...
}
複製程式碼

每一項代表的含義是:

  1. name:命令的名稱
  2. function:命令對應的函式名。redis-server處理命令時要執行的函式
  3. arity:命令的引數個數,如果是-N代表大於等於N
  4. sflags:命令標誌,標識命令的型別(read/write/admin...)
  5. flags:位掩碼,由Redis根據sflags計算
  6. get_keys_proc:可選函式,當下面三個項不能指定哪些引數是key時使用
  7. first_key_index:第一個是key的引數
  8. last_key_index:最後一個是key的引數
  9. key_step:key的“步長”,比如MSET的key_step是2,因為它的引數是key,val,key,val這樣的形式
  10. microseconds:執行命令所需要的微秒數
  11. calls:該命令被呼叫總次數

設定好命令表後,redis-server還會對一些常用的命令設定快速查詢方式,直接賦予server的成員指標。

server.delCommand = lookupCommandByCString("del");
server.multiCommand = lookupCommandByCString("multi");
server.lpushCommand = lookupCommandByCString("lpush");
server.lpopCommand = lookupCommandByCString("lpop");
server.rpopCommand = lookupCommandByCString("rpop");
server.zpopminCommand = lookupCommandByCString("zpopmin");
server.zpopmaxCommand = lookupCommandByCString("zpopmax");
server.sremCommand = lookupCommandByCString("srem");
server.execCommand = lookupCommandByCString("exec");
server.expireCommand = lookupCommandByCString("expire");
server.pexpireCommand = lookupCommandByCString("pexpire");
server.xclaimCommand = lookupCommandByCString("xclaim");
server.xgroupCommand = lookupCommandByCString("xgroup");
複製程式碼
初始化哨兵模式

變數初始化以後,就會將啟動命令的路徑和引數儲存起來,以備下次重啟的時候使用。如果啟動的服務是哨兵模式,那麼就會呼叫initSentinelConfig()和initSentinel()這兩個方法來初始化哨兵模式。對sentinel不瞭解的同學可以看這裡。initSentinelConfig()和initSentinel()都在sentinel.c檔案中。initSentinelConfig函式負責初始化sentinel的埠號,以及解除伺服器的保護模式。initSentinel函式負責將command table設定為只支援sentinel命令,以及初始化sentinelState資料格式。

修復持久化檔案

啟動模式如果是redis-check-rdb/aof,那麼就會執行redis_check_rdb_main()或redis_check_aof_main()這兩個函式來修復持久化檔案,不過redis_check_rdb_main函式所做的事情在Redis啟動過程中已經做了,所以這裡不需要做,直接使這個函式載入錯誤就可以了。

處理引數

如果是簡單的引數例如-v或--version、-h或--help,就會直接呼叫相應的方法,列印資訊。如果是使用其他配置檔案,則修改server.exec_argv。對於其他資訊,會將他們轉換成字串,然後新增進配置檔案,例如“--port 6380”就會被轉換成“port 6380\n”加進配置檔案。這時,redis就會呼叫loadServerConfig()函式來載入配置檔案,這個過程會覆蓋掉前面初始化預設配置檔案的變數的值。

initServer()

initServer()函式負責結束server變數初始化工作。首先設定處理訊號(SIGHUP和SIGPIPE除外),接著會建立一些雙向列表用來跟蹤客戶端、從節點等。

server.current_client = NULL;
server.clients = listCreate();
server.clients_index = raxNew();
server.clients_to_close = listCreate();
server.slaves = listCreate();
server.monitors = listCreate();
server.clients_pending_write = listCreate();
server.slaveseldb = -1; /* Force to emit the first SELECT command. */
server.unblocked_clients = listCreate();
server.ready_keys = listCreate();
server.clients_waiting_acks = listCreate();
複製程式碼
Shared object

createSharedObjects()函式會建立一些shared物件儲存在全域性的shared變數中,對於不同的命令,可能會有相同的返回值(比如報錯)。這樣在返回時就不必每次都去新增物件了,儲存到記憶體中了。這個設計就是以Redis啟動時多消耗一些時間為代價,換取執行的更小的延遲。

shared.crlf = createObject(OBJ_STRING,sdsnew("\r\n"));
shared.ok = createObject(OBJ_STRING,sdsnew("+OK\r\n"));
shared.err = createObject(OBJ_STRING,sdsnew("-ERR\r\n"));
shared.emptybulk = createObject(OBJ_STRING,sdsnew("$0\r\n\r\n"));
shared.czero = createObject(OBJ_STRING,sdsnew(":0\r\n"));
shared.cone = createObject(OBJ_STRING,sdsnew(":1\r\n"));
shared.cnegone = createObject(OBJ_STRING,sdsnew(":-1\r\n"));
shared.nullbulk = createObject(OBJ_STRING,sdsnew("$-1\r\n"));
shared.nullmultibulk = createObject(OBJ_STRING,sdsnew("*-1\r\n"));
shared.emptymultibulk = createObject(OBJ_STRING,sdsnew("*0\r\n"));
shared.pong = createObject(OBJ_STRING,sdsnew("+PONG\r\n"));
shared.queued = createObject(OBJ_STRING,sdsnew("+QUEUED\r\n"));
shared.emptyscan = createObject(OBJ_STRING,sdsnew("*2\r\n$1\r\n0\r\n*0\r\n"));
shared.wrongtypeerr = createObject(OBJ_STRING,sdsnew(
    "-WRONGTYPE Operation against a key holding the wrong kind of value\r\n"));
shared.nokeyerr = createObject(OBJ_STRING,sdsnew(
    "-ERR no such key\r\n"));
複製程式碼
Shared integers

除了上述的一些返回值以外,createSharedObjects()函式還會建立一些共享的整數物件。對Redis來說,有許多型別(比如lists或者sets)都需要一些整數(比如數量),這時就可以複用這些已經建立好的整數物件,而不需要重新分配記憶體並建立。這同樣是犧牲了啟動時間來換取執行時間。

新增迴圈事件

initServer()函式呼叫aeCreateEventLoop()函式(ae.c檔案)來增加迴圈事件,並將結果返回給server的el成員。Redis使用不同的函式來相容各個平臺,在Linux平臺使用epoll,在BSD使用kqueue,都不是的話,最終會使用select。Redis輪詢新的連線以及I/O事件,有新的事件到來時就會及時作出響應。

分配資料庫

Redis初始化需要的資料庫,並將結果賦給server的db成員。

server.db = zmalloc(sizeof(redisDb)*server.dbnum);
複製程式碼
監聽TCP埠

listenToPort()用來初始化一些檔案描述符,從而監聽server配置的地址和埠。listenToPort函式會根據引數中的地址判斷要監聽的是IPv4還是IPv6,對應的呼叫anetTcpServer()或anetTcp6Server()函式,如果引數中未指明地址,則會強行繫結0.0.0.0

初始化LRU鍵池

evictionPoolAlloc()(evict.c檔案中)用於初始化LRU的鍵池,Redis的key過期策略是近似LRU演算法。

void evictionPoolAlloc(void) {
    struct evictionPoolEntry *ep;
    int j;

    ep = zmalloc(sizeof(*ep)*EVPOOL_SIZE);
    for (j = 0; j < EVPOOL_SIZE; j++) {
        ep[j].idle = 0;
        ep[j].key = NULL;
        ep[j].cached = sdsnewlen(NULL,EVPOOL_CACHED_SDS_SIZE);
        ep[j].dbid = 0;
    }
    EvictionPoolLRU = ep;
}
複製程式碼
Server cron

initServer()函式接下來會為資料庫和pub/sub再生成一些列表和字典,重置一些狀態,標記系統啟動時間。在這之後,Redis會執行aeCreateTimeEvent()(在ae.c檔案中)函式,用來新建一個迴圈執行serverCron()函式的事件。serverCron()預設每100毫秒執行一次。

 /* Create the timer callback, this is our way to process many background
  * operations incrementally, like clients timeout, eviction of unaccessed
  * expired keys and so forth. */
if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
    serverPanic("Can't create event loop timers.");
    exit(1);
}
複製程式碼

可以看到,程式碼中建立迴圈事件時指定每毫秒執行一次serverCron()函式,這是為了使迴圈馬上啟動,但是serverCron()函式的返回值又會被作為下次執行的時間間隔。預設為1000/server.hz。server.hz隨著客戶端數量的增加而增加。

serverCron()函式做了許多定時執行的任務,包括rehash、後臺持久化,AOF重新與清理、清理過期key,交換虛擬記憶體、同步主從節點等等。總之能想到的Redis的定時任務幾乎都在serverCron()函式中處理。

開啟AOF檔案
/* Open the AOF file if needed. */
if (server.aof_state == AOF_ON) {
    server.aof_fd = open(server.aof_filename,
                         O_WRONLY|O_APPEND|O_CREAT,0644);
    if (server.aof_fd == -1) {
        serverLog(LL_WARNING, "Can't open the append-only file: %s",
                  strerror(errno));
        exit(1);
    }
}
複製程式碼
最大記憶體限制

對於32位系統,最大記憶體是4GB,如果使用者沒有明確指出Redis可使用的最大記憶體,那麼這裡預設限制為3GB。

/* 32 bit instances are limited to 4GB of address space, so if there is
     * no explicit limit in the user provided configuration we set a limit
     * at 3 GB using maxmemory with 'noeviction' policy'. This avoids
     * useless crashes of the Redis instance for out of memory. */
if (server.arch_bits == 32 && server.maxmemory == 0) {
    serverLog(LL_WARNING,"Warning: 32 bit instance detected but no memory limit set. Setting 3 GB maxmemory limit with 'noeviction' policy now.");
    server.maxmemory = 3072LL*(1024*1024); /* 3 GB */
    server.maxmemory_policy = MAXMEMORY_NO_EVICTION;
}
複製程式碼
Redis Server啟動

如果Redis被設定為後臺執行,此時Redis會嘗試寫pid檔案,預設路徑是/var/run/redis.pid。這時,Redis伺服器已經啟動,不過還有一些事情要做。

從磁碟載入資料

如果存在AOF檔案或者dump檔案(都有的話AOF檔案的優先順序高),loadDataFromDisk()函式負責將資料從磁碟載入到記憶體。

最後的設定

每次進入迴圈事件時,要呼叫beforeSleep()函式,它做了以下這些事情:

  • 如果server是cluster中的一個節點,呼叫clusterBeforeSleep()函式
  • 執行一個快速的週期
  • 如果有客戶端在前一個迴圈事件被阻塞了,向所有的從節點傳送ACK請求
  • 取消在同步備份過程中被阻塞的客戶端的阻塞狀態
  • 檢查是否有因為阻塞命令而被阻塞的客戶端,如果有,解除
  • 把AOF緩衝區寫到磁碟
  • 執行緒釋放GIL
進入主迴圈事件

程式呼叫aeMain()函式,進入主迴圈,這時其他的一些迴圈事件也會分別被呼叫

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
}
複製程式碼

到此,Redis伺服器已經完全準備好處理各種事件了。後面我們會繼續瞭解Redis命令執行過程究竟做了哪些事情。

參考Redis: under the hood

相關文章