Redis基礎—瞭解Redis是如何做資料持久化的

detectiveHLH發表於2020-11-10

之前的文章介紹了Redis的簡單資料結構的相關使用和底層原理,這篇文章我們就來聊一下Redis應該如何保證高可用。

資料持久化

我們知道雖然單機的Redis雖然效能十分的出色, 單機能夠扛住10w的QPS,這是得益於其基於記憶體的快速讀寫操作,那如果某個時間Redis突然掛了怎麼辦?我們需要一種持久化的機制,來儲存記憶體中的資料,否則資料就會直接丟失。

Redis有兩種方式來實現資料的持久化,分別是RDB(Redis Database)和AOF(Append Only File),你可以先簡單的把RDB理解為某個時刻的Redis記憶體中的資料快照,而AOF則是所有記錄了所有修改記憶體資料的指令的集合(也就是Redis指令的集合),而這兩種方式都會生成相應的檔案落地到磁碟上,實現資料的持久化,方便下次恢復使用。

接下來就分別來聊聊這兩種持久化方案。

RDB

在redis中生成RDB快照的方式有兩種,一種是使用save,另一種是bgsave,但是底層實現上,其呼叫的是同一個函式,叫rdbsave,只是其呼叫的方式不同而已。

生成方法

save

save命令直接呼叫rdbsave方法,此時會阻塞Redis主程式,直至快照檔案生成。

void saveCommand(client *c) {
    if (server.rdb_child_pid != -1) {
        addReplyError(c,"Background save already in progress");
        return;
    }
    rdbSaveInfo rsi, *rsiptr;
    rsiptr = rdbPopulateSaveInfo(&rsi);
    if (rdbSave(server.rdb_filename,rsiptr) == C_OK) {
        addReply(c,shared.ok);
    } else {
        addReply(c,shared.err);
    }
}

bgsave

bgsave命令會fork出一個子程式,由fork出來的子程式呼叫rdbsave。父程式會繼續響應來自客戶端的讀寫請求。子程式完成RDB檔案生成之後會給父程式傳送訊號,通知父程式儲存完成。

/* BGSAVE [SCHEDULE] */
void bgsaveCommand(client *c) {
    int schedule = 0;

    /* The SCHEDULE option changes the behavior of BGSAVE when an AOF rewrite
     * is in progress. Instead of returning an error a BGSAVE gets scheduled. */
    if (c->argc > 1) {
        if (c->argc == 2 && !strcasecmp(c->argv[1]->ptr,"schedule")) {
            schedule = 1;
        } else {
            addReply(c,shared.syntaxerr);
            return;
        }
    }

    rdbSaveInfo rsi, *rsiptr;
    rsiptr = rdbPopulateSaveInfo(&rsi);

    if (server.rdb_child_pid != -1) {
        addReplyError(c,"Background save already in progress");
    } else if (hasActiveChildProcess()) {
        if (schedule) {
            server.rdb_bgsave_scheduled = 1;
            addReplyStatus(c,"Background saving scheduled");
        } else {
            addReplyError(c,
            "Another child process is active (AOF?): can't BGSAVE right now. "
            "Use BGSAVE SCHEDULE in order to schedule a BGSAVE whenever "
            "possible.");
        }
    } else if (rdbSaveBackground(server.rdb_filename,rsiptr) == C_OK) {
        addReplyStatus(c,"Background saving started");
    } else {
        addReply(c,shared.err);
    }
}

這也就是為什麼Redis是單執行緒的,但卻能夠在生成RDB檔案的同時對外提供服務。fork是unix系統上建立程式的主要方法,會把父程式的所有資料拷貝到子程式中,父子程式共享記憶體空間。

fork之後,作業系統核心會把父程式中的所有記憶體設定為只讀,只有當發生寫資料時,會發生頁異常中斷,核心會把對應的記憶體頁拷貝一份,父子程式各持有一份,所以在生成RDB過程中,由於使用了COW,記憶體髒頁會逐漸和子程式分開。

那麼有沒有可能在呼叫bgsave的過程中,我再呼叫save命令呢,這個時候豈不是會生成兩份RDB檔案?

實際上在呼叫save命令時,Redis會判斷bgsave是否正在執行,如果正在執行伺服器就不能再呼叫底層的rdbsave函式了,這樣做可以避免兩個命令之間出現資源競爭的情況。

例如,在save命令中,有如下的判斷:

if (server.rdb_child_pid != -1) {
  addReplyError(c,"Background save already in progress");
  return;
}

而在bgsave中又有如下的判斷:

if (server.rdb_child_pid != -1) {
  addReplyError(c,"Background save already in progress");
} else if (hasActiveChildProcess()) {
  ...
}

可以看到都是對同一個變數的判斷,如下:

pid_t rdb_child_pid; /* PID of RDB saving child */

換句話說,在呼叫save、bgsave命令的時候,會提前去判斷bgsave是否仍然在執行當中,如果在執行當中,則不會繼續執行bgsave命令。而save命令本身就是阻塞的,如果此時有其他的命令過來了都會被阻塞, 直到save執行完畢,才會去處理。

那我把RDB檔案生成了之後怎麼使用呢?

Redis在啟動伺服器的時候會呼叫rdbLoad函式,會把生成的RDB檔案給載入到記憶體中來,在載入的期間,每載入1000個鍵就會處理一次已經到達的請求,但是隻會處理publish、subscribe、psubscribe、unsubscribe、punsubscribe這個五個命令。其餘的請求一律返回錯誤,直到載入完成。

你吹的這麼好,RDB的優缺點分別是啥?

優點

RDB策略可以靈活配置週期,取決於你想要什麼樣的備份策略。例如:

  • 每小時生成一次最近24小時的資料
  • 每天生成最近一週的資料
  • 每天生成最近一個月的資料

基於這個策略,可以快速的恢復之前某個時間段的資料。

其次,RDB非常的適合做冷備份,你可以把RDB檔案儲存後轉移到其他的儲存介質上。甚至可以做到跨雲端儲存,例如放到OSS上的同時,又放到S3上,跨雲端儲存讓資料備份更加的健壯。

而且,基於RDB模式的恢復速度比AOF更快,因為AOF是一條一條的Redis指令,RDB則是資料最終的模樣。資料量大的話所有AOF指令全部重放要比RDB更慢。

缺點

RDB作為一個資料持久化的方案是可行的,但是如果要通過RDB做到Redis的高可用,RDB就不那麼合適了。

因為如果Redis此時還沒有來得及將記憶體中的資料生成RDB檔案,就先掛了,那麼距離上次成功生成RDB檔案時新增的這部分資料就會全部丟失,而且無法找回。

而且,如果記憶體的資料量很大的話,RDB即使是通過fork子程式來做的,但是也需要佔用到機器的CPU資源,也可能會發生很多的也異常中斷,也可能造成整個Redis停止響應幾百毫秒。

AOF

上面提到過RDB不能滿足Redis的高可用。因為在某些情況下,會永久性的丟失一段時間內的資料,所以我們來聊聊另一種解決方案AOF。首先我們得有個概念,那就是RDB是對當前Redis Server中的資料快照,而AOF是對變更指令的記錄(所有的獲取操作不會記錄,因為對當前的Redis資料沒有改變)。

但是也正因為如此,AOF檔案要比RDB檔案更大。下面聊一下一個Redis命令請求從客戶端到AOF檔案的過程。

AOF記錄過程

首先Redis的客戶端和伺服器之間需要通訊,客戶端傳送的不是我們寫入的字串,而是專門的協議文字。如果你可以熟悉Thrift或者Protobuf的話應該就能理解這個協議。

例如執行命令 SET KEY VALUE,傳到伺服器就變成了"*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n"

然後Redis伺服器就會根據協議文字的內容,選擇適當的handler進行處理。當客戶端將指令傳送到Redis伺服器之後,只要命令成功執行,就會將這個命令傳播到AOF程式中。

注意,傳播到AOF程式中之後不會馬上寫入磁碟,因為頻繁的IO操作會帶來巨大的開銷,會大大降低Redis的效能,協議文字會被寫到Redis伺服器中的aof_buf中去,也叫AOF的寫入緩衝區

你這全部都寫到緩衝區去了,啥時候落地?

每當serverCron(先有一個定時任務的概念,下面馬上就會講serverCron是啥)被執行的時候,flushAppendOnlyFile 這個函式就被呼叫。

這個命令會呼叫 write將寫入緩衝區的資料寫入到AOF檔案中,但是這個時候還是沒有真正的落到磁碟上。這是OS為了提高寫入檔案的效率,會將資料暫時寫入到OS的記憶體的緩衝區內,等到緩衝區被填滿了或超過了指定的時間,才會呼叫fsync或者sdatasync真正的將緩衝區的內容寫入到磁碟中。

但是如果在這期間機器宕了,那麼資料仍然會丟失。所以如果想要真正的將AOF檔案儲存在磁碟上,必須要呼叫上面提到的兩個函式才行。

ServerCron

作用

現在我們就來具體聊一下serverCron函式,它主要是用於處理Redis中的常規任務

什麼叫常規任務?

就比如上面提到的AOF寫入緩衝區,每次serverCron執行的時候就會把緩衝區內的AOF寫入檔案(當然,OS會寫入自己的buffer中)。其餘的就像AOF和RDB的持久化操作,主從同步和叢集的相關操作,清理失效的客戶端、過期鍵等等。

那這個cron間隔多久執行一次?

很多部落格是直接給出的結論,100ms執行一次,口說無憑,我們直接擼原始碼。下面是serverCron的函式定義。

/* This is our timer interrupt, called server.hz times per second.
 * .............
 */
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
  ...
  server.hz = server.config_hz;
}

為了避免影響大家的思路,我省略了暫時對我們沒用的程式碼和註釋。可以看到註釋中有called server.hz times per second。意思就是serverCron這個函式將會在每一秒中呼叫server.hz次,那這個server.hz又是啥?

server.hz

相信大家都知道HZ(赫茲)這個單位,它是頻率的國際單位制單位,表示每一條週期性事件發生的次數。所以,我們知道這個配置項是用於控制週期性事件發生的頻率的。

其賦值的地方在上面的函式中已經給出,可以看到其初始值是來源於redis.conf的配置檔案。那讓我們看一下具體的配置。

# Redis calls an internal function to perform many background tasks, like
# closing connections of clients in timeout, purging expired keys that are
# never requested, and so forth.
#
# Not all tasks are performed with the same frequency, but Redis checks for
# tasks to perform according to the specified "hz" value.
#
# By default "hz" is set to 10. Raising the value will use more CPU when
# Redis is idle, but at the same time will make Redis more responsive when
# there are many keys expiring at the same time, and timeouts may be
# handled with more precision.
#
# The range is between 1 and 500, however a value over 100 is usually not
# a good idea. Most users should use the default of 10 and raise this up to
# 100 only in environments where very low latency is required.
hz 10

簡單的提取一下有用的資訊,Redis會在內部呼叫函式來執行很多後臺的任務,而呼叫這些函式的頻率就由這個hz來決定的,其預設值為10。那也就是說,上面提到的 serverCron函式會在一秒鐘執行10次,這樣平均下來就是每100ms(1000ms/10)呼叫一次。

寫入策略

上面說到,如果Redis的AOF已經位於OS的緩衝中,如果此時當機,那麼AOF的資料同樣會丟失。

你這不行啊,那你這個持久化有什麼意義?怎麼樣資料才能不丟失?

這得聊一下AOF日誌的寫入策略,它有三種策略,分別如下:

  • always 每個命令都會寫入檔案並且同步到磁碟
  • everysec 每秒鐘同步一次資料到磁碟
  • no 不強制寫,等待OS自己去決定什麼時候寫

很明顯always這種策略在真正的生產環境上是不可取的,每個命令都去寫檔案,會造成極大的IO開銷,會佔用Redis伺服器的很多資源,降低Redis的服務效率。

而如果使用everysec策略的話,即使發生了斷電,機器當機了,我最多也只會丟失一秒鐘的資料。

no則完全交與作業系統去排程,可能會丟失較多的資料。

??,那這AOF檔案咋用的,怎麼恢復?

上面提到過,AOF檔案是記錄了來自客戶端的所有寫命令,所以伺服器只需要讀入並重放一遍即可將Redis的狀態恢復。

但是,Redis的命令只能在客戶端中的上下文才能夠執行,所以Redis搞了一個沒有網路連線的偽客戶端來執行命令,直到命令執行完畢。

老鐵,你這不行啊,萬一AOF日誌資料量很大,你這豈不是要恢復很長時間,那服務豈不是不可用了?

的確,隨著伺服器的執行,AOF的資料量會越來越大,重放所需要的時間也會越來越多。所以Redis有一個重寫(AOF Rewrite)機制,來實現對AOF檔案的瘦身

雖然名字叫對AOF檔案的瘦身,但是實際上要做的操作跟之前已經生成的AOF檔案沒有一毛錢的關係。

所謂瘦身是通過讀取Redis伺服器當前的資料狀態來實現的,當然,這裡的當前是在伺服器正常執行的時候。其實你也可以理解為快照,只不過不是實打實的二進位制檔案了,而是直接設定快照值的命令。

用人話舉個例子,假設你Redis中有個鍵叫test,它的值的變化歷史是1 -> 3 -> 5 -> 7 -> 9這樣,那麼如果是正常的AOF檔案就會記錄5條Redis指令。而AOF Rewrite此時介入,就只會記錄一條test=9這樣的資料。

而之前的AOF檔案還是照常的寫入,當新的AOF檔案生成後替換即可。

你tm在逗我?你在rewrite的同時,伺服器仍然在處理正常的請求,此時如果對伺服器的狀態做了更改,你這個瘦身之後的AOF檔案資料不就不一致了?

這種情況的確會出現,但是Redis通過一個AOF重寫緩衝區來解決了這個問題。

rewrite開始後,Redis會fork一個子程式,讓子程式來實現AOF的瘦身操作,父程式則可以正常處理請求。AOF重寫緩衝區會在rewrite開始建立了子程式之後開始使用,此時Redis伺服器會把寫的指令同時傳送到兩個地方:

  1. aof_buf,也就是上面提到的AOF檔案的寫入緩衝區
  2. AOF重寫緩衝區

你可能會問,為啥要記錄到兩個地方?上面提到過,Redis執行瘦身操作時,常規的AOF檔案仍然是正常生成的,所以新的Redis指令一定會傳送到寫入緩衝區。

而傳送到AOF重寫緩衝區是為了重放在瘦身操作進行當中對Redis狀態進行的更改,這樣瘦身之後的AOF檔案狀態才能保證與Redis的狀態一致。總的來說,就是為了保證瘦身的AOF檔案中的資料狀態與Redis當時的記憶體狀態保持資料上的一致性。

End

關於Redis資料持久化的問題,就先聊這麼多,下一期的計劃的應該就是聊一聊Redis的高可用的相關機制了,感興趣的可以微信搜尋「SH的全棧筆記」持續關注,公眾號會比其他的平臺先推送。

往日文章:

相關文章