Redis是單線程程序。單線程的Redis為何還能這么快?
1、所有的數據都在內存中,所有的運算都是內存級別的運算(因此時間復雜度為O(n)的指令要謹慎使用)
2、單線程操作,避免了頻繁的上下文切換
3、多路復用(非阻塞IO多路復用),NIO來處理客戶端的并發連接
非阻塞IO,Non-block IO, NIO,非阻塞模式,使一個線程從某通道發送請求數據讀取數據,如果目前沒有數據可讀時,就什么都
不會獲取,而不是保持線程阻塞,直到有數據可讀之前,該線程可以繼續做別的事情,非阻塞寫也是如此,能寫多少取決
于內核為套接字分配的寫緩沖區的空閑字節數,不必等到完全寫入這個線程可以去做別的事情。線程通常將非阻塞IO的空
閑時間用于在其他通道上執行IO操作,所以一個單獨的線程現在可以管理多個輸入和輸出通道(channel)。
阻塞IO,BIO,如Java IO中的各種流都是BIO,阻塞的。當一個線程調用read或者write方法時,該線程會被阻塞,知道有一些數據
被讀取,或數據完全寫入。該線程在此期間不能再干別的事情了。
?
事件輪詢(多路復用)
非阻塞IO有個問題,那就是線程要讀數據,結果讀了一部分后返回了,那么當數據到來時,如何通知線程繼續讀呢?寫也是一
樣,如果緩沖區寫滿了沒有寫完,剩下的數據何時繼續寫,線程也應該得到通知。
事件輪詢API就是用來解決這個問題的。最簡單的事件輪詢API是select函數。它是操作系統提供給用戶程序的API。輸入是讀
寫描述符列表,read_fds&write_fds,輸出是與之對應的可讀可寫事件。同時還提供了一個timeout參數,如果沒有任務事件到來,
那么就最多等待timeout的值的時間,線程處于阻塞狀態。一旦期間沒有任務事件到來,就可以立即返回。時間過了之后沒有任務
事件到來,也會立即返回。拿到事件后,線程就可以挨個處理相應的事件。處理完了繼續過來輪詢。于是線程就進入了一個死循環
,這個死循環稱為事件循環,一個循環為一個周期。
通過系統提供的epoll函數同時處理多個通道描述符的讀寫事件,因此將這類調用稱為多路復用API。
? (一句話總結就是利用操作系統提供的epoll函數(基于事件驅動)同時處理多個通道描述符的讀寫事件來實現多路復用)
?
指令隊列:Redis會將每個客戶端套接字都關聯一個指令隊列。客戶端的指令通過隊列來排隊進行順序處理,先到先服務。
響應隊列:Redis會為每個客戶端套接字都關聯一個響應隊列。Redis服務器通過響應隊列來將指令的返回結果回復給客戶端。如果隊列
為空,那么意味著連接暫時處于空閑狀態,不需要去獲取寫事件。
定時任務:服務器除了要響應IO事件外,還要處理其他的事情。比如定時任務就是非常重要的一件事。如果線程阻塞在select系統調用上
,定時任務無法得到準時調度。Redis的定時任務會記錄在一個被稱為最小堆的數據結構中。在這個堆中,最快要執行的任務排
在堆的最上方,每個循環周期里。Redis都會對最小堆里面已經到時間點的任務進行處理。處理完畢后,將最快要執行的任務還
需要的時間記錄下來,這個時間就是select系統地哦暗涌的timeout參數。因為Redis知道未來timeout的值的時間內,沒有其他定
時任務需要處理,所以可以安心睡眠timeout的值的時間。
?
Redis單線程特性的優缺點
優點:
1、代碼更清晰,邏輯更簡單
2、不用因為同步去考慮各種鎖的問題,不存在加鎖和釋放鎖的操作,基本不會出現死鎖而導致的性能消耗
3、不存在多線程切換導致的CPU消耗
缺點:
無法發揮多核CPU的性能,不過可以通過在單機開多個Redis實例來實現
?
持久化
Redis的數據全部在內存里,如果突然宕機,數據就會全部丟失,因此必須有一種機制來保證Redis中的數據不會因為故障而丟失,這
種機制就是Redis的持久化機制。
Redis的持久化機制有兩種:
一、快照RDB
1、一次全量備份,使用 BGSAVE命令
2、保存方式是內存數據的二進制序列化形式,在存儲上非常緊湊
3、使用操作系統的多進程COW(copy on write)機制來實現持久化,持久化時調用glibc的函數fork(分岔)產生一個子進程,持久
化完全交給子進程來處理,父進程繼續處理客戶端請求
4、COW機制的數據頁面的分離。父進程在對頁面的數據進行修改時,會將被共享的頁面復制一份分離出來,然后對復制出來的頁面
進行修改。這時子進程的頁面是沒有變化的,還是進程產生那一瞬間的數據,所以這種持久化叫做快照的原因。
5、使用fork子進程,無法實時,宕機會造成數據丟失
二、AOF日志
1、連續的增量備份,使用appendonly yes開啟
2、存儲的是Redis服務器的順序指令序列,只記錄對內存進行修改的指令序列
3、記錄的是內存數據修改的指令記錄文本,在長期的運行過程中會變得非常龐大,數據庫重啟時需要加載AOF日志進行指令重放
,比較耗時,所以需要定期進行AOF重寫,給AOF日志瘦身
4、Redis收到客戶端修改指令后,進行參數校驗、邏輯處理,如果沒問題,就立即將該指令寫到緩沖區中,然后每秒鐘調用一次
fsync將指令存儲到AOF日志中
5、使用bgrewriteaof指令對AOF日志進行瘦身,即開辟一個子進程對內存進行遍歷,轉換成一系列Redis的操作指令,序列化到一
個新的AOF日志文件中,再將操作期間新增的AOF日志追加到這個新的AOF日志文件中,替代舊的AOF日志文件,完成瘦身。
?
Redis4.0混合持久化
實際應用中重啟Redis時,很少使用RDB來恢復內存狀態,因為會丟失大量數據。所以我們通常使用AOF日志重放,但是重放AOF日志
相對于RDB要慢得多。
混合持久化:將RDB文件的內容和增量的AOF日志文件存在一起。這里的AOF日志不是全量的日志而是自持久化開始到持久化結束的這
段時間發生的增量AOF日志,通常這部分AOF日志很小。因此重啟的時候先加在RDB內容,然后再重放增量AOF日志,替
代之前的AOF的全量文件重放,重啟效率得到大幅度提升。
事務
Redis的事務模型并不嚴格(不具備原子性,事務的命令如果有執行失敗,并不會回滾)
基本的事務操作都有begin、commit和rollback。begin指示事務的開始,commit指示事務的提交,rollback指示事務的回滾。
Redis事務的指令也差不多,分別是multi、exec、discard。multi指示事務的開始,exec指示事務的執行,discard指示事務的丟棄。
Redis的指令在exec之前不執行,而是緩存在服務器的事務隊列中,服務器一旦收到exec指令,才開始執行整個事務。因為Redis是單
線程,所以在執行隊列中的命令時不會被其他指令打攪。
但是Redis的事務不具備原子性,而僅僅滿足了事務的隔離性中的串行化,當前事務執行不會被其他事務干擾。
優化:Redis事務在每發送一個指令到事務緩存隊列都要經過一次網絡讀寫,當一個事務內部的指令較多時,需要的網絡IO也會線性增長,
所以通常Redis的客戶端在執行事務時都會結合pipeline一起使用。
?
Watch(CAS機制):
多個客戶端并發修改Redis中的一條記錄。需要先讀,再寫。為了保證線程安全,一種方式是通過Redis分布式鎖的方式,但是Redis
分布式鎖是悲觀鎖。Redis提供了watch機制,是一種樂觀鎖在multi之前監視某個關鍵變量,若在watch之后被修改了(包含當前事務
所在的客戶端),如果關鍵字被修改了,則exec指令就會返回NULL回復告知客戶端事務執行失敗,這個時候客戶端一般會選擇重試。
?
Redis管道技術Pipeline
Redis是一種基于客戶端-服務端模型以及請求/響應協議的TCP服務。這意味著通常情況下一個請求會遵循以下步驟:
1、客戶端每發送一個查詢請求,并監聽Socket返回,通常是以阻塞模式,等待服務端響應
2、服務端處理命令,并將結果返回給客戶端
Redis管道技術可以在服務端未響應時,客戶端可以繼續向服務端發送請求,并最終一次性讀取所有的服務端響應。
管道技術顯著的提高了Redis的性能,尤其是在大量寫操作的情況下。
?
RESP
REdis?Serialization?Protocol:Redis序列化協議。Redis服務端與客戶端通過RESP協議進行通信,節點交互不使用這個協議。
有如下特性:是二進制安全的、在TCP層、基于請求-響應的模式
RESP有五種最小的單元類型,單元結束時統一加上回車換行符【\r\n】
1、單行字符串:以+符號開頭,如:字符串hello world-->+hello world\r\n
2、多行字符串:以$符號開頭,后跟字符串長度,如:多行字符串hello world-->$11\r\nhello world\r\n
3、整數值:以:符號開頭,后跟整數的字符串形式,如:整數12-->:12\r\n
4、錯誤信息:以-符號開頭,如:-WRONGTYPE\r\n
5、數組:以*號開頭,后跟數組的長度,如:數組[1,2,3]-->*3\r\n:1\r\n:2\r\n:3\r\n
客戶端向服務端發送的指令只有一種格式,多行字符串數組。
比如一個簡單的 set 指令set author codehole會被序列化成下面的字符串。*3\r\n$3\r\nset\r\n$6\r\nauthor\r\n$8\r\ncodehole\r\n
服務端對客戶端的響應支持多種數據結構,即以上5種的基本類型的組合。
?
發布訂閱 PubSub
前面所講的Redis的消息隊列,一個消息只能被一個消費者消費,不支持消息的多播機制。
消息多播允許生產者只生產一次消息,由中間件負責將消息復制到多個消息隊列,每個消息隊列由相應的消費組進行消費,是分布式
系統常用的一種解耦方式,用于將多個消費組的邏輯進行拆分。支持了消息多播,每個消費組對應的不同子系統可以有不同的邏輯處理。
在生產環境中,一般將生產者和消費者分離
消費者可通過listen來阻塞監聽消息來進行處理。
模式訂閱:消費者可以同時訂閱多個主題的消息,但是如果生產者新增了一個主題,消費者也必須增加一個訂閱指令才能收到新增的
主題的消息。為了簡化這種訂閱的繁瑣,Redis提供了模式訂閱功能Pattern Subscribe,這樣就可以一次訂閱多個主題,即使生產者新增
加了同模式的主題,消費者也可以立即收到消息。
如:psubscribe code.*? ?
那么所有以"code.?" 開頭的主題的消息都能訂閱到。
消息結構:
1、data:消息的內容,一般一個字符串
2、channel:當前訂閱的主題的名稱
3、type:消息的類I型那個,如果是普通的消息,那么類型就是message;如果是控制消息,比如訂閱指令的反饋,它的類型就是
subscibe;如果是模式訂閱的反饋,它的類I型那個就是psubscribe;此外還有取消訂閱指令的反饋unsubscribe和punsubscribe。
4、pattern:表示當前消息是使用哪種模式訂閱到的。如果是通過subscribe指令訂閱到的,這個字段就是None
缺點:
1、消費者掛掉重連后,在斷連期間生產者發送的消息,就丟失了
2、如果Redis停機重啟,PubSub的消息不會持久化,所有的消息都會丟失
?
小對象壓縮存儲ziplist
Redis的所有數據都放在內存中,所以在使用過程中要注意節約內存,否則就可能出現Redis內存不足導致崩潰。如果Redis內部管理
的集合list數據結構的數據很小,則它會使用緊湊存儲形式壓縮存儲。
Redis的ziplist是一個緊湊的字節數組結構,每個元素之間都是緊挨著的
?
內存回收機制
Redis并不總是將空閑內存立即歸還給操作系統。
如果當前Redis內存有10GB,當你刪除了1GB的key后,再去觀察內存,你會發現內存變化不會太大。這是以為內操作系統是以頁為
單位回收內存的,這個頁上只要還有一個key在使用,那么這個頁就不能被回收。Redis雖然刪除了1GB的key,但是這些key分散到了很
多的頁中,每個頁都還有其他的key存在,這就導致了內存不會被立即回收。
不過,如果你執行flushdb,然后再觀察內存,會發現內存確實被回收了。原因是所有的key都被刪掉了,大部分之前使用的頁都完全
空了,就會立即被操作系統回收。
Redis雖然無法保證立即回收已經刪除的key的內存,但是它會重新使用那些尚未回收的空閑內存。
?
內存分配算法:
內存分配是一個非常復雜的課題,需要適當的算法劃分內存頁,需要考慮內存碎片,需要平衡性能和效率,Redis將內存分配的細節交給
了第三方內存分配庫去實現。默認的內存分配庫是jemalloc
?
集群
主從同步:當主節點master掛掉的時候,從節點slave接管服務,使服務可以繼續。否則主節點需要經過數據恢復和重啟,使服務中斷
很長時間。
分布式系統存儲的理論基石——CAP原理:
C:Consistent,一致性
A:Availability,可用性
P:Partition tolerance,分區容忍性
分布式系統的節點往往都是分布在不同的機器上進行網絡隔開的,這意味著必然有網絡斷開的風險,這個網絡斷開的場景的專業詞匯叫做
網絡分區。
在網絡分區發生時,兩個分布式節點之間無法進行通信,我們對一個節點的修改操作無法同步到另一個節點,所以數據的一致性將無法滿
足,因為兩個分布式節點的數據不再保持一致,除非犧牲可用性,也就是暫停分布式節點服務,在網絡分區發生時,不再提供修改數據的
功能,直到網絡狀況完全恢復正常再繼續對外提供服務。即當網絡分區發生時,一致性和可用性不可兼得(Redis滿足AP)。
最終一致
Redis的主從數據是異步同步的,所以分布式的Redis系統并不滿足一致性要求。當客戶端在Redis的主節點修改了數據后,立即返回
,即使在主從網絡斷開的情況下,主節點依舊可以正常對外提供修改服務,所以Redis滿足可用性。
Redis保證最終一致性,從節點會努力追趕主節點,最終從結點的狀態會和主節點的狀態保持一致。如果網絡斷開了,主從節點的數據會
出現大量不一致,但一旦網絡恢復,從節點會采用多種策略努力追趕,繼續盡力保持和主節點一致。
增量同步:
Redis同步的是指令流,主節點會將那些對自己的狀態產生修改性影響的指令記錄在本地的內存buffer中,然后異步將buffer中的指令
同步到從節點,從節點一邊執行同步的指令流來達到和主節點一樣的狀態,一邊向主節點反饋自己同步到哪里了(偏移量)。
因為內存的buffer是有限的,所以Redis主節點不能將所有的指令都記錄在內存buffer中,Redis的復制內存buffer是一個定長的環形數
組,如果數組內容滿了,就會從頭開始覆蓋前面的內容,如果因為網絡狀況不好,從節點在短時間內無法和主節點進行同步,那么當
網絡恢復時,Redis的主節點中那些沒有同步的指令在buffer中有可能被后續的指令覆蓋掉了,從節點將無法直接通過指令流來進行同
步,這時就需要用到更加復雜的同步機制——快照同步。
快照同步:
快照同步是一個十分消耗資源的操作,它首先需要在主節點上進行一次bgsave,將當前內存的數據全部快照到磁盤文件中,然后再將
快照文件的內容全部傳送到從節點。從節點將快照文件接收完畢后,立即執行一次全量加載。加載之前要先將當前內存的數據清空,加載
完畢后繼續通知主節點繼續進行增量同步。
在快照同步的過程中,主節點的buffer還在不停的往前移動,如果快照同步的時間過長或者復制buffer太小,都會導致同步期間的增量指令
在復制buffer中被覆蓋,這樣就會導致快照同步完成后無法進行增量復制,然后再次發起快照同步,如此極有可能會陷入快照同步的死循環。
所以buffer大小參數一定要設置合適,避免快照復制的死循環。
?
哨兵Sentinel
如果主節點突發宕機那么如何自動主從切換?Redis Sentinel哨兵就是一種抵抗結點故障的高可用方案。
可以將Redis Sentinel集群看成是一個zookeeper集群,它是集群高可用的核心。一般由3-5個節點組成,這樣即使個別節點掛了,集群還
可以正常運轉。
Sentinel負責持續監控主從節點的健康,當主節點掛掉時,自動選擇一個最優的從節點切換成主節點。
客戶端來連接集群時會首先連接Sentinel,通過Sentinel來查詢主節點的地址,然后再連接主節點進行數據交互。主節點發生故障時,客戶
端會重新向Sentinel獲取新的主節點的地址,如此應用程序將無需重啟即可自動完成節點切換。
如果主節點掛掉了,原先的主從復制也斷開了,客戶端和損壞的主節點也斷開了。一個從節點被提升為新的主節點,其他從節點開始和新
的主節點建立復制關系。客戶端通過新的主節點繼續進行交互。Sentinel會持續監控已經掛掉了的前主節點,待它恢復后,變成從節點和
新的主節點建立復制關系。
?
消息丟失:
Redis主從采用異步復制,意味著當主節點掛掉時,從節點可能還未收到全部的同步消息,這部分未同步的消息就丟失了。如果主從延
遲特別大,那么丟失的數據就可能會特別多。Sentinel無法保證消息完全不丟失,但是也盡量保證消息少丟失。有下面兩個選項避免主從延
遲過大:
min-slaves-to-write 1 表示主節點必須至少有一個從節點在進行正常復制,否則就對外停止寫服務,喪失可用性
min-slaves-max-lag 10 單位秒,表示如果在10s內沒有收到從節點的反饋,就意味著從節點同步不正常。
Sentinel的默認端口是26379,不同于Redis的默認端口6379,通過Sentinel對象的discover_xxx方法可以發現主從地址,主地址只有一個,
從地址可以有多個。通過master_for 或者 slave_for方法可以從連接池中獲取主節點或者從節點的連接實例。因為從地址有多個,所以Redis
客戶端對從地址采用RoundRobin輪詢方案。
?
集群Codis:
在大數據高并發情況下,單個Redis實例往往會顯得捉襟見肘。
首先體現在內存上,單個Redis的內存不宜過大,內存太大會導致rdb文件過大,進一步導致主從同步時全量同步時間過長,在實例重啟恢復
時也會消耗很長的數據加載時間。
其次體現在CPU的利用率上,單個Redis實例只能利用單個核心,這單個核心要完成海量數據的存取和管理工作,壓力非常大。
所以Redis集群應運而生。它可以將眾多小內存的Redis實例整合起來,將分布在多臺機器上的眾多CPU核心的計算能力聚集在一起,完成海
量數據存儲和高并發讀寫操作。
Codis是一個代理中間件,和Redis一樣也使用Redis協議對外提供服務,當客戶端向Codis發送指令時,Codis負責將指令轉發到后面的Redis
實例來執行,并將返回結果再轉回給客戶端。
Codis上掛載的所有Redis實例構成一個Redis集群,當集群空間不足時,可以通過動態增加Redis實例來實現擴容需求。
因為Codis是無狀態的,它只是一個轉發代理中間件,這意味著我們可以啟動多個Codis實例,供客戶端使用,每個Codis節點都是對等的。因
為單個Codis代理能支撐的QPS比較有限,通過啟動多個Codis代理可以顯著增加整體的QPS需求,還能起到容災功能,掛掉一個Codis代理實
例沒有關系,還有很多的Codis代理實例可以提供服務。
Codis分片原理:
Codis負責將特定的key轉發到特定的Redis實例,這種對應關系Codis是如何管理的呢?Codis默認將所有的key劃分為1024個槽位(slot),
如果集群節點比較多,也可以手動設置大一些,如2048;
它首先對客戶端傳來的key進行crc32運算計算hash值,再將hash后的整數值對1024這個整數進行取模得到一個余數,這個余數就是對應
的key的槽位。而每個槽位都會唯一映射到后面的多個Redis實例中的一個。Codis會在內存中維護槽位和Redis實例的映射關系,這樣有了
key對應的槽位,將這個key轉發到那個Redis實例就很明確了。
?不同的Codis實例之間槽位關系如何同步:
如果Codis的槽位映射關系只存儲在內存里,那么不同的Codis實例之間的映射關系就無法得到同步。所以Codis還需要一個分布式配置存儲
數據庫專門用來持久化槽位關系,Codis支持zookeeper和etcd。
Codis將槽位關系存儲在zookeeper中,并且提供了一個Dashboard可以用來觀察和修改槽位關系,當槽位關系變化時,Codis?Proxy會監聽
到變化并重新同步槽位關系,從而實現多個Codis?Proxy之間共享槽位關系配置。
?
集群Cluster:
Redis?Cluster?是Redis的作者自己提供的Redis集群化方案。與Codis不同,Redis?Cluster是去中心化的,該集群由三個Redis節點組成,每個
節點負責整個集群的一部分數據,每個節點負責的數據多少可能不一樣,這三個節點組成一個對等的集群,它們之間通過一種特殊的二進制協議交
互集群信息。
Redis?Cluster將所有數據劃分為16384個槽位,它比Codis的1024個槽位劃分的更為精細,每個節點負責其中一部分槽位。槽位的信息存儲于每個
節點中,不像Codis,不需要另外的分布式存儲空間來存儲節點信息。
當Redis?Cluster的客戶端來連接集群時,也會得到一份集群的槽位配置信息。這樣當客戶端要查找某個key時,可以直接定位到目標節點。這一點
于Codis也不同,Coids需要通過Proxy來定位目標節點,Redis?Cluster則是直接定位。
客戶端為了直接定位某個具體的key所在的節點,需要緩存槽位的相關信息,這樣才可以準確快速地定位到相應的節點。同時因為可能會存在客戶端
與服務端存儲槽位的信息不一致的情況,還需要糾正機制來實現槽位信息的校驗調整。另外,Redis?Cluster的每個節點會將集群的配置信息持久化到
配置文件中,所以必須確保配置文件是可寫的,而且盡量不要依靠人工修改配置文件。
槽位定位算法:
Redis?Cluster默認會對key值使用crc16算法進行hash,得到?一個整數值,然后用這個整數值對16384進行取模來得到槽位。
容錯:Redis?Cluster可以為每個主節點設置若干個從節點,當主節點發生故障時,集群會自動將其中某個從節點提升為主節點。如果某個主節點沒有
從節點,那么當它發生故障時,集群將完全處于不可用狀態。
?
Info指令:
在使用Redis時,時長會遇到很多問題需要診斷,在診斷之前需要了解Redis的運行狀態,通過強大的Info指令,可以清晰地知道Redis內部一系列運行
參數。Info指令顯示的信息繁多,分為9大塊,每個塊都有非常多的參數
1、Server 服務器運行的環境參數
2、Clients 客戶端相關信息
3、Memory 服務器運行內存統計數據
4、Persistence 持久化信息
5、Stats 通用統計數據
6、Replication 主從復制相關信息
7、CPU CPU使用情況
8、Cluster? 集群信息
9、KeySpace 鍵值對統計數量信息
Info stats|grep ops 每秒操作數
moniter 哪些key被訪問得比較頻繁
Info?clients 連接了多少客戶端
Info?memory Redis占用了多少內存
?
分布式鎖之Redlock算法
在Sentinel集群中,當主節點掛掉時,從節點會取而代之,但客戶端上并沒有明顯感知。比如第一個客戶端在主節點上申請成功了一把鎖,但是
這把鎖還沒有來得及同步到從節點,主節點突然掛掉了,然后從節點變成了主節點,這個新的主節點內部沒有這個鎖,所以當另一個客戶端過來請
求加鎖時,立即就批準了。這樣導致系統中同樣一把鎖被兩個客戶端同時持有,不安全性由此產生。
這種不安全僅在主從發生failover(失效接管)的情況下才會產生,持續的時間極短,業務系統多數情況下可以容忍。
Redlock的出現就是為了解決這個問題。要使用Redlock,需要提供多個Redis實例,這些實例之前相互獨立,沒有主從關系。同很多分布式算法
一樣,Redlock也使用? “大多數機制“;
加鎖時,它會向過半節點發送? set(key,value,nx=True,ex=xxx)指令,只要過半節點set成功,就認為加鎖成功。釋放鎖時,需要向所有節點發
送del指令。不過Redlock算法還需要考慮出錯重試、時鐘漂移(時鐘抖動頻率在10hz一下)等很多細節問題。同時因為Redlock需要向多個節點進行
讀寫,意味著其相比單實例Redis的性能會下降一些
Redlock使用場景:非常看重高可用性,即使Redis掛了一臺也完全不受影響就使用Redlock。代價是需要更多的Redis實例,性能也會下降,需
要引入額外的library,運維上也需要區別對待。
?
分布式鎖之過期時間到了鎖失效但任務還未執行完畢
? 某個線程在申請分布式鎖的時候,為了應對極端情況,比如機器宕機,那么這個鎖就一直不能被釋放。一個比較好的解決方案是,申請鎖的時候
,預估一個程序的執行時間,然后給鎖設置一個超時時間,這樣,即使機器宕機,鎖也能自動釋放。
但是這也帶來了一個問題,就是在有時候負載很高,任務執行的很慢,鎖超時自動釋放了任務還未執行完畢,這時候其他線程獲得了鎖,導致程序
執行的并發問題。對這種情況的解決方案是:在獲得鎖之后,就開啟一個守護線程,定時去查詢Redis分布式鎖的到期時間,如果發現將要過期了,就
進行續期。
?
朝生暮死-過期策略
設置了有效期的key到期了怎么刪除呢?
Redis會將每個設置了過期時間的key放入一個獨立的字典中,以后會定時遍歷這個字典來刪除到期的key。除了定時遍歷之外還會使用惰性刪除
過期的key。所謂惰性刪除就是在客戶端訪問這個key的時候,Redis對key的過期時間進行檢查,如果過期了就會立即刪除。所以過期key的刪除策略
是 定時刪除+惰性刪除
定時刪除:Redis默認每秒進行10次過期掃描,過期掃描不會遍歷過期字典中所有的key,而是采用了一種簡單的貪心策略,步驟如下:
1、從過期字典中隨機選出20個key
2、刪除這20個key中已經過期的key
3、如果過期的key的比例超過1/4,那就重復步驟1
同時,為了保證過期掃描不會出現循環過度,導致線程卡死的現象,算法還增加了掃描時間的上限,默認不會超過25ms。
假設一個大型的Redis實例中所有的key在同一時間過期了,會出現怎么樣的結果呢?毫無疑問,Redis會持續循環多次掃描過期字典,直到過期
字典中過期的key變得稀疏,才會停止(循環次數明顯下降)。這就會導致線上讀寫請求出現明顯的卡頓現象。導致這種卡頓的另外一種原因是內存
管理器需要頻繁回收內存頁,這也會產生一定的CPU消耗。
如當客戶端到來時,服務器正好進入過期掃描狀態,客戶端的請求將會等待至少25ms后才會進行處理,如果客戶端將超時時間設置的比較短,如10
ms,那么就會出現大量的請求因為超時而關閉。業務端會出現很多異常,而且這是你還無法從Redis的slowlog中看到慢查詢記錄,因為慢查詢指的是
邏輯處理過程慢,而不包含等待時間。所以當客戶端出現大量超時而慢查詢日志無記錄時,可能是當前時間段大量的key過期導致的。
所以在開發過程中一定要避免在同一時間內出現大量的key同時過期。盡量給key的過期時間設置一個隨機范圍,使其過期時間均勻分布。
? 從節點不會進行過期掃描,過期的處理是被動的,主節點在key到期時,會在AOF日志文件中增加一條del指令,同步到所有的從節點,從節點通過執行
這條del指令來刪除過期的key。因為指令同步是異步的,所以會出現從節點的key刪除不及時的情況。
?
惰性刪除:
實際上Redis內部并不是只有一個主線程,它還有幾個異步線程來處理一些耗時的操作。如果被刪除的key是一個非常大的對象,那么del指令刪除操作就
會導致單線程卡頓。所以4.0版本引入了unlink指令,可以對刪除操作進行懶處理,丟給后臺線程來異步回收內存。
在獲取某個key的時候,Redis會檢查一下這個key是否設置了過期時間以及這個是否到期了,如果到期了就交給后臺線程去刪除這個key,然后主線程什
么也不會返回。
優勝劣汰-LRU內存淘汰機制
當Redis內存超過物理內存限制時,內存的數據會開始和磁盤產生頻繁的交換swap,交換會讓Redis的性能急劇下降,對于訪問量比較大的Redis來說,會
導致響應時間過長。所以在生產環境中不允許有這種交換行為,為了限制最大內存,Redis提供了配置參數maxmemory參數來限制內存使用閥值,當超出這個
閥值時,Redis提供了幾種可選的內存淘汰策略供用戶選擇以騰出空間以繼續提供讀寫服務。
1、noeviction 不會繼續服務寫請求,del和讀服務可以繼續進行,這是默認的淘汰策略
2、volatile-lru 嘗試淘汰設置了過期時間的最近最少使用的key
3、volatile-ttl 嘗試淘汰了設置了過期時間的ttl(Time to live)最少的key
4、volatile-random 嘗試從設置了過期時間的key中隨機淘汰一部分key
5、allkeys-lru 嘗試淘汰所有的key中最近最少使用的key
6、allkeys-random 嘗試從所有的key中隨機淘汰一部分key
LRU算法
實現LRU算法除了需要key/value字典外,還需要附加一個鏈表,鏈表中的元素按照一定的順序進行排列。當字典中的某個元素被訪問時,會將它從在鏈
表中的某個位置移動到鏈表頭部;當空間滿的時候,會踢掉鏈表尾部的元素。所以鏈表元素的排列順序就是元素最近被訪問的順序。
Redis使用的是近似的LRU算法,因為LRU算法需要占用大量的額外內存,還需要對現有的數據結構進行比較大的改造。近似LRU算法很簡單,在現有的數據
結構的基礎上使用隨機采樣法淘汰元素,通過給每個key增加一個額外的24bit的小字段存儲最后一次被訪問的時間戳。而且采用的是惰性策略,Redis在執行
寫操作時,發現內存超過maxmemory,就會執行一次近似LRU算法,隨機采樣出5(可以設置)個key,然后淘汰掉最舊的key,如果淘汰后內存仍大于
maxmemory,繼續采樣淘汰,知道內存小于maxmemory為止。
手寫一個LRU算法,有三種方案
1、數組,用數組來存儲數據,并給每個數據項標記一個時間戳,每次插入新數據項的時候,先把數組中存在的數據項對應的時間戳自增,并將新
數據項的時間戳置為0并插入到數組中。每次訪問數組中新數據項的時候,將被訪問的數據項的時間戳置為0。當數組空間滿時,將時間戳最大的數
據項淘汰。
2、鏈表,每次插入新數據的時候將新數據插入到鏈表的頭部,每次訪問數據也將被訪問的數據移動到鏈表頭部,當鏈表滿時將鏈表尾部的數據淘汰
3、鏈表+hashMap,LinkedHashMap。當需要插入新的數據項的時候,如果新數據項在鏈表中存在(即命中),則把該節點移動到鏈表頭部,如
果不存在,則新建一個節點,放到鏈表頭部,若緩存滿了,則把鏈表最后一個節點刪除即可。在訪問數據的時候,如果數據項在鏈表中存在,則把
該節點移到鏈表頭部,否則返回-1,這樣鏈表尾部的節點就是最近最少訪問的數據項。
分析:使用數組需要不停維護數據項的訪問時間戳,并且在插入數據,訪問和刪除數據(不知道數組下標)的時候,時間復雜度都是O(n),僅使用鏈表的情況下,
在訪問定位數據的時間復雜度為O(n),所以一般使用LinkedHashMap的方式。LinkedHashMap的底層就是使用HashMap加雙向鏈表實現的,而且本身是有序的(
插入和訪問順序相同),新插入的元素放入鏈表的尾部;且其有removeEldestEntry方法用于移除最老的元素,不過默認返回false,表示不移除,需要重寫此方法當超過map容量時移除最老的元素即可。
LinkedHashMap:
/*** Returns <tt>true</tt> if this map should remove its eldest entry.* This method is invoked by <tt>put</tt> and <tt>putAll</tt> after* inserting a new entry into the map. It provides the implementor* with the opportunity to remove the eldest entry each time a new one* is added. This is useful if the map represents a cache: it allows* the map to reduce memory consumption by deleting stale entries.** <p>Sample use: this override will allow the map to grow up to 100* entries and then delete the eldest entry each time a new entry is* added, maintaining a steady state of 100 entries.* <pre>* private static final int MAX_ENTRIES = 100;** protected boolean removeEldestEntry(Map.Entry eldest) {* return size() > MAX_ENTRIES;* }* </pre>** <p>This method typically does not modify the map in any way,* instead allowing the map to modify itself as directed by its* return value. It <i>is</i> permitted for this method to modify* the map directly, but if it does so, it <i>must</i> return* <tt>false</tt> (indicating that the map should not attempt any* further modification). The effects of returning <tt>true</tt>* after modifying the map from within this method are unspecified.** <p>This implementation merely returns <tt>false</tt> (so that this* map acts like a normal map - the eldest element is never removed).** @param eldest The least recently inserted entry in the map, or if* this is an access-ordered map, the least recently accessed* entry. This is the entry that will be removed it this* method returns <tt>true</tt>. If the map was empty prior* to the <tt>put</tt> or <tt>putAll</tt> invocation resulting* in this invocation, this will be the entry that was just* inserted; in other words, if the map contains a single* entry, the eldest entry is also the newest.* @return <tt>true</tt> if the eldest entry should be removed* from the map; <tt>false</tt> if it should be retained.*/protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {return false;}
構造方法:
/*** Constructs an empty <tt>LinkedHashMap</tt> instance with the* specified initial capacity, load factor and ordering mode.** @param initialCapacity the initial capacity* @param loadFactor the load factor* @param accessOrder the ordering mode - <tt>true</tt> for* access-order, <tt>false</tt> for insertion-order* @throws IllegalArgumentException if the initial capacity is negative* or the load factor is nonpositive*/public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder) {super(initialCapacity, loadFactor);this.accessOrder = accessOrder;}
自定義LRU實現:
package lru;import java.util.LinkedHashMap; import java.util.Map;/*** LinkedHashMap 實現LRU算法* 〈功能詳細描述〉** @author 17090889* @see [相關類/方法](可選)* @since [產品/模塊版本] (可選)*/ public class LRU<k, v> extends LinkedHashMap<k, v> {/*** 容量,實際能存儲多少數據*/private final int MAX_ENTRIES;/*** Math.ceil(cacheSize/0.75f)+1 HashMap的initialCapacity* 0.75f 負載因子* accessOrder 排序模式,true:按照訪問順序進行排序,最近訪問的放在尾部 false:按照插入順序** @param maxEntries*/public LRU(int maxEntries) {super((int) (Math.ceil(maxEntries / 0.75f) + 1), 0.75f, true);MAX_ENTRIES = maxEntries;}/*** 重寫移除最老元素方法* 返回true,表示刪除最老元素 false 表示不刪除** @param eldest* @return*/@Overrideprotected boolean removeEldestEntry(Map.Entry<k, v> eldest) {// 當實際容量大于指定的容量的時候就自動刪除最老的元素,鏈表頭部的元素return size() > MAX_ENTRIES;} }
?插入測試:
LRU<String, String> lru = new LRU<>(5);lru.put("3", "3");lru.put("1", "1");lru.put("5", "5");lru.put("2", "22");lru.put("3", "33");lru.get("5");
遍歷得到的結果:1 2 3 5
存儲鏈表結構為:
LFU算法
Redis 4.0 引入了一個新的淘汰策略,LFU(Lasted Fequently Used)?:最少頻繁使用。按照最近的訪問頻率進行淘汰,比LRU更加精確地表示了一個key被訪問的熱度。
如果一個key長時間不被訪問,只是偶爾被訪問了一下,那么它在LRU算法中就被移動到了鏈表的頭部,是不容易被淘汰的,因為LRU算法會認為它是一個熱點key。
而LFU需要追蹤最近一段時間內key的訪問頻率,只有最近一段時間內被訪問多次的key,LFU才認為是熱點key。
?
緩存穿透
指查詢一個數據庫中一定不存在的數據,如根據商品編號查詢詳情;首先去查詢緩存,緩存中自然沒有然后去查詢數據庫,如果對這個key的請求
量巨大,會直接穿透緩存直接查詢數據庫給數據庫造成很大的壓力
解決方案:
1、對查詢結果為空的情況也進行緩存,不過緩存時間設置短一些,如60s。如果對該key插入了數據到db之后要清理緩存
2、使用布隆過濾器,將所有可能存在數據的key放在布隆過濾器中,查詢緩存中沒有數據之后,再使用布隆過濾器進行過濾請求,判斷查詢的key
是否在布隆過濾器中,如果不在,則直接返回不再查詢數據庫
緩存擊穿
某個key是熱點數據,扛著高并發請求集中對這個key進行訪問,避免了訪問數據庫。那么在這個key失效的瞬間,持續的高并發就擊穿了緩存直接
請求數據庫,對數據庫造成很大壓力。
解決方案:
1、熱點key緩存永遠不過期
不設置過期時間
將過期時間存在key的value中,程序判斷將要過期時,異步線程對改key進行更新
2、互斥鎖,mutex lock 。當緩存失效的時候,線程不是立即去查詢數據庫,而是通過設置鎖的方式占坑,如Redis的SETNX,判斷返回值,誰拿
到了鎖誰去查詢數據庫然后設置緩存,沒拿到鎖的線程重試get方法。
緩存雪崩
在某個時間段,緩存中大量的key集中過期失效
解決方案:
1、使用互斥鎖
2、數據預熱
通過緩存reload機制,預先去設置或更新緩存,在即將大并發訪問前手動在后臺觸發加載緩存不同的key,然后設置不同的過期時間,讓緩存
過期失效的時間點盡量均勻
3、二級緩存
兩個緩存,a1為原始緩存,a2為備份緩存;a1短期,a2長期;a1過期了再去查詢a2
4、緩存永遠不過期