Redis複習
筆者是名大三的菜雞,現正在複習,準備明年的春招,歡迎互關呀,如果文章有錯誤,歡迎及時指出呀
一,Redis底層數據結構
1.String類型
底層數據結構
String在redis中的底層數據結構是SDS(簡單動態字符串),這是一種和傳統c字符串( 一個字符數組並且是一個以空字符結尾的字符數組 )不同的數據結構
不同在哪?
先看SDS的底層結構體:
// sds 類型 聲明類型別名,可以這麼理解
// typedef char *String
typedef char *sds;
// sdshdr 結構
struct sdshdr {
// buf 已佔用長度
int len;
// buf 剩餘可用長度
int free;
// 實際保存字符串數據的地方
char buf[];
};
SDS底層也是字符數組組成,那麼和傳統c字符串的區別在哪呢?
-
對於傳統的c字符串,我們如果想要獲取該字符串的長度。我們需要遍歷一遍字符數組纔行,複雜度是O(N),而在redis中,底層定義了len字段,想要獲取字符串長度,只需要sds->len即可,複雜度優化爲O(1)
-
傳統c字符串是一個已經分配好內存大小的字符數組,如果遇到不夠內存的情況下,需要手動重新分配內存;而redis中的SDS則類似於java中的ArrayList,在數組容量不足時,可以動態擴容。
-
對於傳統c字符串,他通過判斷當前字符串是否是空字符來判斷是否是字符串結尾,這就要求你的字符串中間不能包含一個空字符,否則會影響判斷,導致後邊的字符無法讀取, 而redis sds 不是通過空字符判斷字符串結尾,而是通過 len 字段的值判斷字符串的結尾,所以說,sds 還具備二進制安全這個特性,即它可以安全的存儲具備特殊格式要求的二進制數據。
2.list類型
list在redis中的實現是雙向鏈表
底層結構體:
/*
* 鏈表
*/
typedef struct list {
// 表頭指針
listNode *head;
// 表尾指針
listNode *tail;
// 節點數量
unsigned long len;
// 複製函數
void *(*dup)(void *ptr);
// 釋放函數
void (*free)(void *ptr);
// 比對函數
int (*match)(void *ptr, void *key);
} list;
/*
* 鏈表節點
*
* 【從鏈表節點這個結構體可以看出,redis的鏈表的數據類型底層是一個雙向鏈表的實現】
*/
typedef struct listNode {
// 前驅節點
struct listNode *prev;
// 後繼節點
struct listNode *next;
// 值
void *value;
} listNode;
redis中的底層鏈表的操作可以參考我的另一篇博客redis底層鏈表操作
3.hash字典類型
字典相對於數組,鏈表來說,是一種較高層次的數據結構,像我們的漢語字典一樣,可以通過拼音或偏旁唯一確定一個漢字,在程序裏我們管每一個映射關係叫做一個鍵值對,很多個鍵值對放在一起就構成了我們的字典結構。
有很多高級的字典結構實現,例如我們 Java 中的 HashMap 底層實現,根據鍵的 Hash 值均勻的將鍵值對分散到數組中,並在遇到哈希衝突時,衝突的鍵值對通過單向鏈表串聯,並在鏈表結構超過八個節點裂變成紅黑樹。
那麼 redis 中是怎麼實現的呢?我們一起來看一看。
redis底層hash字典定義
redis中的hash字典是使用拉鍊法的哈希表
/*
* 哈希表節點
*/
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 鏈往後繼節點(拉鍊法)
struct dictEntry *next;
} dictEntry;
其本質也是數組加上單鏈表的形式,數組用來存放key,當遇到哈希衝突不同key映射到數組同一位置時,採取拉鍊法放於數組同一位置來解決衝突,與hashmap很類似
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表節點指針數組(俗稱桶,bucket)
dictEntry **table;
// 指針數組的大小
unsigned long size;
// 指針數組的長度掩碼,用於計算索引值
unsigned long sizemask;
// 哈希表現有的節點數量
unsigned long used;
} dictht;
上面就是字典的底層實現----哈希表,其實就是兩張哈希表
/*
* 字典
*
* 每個字典使用兩個哈希表,用於實現漸進式 rehash
*/
typedef struct dict {
// 特定於類型的處理函數
dictType *type;
// 類型處理函數的私有數據
void *privdata;
// 哈希表(2個)
dictht ht[2];
// 記錄 rehash 進度的標誌,值爲-1 表示 rehash 未進行
int rehashidx;
// 當前正在運作的安全迭代器數量
int iterators;
} dict;
使用兩張哈希表作爲字典的實現是爲了後續字典的擴展rehash用的
- ht[0]:就是平時用來存放普通鍵值對的哈希表,經常使用字典中的這個
- ht[2]:是在進行字典擴張時rehash才啓用
字典中漸進式rehash
在redis中,哈希表的擴容或者縮容都需要進行rehash,即將ht[0]中的所有鍵值對rehash到ht[1]中,但是這個rehash的過程不是一次性完成的,而是分多次,漸進式完成的,爲了避免rehash對服務器性能造成的影響,服務器不是一次性將 ht[0] 裏面的所有鍵值對全部 rehash 到 ht[1] , 而是分多次、漸進式地將 ht[0] 裏面的鍵值對慢慢地 rehash 到 ht[1] 。
rehash過程:
- 爲 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表
- 在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 , 表示 rehash 工作正式開始。
- 在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操作時, 程序除了執行指定的操作以外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的所有鍵值對 rehash 到 ht[1] , 當 rehash 工作完成之後, 程序將 rehashidx 屬性的值增一。
- 隨着字典操作的不斷執行, 最終在某個時間點上, ht[0] 的所有鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設爲 -1 , 表示 rehash 操作已完成。
漸進式 rehash 的好處在於它採取分而治之的方式, 將 rehash 鍵值對所需的計算工作均灘到對字典的每個添加、刪除、查找和更新操作上, 從而避免了集中式 rehash 而帶來的龐大計算量。
4.跳躍鏈表
redis中的sort set是通過跳躍鏈表加上我們上邊說的字典實現的
- 字典保存數據和分數score的映射關係,每次插入數據都會從字典中查詢,如果已經存在了該key,則不再插入,防止重複
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
跳躍鏈表的插入,刪除,查找性能和平衡樹相當,實現比平衡樹要簡單。
typedef struct zskiplistNode {
// 後退指針
struct zskiplistNode *backward;
// 分值
double score;
// 成員對象
robj *obj;
// 層
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 跨度,跨過多少個節點
unsigned int span;
} level[];
} zskiplistNode;
跳錶底層:
typedef struct zskiplist {
// 表頭節點和表尾節點
struct zskiplistNode *header, *tail;
// 表中節點的數量
unsigned long length;
// 表中層數最大的節點的層數
int level;
} zskiplist;
爲什麼redis採用跳錶實現而不採用平衡樹或者紅黑樹呢?
- 算法實現難度上,跳錶實現難度小於平衡樹,平衡樹在插入和刪除節點後,需要重新調整平衡,而跳錶在插入和刪除後只需要修改相鄰節點的指針
- 查找性能上,對於單個節點的查找,跳錶和平衡樹都是O(logN),但是對於範圍查找,平衡樹需要進行多次單個節點的查找,而跳錶的第一層是一個單向或雙向鏈表,範圍查找效率要更高,類似B樹和B+樹的區別
redis中的跳錶和普通跳錶的區別
redis的跳錶和普通的跳錶實現沒有多大區別,主要區別在三處:
-
redis的跳錶引入了score,且score可以重複
-
排序不止根據分數,還可能根據成員對象(當分數相同時)
-
有一個前繼指針,因此在第1層,就形成了一個雙向鏈表,從而可以方便的從表尾向表頭遍歷
5.整數集合
redis對整數存儲專門做了優化,intset就是redis用來保存整數值的集合的數據結構,當一個集合中只包含整數時,redis就會用這個來存儲
typedef struct intset {
// 保存元素所使用的類型的長度
uint32_t encoding;
// 元素個數
uint32_t length;
// 保存元素的數組
int8_t contents[];
} intset;
編碼方式(encoding)字段是幹嘛用的呢?
- 如果 encoding 屬性的值爲 INTSET_ENC_INT16 , 那麼 contents 就是一個 int16_t 類型的數組, 數組裏的每個項都是一個 int16_t 類型的整數值 (最小值爲 -32,768 ,最大值爲 32,767 )。
- 如果 encoding 屬性的值爲 INTSET_ENC_INT32 , 那麼 contents 就是一個 int32_t 類型的數組, 數組裏的每個項都是一個 int32_t 類型的整數值 (最小值爲 -2,147,483,648 ,最大值爲 2,147,483,647 )。
- 如果 encoding 屬性的值爲 INTSET_ENC_INT64 , 那麼 contents 就是一個 int64_t 類型的數組, 數組裏的每個項都是一個 int64_t 類型的整數值 (最小值爲 -9,223,372,036,854,775,808 ,最大值爲 9,223,372,036,854,775,807 )。
說白了就是根據contents字段來判斷用哪個int類型更好,也就是對int存儲作了優化。
encoding升級
如果我們有個16位整數集合,現在要將一個32位整數加入這個集合中,那麼原來16位整數集合是存儲不下來的,所以就需要對整數集合進行升級
升級過程:
假如現在有2個int16的元素:1和2,新加入1個int32位的元素65535。
- 內存重分配,新加入後應該是3個元素,所以分配3*32-1=95位。
- 選擇最大的數65535, 放到(95-32+1, 95)位這個內存段中,然後2放到(95-32-32+1+1, 95-32)位…依次類推。
注意,整數集合支持升級,不支持降級,即32位整數集合無法降到16位整數集合
另外整數集合中的元素是有序的,所以其查找過程時採用二分查找法進行搜索
6.壓縮列表
ziplist是redis爲了節約內存而開發的順序型數據結構。它被用在列表鍵和哈希鍵中。一般用於小數據存儲
typedef struct entry {
/*前一個元素長度需要空間和前一個元素長度*/
unsigned int prevlengh;
/*元素內容編碼*/
unsigned char encoding;
/*元素實際內容*/
unsigned char *data;
}zlentry;
-
previous_entry_length:每個節點會使用一個或者五個字節來描述前一個節點佔用的總字節數,如果前一個節點佔用的總字節數小於 254,那麼就用一個字節存儲,反之如果前一個節點佔用的總字節數超過了 254,那麼一個字節就不夠存儲了,這裏會用五個字節存儲並將第一個字節的值存儲爲固定值 254 用於區分。
-
encoding: 壓縮列表可以存儲 16位、32位、64位的整數以及字符串,encoding 就是用來區分後面的 content 字段中存儲於的到底是哪種內容,分別佔多少字節
-
**content:**存儲的二進制內容
typedef struct ziplist{
/*ziplist分配的內存大小*/
uint32_t zlbytes;
/*達到尾部的偏移量*/
uint32_t zltail;
/*存儲元素實體個數*/
uint16_t zllen;
/*存儲內容實體元素*/
unsigned char* entry[];
/*尾部標識*/
unsigned char zlend;
}ziplist;
-
**ZIPLIST_BYTES:**四個字節,記錄了整個壓縮列表總共佔用了多少字節數
-
ZIPLIST_TAIL_OFFSET:四個字節,記錄了整個壓縮列表第一個節點到最後一個節點跨越了多少個字節,通故這個字段可以迅速定位到列表最後一個節點位置,相當於尾指針
-
**ZIPLIST_LENGTH:**兩個字節,記錄了整個壓縮列表中總共包含幾個 zlentry 節點
-
**zlentry:**非固定字節,記錄的是單個節點,這是一個複合結構,我們等下再說,entry序列
-
**0xFF:**一個字節,十進制的值爲 255,標誌壓縮列表的結尾
連鎖更新問題
假設原本 entry1 節點佔用字節數爲 211(小於 254),那麼 entry2 的 previous_entry_length 會使用一個字節存儲 211,現在我們新插入一個節點 NEWEntry,這個節點比較大,佔用了 512 個字節。
那麼,我們知道,NEWEntry 節點插入後,entry2 的 previous_entry_length 存儲不了 512,那麼 redis 就會重分配內存,增加 entry2 的內存分配,並分配給 previous_entry_length 五個字節存儲 NEWEntry 節點長度。
看似沒什麼問題,但是如果極端情況下,entry2 擴容四個字節後,導致自身佔用字節數超過 254,就會又觸發後一個節點的內存佔用空間擴大,非常極端情況下,會導致所有的節點都擴容,這就是連鎖更新,一次更新導致大量甚至全部節點都更新內存的分配。
如果連鎖更新發生的概率很高的話,壓縮列表無疑就會是一個低效的數據結構,但實際上連鎖更新發生的條件是非常苛刻的,其一是需要大量節點長度小於 254 連續串聯連接,其二是我們更新的節點位置恰好也導致後一個節點內存擴充更新。
基於這兩點,且少量的連鎖更新對性能是影響不大的,所以這裏的連鎖更新對壓縮列表的性能是沒有多大的影響的,可以忽略,但需要知曉。
二,redis事務
2.1redis中的事務
客戶端通過和redis服務器兩階段的交互做到了批量命令原子化執行的事務效果
-
入隊階段:通過MULTI命令開啓事務後,客戶端將請求發送到服務器端,後者將其暫存在連接對象(即發送請求的客戶端)對應的請求隊列中,入隊階段出現錯誤,則不執行後序的exec
-
執行階段:發送完一個批次的所有請求後,redis服務器依次執行連接對象隊列中的所有請求,由於單個實例的redis僅僅單個線程執行所有請求,所以在批量處理期間不會接受其他客戶端的請求
-
批量處理:EXEC命令可以讓批量的請求一次性全部執行,執行結果以一個數組形式發送給客戶端,但是如果執行期間有一條命令錯誤,事務並不會回滾或者整體事務失敗,而是會繼續執行下去
開啓事務:MULTI
執行事務:EXEC
取消事務:DISCARD
redis 127.0.0.1:6379> MULTI
OK
redis 127.0.0.1:6379> SET book-name "Mastering C++ in 21 days"
QUEUED
redis 127.0.0.1:6379> GET book-name
QUEUED
redis 127.0.0.1:6379> SADD tag "C++" "Programming" "Mastering Series"
QUEUED
redis 127.0.0.1:6379> SMEMBERS tag
QUEUED
redis 127.0.0.1:6379> EXEC
1) OK
2) "Mastering C++ in 21 days"
3) (integer) 3
4) 1) "Mastering Series"
2) "C++"
3) "Programming"
單個redis的命令是原子性的,但是redis沒有在事務上增加原子性,所以redis的事務不具備原子性,中間某條命令失敗了,並不會導致整個事務失敗,其他命令照常執行,所以redis沒有回滾機制使得redis的事務實現大大簡化
- 無需爲事務引入數據版本機制
- 無需爲每個操作引入逆向操作
這也說明,redis的事務並不一致
如何解決這個問題
redis通過watch機制來解決上述的一致性問題
*WATCH命令可以監控一個或多個鍵,一旦其中有一個鍵被其他客戶端修改(或刪除),之後的事務就不會執行。監控一直持續到EXEC命令(事務中的命令是在EXEC之後才執行的,所以在MULTI命令後可以修改WATCH監控的鍵值)*
這裏開啓兩個客戶端
客戶端1
set key1 value1
watch key1
multi
set key2 value2
exec
客戶端2在客戶端1執行到set key2 value2時執行
set key1 val
此時客戶端1的watch機制檢測到key1被修改過,所以EXEC直接失敗,拒絕執行
最後,無論exec是否執行,都會UNWATCH所有原來註冊的key
三,緩存雪崩,穿透,擊穿
通過一個查詢業務來分析下緩存雪崩,穿透和擊穿。
流程如下:
1.緩存雪崩
緩存雪崩一句話概括就是大量的key在同一時間失效,造成大多數請求直接落到數據庫,將數據庫打崩
場景
同一時間key大面積失效,那一瞬間Redis跟沒有一樣,那這個數量級別的請求直接打到數據庫
解決方法
-
在批量往redis存數據的時候,key的過期時間都加上一個隨機值,防止大量的key在同一時間過期
setRedis(Key,value,time + Math.random() * 10000);
-
或者將訪問頻繁的熱點數據設置永不過期,熱點數據有更新,則緩存數據庫一併更新就好,但是這樣操作就會變得複雜
2.緩存穿透
緩存穿透是指緩存和數據庫中都沒有的數據,而用戶不斷髮起請求這些緩存合數據庫中都沒有的數據,造成數據庫壓力增大 ,嚴重會擊垮數據庫。
場景
利用一些不存在key(數據庫也沒有該記錄的數據)發起查詢請求,那麼這些不存在的key就會直接跳過redis,直接請求數據庫,而數據庫也沒有相關記錄(比如主鍵id爲-1的,一般我們主鍵id是從1開始遞增的),當請求比較頻繁時,數據庫壓力也會隨着增大
解決方法
-
請求參數校驗
可以在controller利用自定義註解去攔截請求,然後對請求參數做一個基礎的校驗,降低風險
具體步驟:
- 編寫校驗註解
- 編寫校驗邏輯
- aop對controller的方法進行增加,在執行原方法前,利用反射掃描出有註解的方法參數,對這些參數執行校驗
實現如下:
這裏我做一個自定義的參數非空註解:
註解
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface ParamCheck { /** * 是否非空,默認不能爲空 */ boolean notNull() default true; }
AOP
@Aspect @Component public class AopValidation { /** * 切入點表達式 * 1.格式:方法修飾符 + 返回值類型 + 包名 + 類名 + 方法名 +方法參數 */ @Pointcut("execution(public * com.springboot.learning.controller.*.*(..))") public void validation(){ } /** * 註解解析增強 * @param joinPoint * @return */ @Around("validation()") public Object arround(ProceedingJoinPoint joinPoint) throws Throwable { //反射獲取攔截到方法上的註解 MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); //獲取攔截的方法 Method method = methodSignature.getMethod(); //獲取方法參數註解,返回二維數組是因爲某些參數可能存在多個註解 Annotation[][] parameterAnnotations = method.getParameterAnnotations(); //如果沒有註解則直接執行方法 if (parameterAnnotations == null || parameterAnnotations.length == 0) { return joinPoint.proceed(); } //獲取參數值 Object[] paranValues = joinPoint.getArgs(); for (int i = 0; i < parameterAnnotations.length; i++) { for (int j = 0; j < parameterAnnotations[i].length; j++) { System.out.println("當前遍歷到註解:------>"+parameterAnnotations[i][j].annotationType().getName()); //如果該參數前面的註解是ParamCheck的實例,並且notNull()=true,則進行非空校驗 if (parameterAnnotations[i][j] != null && parameterAnnotations[i][j] instanceof ParamCheck //註解中定義的規則 && ((ParamCheck) parameterAnnotations[i][j]).notNull()) { paramIsNull(paranValues[i]); break; } } } return joinPoint.proceed(); } /** * 參數非空校驗,如果參數爲空,則拋出ParamIsNullException異常 */ private void paramIsNull(Object value) { if (value == null || "".equals(value.toString().trim())) { //throw new ParamIsNullException(paramName, parameterType); System.out.println("參數非空校驗,如果參數爲空,則拋出ParamIsNullException異常"); } } }
controller
@RequestMapping("/test/mongodb") public String test(@RequestParam @ParamCheck String value){ System.out.println("test"); return "test"; }
測試
控制檯:
如果覺得Aop的方式有點麻煩,還可以使用springboot的自定義校驗功能,也是基於自定義註解實現
@Target({ElementType.PARAMETER,ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Documented //聲明使用哪個校驗器 @Constraint(validatedBy = {NotBlankValidator.class }) public @interface NotBlank { boolean required() default true;//是否需要傳 //下面三個必須要加 String message() default "不能爲空";//提示信息 //解決自定義註解異常 Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; }
校驗器:實現ConstraintValidator接口即可
public class NotBlankValidator implements ConstraintValidator<NotBlank,String> { private boolean require = false; /** * 初始化得到註解數據 * @param constraintAnnotation */ @Override public void initialize(NotBlank constraintAnnotation) { this.require = constraintAnnotation.required(); } /** * 校驗邏輯 * @param value * @param context * @return */ @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (require){ System.out.println("---->"+value); if(value.isEmpty() || value.length() == 0 ){ //context.getDefaultConstraintMessageTemplate()獲取註解內定義的message System.out.println("value 爲空"+context.getDefaultConstraintMessageTemplate()); //如果是空參數則拋出異常,可以使用全局異常處理器捕獲,然後返回錯誤信息json給前端 throw new RuntimeException(); }else{ return true; } }else{ System.out.println("----222>"+value); return true; } } }
實體類:
@Data public class Test { @NotBlank private String num; }
然後在controller加上@Valid開啓校驗
@RequestMapping("/test/mongodb") public String test(@RequestParam @ParamCheck String value, @ModelAttribute @Valid Test t){ System.out.println("test"); return "test"; }
-
布隆過濾器
布隆過濾器
對於我個人的理解,布隆過濾器其實是一種能在一定的空間下快速檢索一個元素是否存在於一個較大的元素集合中的一個數據結構。
原理
布隆過濾器其實本質上是一個只包含0和1的大數組(位圖),當一個key要被加入到這個大數組中時,該key會被k個哈希函數運算得到k個hash值,然後將這k個hash值映射到數組對應的位置,這些對應位置設爲1。
當查詢某個key在不在大數組時,我們就看對應的應設點是否全爲1,如果全爲1則有可能存在(哈希可能衝突),如果有一個位置是0,則一定不存在。
所以,布隆過濾器只能肯定的判斷某元素不在該集合中,而不能準確的判斷判斷某元素一定在該集合
注意,布隆過濾器不支持刪除,因爲刪除操作的話,可能會影響到其他的key(如果其他的key經過哈希函數後映射到需要刪除的位置)這樣就會影響其他元素的判斷。
應用
我之前用的是guava的布隆過濾器
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>19.0</version>
</dependency>
簡單實現:
/**
* 布隆過濾器測試類
*/
//預計要插入的數據量(給位數組分配的空間,空間越大,誤判率越小)
private static int EXPECT_SIZE = 1000000;
//誤判率
private static double FPP = 0.1;
private static BloomFilter<Integer> bloomFilter = BloomFilter
.create(Funnels.integerFunnel(),EXPECT_SIZE,FPP);
public static void main(String[] args) {
//插入數據
for (int i = 0; i < 1000000; i++) {
bloomFilter.put(i);
}
double count = 0;
//故意用1000000個不在集合中的數據,測試下誤判率和自定義的誤判率的差別
for (int i = 1000000; i < 2000000; i++) {
if (bloomFilter.mightContain(i)) {
count++;
System.out.println(i + "誤判了");
}
}
System.out.println("總共的誤判數:" + count);
System.out.println("誤判率
:"+count / 1000000);
}
實際處理流程:
因爲我沒有在實際項目中使用過,所以我能想到的只有這樣,怪我太菜了【捂臉】
這樣的話,其實是由缺點的,如果數據庫中有某個key刪除了,布隆過濾器因爲不支持刪除,所以別人也可以通過先刪除這個key,在不斷請求這個key,以跳過redis直接到mysql。
3.緩存擊穿
緩存擊穿就是指一個Key非常熱點,在不停的扛着大併發,大併發集中對這一個點進行訪問,當這個Key在失效的瞬間,持續的大併發就穿破緩存,直接請求數據庫,就像在一個完好無損的桶上鑿開了一個洞。
場景
有個很搶手的商品,請求該商品的頻率很高,一旦該商品的key失效,那麼所有的請求將會一下子湧到數據庫,數據庫壓力一下子增高承受不了而掛掉。道理就像你一直捶打水桶的某一個點,當他頂不住了,水桶就被擊穿了。
解決方法
- 熱點數據永不過期
- 互斥鎖,分佈式redis的話得靠lua腳本
四,數據一致性問題
只要使用緩存,就一定會涉及到數據庫和緩存的雙儲存和雙寫,就一定會有數據一致性問題
最經典的緩存和數據庫的讀寫模式就是:
- 讀的時候,先讀緩存,緩存沒有的話,再讀數據庫,然後取出數據放入緩存,同時返回響應
- 寫的時候,先更新數據庫,再刪除緩存
爲什麼是刪除緩存,而不是更新緩存?
在很多時候,複雜的場景下,緩存不單單是數據庫中直接取出來的值
比如,我更新了一個表的某個字段,但是對應的緩存是需要查詢其他兩個表的數據進行計算的出來的,那麼,如果我想更新緩存的話,我更新了這個表的字段後,我還需要去查另外兩個表,然後去計算新緩存的值,如果這個緩存被頻繁更新,那麼我就得頻繁的查表,計算,所以,這裏可以分爲兩種情況去考慮的:
-
如果更新緩存的代價很小,那麼可以先更新緩存,這個代價很小的意思是我不需要很複雜的計算去獲得最新的餘額數字。
-
如果是更新緩存的代價很大,意味着需要通過多個接口調用和數據查詢才能獲得最新的結果,那麼可以先淘汰緩存。淘汰緩存以後後續的請求如果在緩存中找不到,自然去數據庫中檢索
五,redis持久化
1.何爲持久化?
持久化就是將內存中的數據同步到磁盤,redis的數據是存儲在內存中的,如果redis一旦掛掉,不進行持久化同步到磁盤的話,redis中的數據就會丟失
2.redis中的持久化
2.1AOF
AOF對每條寫入命令作爲日誌,以append—only模式追加到日誌文件中,因爲這個模式是隻追加的方式,所以沒有任何磁盤尋址的開銷,所以很快,有點像mysql 的binlog
2.1.1AOF流程
-
追加寫入命令到緩衝區
redis將每一條寫通過redis通訊協議添加到緩衝區aof_buf中,等緩衝區滿了在根據策略一次性寫入磁盤,減少了磁盤IO
-
同步到磁盤
同步策略: 由配置參數appendfsync決定
-
no:不使用fsync方法同步,使用系統函數write去執行同步寫入到文件中, 在linux操作系統中大約每30秒刷一次緩衝。這種情況下,緩衝區數據同步不可控,並且在大量的寫操作下,aof_buf緩衝區會堆積會越來越嚴重,一旦redis出現故障,數據丟失嚴重。
-
always:表示每次有寫操作都調用fsync方法強制內核將數據寫入到aof文件。這種情況下由於每次寫命令都寫到了文件中, 雖然數據比較安全,但是因爲每次寫操作都會同步到AOF文件中,所以在性能上會有影響,同時由於頻繁的IO操作,硬盤的使用壽命會降低。
-
everysec:數據將使用調用操作系統write寫入文件,並使用fsync每秒一次從內核刷新到磁盤。 這是折中的方案,兼顧性能和數據安全,所以redis默認推薦使用該配置。
-
-
文件重寫
當開啓的AOF時,隨着時間推移,AOF文件會越來越大,當然redis也對AOF文件進行了優化,即觸發AOF文件重寫條件(後續會說明)時候,redis將使用bgrewriteaof對AOF文件進行重寫。這樣的好處在於減少AOF文件大小,同時有利於數據的恢復。
爲什麼重寫?比如先後執行了“set foo bar1 set foo bar2 set foo bar3” 此時AOF文件會記錄三條命令,這顯然不合理,因爲文件中應只保留“set foo bar3”這個最後設置的值,前面的set命令都是多餘的,下面是一些重寫時候策略:
- 重複或無效的命令不寫入文件
- 過期的數據不再寫入文件
- 多條命令合併寫入(當多個命令能合併一條命令時候會對其優化合並作爲一個命令寫入,例如“RPUSH list1 a RPUSH list1 b" 合併爲“RPUSH list1 a b” )
重寫流程:
2.1.2AOF配置文件
auto-aof-rewrite-min-size 64mb
#AOF文件最小重寫大小,只有當AOF文件大小大於該值時候纔可能重寫,4.0默認配置64mb。
auto-aof-rewrite-percentage 100
#當前AOF文件大小和最後一次重寫後的大小之間的比率等於或者等於指定的增長百分比,如100代表當前AOF文件是上次重寫的兩倍時候才重寫。
appendfsync everysec
#no:不使用fsync方法同步,而是交給操作系統write函數去執行同步操作,在linux操作系統中大約每30秒刷一次緩衝。這種情況下,緩衝區數據同步不可控,並且在大量的寫操作下,aof_buf緩衝區會堆積會越來越嚴重,一旦redis出現故障,數據
#always:表示每次有寫操作都調用fsync方法強制內核將數據寫入到aof文件。這種情況下由於每次寫命令都寫到了文件中, 雖然數據比較安全,但是因爲每次寫操作都會同步到AOF文件中,所以在性能上會有影響,同時由於頻繁的IO操作,硬盤的使用壽命會降低。
#everysec:數據將使用調用操作系統write寫入文件,並使用fsync每秒一次從內核刷新到磁盤。 這是折中的方案,兼顧性能和數據安全,所以redis默認推薦使用該配置。
aof-load-truncated yes
#當redis突然運行崩潰時,會出現aof文件被截斷的情況,Redis可以在發生這種情況時退出並加載錯誤,以下選項控制此行爲。
#如果aof-load-truncated設置爲yes,則加載截斷的AOF文件,Redis服務器啓動發出日誌以通知用戶該事件。
#如果該選項設置爲no,則服務將中止並顯示錯誤並停止啓動。當該選項設置爲no時,用戶需要在重啓之前使用“redis-check-aof”實用程序修復AOF文件在進行啓動。
appendonly no
#yes開啓AOF,no關閉AOF
appendfilename appendonly.aof
#指定AOF文件名,4.0無法通過config set 設置,只能通過修改配置文件設置。
dir /etc/redis
#RDB文件和AOF文件存放目錄
2.1.3優缺點
優點:
-
AOF在對日誌文件進行操作的時候是以
append-only
的方式去寫的,他只是追加的方式寫數據,自然就少了很多磁盤尋址的開銷了,寫入性能驚人,文件也不容易破損。 -
AOF的日誌是通過一個叫非常可讀的方式記錄的,這樣的特性就適合做災難性數據誤刪除的緊急恢復了,比如公司的實習生通過flushall清空了所有的數據,只要這個時候後臺重寫還沒發生,你馬上拷貝一份AOF日誌文件,把最後一條flushall命令刪了就完事了。
缺點:
- 一樣的數據,AOF文件比RDB還要大。
2.2RDB
RDB則是類似定時任務,對redis中的數據進行週期的持久化到磁盤
RDB持久化是通過快照的方式進行的,何爲快照,就是某個時間點對數據進行一次照相,數據就相當於保存在了這張照片中。
2.2.1快照觸發方式
-
自動觸發
- 根據配置文件save m n規則自動快照
- 客戶端在執行數據庫清空命令flushall時,觸發快照
- 客戶端在執行shutdown關閉redis時,觸發快照
-
手動觸發
- 客戶端執行命令bgsave和save時會生成快照(客戶端在執行save命令時,處於阻塞狀態,不接受其他命令,直到RDB完成,謹慎使用)
bgsave命令,可以理解爲後臺執行快照
bgsave執行過程:
- 客戶端執行bgsave命令,redis主進程收到指令後先判斷此時是否在進行AOF重寫,如果此時正在執行,則不fork子進程,直接返回;
- 主進程調用fork方法創建子進程,在fork子進程過程中redis阻塞,不響應客戶端請求
- 子進程創建完成後,bgsave返回 “Background saving started”,此時標誌着redis可以響應客戶端請求了
- 子進程執行內存數據快照,快照完成後替換原來快照文件
- 子進程發送信號給redis主進程完成快照操作,主進程更新統計信息(info Persistence可查看),子進程退出;
關於save m n
save m n :在指定的m秒內,redis中有n個鍵發生改變,則自動觸發bgsave , 該規則默認在redis.conf中進行了配置,並且可組合使用,滿足其中一個規則,則觸發bgsave , 以save 900 1爲例,表明當900秒內至少有一個鍵發生改變時候,redis觸發bgsave操作。
flushall命令
flushall命令用於清空數據庫,請慎用,當我們使用了則表明我們需要對數據進行清空,那redis當然需要對快照文件也進行清空
關於shutdown
redis在關閉前處於安全角度將所有數據全部保存下來,以便下次啓動會恢復
2.2.2RDB配置文件
save m n
#配置快照(rdb)促發規則,格式:save <seconds> <changes>
#save 900 1 900秒內至少有1個key被改變則做一次快照
#save 300 10 300秒內至少有300個key被改變則做一次快照
#save 60 10000 60秒內至少有10000個key被改變則做一次快照
#關閉該規則使用svae “”
dbfilename dump.rdb
#rdb持久化存儲數據庫文件名,默認爲dump.rdb
stop-write-on-bgsave-error yes
#yes代表當使用bgsave命令持久化出錯時候停止寫RDB快照文件,no表明忽略錯誤繼續寫文件。
rdbchecksum yes
#在寫入文件和讀取文件時是否開啓rdb文件檢查,檢查是否有無損壞,如果在啓動是檢查發現損壞,則停止啓動。
dir "/etc/redis"
#數據文件存放目錄,rdb快照文件和aof文件都會存放至該目錄,請確保有寫權限
rdbcompression yes
#是否開啓RDB文件壓縮,該功能可以節約磁盤空間
2.2.3優缺點
-
優點:
- 適合做冷備
-
缺點:
- RDB在生成快照文件時,如果文件很大,客戶端會暫停幾毫秒甚至幾秒
- RDB都是快照文件,都是默認五分鐘甚至更久的時間纔會生成一次,這意味着你這次同步到下次同步這中間五分鐘的數據都很可能全部丟失掉
總結
RDB做鏡像全量持久化,AOF做增量持久化。因爲RDB會耗費較長時間,不夠實時,在停機的時候會導致大量丟失數據,所以需要AOF來配合使用。在redis實例重啓時,會使用RDB持久化文件重新構建內存,再使用AOF重放近期的操作指令來實現完整恢復重啓之前的狀態。
這裏很好理解,把RDB理解爲一整個表全量的數據,AOF理解爲每次操作的日誌就好了,服務器重啓的時候先把表的數據全部搞進去,但是他可能不完整,你再回放一下日誌,數據不就完整了嘛。不過Redis本身的機制是 AOF持久化開啓且存在AOF文件時,優先加載AOF文件;AOF關閉或者AOF文件不存在時,加載RDB文件;加載AOF/RDB文件城後,Redis啓動成功; AOF/RDB文件存在錯誤時,Redis啓動失敗並打印錯誤信息
。
六,LRU和LFU
6.1 LFU最近最少使用(統計維度)
LFU每一個數據塊都有一個引用計數器,所有數據塊按照引用計數排序,引用計數相同的則按照時間先後排序
-
新加入數據插入到隊列尾部(因爲引用計數爲1);
-
隊列中的數據被訪問後,引用計數增加,隊列重新排序;
-
當需要淘汰數據時,將已經排序的列表最後的數據塊刪除。
6.2LRU最近最久未使用(時間維度)
-
新數據插入到鏈表頭部;
-
每當緩存命中(即緩存數據被訪問),則將數據移到鏈表頭部;
-
當鏈表滿的時候,將鏈表尾部的數據丟棄。
redis採用的過期策略是:定期刪除加惰性刪除, 定期好理解,默認100ms就隨機抽一些設置了過期時間的key,去檢查是否過期,過期了就刪了。
七,Redis的IO模型
7.1 IO多路複用
I/O多路複用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作
-
(1)select==>時間複雜度O(n)
它僅僅知道了,有I/O事件發生了,卻並不知道是哪那幾個流(可能有一個,多個,甚至全部),我們只能無差別輪詢所有流,找出能讀出數據,或者寫入數據的流,對他們進行操作。所以select具有O(n)的無差別輪詢複雜度,同時處理的流越多,無差別輪詢時間就越長。
-
(2)poll==>時間複雜度O(n)
poll本質上和select沒有區別,它將用戶傳入的數組拷貝到內核空間,然後查詢每個fd對應的設備狀態, 但是它沒有最大連接數的限制,原因是它是基於鏈表來存儲的.
-
(3)epoll==>時間複雜度O(1)
epoll可以理解爲event poll,不同於忙輪詢和無差別輪詢,epoll會把哪個流發生了怎樣的I/O事件通知我們。所以我們說epoll實際上是事件驅動(每個事件關聯上fd)的,此時我們對這些流的操作都是有意義的。(複雜度降低到了O(1))
但select,poll,epoll本質上都是同步I/O,因爲他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。
7.2 redis中的IO模型
爲什麼redis是單線程,但是卻能處理那麼多併發的客戶端連接呢?
- 因爲
Redis
採用了多路IO複用
及非阻塞IO
技術,( 採用死循環方式輪詢每一個流,如果有IO事件就處理,這樣可以使得一個線程可以處理多個流,但是效率不高 )多路IO複用
模型是利用select、poll、epoll
可以同時監察多個流的IO
事件的能力,在空閒的時候,會把當前線程阻塞掉,當有一個或多個流有I/O事件時,就從阻塞態中喚醒,於是程序就會輪詢一遍所有的流(epoll是隻輪詢那些真正發出了事件的流),並且只依次順序的處理就緒的流,這種做法就避免了大量的無用操作。 - 多路指的是多個網絡連接,複用指的是複用同一個線程。
採用多路IO複用
技術可以讓單個線程高效的處理多個連接請求(儘量減少網絡IO的時間消耗),且Redis
在內存中操作數據的速度非常快(內存內的操作不會成爲這裏的性能瓶頸),主要以上兩點造就了Redis
具有很高的吞吐量。