Redis——進階篇

發佈訂閱模式

列表list使用發佈訂閱模式的侷限性

之前說可以通過隊列的rpush和lpop可以實現消息隊列,但是消費者需要不停地調用lpop查看list中是否有等待處理的消息。爲了減少通信消耗,可以sleep()一段時間再調用lpop,如此會有兩個問題:

  1. 如果生產者生產消息的速度遠大於消費者消費信息的速度,List會佔用大量的內存。
  2. 消息的實時性降低。

改變思路,List提供一個阻塞式的出棧命令:blpop,沒有任何元素可以彈出的時候,連接會被阻塞。

基於此方式實現的訂閱發佈,不支持一對多的消息分發。

發佈訂閱模式

除了通過List實現消息隊列之外,Redis還提供了一組命令實現pub/sub模式。

這種方式,發送者和接收者沒有直接關聯,接收者也不需要持續嘗試獲取消息。

訂閱頻道

首先,我們有很多的頻道(chennel),我們也可以把這寫頻道理解爲queue。訂閱者可以訂閱一個或多個頻道。消息的發佈者可以給指定的頻道發佈消息。只要有消息到達了頻道,所有訂閱了這個頻道的訂閱者都會收到這條消息。

需要注意的是,發出去的消息不會被持久化,因爲它已經從隊列裏面移除了,所以消費者只能收到它開始訂閱這個頻道之後發佈的消息。

下面可以看一下發布訂閱命令的使用方法。

訂閱者訂閱:可以一次訂閱多個,比如訂閱3個頻道

subscribe channel-1 channel-2 channel-3

發佈者可以向指定頻道發佈消息(並不支持一次向多個頻道發送消息)

publish channel-1 kingTest

當然也提供了取消訂閱命令(不能在訂閱狀態下使用)

unsubscribe channel-1

根據規則(Pattern)訂閱頻道

支持?和*佔位符。?代表一個字符,*代表0個或多個字符。

消費端一,關注新聞:

psubscribe news*

生產者,發佈消息

publish news-weather rain
publish news-sport NBA
publish news-music song

可以看到發佈的消息都是存在news開頭的信息,消費端都將獲取這些消息。

Redis事務

爲什麼要使用事務

我們知道Redis的單個命令是原子性的,如果涉及到多個命令的時候,需要把多個命令作爲一個不可分割的處理序列,就需要用到事務。

例如我們之前說的用setnx實現分佈式鎖,我們先set然後設置expire,防止del發生異常的時候鎖不會被釋放,業務處理完了以後再del,這三個動作我們就希望他們作爲一組命令執行。

Redis的事務有兩個特點:

  1. 按進入隊列的順序執行;
  2. 不會受到其他客戶端的請求的影響。

Redis的事務涉及到四個命令:MULTI(開啓事務),EXEC(執行事務),DISCARD(取消事務),WATCH(監視)

事務的用法

轉賬場景

A給B各有1000元,A需要給B轉賬500元。那麼A賬戶少了500元,B賬戶多了500元。這一系列操作必須保證原子性。

set A 1000
set B 1000

multi
decrby A 500
incrby B 500
exec

get A
get B

通過multi的命令開啓事務。事務不能嵌套,多個multi命令效果一樣。

multi執行後,客戶端可以繼續向服務器發送任意多條命令,這些命令不會立即被執行,而是被放到一個隊列中,當exec命令被調用時,所有隊列中的命令纔會被執行。

通過exec的命令執行事務。如果沒有執行exec,所有的命令都不會被執行。

如果中途不想執行事務了呢?可以調用discard可以清空事務隊列,放棄執行。

multi
set k1 1
set k2 2
set k3 3
discard

watch命令

在Redis中還提供了一個watch命令。

它可以爲Redis事務提供CAS樂觀鎖行爲(Check and Set / Compare and Swap),也就是多個線程更新變量的時候,會跟原值做比較,只有它沒有被其他線程修改的情況下,才更新成新的值。

我們可以用watch監視一個或多個Key,如果開啓事務之後,至少有一個被監視key鍵在exec執行之前被修改了,那麼整個事務都會被取消(key提前過期除外)。可以用unwatch取消。

# client1
set balance 1000
watch balance
multi
incrby balance 100

# client2
decrby balance 100

# client1
exec
get balance

事務可能遇到的問題

我們將事務執行遇到的問題分成兩種,一種是在執行exec之前發生錯誤,一種是在執行exec之後發生錯誤。

在執行exec之前發生錯誤

入隊的命令存在語法錯誤,包括參數數量,參數名等等(編譯器錯誤)。

在這種情況下事務會被拒絕執行,也就是隊列中所有的命令都不會得到執行。

在執行exec之後發生錯誤

比如類型錯誤,對String使用了hash的命令,這是一種運行時錯誤。

這種情況,發現一個問題,錯誤發生之前到multi命令之後的命令,是執行成功的。也就是說在這種發生了運行時異常的情況下,只有錯誤的命令沒有被執行,但是其他命令沒有收到影響。

這顯然不符合我們對原子性的定義,也就是我們沒辦法用Redis這種事務機制來實現原子性,保證數據的一致性。

Lua腳本

Lua是一種輕量級腳本語言,它是用C語言編寫的,跟數據的存儲過程有點類似。使用Lua腳本來執行Redis命令的好處:

  1. 一次發送多個命令,減少網絡開銷。
  2. Redis會將整個腳本作爲一個整體執行,不會被其他請求打斷,保持原子性。
  3. 對於複雜的組合命令,我們可以放在文件中,可以實現程序之間的命令集複用。

在Redis中調用Lua腳本

使用eval方法,語法格式:

eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]
  • eval代表執行Lua語言的命令
  • lua-script代表Lua語言腳本內容
  • key-num表示參數中有多少個key,需要注意的是Redis中key是從1開始的,如果沒有key的參數,那麼寫0.
  • [key1 key2 key3...]是key作爲參數傳遞給Lua語言,也可以不填,但是需要和key-num的個數對應
  • [value1 value2 value3...]這些參數傳遞給Lua語言,他們是可填可不填,與key1...對應。

示例,返回一個字符串,0個參數

eval "return 'Hello World'" 0

在Lua腳本中調用Redis命令

使用redis.call(command,key [param1,param2...])進行操作。語法格式:

eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value
  • command是命令,包括set,get,del等
  • key是被操作的鍵
  • param1,param2...代表給key的參數。

注意與Java不同的是,定義只有形參,調用只有實參。

Lua是在調用時用key表示形參,argv表示參數值(實參)

設置鍵值對

在Redis中調用Lua腳本執行Redis命令

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 test 2673
get test

在redis-cli中直接寫Lua腳本不夠方便,也不能實現編輯和複用,通常我們會把腳本放在文件裏面,然後執行這個文件。

在Redis中調用Lua腳本文件中的命令

創建Lua腳本文件:

cd /home/soft/redis5.0.5/src
vim king.lua

Lua腳本內容,先設置,再取值:

redis.call('set','king','niubi')
return redis.call('get','king')

在Redis客戶端中調用Lua腳本

redis-cli --eval king.lua 0

緩存Lua腳本

爲什麼要緩存

在腳本比較長的情況下,如果每次調用腳本都需要把整個腳本給Redis服務端,會產生比較大的網絡開銷。爲了解決這個問題,Redis提供了EVALSHA命令,允許開發者通過腳本內容的SHA1摘要來執行腳本。

如何緩存

Redis在執行script load命令時會計算腳本SHA1摘要並記錄在腳本緩存中,執行EVALSHA命令時Redis會根據提供的摘要從腳本緩存中查找對應的腳本內容,如果找到了則執行腳本,否則會返回錯誤:"NOSCRIPT No matching script. Please use EVAL."

自乘案例

Redis有incrby這樣的自增命令,但是沒有自乘,比如乘以3,乘以4等。

我們可以寫一個自乘運算,讓它乘以後面的參數:

local curVal = redis.call("get", KEYS[1])
if curVal == false then
    curVal = 0
else
    curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[1])
redis.call("set", KEYS[1], curVal)
return curVal

把這個腳本變成單行,語句之間使用分號隔開

local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal

script load '命令'

script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'

此時會返回一個SHA1摘要ID:"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"

調用:

set num 2
evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6

腳本超時

Redis的指令執行本身時單線程的,這個線程還要執行客戶端的Lua腳本,如果Lua腳本執行超時或者陷入了死循環,是不是沒有辦法爲客戶端提供服務了呢?

爲了方式某個腳本執行時間過長導致Redis無法提供服務,Redis提供了lua-time-limit參數限制腳本的最長運行時間,默認爲5秒。

redis.conf配置文件中 :lua-time-limit 5000

當腳本運行時間超過這一限制後,Redis將開始接受其他命令但不會執行(以確保腳本原子性,因爲此時腳本並沒有被終止),而是會返回“BUSY”錯誤。

Redis提供了一個script kill命令來終止腳本的執行。新開一個客戶端,執行:

script kill

如果當前執行的Lua腳本對Redis的數據進行了修改(set,del等),那麼通過script kill命令是不能終止腳本運行的。

要保證腳本運行的原子性,如果腳本執行了一部分終止,那就違背了腳本原子性的要求。最終要保證腳本要麼都執行,要麼都不執行。

遇到這種情況,腳本幹不掉,只能通過shutdown nosave命令來強行終止redis。

Redis爲什麼會這麼快

Redis到底有多快?

官方提供測試用例,命令如下

cd /home/soft/redis-5.0.5/src
redis-benchmark -t set,lpush -n 100000 -q

執行結果

🐂吧。每秒處理10w+次set請求和lphsh請求。

redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"

 每秒10.7w次lua腳本調用。

不禁想問爲什麼這麼快?

總結:

  1. 純內存結構
  2. 單線程
  3. 多路複用

內存

KV結構的內存數據庫,時間複雜度O(1)。

單線程

單線程有什麼好處

  1. 沒有創建線程、銷燬線程帶來的消耗
  2. 避免了上線文切換導致的CPU消耗
  3. 避免了線程之間帶來的競爭問題,例如加鎖釋放鎖死鎖等問題

異步非阻塞

異步非阻塞I/O,多路複用處理併發連接。

Redis爲什麼是單線程的

因爲,單線程已經完全夠用了,CPU不是Redis的瓶頸。Redis的瓶頸最有可能是機器內存或者網絡帶寬。既然單線程容易實現,而且CPU不會成爲瓶頸,那麼順理成章了

單線程爲什麼這麼快

因爲Redis是基於內存的操作,我們先從內存開始說起。

虛擬存儲器

名詞解釋:主存:內存;輔存:硬盤

計算機主存可以看作一個由M個連續的字節大小的單元組成的數組,每個字節由一個唯一的地址,這個地址叫做物理地址(PA)。早期計算機中,如果CPU需要內存,使用物理尋址,直接訪問主存儲器。

這種方式有幾個弊端:

  1. 在多用戶多任務操作系統中,所有的進程共享主存,如果每個進程都獨佔一塊物理地址空間,主存很快就會被用完。我們希望在不同的時刻,不同的進程可以共用同一塊物理地址空間。
  2. 如果所有進程都是直接訪問物理內存,那麼一個進程就可以修改其他進程的內存,導致物理地址空間被破壞,程序運行就會出現異常。

爲了解決這些問題,我們就想了一個辦法,在CPU和主存之間增加一箇中間層。CPU不再使用物理地址訪問,而是訪問一個虛擬地址,由這個中間層把地址轉換成物理地址,最終獲得數據。這個中間層就叫做虛擬存儲器(Virtual Memory)。

具體的操作如下所示:

在每一個進程開始創建的時候,都會分配一段虛擬地址,然後通過虛擬地址和物理地址的映射來獲取真實數據,這樣進程就不會直接接觸到物理地址,甚至不知道自己調用的哪塊物理地址的數據。

目前,大多數操作系統都使用了虛擬內存,如Windows系統的虛擬內存、Linux系統的交換空間等等。Windows的虛擬內存是磁盤空間的一部分。

cat /proc/cpuinfo

總結:引入虛擬內存,可以提供更大的地址空間,並且地址空間是連續的,是的程序編寫、鏈接更加簡單。並且可以對物理內存進行隔離,不同的進程操作互不影響。還可以通過把同一塊物理內存映射到不同的虛擬地址空間實現內存共享。

用戶空間和內核空間

爲了避免用戶進程直接操作內核,保證內核安全,操作系統將虛擬內存劃分爲兩部分,一部分是內核空間,一部分是用戶空間。

內核是操作系統的核心,獨立於普通的應該用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的權限。

內核空間中存放的是內核代碼和數據,而進程的用戶空間中存放的是用戶程序的代碼和數據。不管是內核空間還是用戶空間,它們都處於虛擬空間中,都是對物理地址的映射。

在Linux系統中,內核進程和用戶進程所佔的虛擬內存比例是1:3 。

當進程運行在內核空間時就處於內核態,而進程運行在用戶空間時則處於用戶態。

進程在內核空間以執行任意命令,調用系統的一切資源;在用戶空間只能執行簡單的運算,不能直接調用系統資源,必須通過系統接口(又稱system call),才能向內核發出指令。

us:代表CPU消耗在User space的時間百分比;

sy:代表CPU消耗在Kernel space的時間百分比。

進程切換(上下文切換)

多任務操作系統是怎麼實現運行遠大於CPU數量的任務個數的?當然,這些任務實際上並不是真的在同時運行,而是因爲系統通過時間片分片算法,在很短的時間內,將CPU輪流分配給它們,造成多任務同時運行的錯覺。

爲了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程的執行。這種行爲被稱爲進程切換。

什麼叫上下文?

在每個任務運行前,CPU都需要知道任務從哪裏加載、又從哪裏開始運行,也就是說,需要系統事先設置好CPU寄存器和程序計數器,這個叫做CPU的上下文。

而這些保存下來的上下文,會存儲在系統內核中,並在任務重新調度執行時再次加載進來。這樣就能保證任務原來的狀態不受影響,讓任務看起來還在連續運行。

在切換上下文的時候,需要完成一系列的工作,這是一個很消耗資源的操作。

進程的阻塞

正在運行的進程由於提出系統服務請求(如I/O操作),但因爲某種原因未得到操作系統的立即響應,該進程只能把自己變成阻塞狀態,等待響應的時間出現後才被喚醒。進程在阻塞狀態不佔用CPU資源。

文件描述符FD

Linux系統將所有設備都當作文件來處理,而Linux用文件描述符來標識每個文件對象。

文件描述符(File Descriptor)是內核爲了高效管理已被打開的文件所創建的索引,用於指向被打開的文件,所有執行I/O操作的系統調用都通過文件描述符;文件描述符是一個簡單的非負整數,用以表明每個被進程打開的文件。

Linux系統裏面有三個標準文件描述符。

  • 0:標準輸入(鍵盤)
  • 1:標準輸出(顯示器)
  • 2:標準錯誤輸出(顯示器)

傳統I/O數據拷貝

以讀操作爲例:

當應用程序執行read系統調用讀取文件描述符(FD)的時候,如果這塊數據已經存在與用戶進程的頁內存中,就直接從內存中讀取數據。如果數據不存在,則先將數據從磁盤加載到內核緩衝區中,在從內核緩衝區拷貝到用戶進程的頁內存中。(兩次拷貝,兩次user和kernel的上下文切換)。

I/O的阻塞到底阻塞在哪裏?

Blocking I/O

當使用read或write對某個文件描述符進行過讀寫時,如果當前FD不可讀,系統就不會對其他的操作做出響應。從設備複製數據到內核緩衝區時阻塞的,從內存緩衝區拷貝到用戶空間,也是阻塞的,知道copy complete,內核返回結果,用戶進程才接觸block的狀態。

爲了解決阻塞的問題,我們有幾個思路。

  1. 在服務端創建多個線程或者使用線程池,但是在高併發的情況下需要的線程會很多,系統無法承受,而且創建和釋放線程都需要消耗資源。
  2. 由請求方定期輪詢,在數據準備完畢後再從內核緩存緩衝區複製數據到用戶空間(非阻塞式I/O),這種方式會存在一定的延遲。

能不能用一個線程處理多個客戶端請求?

I/O多路複用(I/O Multiplexing)

  • I/O:指的是網絡I/O。
  • 多路:指的是多個TCP連接(Socket或Channel)
  • 複用:指的是複用一個或多個線程

它的基本原理就是不再由應用程序自己監視連接,而是由內核替應用程序監視文件描述符。

客戶端在操作的時候,會產生具有不同事件類型的socket。在服務端,I/O多路複用程序(I/O Multiplexing Module)會把消息放入隊列中,然後通過文件事件分派器(File event Dispatcher),轉發到不同的事件處理器中。

多路複用有很多的實現,以select爲例,當用戶進程調用了多路複用器,進程會被阻塞。內核會監視多路複用器負責的所有socket,當任何一個socket的數據準備好了,多路複用器就會返回。這時候用戶進程再調用read操作,把數據從內核緩衝區拷貝到用戶空間。

所以,I/O多路複用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒(readable)狀態,select()函數就可以返回。

Redis的多路複用,提供了select,epoll,evport,kqueue幾種選擇,在編譯的時候來選擇一種。源碼:ae.c

/* Include the best multiplexing layer supported by this system.
 * The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif
  • evport:是Solaris系統內核提供支持的;
  • epoll:是Linux系統內核提供支持的;
  • kqueue:是Mac系統提供支持的;
  • select:是Posix提供的,一般的操作系統都有支撐。

分別對應源碼:ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c

內存回收

Redis所有的數據都是存儲在內存中的,在某些情況下需要對佔用的內存空間進行回收。內存回收主要分爲兩類,一類是key過期,一類是內存使用達到上限(max_memory)觸發內存淘汰。

過期策略

要實現key過期,我們有幾種思路。

定時過期(主動淘汰)

每個設置過期時間的key都需要創建一個定時器,到過期時間就會立即清除。該策略可以立即清除過期的數據,對內存很友好;但是會佔用大量的CPU資源去處理過期的數據,從而影響緩存的響應時間和吞吐量。

惰性過期(被動淘汰)

只有當訪問一個key時,纔會判斷該key是否已過期,過期則清除。該策略可以最大化地節省CPU資源,卻對內存非常不友好。極端情況下可能出現大量的過期key沒有再次被訪問,從而不會被清除,佔用大量內存。

例如:String,在getCommand裏面會調用expireIfNeeded

server.c    expireIfNeeded(redisDb *db, robj *key)

第二種情況,每次寫入key時,發現內存不夠,調用activeExpireCycle釋放一部分內存。

expire.c    activeExpireCycle(int type)

定期過期

源碼:server.h

/* Redis database representation. There are multiple databases identified
 * by integers from 0 (the default database) up to the max configured
 * database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB,所有的鍵值對 */
    dict *expires;              /* Timeout of keys with a timeout set,設置了過期時間的鍵值對 */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

每隔一定的時間,會掃描一定數量的數據庫的expires字典中一定數量的key,並清除其中已過期的key。該策略是前兩者的一個折中方案。通過調整定時掃描的時間間隔和每次掃描的限定耗時,可以在不同情況下使得CPU和內存資源達到最優的平衡效果。

Redis中同時使用了惰性過期和定期過期兩種過期策略。

當兩種方式都不過期,Redis內存滿了怎麼辦?

淘汰策略

Redis的內存淘汰策略,是指當內存使用達到最大內存極限時,需要使用淘汰算法來決定清理掉哪些數據,以保證新數據的存入。

最大內存設置

redis.conf參數配置:

# maxmemory <bytes>

如果不設置maxmemory或者設置爲0,64位系統不限制內存,32位系統最多使用3GB內存

動態修改:

config set maxmemory 2GB

到達最大內存以後怎麼辦?

淘汰策略maxmemory-policy

redis.conf

# The default is:
#
# maxmemory-policy noeviction

策略分別有如下幾種:

# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select among five behaviors:
#
# volatile-lru -> Evict using approximated LRU among the keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU among the keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key among the ones with an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.

先從算法來看:

LRU,Least Recently Used:最近最少使用。判斷最近被使用的時間,目前最遠的數據優先被淘汰。

LFU,Least Frequently Used:最不常用。

random:隨機刪除。

策略 含義
volatile-lru 根據LRU算法刪除設置了超時屬性(expire)的鍵,知道騰出足夠內存爲止。如果沒有可刪除的鍵對象,回退到noeviction策略。
allkeys-lru

根據LRU算法刪除鍵,不管數據有沒有設置超時屬性,知道騰出足夠內存爲止。

volatile-lfu 在帶有過期時間的鍵中選擇最不常用的。
allkeys-lfu 在所有的鍵中選擇最不常用的,不管數據有沒有設置超時屬性。
volatile-random 在帶有過期時間的鍵中隨機選擇。
allkeys-random 隨機刪除所有鍵,直到騰出足夠內存爲止。
volatile-ttl 根據鍵值對象的ttl屬性,刪除最近將要過期數據。如果沒有,回退到noeviction策略。
noeviction 默認策略,不會刪除任何數據,拒絕所有寫入操作並返回客戶端錯誤信息(error)OOM command not allowed when used memory,此時Redis只響應讀操作。

 

 

 

 

 

 

 

 

 

 

如果沒有符合前提條件的key被淘汰,那麼volatile-lru、volatile-random、volatile-ttl相當於noeviction(不做內存回收)。

動態修改淘汰策略:

config set maxmemory-policy volatile-lru

建議使用volatile-lru,在保證正常服務的情況下,優先刪除最近最少使用的key。

LRU淘汰原理

Redis LRU對傳統的LRU算法進行了改良,通過隨機採樣來調整算法的精度。

如果淘汰策略是LRU,則根據配置的採樣值maxmemory_samples(默認是5個),隨機從數據庫中選擇m個key,淘汰其中熱度最低的key對應的緩存數據。所以採樣參數m配置的數值越大,就越能精確的查找到待淘汰的緩存數據,但是也會消耗更多的CPU計算,執行效率隨之降低。

  • 那麼應該如何找出熱度最低的數據?

Redis中所有對象結構都有一個lru字段,且使用了unsigned的低24位,這個字段用來記錄對象的熱度。對象被創建時會記錄lru值。在被訪問的時候也會更新lru的值。但是不是獲取系統當前的時間戳,而是設置爲全局變量server.lruclock的值。

源碼:server.h

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;
  • server.lruclock的值怎麼來的?

Redis中有個定時處理的函數serverCron,默認每100ms調用函數updateCachedTime更新一次全局變量servver.lruclock的值,它記錄的是當前unix時間戳。

源碼:server.c

/* We take a cached value of the unix time in the global state because with
 * virtual memory and aging there is to store the current time in objects at
 * every object access, and accuracy is not needed. To access a global var is
 * a lot faster than calling time(NULL) */
void updateCachedTime(void) {
    time_t unixtime = time(NULL);
    atomicSet(server.unixtime,unixtime);
    server.mstime = mstime();

    /* To get information about daylight saving time, we need to call localtime_r
     * and cache the result. However calling localtime_r in this context is safe
     * since we will never fork() while here, in the main thread. The logging
     * function will call a thread safe version of localtime that has no locks. */
    struct tm tm;
    localtime_r(&server.unixtime,&tm);
    server.daylight_active = tm.tm_isdst;
}
  • 爲什麼不獲取精確的時間而是放在全局變量中?不會有延遲的問題嗎?

這樣函數lookupKey中更新數據的lru熱度值時,就不用每次調用系統函數time,可以提高執行效率。

當對象裏面已經有了lru字段的值,就可以評估對象的熱度了。

函數estimateObjectIdleTime評估指定對象的lru熱度,思想就是對象的lru值和全局的server.lruclock的差值越大(越久沒有得到更新),該對象熱度越低。

源碼:evict.c

/* Given an object returns the min number of milliseconds the object was never
 * requested, using an approximated LRU algorithm. */
unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
                    LRU_CLOCK_RESOLUTION;
    }
}

server.lruclock只有24位,按秒爲單位來表示才能存儲194天。當超過24bit能表示的最大時間的時候,它會從頭開始計算。

源碼:server.h

#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */

在這種情況下,可能會出現對象的lru大於server.lruclock的情況,如果這種情況出現那麼就兩個相加而不是象間來求最久的key。

  • 爲什麼不用常規的哈希表+雙向鏈表的方式實現?

需要額外的數據結構,消耗資源。而Redis LRU算法在sample爲10的情況下,已經能接近傳統LRU算法了。

  • 除了消耗資源之外,傳統LRU還有什麼問題?

如圖所示,假設A在10秒內被訪問了5次,而B在10秒內被訪問了3次。因爲B最後一次被訪問的時間比A要晚,在同等的情況下,A反而先被回收。

LFU淘汰原理

server.h

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

當這24bits用作lfu時,其被分爲兩部分:

  • 高16位用來記錄訪問時間(單位爲分鐘,ldt,last decrement time)
  • 低8位用來記錄訪問頻率,簡稱counter(logc,logistic counter)

counter是用基於概率的對數計數器實現的,8位可以表示百萬次的訪問頻率。對象被讀寫的時候,lfu的值會被更新。

db.c —— lookupKey

/* Update LFU when an object is accessed.
 * Firstly, decrement the counter if the decrement time is reached.
 * Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

增長的速率由,lfu-log-factor越大,counter增長的越慢

redis.conf

# The default lfu-log-factor is 10. This is a table of how the frequency
# counter changes with a different number of accesses with different
# logarithmic factors:
#
# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+
#
# lfu-log-factor 10

如果計數器只會遞增不會遞減,也不能體現對象的熱度。沒有被訪問的時候,計數器怎麼遞減呢?

減少的值由衰減因子lfu-decay-time(分鐘)來控制,如果值是1的話,N分鐘沒有訪問就要減少N。

redis.conf配置文件

# lfu-decay-time 1

持久化機制

Redis速度快,很大一部分原因是因爲它所有的數據都存儲在內存中。如果斷電或者宕機,都會導致內存中的數據丟失。爲了實現重啓後數據不丟失,Redis提供了兩種持久化的方案,一種是RDB快照(Redis DataBase),一種是AOF(Append Only File)。

RDB

RDB是Redis默認的持久化方案。當滿足一定條件的時候,會把當前內存中的數據寫入磁盤,生成一個快照文件dump.rdb(文件名,寫入路徑可修改)。Redis重啓會通過加載dump.rdb文件恢復數據。

RBD觸發條件

1、自動觸發

配置規則觸發

redis.conf,SNAPSHOTTING,其中定義了觸發數據保存到磁盤的觸發頻率。如果不需要RDB方案,註釋save或者配置成空字符串""。

################################ SNAPSHOTTING  ################################
#
# Save the DB on disk:
#
#   save <seconds> <changes>
#
#   Will save the DB if both the given number of seconds and the given
#   number of write operations against the DB occurred.
#
#   In the example below the behaviour will be to save:
#   after 900 sec (15 min) if at least 1 key changed
#   after 300 sec (5 min) if at least 10 keys changed
#   after 60 sec if at least 10000 keys changed
#
#   Note: you can disable saving completely by commenting out all "save" lines.
#
#   It is also possible to remove all the previously configured save
#   points by adding a save directive with a single empty string argument
#   like in the following example:
#
#   save ""

save 900 1
save 300 10
save 60 10000

注意以上三條配置並不衝突,只要滿足任意一條都會觸發。

RDB文件位置和目錄:

# Compress string objects using LZF when dump .rdb databases?
# For default that's set to 'yes' as it's almost always a win.
# If you want to save some CPU in the saving child set it to 'no' but
# the dataset will likely be bigger if you have compressible values or keys.
# 是否是LZF壓縮rdb文件
rdbcompression yes

# Since version 5 of RDB a CRC64 checksum is placed at the end of the file.
# This makes the format more resistant to corruption but there is a performance
# hit to pay (around 10%) when saving and loading RDB files, so you can disable it
# for maximum performances.
#
# RDB files created with checksum disabled have a checksum of zero that will
# tell the loading code to skip the check.
# 開啓數據校驗
rdbchecksum yes

# The filename where to dump the DB
# rdb文件名
dbfilename dump.rdb

# The working directory.
#
# The DB will be written inside this directory, with the filename specified
# above using the 'dbfilename' configuration directive.
#
# The Append Only File will also be created inside this directory.
#
# Note that you must specify a directory here, not a file name.
# 文件存儲路徑
dir ./

RDB還有兩種觸發方式:

  • shutdown觸發,保證服務器正常關閉
  • flushall,RDB文件是空的,無意義。

2、手動觸發

如果我們需要重啓服務或者遷移數據,這個時候就需要手動觸發RDB快照保存。Redis提供兩條命令:

  • save

save在生成快照的時候會阻塞當前Redis服務器,Redis不能再處理其他命令。如果內存中的數據比較多,會造成Redis長時間阻塞。生產環境不建議使用這條命令。

爲了解決這個問題,Redis提供了第二種方式。

  • bgsave

執行bgsave時,Redis會在後臺異步進行快照操作,快照同時還可以響應客戶端請求。

具體操作是Redis進程進行fork操作創建子進程(copy-on-write),RDB持久化過程由子進程負責,完成後自動結束。它不會記錄fork之後的命令。阻塞只發生在fork階段,一般時間很短。

用lastsave命令可以查看最近一次成功生成快照的時間。

RDB數據的恢復

1、shutdown觸發持久化

2、模擬數據丟失

3、通過備份文件恢復數據

RBD文件的優劣

  1. 優勢
    1. RBD是一個分常緊湊(compact)的文件,它保存了redis在某個時間點上的數據集。這種文件非常適合用於進行備份和災難恢復。
    2. 生成RDB文件的時候,redis主進程會fork()一個子進程來處理所有保存工作,主進程不需要進行任何磁盤I/O操作。
    3. RDB在恢復大數據集時的速度比AOF的恢復速度快。
  2. 劣勢
    1. RDB方式數據沒辦法做到實時持久化。因爲bgsave每次運行都要執行fork操作創建子進程,頻繁執行成本過高。
    2. 在一定間隔時間做一次備份,所以如果redis意外宕機,就會丟失最後一次快照之後的所有修改(部分數據丟失)。如果數據相對重要,損失降低到最小,需要使用AOF方式進行持久化。

AOF

AOF:Redis默認不開啓。AOF採用日誌的形式來記錄每個寫操作,並追加到文件中。開啓後,執行更改Redis數據的命令時,就會把命令寫入到AOF文件中。

Redis重啓時會根據日誌文件的內容把寫指令從前到後執行一次以完成數據的恢復工作。

AOF的配置

配置文件redis.conf

# 是否開啓AOF
appendonly no

# The name of the append only file (default: "appendonly.aof")
# AOF寫文件的文件名
appendfilename "appendonly.aof"

AOF觸發

由於操作系統的緩存機制,AOF數據並沒有真正的寫入硬盤,而是進入了系統的硬盤緩存。那麼何時把緩衝區的內容寫入到AOF文件呢?

redis.conf

# always 表示每次寫入都執行 fsync,以保證數據同步到磁盤,效率很低
# appendfsync always

# everysec 表示每秒執行一次 fsync,可能會導致丟失這 1s 數據。通常選擇 everysec,兼顧安全性和效率
appendfsync everysec

# no 表示不執行 fsync,由操作系統保證數據同步到磁盤,速度最快,但是不太安全
# appendfsync no

AOF文件處理

由於AOF持久化是Redis不斷將寫命令記錄到AOF文件中,隨着Redis不斷的進行,AOF的文件會越來越大;文件越大,佔用的服務器內存就會越大,同時AOF恢復所需要的時間越長。

當重複的命令重複執行時,Redis時如何處理的呢?例如 set king test 命令執行了1w次。

爲了解決這個問題,Redis新增了重寫機制,當AOF文件的大小超過所設定的閾值時,Redis就會啓動AOF文件的內容壓縮,只保留可以恢復數據的最小指令集。

可以使用命令:bgrewriteaof

AOF文件重寫並不是對源文件進行重新整理,而是直接讀取服務器現有的鍵值對,然後用一條命令去代替之前記錄這個鍵值對的多條命令,生成一個新的文件後替換原來的AOF文件。

redis.conf

# Automatic rewrite of the append only file.
# Redis is able to automatically rewrite the log file implicitly calling
# BGREWRITEAOF when the AOF log size grows by the specified percentage.
#
# This is how it works: Redis remembers the size of the AOF file after the
# latest rewrite (if no rewrite has happened since the restart, the size of
# the AOF at startup is used).
#
# This base size is compared to the current size. If the current size is
# bigger than the specified percentage, the rewrite is triggered. Also
# you need to specify a minimal size for the AOF file to be rewritten, this
# is useful to avoid rewriting the AOF file even if the percentage increase
# is reached but it is still pretty small.
#
# Specify a percentage of zero in order to disable the automatic AOF
# rewrite feature.
# 默認值爲100,aof自動重寫配置,當目前aof文件大小超過上次重寫的aof文件大小的百分比 100% 進行重寫。
auto-aof-rewrite-percentage 100
# 默認64mb,設置允許重寫的最小aof文件大小,避免了達到約定百分比但是文件任然很小的情況發生重寫。
auto-aof-rewrite-min-size 64mb

重寫過程中的AOF文件更改問題

另外有兩個與AOF相關的參數

# 在 aof 重寫或者寫入 rdb 文件的時候,會執行大量 IO,此時對於 everysec和always的aof模式來說,
# 執行fsync會造成阻塞過長時間,no-appendfsync-on-rewrite 字段設置爲默認設置爲 no。
# 如果對延遲要求很高的應用,這個字段可以設置爲yes,否則還是設置爲no,這樣對持久化特性來說這是更安全的選擇。
# 設置爲yes表示rewrite 期間對新寫操作不 fsync,暫時存在內存中,等rewrite完成後再寫入,默認爲no,建議修改爲yes。
# Linux的默認 fsync策略是 30 秒。可能丟失 30 秒數據。
no-appendfsync-on-rewrite no

# aof 文件可能在尾部是不完整的,當 redis 啓動的時候,aof 文件的數據被載入內存。
# 重啓可能發生在 redis所在的主機操作系統宕機後,尤其在ext4文件系統沒有加上data=ordered選項,出現這種現象。
# redis 宕機或者異常終止不會造成尾部不完整現象,可以選擇讓 redis退出,或者導入儘可能多的數據。
# 如果選擇的是 yes,當截斷的 aof 文件被導入的時候,會自動發佈一個 log 給客戶端然後 load。
# 如果是 no,用戶必須手動 redis-check-aof 修復 AOF文件纔可以。默認值爲 yes。
aof-load-truncated yes

AOF數據恢復

重啓Rdis之後就會進行AOF文件的恢復。

AOF優劣

  1. 優勢
    1. AOF持久化方法提供了多種同步頻率,即使使用默認的同步頻率每秒同步一次,Redis最多也就會丟失1秒的數據。
  2. 缺點
    1. 對於具有相同數據的Redis,AOF文件通常會比RDB文件佔用更大空間。除非觸發rewrite,否則重複命令過多。
    2. 雖然AOF提供了多種同步的頻率,默認情況下,每秒同步一次的頻率也具有較高的性能。在高併發的情況下,RDB比AOF具有更好的性能。

RBD比之AOF

我們應該如何合理的選擇這兩種方案呢?

如果可以忍受一小段時間內數據的丟失,毫無疑問RDB是最好的,定時生成RDB快照非常便於進行數據庫的備份,並且RDB恢復數據集的速度也要比AOF恢復的速度要快。

否則就是用AOF重寫。但是一般情況下建議不要單獨使用某一種持久化機制,而是結合兩種一起使用,在這種情況下,當redis重啓的時候會優先載入AOF文件來恢復原始的數據,因爲在通常情況下AOF文件保存的數據集要比RDB文件保存的數據集要更加完整。

 

LRU

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