來源:《Redis設計與實現》 ---黃健宏
Redis的線程模型是單線程,IO復用。
RDB持久化
Redis是內存數據庫,為了防止進程退出導致服務器中的數據庫狀態(tài)消失,Redis提供了RDB持久化功能,可以將Redis在內存中的數據庫狀態(tài)保存在磁盤里面,避免數據意外丟失。
RDB持久化可以手動執(zhí)行,也可以配置服務器定期執(zhí)行。生成的RDB文件是一個經過壓縮的二進制文件。
RDB文件的創(chuàng)建和載入
創(chuàng)建
SAVE
SAVE命令會阻塞Redis服務器進程,知道RDB文件創(chuàng)建完成為止,在阻塞期間,服務器不能處理任何命令請求。
BGSAVE
BGSAVE命令會派生出一個子進程,然后由子進程負責創(chuàng)建RDB文件,服務器進程(父進程)可以繼續(xù)處理命令請求。
BGSAVE期間父進程會拒絕SAVE命令和BGSAVE命令,如果有BGREWRITEAOF命令,則會延遲到BGSAVE執(zhí)行完再執(zhí)行。
載入
RDB文件的載入工作是在服務器啟動時自動執(zhí)行的,所以Redis并沒有專門用于載入RDB文件的命令,只要Redis服務器在啟動時檢測到RDB文件存在,它就會自動載入RDB文件。
因為AOF文件的更新頻率通常比RDB文件的更新頻率高,所以:
- 如果服務器開啟了AOF持久化功能,則會優(yōu)先使用AOF文件來還原數據庫。
- 如果服務器關閉了AOF持久化功能,則使用RDB文件來還原數據庫。
AOF持久化
AOF持久化通過保存Redis服務器所執(zhí)行的寫命令來記錄數據庫狀態(tài)。
實現
命令追加
當AOF持久化功能打開的時候,服務器在執(zhí)行完一個寫命令后,會以協議格式將被執(zhí)行的寫命令追加到服務器的aof buf緩沖區(qū)的末尾。
Redis的服務器進程就是一個事件循環(huán),這個循環(huán)中的文件事件負責接收客戶端的命令請求,處理并回復,而時間事件則負責執(zhí)行一些定時的任務。服務器在結束一個事件循環(huán)之前,會調用flushAppendOnlyFile函數檢查是否需要將aof buf緩沖區(qū)的內容寫入到AOF文件里面,偽代碼如下:
def eventLoop():
while True:
# 處理文件事件
processFileEvents()
# 處理時間事件
processTimeEvents()
# 處理aof buf緩沖區(qū)
flushAppendOnlyFile()
載入
因為AOF文件里面包含了重建數據庫狀態(tài)所需的所有寫命令,所以服務器只要讀入并重新執(zhí)行一遍AOF文件里面保存的寫命令,就可以還原服務器關閉之前的數據庫狀態(tài)。
- 創(chuàng)建一個不帶網絡連接的偽客戶端
- 從AOF文件中分析并讀取一條寫命令
- 使用偽客戶端執(zhí)行被讀出的寫命令
- 重復步驟2和步驟3,知道AOF文件中的所有寫命令都處理完成
AOF重寫
如果不做處理的話,AOF文件會隨著服務器運行的時間的增加而越來越大,同時使得使用AOF文件來還原的時間越來越長。
AOF重寫會遍歷數據庫中當前所有的鍵值對,用一條命令記錄當前鍵值對的狀態(tài)代替之前記錄這個鍵值對的多條命令。
客戶端
Redis用redisClient結構來保存客戶端當前的狀態(tài)信息
typedef struct redisClient {
//...
int fd;
int flags;
sds querybuf;
robj **argv;
int argc;
char buf[REDIS_REPLY_CHUNK_BYTES];
int bufpos;
list *reply;
int authenticated;
} redisClient;
客戶端屬性
套接字描述符 fd
- 偽客戶端的fd屬性的值為-1,主要用于處理AOF文件還原數據庫狀態(tài),以及執(zhí)行Lua腳本中包含的Redis命令。
- 普通客戶端的fd屬性的值為大于-1的整數,表示客戶端套接字的描述符。
標志 flags
flags可以是單個標志或者多個標志的或,如:
- REDIS_MASTER表示客戶端代表的是一個主服務器,REDIS_SLAVE表示客戶端代表的是一個從服務器
- REDIS_LUA_CLIENT表示客戶端是專門用于處理Lua腳本里面包含的Redis命令的偽客戶端
輸入緩沖區(qū) querybuf
保存客戶端發(fā)送的命令請求
命令與命令參數 argv與argc
保存從querybuf解析得到的命令參數以及命令參數的個數。argv是一個數組,第一項argv[0]是要執(zhí)行的命令,之后的項是命令的參數。
輸出緩沖區(qū) buf和reply
有兩個輸出緩沖區(qū),一個大小固定,一個大小可變
- 固定大小的緩沖區(qū)用于保存那些長度較小的回復
- 可變大小的緩沖區(qū)用于保存那些長度較大的回復
固定大小的緩沖區(qū)大小由REDIS_REPLY_CHUNK_BYTES指示,值為16*1024,即16KB。
可變大小的緩沖區(qū)由鏈表來連接多個字符串對象,可以保存非常長的命令回復。
身份驗證 authenticated
為0表示客戶端未通過身份驗證,為1表示客戶端已經通過了身份驗證。如果服務器啟用了身份驗證,當未通過身份驗證時,服務器會拒絕所有的非AUTH的命令。
復制
舊版復制功能的實現
Redis2.8版本之前使用的復制功能的實現
- 同步,將從服務器的數據庫狀態(tài)更新至主服務器當前所處的數據庫狀態(tài)
- 命令傳播,主服務器的數據庫狀態(tài)被修改時,將對應的命令傳播給從服務器
同步
當從服務器收到SLAVEOF命令時,會首先將服務器狀態(tài)更新至和主服務器的當前服務器狀態(tài)一致
- 從服務器向主服務器發(fā)送SYNC命令
- 主服務器執(zhí)行BGSAVE,在后臺生成一個RDB文件,并使用一個緩沖區(qū)記錄從現在開始執(zhí)行的所有寫命令
- 主服務器將RDB文件發(fā)送給從服務器,從服務器載入該RDB文件,更新自己的數據庫狀態(tài)
- 主服務器將記錄在緩沖區(qū)的所有寫命令發(fā)送給從服務器,從服務器執(zhí)行所有的寫命令
舊版復制功能的缺陷
從服務器對主服務器的復制可以分為兩種情況
- 初次復制:從服務器之前沒有復制過主服務器,一切重新開始
- 斷線重連:處于命令傳播階段的主從服務器因為網絡原因中斷了復制,但從服務器通過自動重連重新連上了主服務器,并繼續(xù)復制主服務器。這種情況下,如果還是重新同步的話,效率會非常低下。
新版復制功能的實現
Redis2.8開始,使用PSYNC命令代替SYNC命令來執(zhí)行復制時的同步操作。有完整重同步和部分重同步兩種模式。
- 完整重同步用于處理初次復制情況,執(zhí)行步驟和SYNC命令的執(zhí)行步驟一樣
- 部分重同步用于處理斷線重連后的復制情況。重連后,主服務器只需要將斷開連接期間的寫命令發(fā)送給從服務器,從服務器執(zhí)行這些寫命令即可。
部分重同步的實現
部分重同步的實現由以下三個功能構成:
- 主從服務器的復制偏移量
- 主服務器的復制積壓緩沖區(qū)
- 服務器的運行ID
復制偏移量
- 當主服務器向從服務器傳播N個字節(jié)的數據時,會將自己的復制偏移量加上N
- 當從服務器收到主服務器傳播的N個字節(jié)數據時,會將自己的復制偏移量加上N
如果復制偏移量一樣,則主從服務器處于一致狀態(tài),否則處于不一致狀態(tài)。
復制積壓緩沖區(qū)
主服務器維護了一個固定長度的FIFO隊列,默認大小為1MB。當主服務器進行命令傳播的時候,會同時將其寫入復制積壓緩沖區(qū)。
從服務器斷線重連后,會將自己的復制偏移量發(fā)送給主服務器,如果:
- 從服務器復制偏移量之后的數據都在主服務器的復制緩沖區(qū)里,則執(zhí)行部分重同步;
- 否則,執(zhí)行完整重同步。
服務器運行ID
每個服務器都會有一個自己的運行ID,從服務器會記錄下自己上次復制的主服務器的ID,重連同步時,會將該ID發(fā)給主服務器,主服務器判斷如果ID相同則可以嘗試部分重同步,否則執(zhí)行完整同步。
Sentinel
Sentinel是Redis的高可用解決方案:由一個或多個Sentinel實例組成Sentinel系統(tǒng)可以監(jiān)視任意多個主從服務器,在主服務器下線時,自動將某個從服務器升級為新的主服務器。
集群
Redis集群是Redis提供的分布式數據庫方案,集群通過分片來進行數據共享,并提供復制和故障轉移功能。
節(jié)點
一個Redis集群通常由多個節(jié)點組成,在剛開始的時候,每個節(jié)點是互相獨立的,都處于一個只包含自己的集群當中,要組建一個集群,需要使用CLUSTER MEET命令來完成。
CLUSTER MEET <ip> <port>
向一個節(jié)點發(fā)送CLUSTER MEET命令,會讓該節(jié)點向ip:port指定的節(jié)點進行握手,當握手成功時,該節(jié)點會將ip:port指定的節(jié)點添加到自己的集群中。
啟動節(jié)點
Redis服務器在啟動時會根據cluster-enabled配置選項是否為yes來決定是否開啟集群模式。
集群數據結構
每個節(jié)點會使用clusterNode結構來記錄自己的狀態(tài),同時為集群中的所有其他節(jié)點創(chuàng)建一個對應的clusterNonde結構。
struct clusterNonde {
mstime_t ctime; // 創(chuàng)建節(jié)點的時間
char name[REDIS_CLUSTER_NAMELEN]; // 節(jié)點的名字
int flags; //節(jié)點標識,記錄節(jié)點的角色(如主從),以及節(jié)點的狀態(tài)(如在線或下線)
uint64_t configEpoch; //節(jié)點當前的配置紀元,用于實現故障轉移
char ip[REDIS_IP_STR_LEN]; //節(jié)點的IP地址
int port; //節(jié)點的端口號
clusterLink *link; //保存連接節(jié)點所需的有關信息
};
// clusterNode保存了連接節(jié)點所需的有關信息
typedef struct clusterLink{
mstime_t ctime; //連接的創(chuàng)建時間
int fd; //TCP套接字描述符
sds sndbuf; //輸出緩沖區(qū)
sds rcvbuf; //輸入緩沖區(qū)
struct clusterNode *node; //與這個連接相關聯的節(jié)點,如果沒有則為NULL
} clusterlink;
// 每個節(jié)點都保存這一個clusterState結構,表示在當前節(jié)點的視角下,集群目前的狀態(tài)
typedef struct clusterState {
clusterNonde *myself; //指向當前節(jié)點的指針
unit64_t currentEpoch; //集群當前的配置紀元,用于實現故障轉移
int state; //集權當前的狀態(tài),在線還是下線
int size; //集群中至少處理者一個槽的節(jié)點的數量
dict *nodes; //集群節(jié)點名單(包括myself),健為節(jié)點名字,值為clusterNode結構
}
CLUSTER MEET命令實現
當節(jié)點A收到對B進行CLUSTER MEET的命令后,會進行握手來確認彼此的存在:
- A為B創(chuàng)建一個clusterNode結構,并添加到自己的clusterState.nodes里
- A向B發(fā)送MEET消息
- B為A創(chuàng)建clusterNode結構,并添加到自己的clusterState.nodes里
- B向A返回PONG消息
- A知道B已經接受了自己的MEET消息,并向B發(fā)送PING消息
- B知道A成功接受了自己的PONG消息。至此握手完成
槽指派
Redis集群通過分片的方式來保存數據庫中的鍵值對。集群的整個數據庫被分成了16384個槽(slot),數據庫中的每個鍵都屬于這16384個槽之一,每個節(jié)點可以處理多個槽。
- 當數據庫里的16384個槽都有節(jié)點處理時,集群處于上線狀態(tài)
- 只要有一個槽沒有被處理,集群處于下線狀態(tài)
通過CLUSTER ADDSLOTS命令,可以將一個槽或多個槽指派給節(jié)點負責。
記錄節(jié)點的槽指派信息
clusterNode結構的slots熟悉和numslot屬性記錄了節(jié)點負責處理哪些槽。
struct clusterNode{
unsigned char slots[16384/8];
int numslots; //該節(jié)點需要處理的槽的數量
};
用二進制位來表示節(jié)點是否需要處理對應的槽,為1時表示該節(jié)點需要處理對應的槽。
節(jié)點處理記錄自己的槽信息之外,還會將自己的slots數組發(fā)送給集群中的其他節(jié)點,以此來告知自己處理哪些槽。
在集群中執(zhí)行命令
當客戶端發(fā)送與數據庫鍵有關的命令時,接收命令的節(jié)點會計算該鍵屬于哪個槽,如果:
- 鍵所在的槽指派給了自己,則直接執(zhí)行命令
- 鍵所在的槽沒有指派給自己,則向客戶端返回MOVED錯誤,使客戶端向正確的節(jié)點再次發(fā)送指令
單機服務器可以處理多個數據庫,而節(jié)點服務器只能使用0號數據庫。
重新分片
Redis集群的重新分片由集群管理軟件redis-trib負責執(zhí)行,對單個槽進行重新分片的步驟如下:
- redis-trib向目標節(jié)點發(fā)送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,讓其準備好從源節(jié)點導入屬于槽slot的鍵值對
- redis-trib向源節(jié)點發(fā)送CLUSTER SETSLOT <slot> MGRATING <target_id>命令,使其做好遷移準備
- redis-trib向源節(jié)點發(fā)送CLUSTER GETKEYSINSLOT <slot> <count>命令,獲得最多count個屬于槽的鍵名(Redis記錄了每個槽的所有鍵)
- 對于每一個鍵名,redis-trib都向源節(jié)點發(fā)送MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>命令,將鍵原子的轉移到目標節(jié)點
- 重復3和4,直到屬于該槽的所有鍵值對都被遷移到目標節(jié)點
- redis-trib向集群中的任意一個節(jié)點發(fā)送CLUSTER SETSLOT <slot> NODE <target_id>命令,將槽指派給目標節(jié)點,然后廣播給整個集群
ASK錯誤
在重新分片期間,當客戶端向源節(jié)點發(fā)送的命令的鍵正好屬于被遷移的槽時:
- 源節(jié)點會檢查鍵是否在自己的數據庫里,如果找到的話,直接執(zhí)行客戶端的命令
- 如果沒找到,則向客戶端返回一個ASK錯誤,指引客戶端轉向目標節(jié)點再次發(fā)送命令
復制集
Redis集群中的每個節(jié)點可以是一個復制集,包含一個主節(jié)點和若干從節(jié)點,當主節(jié)點下線時,從節(jié)點可以被選舉成為主節(jié)點,并繼續(xù)處理命令請求。
發(fā)布與訂閱
通過執(zhí)行SUBSCRIBE命令,客戶端可以訂閱一個或多個頻道,當有其他客戶端向被訂閱的頻道發(fā)送消息時,該頻道的所有訂閱者都會收到這條消息。
頻道的訂閱與退訂
Redis將所有頻道的訂閱關系記錄在了服務器的pubsub_channels字典里面,鍵是被訂閱的頻道,值是一個鏈表,鏈表里記錄了所有訂閱這個頻道的客戶端。
struct redisServer {
dict *pubsub_channels;
};
模式的訂閱與退訂
客戶端還可以進行模式訂閱(正則),服務器將所有的模式訂閱關系保存在了pubsub_patterns里
struct redisServer {
list *pubsub_patterns;
};
pubsub_patterns是一個鏈表,每個節(jié)點包含著一個pubsubPattern結構,保存著訂閱的模式和客戶端。
發(fā)送消息
當一個Redis客戶端執(zhí)行PUBLISH <channel> <message>命令時,服務器會:
- 將消息發(fā)送給該頻道的所有訂閱者
- 遍歷pubsub_patterns鏈表,將消息發(fā)送給pattern匹配的訂閱者
事務
Redis有實現事務功能,可以將多個命令請求打包,一次性,按順序的執(zhí)行多個命令。在事務執(zhí)行期間,服務器不會中斷事務去執(zhí)行其他客戶端的命令請求。
事務以MULTI命令開始,接著輸入多個要執(zhí)行的命令,最后以EXEC命令提交事務給服務器執(zhí)行。
redis> MULTI
OK
redis> SET "name" "A"
QUEUED
redis> GET "name"
QUEUED
redid> EXEC
1) OK
2) "A"
MULTI命令會將客戶端的狀態(tài)切換為事務狀態(tài),此后如果接受到其他命令,如果:
- 命令是EXEC,DISCARD,WATCH,MULTI四個命令之一,則立即執(zhí)行這個命令
- 如果是這四個命令之外的命令,服務器會將這個命令放入一個FIFO隊列中
WATCH命令
每個Redis數據庫都保存著一個watched_keys字典,鍵是某個被WATCH命令監(jiān)視的數據庫鍵,值是一個鏈表,記錄了監(jiān)視該鍵的客戶端。
typedef struct redisDb {
dict *watched_keys;
} redisDb;
監(jiān)視機制的觸發(fā)
所有對數據庫進行修改的命令,在執(zhí)行之后,都會對watched_keys字典進行檢查,如果存在,則會將所有監(jiān)視該鍵的客戶端的REDIS_DIRTY_CAS標識打開,表示該客戶端的事務安全性已經被破壞。
判斷事務安全性
當服務器接收到EXEC命令是,會檢查這個客戶端的REDIS_DIRTY_CAS標識是否打開,
- 如果REDIS_DIRTY_CAS標識被打開,則拒絕執(zhí)行事務
- 否則,執(zhí)行被提交的事務
事務的ACID性質
原子性
Redis中的事務要么全部執(zhí)行,要么一個都不執(zhí)行,所以具有原子性。
與關系型數據庫事務最大的區(qū)別是,Redis不支持事務回滾機制,即使隊列中的某個命令在執(zhí)行期間出現了錯誤,整個事務也會繼續(xù)執(zhí)行下去,直到全部執(zhí)行完畢
Redis的作者解釋原因說,不支持事務回滾是因為這種復雜的回滾功能和Redis追求的簡單高效的設計主旨不符合,且事務執(zhí)行錯誤一般通常是編程錯誤導致的,應該由開發(fā)者負責避免。
一致性
一致性指如果數據庫在執(zhí)行事務前是一致的,那么在事務執(zhí)行之后,不論執(zhí)行是否成功,數據庫也應該是一致的。
一致指的是數據符合數據庫本身的定義和要求,沒有包含非法或者無效的錯誤數據。
隔離性
隔離性指如果有多個事務并發(fā)的執(zhí)行,各個事務之間不會互相影響。Redis使用單線程來執(zhí)行事務,并且服務器保證事務執(zhí)行期間不會對事務進行中斷,所以具有隔離性。
耐久性
耐久性指一個事務執(zhí)行完畢時,執(zhí)行這個事務的結果已經保存在磁盤里面了,即使執(zhí)行事務后停機,執(zhí)行的結果也不會丟失。Redis事務只是簡單的執(zhí)行一組Redis命令,故其耐久性由服務器所使用的持久化模式決定。
Lua腳本
Redis服務器內嵌了一個Lua環(huán)境
創(chuàng)建Lua環(huán)境
- 創(chuàng)建一個基礎的Lua環(huán)境,之后所有的修改都時針對這個環(huán)境
- 載入多個函數庫到Lua環(huán)境中,讓Lua腳本可以使用這些函數庫進行數據操作
- 創(chuàng)建全局表格redis,這個表格包含了對Redis進行操作的函數,比如執(zhí)行Redis命令的redis.call函數
- 使用Redis自制的隨機函數替換原來Lua的帶副作用的隨機函數
- 創(chuàng)建排序輔助函數,Lua環(huán)境使用這個函數來對一部分Redis命令的結果進行排序,從而取消這些命令的不確定性
- 創(chuàng)建redis.pcall的錯誤報告輔助函數,提供更詳細的錯誤信息
- 對Lua環(huán)境中的全局環(huán)境進行保護,防止用戶在Lua腳本執(zhí)行中,將額外的全局變量添加到Lua環(huán)境中
- 將修改的Lua環(huán)境保存到服務器狀態(tài)的lua屬性中,等待執(zhí)行Lua腳本
替換隨機函數
為了保證相同的腳本可以在不同的機器上產生相同的結果,Redis要求所有傳入服務器的Lua腳本,以及Lua環(huán)境中的所有函數,都必須時無副作用的純函數。替換后的隨機函數有以下特征:
- 對于相同的seed來說,math.random總產生相同的隨機數序列
- 除非在腳本中使用math.randomseed顯示修改seed,否則每次腳本運行時,都是用固定的math.randomseed(0)來初始化seed
創(chuàng)建排序輔助函數
比如對一個集合來說,因為集合元素的排列時無序的,為了消除不確定性,服務器會為Lua環(huán)境創(chuàng)建一個排序輔助函數__redis__compare_helper,當Lua腳本執(zhí)行一個帶有不確定性的命令之后,會對命令的返回值做一次排序,一次來保證有相同的輸出。
保護Lua全局環(huán)境
當一個腳本試圖創(chuàng)建一個全局變量時,服務器會報告一個錯誤,但是Redis并未禁止修改已存在的全局變量
redis> EVAL "x = 10" 0
(error) ERR Error running script
redis> EVAL "redis = 1; return redis" 0
(integer) 1
Lua環(huán)境協作組件
偽客戶端
因為執(zhí)行Redis命令必須有相應的客戶端狀態(tài),所以為了執(zhí)行Lua腳本中的Redis命令,專門創(chuàng)建了一個偽客戶端,由這個偽客戶端負責處理Lua腳本中的Redis命令。
lua_scripts字典
該字典的鍵是某個Lua腳本的SHA1校驗和,值是對應的Lua腳本
Redis會將所有被EVAL命令執(zhí)行過的Lua腳本,以及所有被SCRIPT LOAD命令載入過的Lua腳本保存在lua_scripts字典里
EVAL命令的實現
- 根據客戶端給定的Lua腳本,在Lua環(huán)境中定義一個Lua函數,函數名為f_<sha1>
- 將腳本保存在lua_scripts字典里
- 執(zhí)行剛剛在Lua環(huán)境中定義的函數