這篇 Redis 文章,圖靈看了都說好

作者:lunnzhang,騰訊 CDG 後臺開發工程師。

2007 年,他和朋友一起創建了一個網站。爲了解決這個網站的負載問題,他自己定製了一個數據庫。2009 年開發的,這個是 Redis。這位意大利程序員是薩爾瓦托勒 · 桑菲利波 (Salvatore Sanfilippo),他被稱爲 Redis 之父,更廣爲人知的名字是 Antirez。

一、Redis 簡介

REmote DIctionary Server(Redis) 是一個開源的使用 ANSI C 語言編寫、遵守 BSD 協議、支持網絡、可基於內存、分佈式、可選持久性的鍵值對 (Key-Value) 存儲數據庫,並提供多種語言的 API。

Redis 通常被稱爲數據結構服務器,因爲值(value)可以是字符串 (String)、哈希(Hash)、列表(list)、集合(sets) 和有序集合 (sorted sets) 等類型。

二、內存模型

首先可以進行 Redis 的內存模型學習,對 Redis 的使用有很大幫助,例如 OOM 時定位、內存使用量評估等。

通過 info memory 命令查看內存的使用情況。

主要參數:

  1. used_memory:從 Redis 角度使用了多少內存,即 Redis 分配器分配的內存總量(單位是字節),包括使用的虛擬內存(即 swap);

  2. used_memory_rss:從操作系統角度實際使用量,即 Redis 進程佔據操作系統的內存(單位是字節),包括進程運行本身需要的內存、內存碎片等,不包括虛擬內存。一般情況下 used_memory_rss 都要比 used_memory 大,因爲 Redis 頻繁刪除讀寫等操作使得內存碎片較多,而虛擬內存的使用一般是非極端情況下是不怎麼使用的;

  3. mem_fragmentation_ratio:即內存碎片比率,該值是 used_memory_rss / used_memory 的比值;mem_fragmentation_ratio 一般大於 1,且該值越大,內存碎片比例越大。如果 mem_fragmentation_ratio<1,說明 Redis 使用了虛擬內存,由於虛擬內存的媒介是磁盤,比內存速度要慢很多,當這種情況出現時,應該及時排查,如果內存不足應該及時處理,如增加 Redis 節點、增加 Redis 服務器的內存、優化應用等。一般來說,mem_fragmentation_ratio 在 1.03 左右是比較健康的狀態(對於 jemalloc 來說);

  4. mem_allocator:即 Redis 使用的內存分配器,一般默認是 jemalloc。

Redis 內存劃分

  1. 數據

作爲數據庫,數據是最主要的部分,這部分佔用的內存會統計在 used_memory 中。

  1. 進程本身運行需要的內存

這部分內存不是由 jemalloc 分配,因此不會統計在 used_memory 中。

  1. 緩衝內存

緩衝內存包括:

這部分內存由 jemalloc 分配,因此會統計在 used_memory 中。

  1. 內存碎片

內存碎片是 Redis 在數據更改頻繁分配、回收物理內存過程中產生的。

內存碎片不會統計在 used_memory 中。

Redis 數據存儲的細節

下面看一張經典的圖。

  1. jemalloc

無論是 DictEntry 對象,還是 RedisObject、SDS 對象,都需要內存分配器(如 jemalloc)分配內存進行存儲。Redis 在編譯時便會指定內存分配器;內存分配器可以是 libc 、jemalloc 或者 tcmalloc,默認是 jemalloc。當 Redis 存儲數據時,會選擇大小最合適的內存塊進行存儲。

jemalloc 劃分的內存單元如下圖所示:

  1. dictEntry

每個 dictEntry 都保存着一個鍵值對,key 值保存一個 sds 結構體,value 值保存一個 redisObject 結構體。

  1. redisObject

前面說到,Redis 對象有 5 種類型;無論是哪種類型,Redis 都不會直接存儲,而是通過 RedisObject 對象進行存儲。

RedisObject 的每個字段的含義和作用如下:

type 字段表示對象的類型,佔 4 個比特;目前包括 REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

encoding 表示對象的內部編碼,佔 4 個比特 (redis-3.0)。

lru 記錄的是對象最後一次被命令程序訪問的時間,佔據的比特數不同的版本有所不同(如 4.0 版本佔 24 比特,2.6 版本佔 22 比特)。

refcount 記錄的是該對象被引用的次數,類型爲整型。refcount 的作用,主要在於對象的引用計數和內存回收:

Redis 中被多次使用的對象 (refcount>1) 稱爲共享對象。Redis 爲了節省內存,當有一些對象重複出現時,新的程序不會創建新的對象,而是仍然使用原來的對象。這個被重複使用的對象,就是共享對象。目前共享對象僅支持整數值的字符串對象。

共享對象的具體實現

Redis 的共享對象目前只支持整數值的字符串對象。之所以如此,實際上是對內存和 CPU(時間)的平衡:共享對象雖然會降低內存消耗,但是判斷兩個對象是否相等卻需要消耗額外的時間。

對於整數值,判斷操作複雜度爲 O(1);

對於普通字符串,判斷複雜度爲 O(n);

而對於哈希、列表、集合和有序集合,判斷的複雜度爲 O(n^2)。

雖然共享對象只能是整數值的字符串對象,但是 5 種類型都可能使用共享對象(如哈希、列表等的元素可以使用)。

就目前的實現來說,Redis 服務器在初始化時,會創建 10000 個字符串對象,值分別是 0~9999 的整數值;當 Redis 需要使用值爲 0~9999 的字符串對象時,可以直接使用這些共享對象。10000 這個數字可以通過調整參數 REDIS_SHARED_INTEGERS(4.0 中是 OBJ_SHARED_INTEGERS)的值進行改變。

ptr 指針指向具體的數據,如前面的例子中,set hello world,ptr 指向包含字符串 world 的 SDS。

  1. SDS

Redis 沒有直接使用 C 字符串 (即以空字符‘\0’結尾的字符數組) 作爲默認的字符串表示,而是使用了 SDS。SDS 是簡單動態字符串 (Simple Dynamic String) 的縮寫。

通過 SDS 的結構可以看出,buf 數組的長度 = free+len+1(其中 1 表示字符串結尾的空字符);所以,一個 SDS 結構佔據的空間爲:free 所佔長度 + len 所佔長度 + buf 數組的長度 = 4+4+free+len+1=free+len+9。

爲什麼使用 SDS 而不直接使用 C 字符串?

SDS 在 C 字符串的基礎上加入了 free 和 len 字段,帶來了很多好處:

三、持久化 Persistence

持久化的功能:Redis 是內存數據庫,數據都是存儲在內存中,爲了避免進程退出導致數據的永久丟失,需要定期將 Redis 中的數據以某種形式(數據或命令)從內存保存到硬盤。當下次 Redis 重啓時,利用持久化文件實現數據恢復。除此之外,爲了進行災難備份,可以將持久化文件拷貝到一個遠程位置。

Redis 持久化分爲 RDB 持久化和 AOF 持久化,前者將當前數據保存到硬盤,後者則是將每次執行的寫命令保存到硬盤(類似於 MySQL 的 Binlog)。由於 AOF 持久化的實時性更好,即當進程意外退出時丟失的數據更少,因此 AOF 是目前主流的持久化方式,不過 RDB 持久化仍然有其用武之地。

RDB 持久化

RDB(Redis Database)持久化方式能夠在指定的時間間隔能對你的數據進行快照存儲。一般通過 bgsave 命令會創建一個子進程,由子進程來負責創建 RDB 文件,父進程 (即 Redis 主進程) 則繼續處理請求。

圖片中的 5 個步驟所進行的操作如下:

  1. Redis 父進程首先判斷:當前是否在執行 save,或 bgsave/bgrewriteaof(後面會詳細介紹該命令)的子進程,如果在執行則 bgsave 命令直接返回。bgsave/bgrewriteaof 的子進程不能同時執行,主要是基於性能方面的考慮:兩個併發的子進程同時執行大量的磁盤寫操作,可能引起嚴重的性能問題。

  2. 父進程執行 fork 操作創建子進程,這個過程中父進程是阻塞的,Redis 不能執行來自客戶端的任何命令;

  3. 父進程 fork 後,bgsave 命令返回”Background saving started” 信息並不再阻塞父進程,並可以響應其他命令;

  4. 子進程進程對內存數據生成快照文件;

  5. 子進程發送信號給父進程表示完成,父進程更新統計信息。

這裏補充一下第 4 點是如何生成 RDB 文件的。一定有讀者也有疑問:在同步到磁盤和持續寫入這個過程是如何處理數據不一致的情況呢?生成快照 RDB 文件時是否會對業務產生影響?

  1. 通過 fork 創建的子進程能夠獲得和父進程完全相同的內存空間,父進程對內存的修改對於子進程是不可見的,兩者不會相互影響;

  2. 通過 fork 創建子進程時不會立刻觸發大量內存的拷貝,採用的是寫時拷貝 COW (Copy On Write)。內核只爲新生成的子進程創建虛擬空間結構,它們來複制於父進程的虛擬究竟結構,但是不爲這些段分配物理內存,它們共享父進程的物理空間,當父子進程中有更改相應段的行爲發生時,再爲子進程相應的段分配物理空間;

AOF 持久化

AOF(Append Only File) 持久化方式記錄每次對服務器寫的操作,當服務器重啓的時候會重新執行這些命令來恢復原始的數據,AOF 命令以 redis 協議追加保存每次寫的操作到文件末尾。Redis 還能對 AOF 文件進行後臺重寫, 使得 AOF 文件的體積不至於過大。

AOF 的執行流程包括:

命令追加 (append)

Redis 先將寫命令追加到緩衝區 aof_buf,而不是直接寫入文件,主要是爲了避免每次有寫命令都直接寫入硬盤,導致硬盤 IO 成爲 Redis 負載的瓶頸。

文件寫入 (write) 和文件同步(sync)

根據不同的同步策略將 aof_buf 中的內容同步到硬盤;

Linux 操作系統中爲了提升性能,使用了頁緩存(page cache)。當我們將 aof_buf 的內容寫到磁盤上時,此時數據並沒有真正的落盤,而是在 page cache 中,爲了將 page cache 中的數據真正落盤,需要執行 fsync / fdatasync 命令來強制刷盤。這邊的文件同步做的就是刷盤操作,或者叫文件刷盤可能更容易理解一些。

AOF 緩存區的同步文件策略由參數 appendfsync 控制,各個值的含義如下:

有同學可能會疑問爲什麼 always 策略還是不能 100% 保障數據不丟失,例如在開啓 AOF 的情況下,有一條寫命令,Redis 在寫命令執行完,寫 aof_buf 未成功的情況下宕機了?

不能,Redis 就不能 100% 保證數據不丟失。

void flushAppendOnlyFile(int force) {
    ssize_t nwritten;
    int sync_in_progress = 0;
    mstime_t latency;

    if (sdslen(server.aof_buf) == 0) return;

    if (server.aof_fsync == AOF_FSYNC_EVERYSEC)
        sync_in_progress = bioPendingJobsOfType(REDIS_BIO_AOF_FSYNC) != 0;

    if (server.aof_fsync == AOF_FSYNC_EVERYSEC && !force) {
        /* With this append fsync policy we do background fsyncing.
         * If the fsync is still in progress we can try to delay
         * the write for a couple of seconds. */
        if (sync_in_progress) {
            if (server.aof_flush_postponed_start == 0) {
                /* No previous write postponing, remember that we are
                 * postponing the flush and return. */
                server.aof_flush_postponed_start = server.unixtime;
                return;
            } else if (server.unixtime - server.aof_flush_postponed_start < 2) {
                /* We were already waiting for fsync to finish, but for less
                 * than two seconds this is still ok. Postpone again. */
                return;
            }
            /* Otherwise fall trough, and go write since we can't wait
             * over two seconds. */
            server.aof_delayed_fsync++;
            redisLog(REDIS_NOTICE,"Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.");
        }
    }
    /* We want to perform a single write. This should be guaranteed atomic
     * at least if the filesystem we are writing is a real physical one.
     * While this will save us against the server being killed I don't think
     * there is much to do about the whole server stopping for power problems
     * or alike */

    latencyStartMonitor(latency);
    nwritten = write(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
    latencyEndMonitor(latency);
    /* We want to capture different events for delayed writes:
     * when the delay happens with a pending fsync, or with a saving child
     * active, and when the above two conditions are missing.
     * We also use an additional event name to save all samples which is
     * useful for graphing / monitoring purposes. */
    if (sync_in_progress) {
        latencyAddSampleIfNeeded("aof-write-pending-fsync",latency);
    } else if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) {
        latencyAddSampleIfNeeded("aof-write-active-child",latency);
    } else {
        latencyAddSampleIfNeeded("aof-write-alone",latency);
    }
    latencyAddSampleIfNeeded("aof-write",latency);

    /* We performed the write so reset the postponed flush sentinel to zero. */
    server.aof_flush_postponed_start = 0;

    if (nwritten != (signed)sdslen(server.aof_buf)) {
        static time_t last_write_error_log = 0;
        int can_log = 0;

        /* Limit logging rate to 1 line per AOF_WRITE_LOG_ERROR_RATE seconds. */
        if ((server.unixtime - last_write_error_log) > AOF_WRITE_LOG_ERROR_RATE) {
            can_log = 1;
            last_write_error_log = server.unixtime;
        }

        /* Log the AOF write error and record the error code. */
        if (nwritten == -1) {
            if (can_log) {
                redisLog(REDIS_WARNING,"Error writing to the AOF file: %s",
                    strerror(errno));
                server.aof_last_write_errno = errno;
            }
        } else {
            if (can_log) {
                redisLog(REDIS_WARNING,"Short write while writing to "
                                       "the AOF file: (nwritten=%lld, "
                                       "expected=%lld)",
                                       (long long)nwritten,
                                       (long long)sdslen(server.aof_buf));
            }

            if (ftruncate(server.aof_fd, server.aof_current_size) == -1) {
                if (can_log) {
                    redisLog(REDIS_WARNING, "Could not remove short write "
                             "from the append-only file.  Redis may refuse "
                             "to load the AOF the next time it starts.  "
                             "ftruncate: %s", strerror(errno));
                }
            } else {
                /* If the ftruncate() succeeded we can set nwritten to
                 * -1 since there is no longer partial data into the AOF. */
                nwritten = -1;
            }
            server.aof_last_write_errno = ENOSPC;
        }

        /* Handle the AOF write error. */
        if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
            /* We can't recover when the fsync policy is ALWAYS since the
             * reply for the client is already in the output buffers, and we
             * have the contract with the user that on acknowledged write data
             * is synced on disk. */
            redisLog(REDIS_WARNING,"Can't recover from AOF write error when the AOF fsync policy is 'always'. Exiting...");
            exit(1);
        } else {
            /* Recover from failed write leaving data into the buffer. However
             * set an error to stop accepting writes as long as the error
             * condition is not cleared. */
            server.aof_last_write_status = REDIS_ERR;

            /* Trim the sds buffer if there was a partial write, and there
             * was no way to undo it with ftruncate(2). */
            if (nwritten > 0) {
                server.aof_current_size += nwritten;
                sdsrange(server.aof_buf,nwritten,-1);
            }
            return; /* We'll try again on the next call... */
        }
    } else {
        /* Successful write(2). If AOF was in error state, restore the
         * OK state and log the event. */
        if (server.aof_last_write_status == REDIS_ERR) {
            redisLog(REDIS_WARNING,
                "

AOF write error looks solved, Redis can write again.");
            server.aof_last_write_status = REDIS_OK;
        }
    }
    server.aof_current_size += nwritten;

    /* Re-use AOF buffer when it is small enough. The maximum comes from the
     * arena size of 4k minus some overhead (but is otherwise arbitrary). */
    if ((sdslen(server.aof_buf)+sdsavail(server.aof_buf)) < 4000) {
        sdsclear(server.aof_buf);
    } else {
        sdsfree(server.aof_buf);
        server.aof_buf = sdsempty();
    }

    /* Don't fsync if no-appendfsync-on-rewrite is set to yes and there are
     * children doing I/O in the background. */
    if (server.aof_no_fsync_on_rewrite &&
        (server.aof_child_pid != -1 || server.rdb_child_pid != -1))
            return;

    /* Perform the fsync if needed. */
    if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
        /* aof_fsync is defined as fdatasync() for Linux in order to avoid
         * flushing metadata. */
        latencyStartMonitor(latency);
        aof_fsync(server.aof_fd); /* Let's try to get this data on the disk */
        latencyEndMonitor(latency);
        latencyAddSampleIfNeeded("

aof-fsync-always",latency);
        server.aof_last_fsync = server.unixtime;
    } else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
                server.unixtime > server.aof_last_fsync)) {
        if (!sync_in_progress) aof_background_fsync(server.aof_fd);
        server.aof_last_fsync = server.unixtime;
    }
}

那麼從上面 redis-3.0 的源碼及上下文

if (server.aof_fsync == AOF_FSYNC_ALWAYS)

分析得出,其實我們每次執行客戶端命令的時候操作並沒有寫到 aof 文件中,只是寫到了 aof_buf 內存當中,只有當下一個事件來臨時,纔會去 fsync 到 disk 中,從 redis 的這種策略上我們也可以看出,redis 和 mysql 在數據持久化之間的區別,redis 的數據持久化僅僅就是一個附帶功能,並不是其主要功能。

結論:Redis 即使在配製 appendfsync=always 的策略下,還是會丟失一個事件循環的數據。

文件重寫 (rewrite)

定期重寫 AOF 文件,達到壓縮的目的。

AOF 重寫是 AOF 持久化的一個機制,用來壓縮 AOF 文件,通過 fork 一個子進程,重新寫一個新的 AOF 文件,該次重寫不是讀取舊的 AOF 文件進行復制,而是讀取內存中的 Redis 數據庫,重寫一份 AOF 文件,有點類似於 RDB 的快照方式。

文件重寫之所以能夠壓縮 AOF 文件,原因在於:

文件重寫時機

相關參數:

同時滿足下面兩個條件,則觸發 AOF 重寫機制:

文件重寫流程

流程說明:

  1. 執行 AOF 重寫請求。

如果當前進程正在執行 bgrewriteaof 重寫,請求不執行。

如果當前進程正在執行 bgsave 操作,重寫命令延遲到 bgsave 完成之後再執行。

  1. 父進程執行 fork 創建子進程,開銷等同於 bgsave 過程。

3.1 主進程 fork 操作完成後,繼續響應其它命令。所有修改命令依然寫入 AOF 文件緩衝區並根據 appendfsync 策略同步到磁盤,保證原有 AOF 機制正確性。

3.2 由於 fork 操作運用寫時複製技術,子進程只能共享 fork 操作時的內存數據由於父進程依然響應命令,Redis 使用 “AOF” 重寫緩衝區保存這部分新數據,防止新的 AOF 文件生成期間丟失這部分數據。

  1. 子進程依據內存快照,按照命令合併規則寫入到新的 AOF 文件。每次批量寫入硬盤數據量由配置 aof-rewrite-incremental-fsync 控制,默認爲 32MB,防止單次刷盤數據過多造成硬盤阻塞。

5.1 新 AOF 文件寫入完成後,子進程發送信號給父進程,父進程更新統計信息。

5.2 父進程把 AOF 重寫緩衝區的數據寫入到新的 AOF 文件。

5.3 使用新的 AOF 文件替換老的 AOF 文件,完成 AOF 重寫。

**Redis 爲什麼考慮使用 AOF 而不是 WAL 呢? **

很多數據庫都是採用的 Write Ahead Log(WAL)寫前日誌,其特點就是先把修改的數據記錄到日誌中,再進行寫數據的提交,可以方便通過日誌進行數據恢復。

但是 Redis 採用的卻是 AOF(Append Only File)寫後日志,特點就是先執行寫命令,把數據寫入內存中,再記錄日誌。

如果先讓系統執行命令,只有命令能執行成功,纔會被記錄到日誌中。因此,Redis 使用寫後日志這種形式,可以避免出現記錄錯誤命令的情況。

另外還有一個原因就是:AOF 是在命令執行後才記錄日誌,所以不會阻塞當前的寫操作。

四、複製 Replication

主從複製過程大體可以分爲 3 個階段:連接建立階段(即準備階段)、數據同步階段、命令傳播階段;下面分別進行介紹。

連接建立階段

  1. 保存主節點信息

  2. 建立 socket 連接

  3. 發送 ping 命令

  4. 身份驗證

  5. 發送從節點端口信息

數據同步階段

執行流程:

  1. 全量複製(完整重同步)

    Redis 通過 psync 命令進行全量複製的過程如下:

  1. 部分複製(部分重同步)

主節點和從節點分別維護一個複製偏移量(offset),代表的是主節點向從節點傳遞的字節數;主節點每次向從節點傳播 N 個字節數據時,主節點的 offset 增加 N;從節點每次收到主節點傳來的 N 個字節數據時,從節點的 offset 增加 N。

複製積壓緩衝區是由主節點維護的、固定長度的、先進先出 (FIFO) 隊列,默認大小 1MB;當主節點開始有從節點時創建,其作用是備份主節點最近發送給從節點的數據。注意,無論主節點有一個還是多個從節點,都只需要一個複製積壓緩衝區。

每個 Redis 節點 (無論主從),在啓動時都會自動生成一個隨機 ID(每次啓動都不一樣),由 40 個隨機的十六進制字符組成;runid 用來唯一識別一個 Redis 節點。

命令傳播階段

主 -> 從:PING。

每隔指定的時間,主節點會向從節點發送 PING 命令,這個 PING 命令的作用,主要是爲了讓從節點進行超時判斷。

從 -> 主:REPLCONF ACK

在命令傳播階段,從節點會向主節點發送 REPLCONF ACK 命令,頻率是每秒 1 次;命令格式爲:REPLCONF ACK {offset},其中 offset 指從節點保存的複製偏移量。

五、架構模式

哨兵模式

哨兵模式工作原理
  1. 每個 Sentinel 以每秒一次的頻率向它所知的 Master,Slave 以及其他 Sentinel 節點發送一個 PING 命令;

  2. 如果一個實例(instance)距離最後一次有效回覆 PING 命令的時間超過配置文件 own-after-milliseconds 選項所指定的值,則這個實例會被 Sentinel 標記爲主觀下線

  3. 如果一個 Master 被標記爲主觀下線,那麼正在監視這個 Master 的所有 Sentinel 要以每秒一次的頻率確認 Master 是否真的進入主觀下線狀態;

  4. 當有足夠數量的 Sentinel(大於等於配置文件指定的值)在指定的時間範圍內確認 Master 的確進入了主觀下線狀態,則 Master 會被標記爲客觀下線

  5. 如果 Master 處於 ODOWN 狀態,則投票自動選出新的主節點。將剩餘的從節點指向新的主節點繼續進行數據複製;

  6. 在正常情況下,每個 Sentinel 會以每 10 秒一次的頻率向它已知的所有 Master,Slave 發送 INFO 命令;當 Master 被 Sentinel 標記爲客觀下線時,Sentinel 向已下線的 Master 的所有 Slave 發送 INFO 命令的頻率會從 10 秒一次改爲每秒一次;

  7. 若沒有足夠數量的 Sentinel 同意 Master 已經下線,Master 的客觀下線狀態就會被移除。若 Master 重新向 Sentinel 的 PING 命令返回有效回覆,Master 的主觀下線狀態就會被移除。

主要缺陷:單個節點的寫能力,存儲能力受到單機的限制,動態擴容困難複雜。

集羣模式

爲了解決哨兵模式存儲受單機的限制,這裏引入分片概念。

分片

Redis Cluster 採用虛擬哈希槽分區,所有的鍵根據哈希函數映射到 0 ~ 16383 整數槽內,計算公式:HASH_SLOT = CRC16(key) % 16384。每一個節點負責維護一部分槽以及槽所映射的鍵值數據。

Redis Cluster 提供了靈活的節點擴容和縮容方案。在不影響集羣對外服務的情況下,可以爲集羣添加節點進行擴容也可以下線部分節點進行縮容。可以說,槽是 Redis Cluster 管理數據的基本單位,集羣伸縮就是槽和數據在節點之間的移動。

參考文獻

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/k8agEub4qmhm3kX_TpETrA