《这是全网最硬核redis总结,谁赞成,谁反对?》六万字大合集
我攤牌了,這篇文章,值得99%的人收藏
此文后續(xù)會改為粉絲可見,所以喜歡的請?zhí)崆瓣P(guān)注和收藏,不迷路。
最近有五本我喜歡的redis實體新書,想要的去評論,我寫個隨機(jī)數(shù)抽獎包郵送給你。
?
?
那么,準(zhǔn)備好了嗎?我們開始吧。
《三天給你聊清楚redis》第1天先嘮嘮redis是個啥(18629字)
一、入門
Redis是一款基于鍵值對的NoSQL數(shù)據(jù)庫,它的值支持多種數(shù)據(jù)結(jié)構(gòu):
字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等。
? Redis將所有的數(shù)據(jù)都存放在內(nèi)存中,所以它的讀寫性能十分驚人,用作數(shù)據(jù)庫,緩存和消息代理。
Redis具有內(nèi)置的復(fù)制,Lua腳本,LRU逐出,事務(wù)和不同級別的磁盤持久性,并通過Redis Sentinel和Redis Cluster自動分區(qū)提供了高可用性。
? Redis典型的應(yīng)用場景包括:緩存、排行榜、計數(shù)器、社交網(wǎng)絡(luò)、消息隊列等
1.1NoSql入門概述
1)單機(jī)Mysql的美好時代
瓶頸:
?
- 數(shù)據(jù)庫總大小一臺機(jī)器硬盤內(nèi)存放不下
- 數(shù)據(jù)的索引(B + tree)一個機(jī)器的運(yùn)行內(nèi)存放不下
- 訪問量(讀寫混合)一個實例不能承受
?
2)Memcached(緩存)+ MySql + 垂直拆分
通過緩存來緩解數(shù)據(jù)庫的壓力,優(yōu)化數(shù)據(jù)庫的結(jié)構(gòu)和索引
垂直拆分指的是:分成多個數(shù)據(jù)庫存儲數(shù)據(jù)(如:賣家?guī)炫c買家?guī)?#xff09;
?
3)MySql主從復(fù)制讀寫分離
?
4)分表分庫+水平拆分+MySql集群
?
MySql擴(kuò)展的瓶頸
常用的Nosql
Redis
memcache
Mongdb
以上幾種Nosql 請到各自的官網(wǎng)上下載并參考使用
Nosql 的核心功能點
KV(存儲)
Cache(緩存)
Persistence(持久化)
……
1.2redis的介紹和特點:
? ? ? ?問題:
? ? ? ? ? ? ? ?傳統(tǒng)數(shù)據(jù)庫:持久化存儲數(shù)據(jù)。
? ? ? ? ? ? ? ?solr索引庫:大量的數(shù)據(jù)的檢索。
? ? ? ? ? ? ? ?在實際開發(fā)中,高并發(fā)環(huán)境下,不同的用戶會需要相同的數(shù)據(jù)。因為每次請求,
? ? ? ? ? ? ? ?在后臺我們都會創(chuàng)建一個線程來處理,這樣造成,同樣的數(shù)據(jù)從數(shù)據(jù)庫中查詢了N次。
? ? ? ? ? ? ? ?而數(shù)據(jù)庫的查詢本身是IO操作,效率低,頻率高也不好。
? ? ? ? ? ? ? ?總而言之,一個網(wǎng)站總歸是有大量的數(shù)據(jù)是用戶共享的,但是如果每個用戶都去數(shù)據(jù)庫查詢
? ? ? ? ? ? ? ?效率就太低了。
? ? ? ?解決:
? ? ? ? ? ? ? ?將用戶共享數(shù)據(jù)緩存到服務(wù)器的內(nèi)存中。 ? ? ? ?
? ? ? ?特點:
? ? ? ? ? ? ? ?1、基于鍵值對
? ? ? ? ? ? ? ?2、非關(guān)系型(redis)
? ? ? ? ? ? ? ? ? ? ? ?關(guān)系型數(shù)據(jù)庫:存儲了數(shù)據(jù)以及數(shù)據(jù)之間的關(guān)系,oracle,mysql
? ? ? ? ? ? ? ? ? ? ? ?非關(guān)系型數(shù)據(jù)庫:存儲了數(shù)據(jù),redis,mdb.
? ? ? ? ? ? ? ?3、數(shù)據(jù)存儲在內(nèi)存中,服務(wù)器關(guān)閉后,持久化到硬盤中
? ? ? ? ? ? ? ?4、支持主從同步
? ? ? ? ? ? ? ?實現(xiàn)了緩存數(shù)據(jù)和項目的解耦。
? ? ? ?redis存儲的數(shù)據(jù)特點:
? ? ? ? ? ? ? ?大量數(shù)據(jù)
? ? ? ? ? ? ? ?用戶共享數(shù)據(jù)
? ? ? ? ? ? ? ?數(shù)據(jù)不經(jīng)常修改。
? ? ? ? ? ? ? ?查詢數(shù)據(jù)
? ? ? ?redis的應(yīng)用場景:
? ? ? ? ? ? ? ?網(wǎng)站高并發(fā)的主頁數(shù)據(jù)
? ? ? ? ? ? ? ?網(wǎng)站數(shù)據(jù)的排名
? ? ? ? ? ? ? ?消息訂閱
1.3redis——數(shù)據(jù)結(jié)構(gòu)和對象的使用介紹? ??
? ? ? ?
redis官網(wǎng)
微軟寫的windows下的redis
我們下載第一個
額案后基本一路默認(rèn)就行了
安裝后,服務(wù)自動啟動,以后也不用自動啟動。
出現(xiàn)這個表示我們連接上了。
?
redis命令參考鏈接
1.3.1String
數(shù)據(jù)結(jié)構(gòu)
struct sdshdr{//記錄buf數(shù)組中已使用字節(jié)的數(shù)量int len;//記錄buf數(shù)組中未使用的數(shù)量int free;//字節(jié)數(shù)組,用于保存字符串char buf[]; }常見操作
127.0.0.1:6379> set hello world OK 127.0.0.1:6379> get hello "world" 127.0.0.1:6379> del hello (integer) 1 127.0.0.1:6379> get hello (nil) 127.0.0.1:6379>應(yīng)用場景
String是最常用的一種數(shù)據(jù)類型,普通的key/value存儲都可以歸為此類,value其實不僅是String,也可以是數(shù)字:比如想知道什么時候封鎖一個IP地址(訪問超過幾次)。INCRBY命令讓這些變得很容易,通過原子遞增保持計數(shù)。
1.3.2LIST
數(shù)據(jù)結(jié)構(gòu)
typedef struct listNode{//前置節(jié)點struct listNode *prev;//后置節(jié)點struct listNode *next;//節(jié)點的值struct value; }常見操作
> lpush list-key item (integer) 1 > lpush list-key item2 (integer) 2 > rpush list-key item3 (integer) 3 > rpush list-key item (integer) 4 > lrange list-key 0 -1 1) "item2" 2) "item" 3) "item3" 4) "item" > lindex list-key 2 "item3" > lpop list-key "item2" > lrange list-key 0 -1 1) "item" 2) "item3" 3) "item"應(yīng)用場景
Redis list的應(yīng)用場景非常多,也是Redis最重要的數(shù)據(jù)結(jié)構(gòu)之一。
我們可以輕松地實現(xiàn)最新消息排行等功能。
Lists的另一個應(yīng)用就是消息隊列,可以利用Lists的PUSH操作,將任務(wù)存在Lists中,然后工作線程再用POP操作將任務(wù)取出進(jìn)行執(zhí)行。
1.3.3HASH
數(shù)據(jù)結(jié)構(gòu)
dictht是一個散列表結(jié)構(gòu),使用拉鏈法保存哈希沖突的dictEntry。
typedef struct dictht{//哈希表數(shù)組dictEntry **table;//哈希表大小unsigned long size;//哈希表大小掩碼,用于計算索引值unsigned long sizemask;//該哈希表已有節(jié)點的數(shù)量unsigned long used; }typedef struct dictEntry{//鍵void *key;//值union{void *val;uint64_tu64;int64_ts64;}struct dictEntry *next; }Redis的字典dict中包含兩個哈希表dictht,這是為了方便進(jìn)行rehash操作。在擴(kuò)容時,將其中一個dictht上的鍵值對rehash到另一個dictht上面,完成之后釋放空間并交換兩個dictht的角色。
typedef struct dict {dictType *type;void *privdata;dictht ht[2];long rehashidx; /* rehashing not in progress if rehashidx == -1 */unsigned long iterators; /* number of iterators currently running */ } dict;rehash操作并不是一次性完成、而是采用漸進(jìn)式方式,目的是為了避免一次性執(zhí)行過多的rehash操作給服務(wù)器帶來負(fù)擔(dān)。
漸進(jìn)式rehash通過記錄dict的rehashidx完成,它從0開始,然后沒執(zhí)行一次rehash例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],這一次會把 dict[0] 上 table[rehashidx] 的鍵值對 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
在 rehash 期間,每次對字典執(zhí)行添加、刪除、查找或者更新操作時,都會執(zhí)行一次漸進(jìn)式 rehash。
采用漸進(jìn)式rehash會導(dǎo)致字典中的數(shù)據(jù)分散在兩個dictht中,因此對字典的操作也會在兩個哈希表上進(jìn)行。
例如查找時,先從ht[0]查找,沒有再查找ht[1],添加時直接添加到ht[1]中。
常見操作
> hset hash-key sub-key1 value1 (integer) 1 > hset hash-key sub-key2 value2 (integer) 1 > hset hash-key sub-key1 value1 (integer) 0 > hgetall hash-key 1) "sub-key1" 2) "value1" 3) "sub-key2" 4) "value2" > hdel hash-key sub-key2 (integer) 1 > hdel hash-key sub-key2 (integer) 0 > hget hash-key sub-key1 "value1" > hgetall hash-key 1) "sub-key1" 2) "value1"1.3.4SET
常見操作
> sadd set-key item (integer) 1 > sadd set-key item2 (integer) 1 > sadd set-key item3 (integer) 1 > sadd set-key item (integer) 0 > smembers set-key 1) "item2" 2) "item" 3) "item3" > sismember set-key item4 (integer) 0 > sismember set-key item (integer) 1 > srem set-key item (integer) 1 > srem set-key item (integer) 0 > smembers set-key 1) "item2" 2) "item3"應(yīng)用場景
Redis為集合提供了求交集、并集、差集等操作,故可以用來求共同好友等操作。
1.3.5ZSET
數(shù)據(jù)結(jié)構(gòu)
typedef struct zskiplistNode{//后退指針struct zskiplistNode *backward;//分值double score;//成員對象robj *obj;//層struct zskiplistLever{//前進(jìn)指針struct zskiplistNode *forward;//跨度unsigned int span;}lever[];}typedef struct zskiplist{//表頭節(jié)點跟表尾結(jié)點struct zskiplistNode *header, *tail;//表中節(jié)點的數(shù)量unsigned long length;//表中層數(shù)最大的節(jié)點的層數(shù)int lever;}跳躍表,基于多指針有序鏈實現(xiàn),可以看作多個有序鏈表。
與紅黑樹等平衡樹相比,跳躍表具有以下優(yōu)點:
- 插入速度非常快速,因為不需要進(jìn)行旋轉(zhuǎn)等操作來維持平衡性。
- 更容易實現(xiàn)。
- 支持無鎖操作。
常見操作
> zadd zset-key 728 member1 (integer) 1 > zadd zset-key 982 member0 (integer) 1 > zadd zset-key 982 member0 (integer) 0 > zrange zset-key 0 -1 1) "member1" 2) "member0" > zrange zset-key 0 -1 withscores 1) "member1" 2) "728" 3) "member0" 4) "982" > zrangebyscore zset-key 0 800 withscores 1) "member1" 2) "728" > zrem zset-key member1 (integer) 1 > zrem zset-key member1 (integer) 0 > zrange zset-key 0 -1 withscores 1) "member0" 2) "982"應(yīng)用場景
以某個條件為權(quán)重,比如按頂?shù)拇螖?shù)排序
ZREVRANGE命令可以用來按照得分來獲取前100名的用戶,ZRANK可以用來獲取用戶排名,非常直接而且操作容易。
Redis sorted set的使用場景與set類似,區(qū)別是set不是自動有序的,而sorted set可以通過用戶額外提供一個優(yōu)先級(score)的參數(shù)來為成員排序,并且是插入有序的,即自動排序。
?
redis命令參考鏈接
1.4Spring整合Redis
引入依賴
- spring-boot-starter-data-redis
配置Redis
- 配置數(shù)據(jù)庫參數(shù)
- 編寫配置類,構(gòu)造RedisTemplate
這個springboot已經(jīng)幫我們配了,但是默認(rèn)object,我想改成string
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.RedisSerializer;@Configuration public class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(factory);// 設(shè)置key的序列化方式template.setKeySerializer(RedisSerializer.string());// 設(shè)置value的序列化方式template.setValueSerializer(RedisSerializer.json());// 設(shè)置hash的key的序列化方式template.setHashKeySerializer(RedisSerializer.string());// 設(shè)置hash的value的序列化方式template.setHashValueSerializer(RedisSerializer.json());template.afterPropertiesSet();return template;}}
訪問Redis
- redisTemplate.opsForValue()
- redisTemplate.opsForHash()
- redisTemplate.opsForList()
- redisTemplate.opsForSet()
- redisTemplate.opsForZSet()
這樣還是稍微有點麻煩,我們其實可以綁定key
// 多次訪問同一個key@Testpublic void testBoundOperations() {String redisKey = "test:count";BoundValueOperations operations = redisTemplate.boundValueOps(redisKey);operations.increment();operations.increment();operations.increment();operations.increment();operations.increment();System.out.println(operations.get());}二、數(shù)據(jù)結(jié)構(gòu)原理總結(jié)
這部分在我看來是最有意思的,我們有必要了解底層數(shù)據(jù)結(jié)構(gòu)的實現(xiàn),這也是我最感興趣的。
比如,你知道redis中的字符串怎么實現(xiàn)的嗎?為什么這么實現(xiàn)?
你知道redis壓縮列表是什么算法嗎?
你知道redis為什么拋棄了紅黑樹反而采用了跳表這種新的數(shù)據(jù)結(jié)構(gòu)嗎?
你知道hyperloglog為什么用如此小的空間就可以有這么好的統(tǒng)計性能和準(zhǔn)確性嗎?
你知道布隆過濾器為什么這么有效嗎?有沒有數(shù)學(xué)證明過?
你是否還能很快寫出來快排?或者不斷優(yōu)化性能的排序?是不是只會調(diào)庫了甚至庫函數(shù)怎么實現(xiàn)的都不知道?真的就是快排?
包括數(shù)據(jù)庫,持久化,處理事件、客戶端服務(wù)端、事務(wù)的實現(xiàn)、發(fā)布和訂閱等功能的實現(xiàn),也需要了解。
2.1數(shù)據(jù)結(jié)構(gòu)和對象的實現(xiàn)
- 1) 字符串
redis并未使用傳統(tǒng)的c語言字符串表示,它自己構(gòu)建了一種簡單的動態(tài)字符串抽象類型。
在redis里,c語言字符串只會作為字符串字面量出現(xiàn),用在無需修改的地方。
當(dāng)需要一個可以被修改的字符串時,redis就會使用自己實現(xiàn)的SDS(simple dynamic string)。比如在redis數(shù)據(jù)庫里,包含字符串的鍵值對底層都是SDS實現(xiàn)的,不止如此,SDS還被用作緩沖區(qū)(buffer):比如AOF模塊中的AOF緩沖區(qū)以及客戶端狀態(tài)中的輸入緩沖區(qū)。
下面來具體看一下sds的實現(xiàn):
struct sdshdr {int len;//buf已使用字節(jié)數(shù)量(保存的字符串長度)int free;//未使用的字節(jié)數(shù)量char buf[];//用來保存字符串的字節(jié)數(shù)組 };sds遵循c中字符串以'\0'結(jié)尾的慣例,這一字節(jié)的空間不算在len之內(nèi)。
這樣的好處是,我們可以直接重用c中的一部分函數(shù)。比如printf;
? ? sds相對c的改進(jìn)
? ? 獲取長度:c字符串并不記錄自身長度,所以獲取長度只能遍歷一遍字符串,redis直接讀取len即可。
? ? 緩沖區(qū)安全:c字符串容易造成緩沖區(qū)溢出,比如:程序員沒有分配足夠的空間就執(zhí)行拼接操作。而redis會先檢查sds的空間是否滿足所需要求,如果不滿足會自動擴(kuò)充。
? ? 內(nèi)存分配:由于c不記錄字符串長度,對于包含了n個字符的字符串,底層總是一個長度n+1的數(shù)組,每一次長度變化,總是要對這個數(shù)組進(jìn)行一次內(nèi)存重新分配的操作。因為內(nèi)存分配涉及復(fù)雜算法并且可能需要執(zhí)行系統(tǒng)調(diào)用,所以它通常是比較耗時的操作。? ?
? ? redis內(nèi)存分配:
1、空間預(yù)分配:如果修改后大小小于1MB,程序分配和len大小一樣的未使用空間,如果修改后大于1MB,程序分配? 1MB的未使用空間。修改長度時檢查,夠的話就直接使用未使用空間,不用再分配。?
2、惰性空間釋放:字符串縮短時不需要釋放空間,用free記錄即可,留作以后使用。
? ? 二進(jìn)制安全
c字符串除了末尾外,不能包含空字符,否則程序讀到空字符會誤以為是結(jié)尾,這就限制了c字符串只能保存文本,二進(jìn)制文件就不能保存了。
而redis字符串都是二進(jìn)制安全的,因為有l(wèi)en來記錄長度。
- 2) 鏈表
作為一種常用數(shù)據(jù)結(jié)構(gòu),鏈表內(nèi)置在很多高級語言中,因為c并沒有,所以redis實現(xiàn)了自己的鏈表。
鏈表在redis也有一定的應(yīng)用,比如列表鍵的底層實現(xiàn)之一就是鏈表。(當(dāng)列表鍵包含大量元素或者元素都是很長的字符串時)
發(fā)布與訂閱、慢查詢、監(jiān)視器等功能也用到了鏈表。
具體實現(xiàn):
//redis的節(jié)點使用了雙向鏈表結(jié)構(gòu) typedef struct listNode {// 前置節(jié)點struct listNode *prev;// 后置節(jié)點struct listNode *next;// 節(jié)點的值void *value; } listNode; //其實學(xué)過數(shù)據(jù)結(jié)構(gòu)的應(yīng)該都實現(xiàn)過 typedef struct list {// 表頭節(jié)點listNode *head;// 表尾節(jié)點listNode *tail;// 鏈表所包含的節(jié)點數(shù)量unsigned long len;// 節(jié)點值復(fù)制函數(shù)void *(*dup)(void *ptr);// 節(jié)點值釋放函數(shù)void (*free)(void *ptr);// 節(jié)點值對比函數(shù)int (*match)(void *ptr, void *key); } list;總結(jié)一下redis鏈表特性:
雙端、無環(huán)、帶長度記錄、
多態(tài):使用?void*?指針來保存節(jié)點值, 可以通過?dup?、?free?、?match?為節(jié)點值設(shè)置類型特定函數(shù), 可以保存不同類型的值。
- 3)字典
其實字典這種數(shù)據(jù)結(jié)構(gòu)也內(nèi)置在很多高級語言中,但是c語言沒有,所以redis自己實現(xiàn)了。
應(yīng)用也比較廣泛,比如redis的數(shù)據(jù)庫就是字典實現(xiàn)的。不僅如此,當(dāng)一個哈希鍵包含的鍵值對比較多,或者都是很長的字符串,redis就會用字典作為哈希鍵的底層實現(xiàn)。
來看看具體是實現(xiàn):
//redis的字典使用哈希表作為底層實現(xiàn) typedef struct dictht {// 哈希表數(shù)組dictEntry **table;// 哈希表大小unsigned long size;// 哈希表大小掩碼,用于計算索引值// 總是等于 size - 1unsigned long sizemask;// 該哈希表已有節(jié)點的數(shù)量unsigned long used;} dictht;table?是一個數(shù)組, 數(shù)組中的每個元素都是一個指向dictEntry?結(jié)構(gòu)的指針, 每個?dictEntry?結(jié)構(gòu)保存著一個鍵值對。
圖為一個大小為4的空哈希表。
我們接著就來看dictEntry的實現(xiàn):
typedef struct dictEntry {// 鍵void *key;// 值union {void *val;uint64_t u64;int64_t s64;} v;// 指向下個哈希表節(jié)點,形成鏈表struct dictEntry *next; } dictEntry;(v可以是一個指針, 或者是一個?uint64_t?整數(shù), 又或者是一個?int64_t?整數(shù)。)
next就是解決鍵沖突問題的,沖突了就掛后面,這個學(xué)過數(shù)據(jù)結(jié)構(gòu)的應(yīng)該都知道吧,不說了。
?
下面我們來說字典是怎么實現(xiàn)的了。
typedef struct dict {// 類型特定函數(shù)dictType *type;// 私有數(shù)據(jù)void *privdata;// 哈希表dictht ht[2];// rehash 索引int rehashidx; //* rehashing not in progress if rehashidx == -1 } dict;type?和?privdata?是對不同類型的鍵值對, 為創(chuàng)建多態(tài)字典而設(shè)置的:
type?指向?dictType?, 每個?dictType?保存了用于操作特定類型鍵值對的函數(shù), 可以為用途不同的字典設(shè)置不同的類型特定函數(shù)。
而?privdata?屬性則保存了需要傳給那些類型特定函數(shù)的可選參數(shù)。
而dictType就暫時不展示了,不重要而且字有點多。。。還是講有意思的東西吧? ? rehash(重新散列)
隨著我們不斷的操作,哈希表保存的鍵值可能會增多或者減少,為了讓哈希表的負(fù)載因子維持在合理的范圍內(nèi),有時需要對哈希表進(jìn)行合理的擴(kuò)展或者收縮。?一般情況下, 字典只使用?ht[0]?哈希表,?ht[1]?哈希表只會在對?ht[0]?哈希表進(jìn)行 rehash 時使用。
redis字典哈希rehash的步驟如下:
1)為ht[1]分配合理空間:如果是擴(kuò)展操作,大小為第一個大于等于ht[0]*used*2的,2的n次冪。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?如果是收縮操作,大小為第一個大于等于ht[0]*used的,2的n次冪。
2)將ht[0]中的數(shù)據(jù)rehash到ht[1]上。
3)釋放ht[0],將ht[1]設(shè)置為ht[0],ht[1]創(chuàng)建空表,為下次做準(zhǔn)備。
? ? 漸進(jìn)rehash
數(shù)據(jù)量特別大時,rehash可能對服務(wù)器造成影響。為了避免,服務(wù)器不是一次性rehash的,而是分多次。
我們維持一個變量rehashidx,設(shè)置為0,代表rehash開始,然后開始rehash,在這期間,每個對字典的操作,程序都會把索引rehashidx上的數(shù)據(jù)移動到ht[1]。
隨著操作不斷執(zhí)行,最終我們會完成rehash,設(shè)置rehashidx為-1.
需要注意:rehash過程中,每一次增刪改查也是在兩個表進(jìn)行的。
- 4)整數(shù)集合
整數(shù)集合(intset)是 Redis 用于保存整數(shù)值的集合抽象數(shù)據(jù)結(jié)構(gòu), 可以保存?int16_t?、?int32_t?、?int64_t?的整數(shù)值, 并且保證集合中不會出現(xiàn)重復(fù)元素。
實現(xiàn)較為簡單:
typedef struct intset {// 編碼方式uint32_t encoding;// 集合包含的元素數(shù)量uint32_t length;// 保存元素的數(shù)組int8_t contents[]; } intset;各個項在數(shù)組中從小到大有序地排列, 并且數(shù)組中不包含任何重復(fù)項。
雖然?intset?結(jié)構(gòu)將?contents?屬性聲明為?int8_t?類型的數(shù)組, 但實際上?contents?數(shù)組并不保存任何?int8_t?類型的值 ——?contents?數(shù)組的真正類型取決于?encoding?屬性的值:
如果?encoding?屬性的值為?INTSET_ENC_INT16?, 那么?contents?就是一個?int16_t?類型的數(shù)組, 數(shù)組里的每個項都是一個?int16_t?類型的整數(shù)值 (最小值為?-32,768?,最大值為?32,767?)。
如果?encoding?屬性的值為?INTSET_ENC_INT32?, 那么?contents?就是一個?int32_t?類型的數(shù)組, 數(shù)組里的每個項都是一個?int32_t?類型的整數(shù)值 (最小值為?-2,147,483,648?,最大值為?2,147,483,647?)。
如果?encoding?屬性的值為?INTSET_ENC_INT64?, 那么?contents?就是一個?int64_t?類型的數(shù)組, 數(shù)組里的每個項都是一個?int64_t?類型的整數(shù)值 (最小值為?-9,223,372,036,854,775,808?,最大值為?9,223,372,036,854,775,807?)。
? ? 升級
c語言是靜態(tài)類型語言,不允許不同類型保存在一個數(shù)組。這樣第一,靈活性較差,第二,有時會用掉不必要的內(nèi)存
比如用long long儲存1
為了提高整數(shù)集合的靈活性和節(jié)約內(nèi)存,我們引入升級策略。
當(dāng)我們要將一個新元素添加到集合里, 并且新元素類型比集合現(xiàn)有元素的類型都要長時, 集合需要先進(jìn)行升級。
分為三步進(jìn)行:
因為每次添加新元素都可能會引起升級, 每次升級都要對已有元素類型轉(zhuǎn)換, 所以添加新元素的時間復(fù)雜度為?O(N)?。
因為引發(fā)升級的新元素比原數(shù)據(jù)都長,所以要么他是最大的,要么他是最小的。我們把它放在開頭或結(jié)尾即可。
?
? ? 降級
略略略,不管你們信不信,整數(shù)集合不支持降級操作。。我也不知道為啥
- 5)壓縮列表
壓縮列表是列表鍵和哈希鍵的底層實現(xiàn)之一。
當(dāng)一個列表鍵只包含少量列表項,并且列表項都是小整數(shù)或者短字符串,redis就會用壓縮列表做列表鍵底層實現(xiàn)。
壓縮列表是 Redis 為了節(jié)約內(nèi)存而開發(fā)的, 由一系列特殊編碼的連續(xù)內(nèi)存塊組成的順序型(sequential)數(shù)據(jù)結(jié)構(gòu)。
一個壓縮列表可以包含任意多個節(jié)點(entry), 每個節(jié)點可以保存一個字節(jié)數(shù)組或者一個整數(shù)值。
具體實現(xiàn):
具體說一下entry:
由三個部分組成:
1、previous_entry_length:記錄上一個節(jié)點的長度,這樣我們就可以從最后一路遍歷到開頭。
2、encoding:記錄了content所保存的數(shù)據(jù)類型和長度。(具體編碼不寫了,不重要)
3、content:保存節(jié)點值,可以是字節(jié)數(shù)組或整數(shù)。(具體怎么壓縮的等我搞明白再補(bǔ))
? ? 連鎖更新
前面說過, 每個節(jié)點的?previous_entry_length?屬性都記錄了前一個節(jié)點的長度:
- 如果前一節(jié)點的長度<?254?KB, 那么?previous_entry_length?需要用?1?字節(jié)長的空間
- 如果前一節(jié)點的長度>=254?KB, 那么?previous_entry_length?需要用?5?字節(jié)長的空間
現(xiàn)在, 考慮這樣一種情況: 在一個壓縮列表中, 有多個連續(xù)的、長度介于?250?字節(jié)到?253?字節(jié)之間的節(jié)點 ,這時, 如果我們將一個長度大于等于?254?字節(jié)的新節(jié)點?new?設(shè)置為壓縮列表的表頭節(jié)點。。。。
然后腦補(bǔ)一下,就會導(dǎo)致連鎖擴(kuò)大每個節(jié)點的空間對吧?e(i)因為e(i-1)的擴(kuò)大而擴(kuò)大,i+1也是如此,以此類推。。。
?
刪除節(jié)點同樣會導(dǎo)致連鎖更新。
這個事情只是想說明一個問題:插入刪除操作的最壞時間復(fù)雜度其實是o(n*n),因為每更新一個節(jié)點都要o(n)。
但是,也不用太過擔(dān)心,因為這種特殊情況并不多見,這些命令的平均復(fù)雜度依舊是o(n)。
?
2.2 跳表專欄
2.2.1跳表是啥
為什么選擇了跳表而不是紅黑樹?
跳表是個啥東西請看這個文章。
我們知道,節(jié)點插入時隨機(jī)出一個層數(shù),僅僅依靠一個簡單的隨機(jī)數(shù)操作而構(gòu)建出來的多層鏈表結(jié)構(gòu),能保證它有一個良好的查找性能嗎?為了回答這個疑問,我們需要分析skiplist的統(tǒng)計性能。
在分析之前,我們還需要著重指出的是,執(zhí)行插入操作時計算隨機(jī)數(shù)的過程,是一個很關(guān)鍵的過程,它對skiplist的統(tǒng)計特性有著很重要的影響。這并不是一個普通的服從均勻分布的隨機(jī)數(shù),它的計算過程如下:
- 首先,每個節(jié)點肯定都有第1層指針(每個節(jié)點都在第1層鏈表里)。
- 如果一個節(jié)點有第i層(i>=1)指針(即節(jié)點已經(jīng)在第1層到第i層鏈表中),那么它有第(i+1)層指針的概率為p。
- 節(jié)點最大的層數(shù)不允許超過一個最大值,記為MaxLevel。
這個計算隨機(jī)層數(shù)的偽碼如下所示:
randomLevel() level := 1 // random()返回一個[0...1)的隨機(jī)數(shù) while random() < p and level < MaxLevel do level := level + 1 return levelrandomLevel()的偽碼中包含兩個參數(shù),一個是p,一個是MaxLevel。在Redis的skiplist實現(xiàn)中,這兩個參數(shù)的取值為:
p = 1/4 MaxLevel = 322.2.2skiplist的算法性能分析
在這一部分,我們來簡單分析一下skiplist的時間復(fù)雜度和空間復(fù)雜度,以便對于skiplist的性能有一個直觀的了解。如果你不是特別偏執(zhí)于算法的性能分析,那么可以暫時跳過這一小節(jié)的內(nèi)容。
我們先來計算一下每個節(jié)點所包含的平均指針數(shù)目(概率期望)。節(jié)點包含的指針數(shù)目,相當(dāng)于這個算法在空間上的額外開銷(overhead),可以用來度量空間復(fù)雜度。
根據(jù)前面randomLevel()的偽碼,我們很容易看出,產(chǎn)生越高的節(jié)點層數(shù),概率越低。定量的分析如下:
- 節(jié)點層數(shù)至少為1。而大于1的節(jié)點層數(shù),滿足一個概率分布。
- 節(jié)點層數(shù)恰好等于1的概率為1-p。
- 節(jié)點層數(shù)大于等于2的概率為p,而節(jié)點層數(shù)恰好等于2的概率為p(1-p)。
- 節(jié)點層數(shù)大于等于3的概率為p^2,而節(jié)點層數(shù)恰好等于3的概率為p^2(1-p)。
- 節(jié)點層數(shù)大于等于4的概率為p^3,而節(jié)點層數(shù)恰好等于4的概率為p^3(1-p)。
- ......
因此,一個節(jié)點的平均層數(shù)(也即包含的平均指針數(shù)目),計算如下:
現(xiàn)在很容易計算出:
- 當(dāng)p=1/2時,每個節(jié)點所包含的平均指針數(shù)目為2;
- 當(dāng)p=1/4時,每個節(jié)點所包含的平均指針數(shù)目為1.33。這也是Redis里的skiplist實現(xiàn)在空間上的開銷。
接下來,為了分析時間復(fù)雜度,我們計算一下skiplist的平均查找長度。查找長度指的是查找路徑上跨越的跳數(shù),而查找過程中的比較次數(shù)就等于查找長度加1。以前面圖中標(biāo)出的查找23的查找路徑為例,從左上角的頭結(jié)點開始,一直到結(jié)點22,查找長度為6。
為了計算查找長度,這里我們需要利用一點小技巧。我們注意到,每個節(jié)點插入的時候,它的層數(shù)是由隨機(jī)函數(shù)randomLevel()計算出來的,而且隨機(jī)的計算不依賴于其它節(jié)點,每次插入過程都是完全獨(dú)立的。所以,從統(tǒng)計上來說,一個skiplist結(jié)構(gòu)的形成與節(jié)點的插入順序無關(guān)。
這樣的話,為了計算查找長度,我們可以將查找過程倒過來看,從右下方第1層上最后到達(dá)的那個節(jié)點開始,沿著查找路徑向左向上回溯,類似于爬樓梯的過程。我們假設(shè)當(dāng)回溯到某個節(jié)點的時候,它才被插入,這雖然相當(dāng)于改變了節(jié)點的插入順序,但從統(tǒng)計上不影響整個skiplist的形成結(jié)構(gòu)。
現(xiàn)在假設(shè)我們從一個層數(shù)為i的節(jié)點x出發(fā),需要向左向上攀爬k層。這時我們有兩種可能:
- 如果節(jié)點x有第(i+1)層指針,那么我們需要向上走。這種情況概率為p。
- 如果節(jié)點x沒有第(i+1)層指針,那么我們需要向左走。這種情況概率為(1-p)。
用C(k)表示向上攀爬k個層級所需要走過的平均查找路徑長度(概率期望),那么:
C(0)=0 C(k)=(1-p)×(上圖中情況b的查找長度) + p×(上圖中情況c的查找長度)代入,得到一個差分方程并化簡:
C(k)=(1-p)(C(k)+1) + p(C(k-1)+1) C(k)=1/p+C(k-1) C(k)=k/p這個結(jié)果的意思是,我們每爬升1個層級,需要在查找路徑上走1/p步。而我們總共需要攀爬的層級數(shù)等于整個skiplist的總層數(shù)-1。
那么接下來我們需要分析一下當(dāng)skiplist中有n個節(jié)點的時候,它的總層數(shù)的概率均值是多少。這個問題直觀上比較好理解。根據(jù)節(jié)點的層數(shù)隨機(jī)算法,容易得出:
- 第1層鏈表固定有n個節(jié)點;
- 第2層鏈表平均有n*p個節(jié)點;
- 第3層鏈表平均有n*p^2個節(jié)點;
- ...
所以,從第1層到最高層,各層鏈表的平均節(jié)點數(shù)是一個指數(shù)遞減的等比數(shù)列。容易推算出,總層數(shù)的均值為log1/pn,而最高層的平均節(jié)點數(shù)為1/p。
綜上,粗略來計算的話,平均查找長度約等于:
- C(log1/pn-1)=(log1/pn-1)/p
即,平均時間復(fù)雜度為O(log n)。
當(dāng)然,這里的時間復(fù)雜度分析還是比較粗略的。比如,沿著查找路徑向左向上回溯的時候,可能先到達(dá)左側(cè)頭結(jié)點,然后沿頭結(jié)點一路向上;還可能先到達(dá)最高層的節(jié)點,然后沿著最高層鏈表一路向左。但這些細(xì)節(jié)不影響平均時間復(fù)雜度的最后結(jié)果。另外,這里給出的時間復(fù)雜度只是一個概率平均值,但實際上計算一個精細(xì)的概率分布也是有可能的。
詳情還請參見William Pugh的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
2.2.3skiplist與平衡樹、哈希表的比較
- skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做單個key的查找,不適宜做范圍查找。所謂范圍查找,指的是查找那些大小在指定的兩個值之間的所有節(jié)點。
- 在做范圍查找的時候,平衡樹比skiplist操作要復(fù)雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續(xù)尋找其它不超過大值的節(jié)點。如果不對平衡樹進(jìn)行一定的改造,這里的中序遍歷并不容易實現(xiàn)。而在skiplist上進(jìn)行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進(jìn)行若干步的遍歷就可以實現(xiàn)。
- 平衡樹的插入和刪除操作可能引發(fā)子樹的調(diào)整,邏輯復(fù)雜,而skiplist的插入和刪除只需要修改相鄰節(jié)點的指針,操作簡單又快速。
- 從內(nèi)存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節(jié)點包含2個指針(分別指向左右子樹),而skiplist每個節(jié)點包含的指針數(shù)目平均為1/(1-p),具體取決于參數(shù)p的大小。如果像Redis里的實現(xiàn)一樣,取p=1/4,那么平均每個節(jié)點包含1.33個指針,比平衡樹更有優(yōu)勢。
- 查找單個key,skiplist和平衡樹的時間復(fù)雜度都為O(log n),大體相當(dāng);而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復(fù)雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結(jié)構(gòu),大都是基于哈希表實現(xiàn)的。
- 從算法實現(xiàn)難度上來比較,skiplist比平衡樹要簡單得多。
2.2.4Redis中的skiplist和經(jīng)典有何不同
- 分?jǐn)?shù)(score)允許重復(fù),即skiplist的key允許重復(fù)。這在最開始介紹的經(jīng)典skiplist中是不允許的。
- 在比較時,不僅比較分?jǐn)?shù)(相當(dāng)于skiplist的key),還比較數(shù)據(jù)本身。在Redis的skiplist實現(xiàn)中,數(shù)據(jù)本身的內(nèi)容唯一標(biāo)識這份數(shù)據(jù),而不是由key來唯一標(biāo)識。另外,當(dāng)多個元素分?jǐn)?shù)相同的時候,還需要根據(jù)數(shù)據(jù)內(nèi)容來進(jìn)字典排序。
- 第1層鏈表不是一個單向鏈表,而是一個雙向鏈表。這是為了方便以倒序方式獲取一個范圍內(nèi)的元素。
- 在skiplist中可以很方便地計算出每個元素的排名(rank)。
2.2.5作者的話
最后我們看看,對于這個問題,Redis的作者 @antirez 是怎么說的:
There are a few reasons:
1) They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
2) A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
3) They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
有幾個原因:
1)它們的記憶力不是很強(qiáng)。基本上由你決定。更改有關(guān)節(jié)點具有給定數(shù)量級別的概率的參數(shù)將使內(nèi)存密集度低于btree。
2)排序集通常是許多Zrange或Zrevrange操作的目標(biāo),即作為鏈表遍歷跳過列表。通過此操作,跳過列表的緩存區(qū)域性至少與其他類型的平衡樹一樣好。
3)它們易于實現(xiàn)、調(diào)試等。例如,由于跳過列表的簡單性,我收到了一個補(bǔ)丁(已經(jīng)在redis master中),其中包含在o(log(n))中實現(xiàn)zrank的擴(kuò)展跳過列表。它只需要對代碼稍作修改。
2.3HyperLogLog 專欄
HyperLogLog 是一種概率數(shù)據(jù)結(jié)構(gòu),用來估算數(shù)據(jù)的基數(shù)。數(shù)據(jù)集可以是網(wǎng)站訪客的 IP 地址,E-mail 郵箱或者用戶 ID。
基數(shù)就是指一個集合中不同值的數(shù)目,比如 a, b, c, d 的基數(shù)就是 4,a, b, c, d, a 的基數(shù)還是 4。雖然 a 出現(xiàn)兩次,只會被計算一次。
使用 Redis 統(tǒng)計集合的基數(shù)一般有三種方法,分別是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前兩個數(shù)據(jù)結(jié)構(gòu)在集合的數(shù)量級增長時,所消耗的內(nèi)存會大大增加,但是 HyperLogLog 則不會。
Redis 的 HyperLogLog 通過犧牲準(zhǔn)確率來減少內(nèi)存空間的消耗,只需要12K內(nèi)存,在標(biāo)準(zhǔn)誤差0.81%的前提下,能夠統(tǒng)計2^64個數(shù)據(jù)。所以 HyperLogLog 是否適合在比如統(tǒng)計日活月活此類的對精度要不不高的場景。
這是一個很驚人的結(jié)果,以如此小的內(nèi)存來記錄如此大數(shù)量級的數(shù)據(jù)基數(shù)。下面我們就帶大家來深入了解一下 HyperLogLog 的使用,基礎(chǔ)原理,源碼實現(xiàn)和具體的試驗數(shù)據(jù)分析。
2.3.1HyperLogLog 在 Redis 中的使用
Redis 提供了?PFADD?、?PFCOUNT?和?PFMERGE?三個命令來供用戶使用 HyperLogLog。
PFADD?用于向 HyperLogLog 添加元素。
> PFADD visitors alice bob carol(integer) 1> PFCOUNT visitors(integer) 3如果 HyperLogLog 估計的近似基數(shù)在?PFADD?命令執(zhí)行之后出現(xiàn)了變化, 那么命令返回 1 , 否則返回 0 。 如果命令執(zhí)行時給定的鍵不存在, 那么程序?qū)⑾葎?chuàng)建一個空的 HyperLogLog 結(jié)構(gòu), 然后再執(zhí)行命令。
PFCOUNT?命令會給出 HyperLogLog 包含的近似基數(shù)。在計算出基數(shù)后,?PFCOUNT?會將值存儲在 HyperLogLog 中進(jìn)行緩存,知道下次?PFADD?執(zhí)行成功前,就都不需要再次進(jìn)行基數(shù)的計算。
PFMERGE?將多個 HyperLogLog 合并為一個 HyperLogLog , 合并后的 HyperLogLog 的基數(shù)接近于所有輸入 HyperLogLog 的并集基數(shù)。
> PFADD customers alice dan(integer) 1> PFMERGE everyone visitors customersOK> PFCOUNT everyone(integer) 42.3.2內(nèi)存消耗對比實驗
我們下面就來通過實驗真實對比一下下面三種數(shù)據(jù)結(jié)構(gòu)的內(nèi)存消耗,HashMap、BitMap 和 HyperLogLog。
我們首先使用 Lua 腳本向 Redis 對應(yīng)的數(shù)據(jù)結(jié)構(gòu)中插入一定數(shù)量的數(shù),然后執(zhí)行 bgsave 命令,最后使用 redis-rdb-tools 的 rdb 的命令查看各個鍵所占的內(nèi)存大小。
下面是 Lua 的腳本
local key = KEYS[1]local size = tonumber(ARGV[1])local method = tonumber(ARGV[2])for i=1,size,1 doif (method == 0)thenredis.call('hset',key,i,1)elseif (method == 1)thenredis.call('pfadd',key, i)elseredis.call('setbit', key, i, 1)endend我們在通過 redis-cli 的?script load?命令將 Lua 腳本加載到 Redis 中,然后使用?evalsha?命令分別向 HashMap、HyperLogLog 和 BitMap 三種數(shù)據(jù)結(jié)構(gòu)中插入了一千萬個數(shù),然后使用?rdb?命令查看各個結(jié)構(gòu)內(nèi)存消耗。
我們進(jìn)行了兩輪實驗,分別插入一萬數(shù)字和一千萬數(shù)字,三種數(shù)據(jù)結(jié)構(gòu)消耗的內(nèi)存統(tǒng)計如下所示。
從表中可以明顯看出,一萬數(shù)量級時 BitMap 消耗內(nèi)存最小, 一千萬數(shù)量級時 HyperLogLog 消耗內(nèi)存最小,但是總體來看,HyperLogLog 消耗的內(nèi)存都是 14392 字節(jié),可見 HyperLogLog 在內(nèi)存消耗方面有自己的獨(dú)到之處。
2.3.3基本原理
HyperLogLog 是一種概率數(shù)據(jù)結(jié)構(gòu),它使用概率算法來統(tǒng)計集合的近似基數(shù)。而它算法的最本源則是伯努利過程。
伯努利過程就是一個拋硬幣實驗的過程。拋一枚正常硬幣,落地可能是正面,也可能是反面,二者的概率都是 1/2 。伯努利過程就是一直拋硬幣,直到落地時出現(xiàn)正面位置,并記錄下拋擲次數(shù)k。比如說,拋一次硬幣就出現(xiàn)正面了,此時 k 為 1; 第一次拋硬幣是反面,則繼續(xù)拋,直到第三次才出現(xiàn)正面,此時 k 為 3。
對于 n 次伯努利過程,我們會得到 n 個出現(xiàn)正面的投擲次數(shù)值 k1, k2 ... kn , 其中這里的最大值是k_max。
根據(jù)一頓數(shù)學(xué)推導(dǎo),我們可以得出一個結(jié)論: 2^{k_ max} 來作為n的估計值。也就是說你可以根據(jù)最大投擲次數(shù)近似的推算出進(jìn)行了幾次伯努利過程。
下面,我們就來講解一下 HyperLogLog 是如何模擬伯努利過程,并最終統(tǒng)計集合基數(shù)的。
HyperLogLog 在添加元素時,會通過Hash函數(shù),將元素轉(zhuǎn)為64位比特串,例如輸入5,便轉(zhuǎn)為101(省略前面的0,下同)。這些比特串就類似于一次拋硬幣的伯努利過程。比特串中,0 代表了拋硬幣落地是反面,1 代表拋硬幣落地是正面,如果一個數(shù)據(jù)最終被轉(zhuǎn)化了 10010000,那么從低位往高位看,我們可以認(rèn)為,這串比特串可以代表一次伯努利過程,首次出現(xiàn) 1 的位數(shù)為5,就是拋了5次才出現(xiàn)正面。
所以 HyperLogLog 的基本思想是利用集合中數(shù)字的比特串第一個 1 出現(xiàn)位置的最大值來預(yù)估整體基數(shù),但是這種預(yù)估方法存在較大誤差,為了改善誤差情況,HyperLogLog中引入分桶平均的概念,計算 m 個桶的調(diào)和平均值。
Redis 中 HyperLogLog 一共分了 2^14 個桶,也就是 16384 個桶。每個桶中是一個 6 bit 的數(shù)組。
HyperLogLog 將上文所說的 64 位比特串的低 14 位單獨(dú)拿出,它的值就對應(yīng)桶的序號,然后將剩下 50 位中第一次出現(xiàn) 1 的位置值設(shè)置到桶中。50位中出現(xiàn)1的位置值最大為50,所以每個桶中的 6 位數(shù)組正好可以表示該值。
在設(shè)置前,要設(shè)置進(jìn)桶的值是否大于桶中的舊值,如果大于才進(jìn)行設(shè)置,否則不進(jìn)行設(shè)置。
此時為了性能考慮,是不會去統(tǒng)計當(dāng)前的基數(shù)的,而是將 HyperLogLog 頭的 card 屬性中的標(biāo)志位置為 1,表示下次進(jìn)行 pfcount 操作的時候,當(dāng)前的緩存值已經(jīng)失效了,需要重新統(tǒng)計緩存值。在后面 pfcount 流程的時候,發(fā)現(xiàn)這個標(biāo)記為失效,就會去重新統(tǒng)計新的基數(shù),放入基數(shù)緩存。
在計算近似基數(shù)時,就分別計算每個桶中的值,帶入到上文的 DV 公式中,進(jìn)行調(diào)和平均和結(jié)果修正,就能得到估算的基數(shù)值。
2.3.4HyperLogLog 具體對象
我們首先來看一下 HyperLogLog 對象的定義
struct hllhdr {char magic[4]; /* 魔法值 "HYLL" */uint8_t encoding; /* 密集結(jié)構(gòu)或者稀疏結(jié)構(gòu) HLL_DENSE or HLL_SPARSE. */uint8_t notused[3]; /* 保留位, 全為0. */uint8_t card[8]; /* 基數(shù)大小的緩存 */uint8_t registers[]; /* 數(shù)據(jù)字節(jié)數(shù)組 */};HyperLogLog 對象中的?registers?數(shù)組就是桶,它有兩種存儲結(jié)構(gòu),分別為密集存儲結(jié)構(gòu)和稀疏存儲結(jié)構(gòu),兩種結(jié)構(gòu)只涉及存儲和桶的表現(xiàn)形式,從中我們可以看到 Redis 對節(jié)省內(nèi)存極致地追求。
我們先看相對簡單的密集存儲結(jié)構(gòu),它也是十分的簡單明了,既然要有 2^14 個 6 bit的桶,那么我就真使用足夠多的?uint8_t?字節(jié)去表示,只是此時會涉及到字節(jié)位置和桶的轉(zhuǎn)換,因為字節(jié)有 8 位,而桶只需要 6 位。
所以我們需要將桶的序號轉(zhuǎn)換成對應(yīng)的字節(jié)偏移量 offsetbytes 和其內(nèi)部的位數(shù)偏移量 offsetbits。需要注意的是小端字節(jié)序,高位在右側(cè),需要進(jìn)行倒轉(zhuǎn)。
當(dāng) offset_bits 小于等于2時,說明一個桶就在該字節(jié)內(nèi),只需要進(jìn)行倒轉(zhuǎn)就能得到桶的值。
?offset_bits 大于 2 ,則說明一個桶分布在兩個字節(jié)內(nèi),此時需要將兩個字節(jié)的內(nèi)容都進(jìn)行倒置,然后再進(jìn)行拼接得到桶的值。
Redis 為了方便表達(dá)稀疏存儲,它將上面三種字節(jié)表示形式分別賦予了一條指令。
-
ZERO : 一字節(jié),表示連續(xù)多少個桶計數(shù)為0,前兩位為標(biāo)志00,后6位表示有多少個桶,最大為64。
-
XZERO : 兩個字節(jié),表示連續(xù)多少個桶計數(shù)為0,前兩位為標(biāo)志01,后14位表示有多少個桶,最大為16384。
-
VAL : 一字節(jié),表示連續(xù)多少個桶的計數(shù)為多少,前一位為標(biāo)志1,四位表示連桶內(nèi)計數(shù),所以最大表示桶的計數(shù)為32。后兩位表示連續(xù)多少個桶。
?
Redis從稀疏存儲轉(zhuǎn)換到密集存儲的條件是:
-
任意一個計數(shù)值從 32 變成 33,因為 VAL 指令已經(jīng)無法容納,它能表示的計數(shù)值最大為 32
-
稀疏存儲占用的總字節(jié)數(shù)超過 3000 字節(jié),這個閾值可以通過 hllsparsemax_bytes 參數(shù)進(jìn)行調(diào)整。
2.4LRU專欄
2.4.1LRU介紹和代碼實現(xiàn)
LRU全稱是Least?Recently Used,即最近最久未使用的意思。
LRU算法的設(shè)計原則是:如果一個數(shù)據(jù)在最近一段時間沒有被訪問到,那么在將來它被訪問的可能性也很小。也就是說,當(dāng)限定的空間已存滿數(shù)據(jù)時,應(yīng)當(dāng)把最久沒有被訪問到的數(shù)據(jù)淘汰。(這一段是找的,讓大家理解一下什么是LRU)。
?
說一下我們什么時候見到過LRU:其實老師們肯定都給大家舉過這么個例子:你在圖書館,你把書架子里的書拿到桌子上。。但是桌子是有限的,你有時候不得不把一些書放回去。這就相當(dāng)于內(nèi)存和硬盤。這個例子都說過吧?
LRU就是記錄你最長時間沒看過的書,就把它放回去。在cache那里見過吧
?
然后最近在研究redis,又看到了這個LRU,所以就想寫一下吧。
題目:設(shè)計一個結(jié)構(gòu),這個結(jié)構(gòu)可以查詢K-V,但是容量有限,當(dāng)存不下的時候就要把用的年代最久遠(yuǎn)的那個東西扔掉。
其實思路很簡單,我們維護(hù)一個雙向鏈表即可,get也就是使用了,我們就把把它提到最安全的位置。新來的KV就依次放即可。
我們就先寫這個雙向鏈表結(jié)構(gòu)
先寫節(jié)點結(jié)構(gòu):
public static class Node<V> {public V value;public Node<V> last;//前public Node<V> next;//后public Node(V value) {this.value = value;}}然后寫雙向鏈表結(jié)構(gòu): 我們沒必要把鏈表操作都寫了,分析一下,我們只有三個操作:
1、加節(jié)點
2、使用了某個節(jié)點就把它調(diào)到尾,代表優(yōu)先級最高
3、把優(yōu)先級最低的移除,也就是去頭部
(不會的,翻我之前的鏈表操作都有寫)
public static class NodeDoubleLinkedList<V> {private Node<V> head;//頭private Node<V> tail;//尾public NodeDoubleLinkedList() {this.head = null;this.tail = null;}public void addNode(Node<V> newNode) {if (newNode == null) {return;}if (this.head == null) {//頭空this.head = newNode;this.tail = newNode;} else {//頭不空this.tail.next = newNode;newNode.last = this.tail;//注意讓本節(jié)點前指針指向舊尾this.tail = newNode;//指向新尾}} /*某個點移到最后*/public void moveNodeToTail(Node<V> node) {if (this.tail == node) {//是尾return;}if (this.head == node) {//是頭this.head = node.next;this.head.last = null;} else {//中間node.last.next = node.next;node.next.last = node.last;}node.last = this.tail;node.next = null;this.tail.next = node;this.tail = node;} /*刪除第一個*/public Node<V> removeHead() {if (this.head == null) {return null;}Node<V> res = this.head;if (this.head == this.tail) {//就一個this.head = null;this.tail = null;} else {this.head = res.next;res.next = null;this.head.last = null;}return res;}}鏈表操作封裝完了就要實現(xiàn)這個結(jié)構(gòu)了。
具體思路代碼注釋
public static class MyCache<K, V> {//為了kv or vk都能查private HashMap<K, Node<V>> keyNodeMap;private HashMap<Node<V>, K> nodeKeyMap;//用來做優(yōu)先級private NodeDoubleLinkedList<V> nodeList;private int capacity;//容量public MyCache(int capacity) {if (capacity < 1) {//你容量連1都不給,搗亂呢throw new RuntimeException("should be more than 0.");}this.keyNodeMap = new HashMap<K, Node<V>>();this.nodeKeyMap = new HashMap<Node<V>, K>();this.nodeList = new NodeDoubleLinkedList<V>();this.capacity = capacity;}public V get(K key) {if (this.keyNodeMap.containsKey(key)) {Node<V> res = this.keyNodeMap.get(key);this.nodeList.moveNodeToTail(res);//使用過了就放到尾部return res.value;}return null;}public void set(K key, V value) {if (this.keyNodeMap.containsKey(key)) {Node<V> node = this.keyNodeMap.get(key);node.value = value;//放新vthis.nodeList.moveNodeToTail(node);//我們認(rèn)為放入舊key也是使用過} else {Node<V> newNode = new Node<V>(value);this.keyNodeMap.put(key, newNode);this.nodeKeyMap.put(newNode, key);this.nodeList.addNode(newNode);//加進(jìn)去if (this.keyNodeMap.size() == this.capacity + 1) {this.removeMostUnusedCache();//放不下就去掉優(yōu)先級最低的}}}private void removeMostUnusedCache() {//刪除頭Node<V> removeNode = this.nodeList.removeHead();K removeKey = this.nodeKeyMap.get(removeNode);//刪除掉兩個map中的記錄this.nodeKeyMap.remove(removeNode);this.keyNodeMap.remove(removeKey);}}?
2.4.2Redis中的LRU算法改進(jìn)
redis通常使用緩存,是使用一種固定最大內(nèi)存的使用。當(dāng)數(shù)據(jù)達(dá)到可使用的最大固定內(nèi)存時,我們需要通過移除老數(shù)據(jù)來獲取空間。redis作為緩存是否有效的重要標(biāo)志是如何尋找一種好的策略:刪除即將需要使用的數(shù)據(jù)是一種糟糕的策略,而刪除那些很少再次請求的數(shù)據(jù)則是一種好的策略。
在其他的緩存組件還有個命中率,僅僅表示讀請求的比例。訪問一個緩存中的keys通常不是分布式的。然而訪問經(jīng)常變化,這意味著不經(jīng)常訪問,相反,有些keys一旦不流行可能會轉(zhuǎn)向最經(jīng)常訪問的keys。 因此,通常一個緩存系統(tǒng)應(yīng)該盡可能保留那些未來最有可能被訪問的keys。針對keys淘汰的策略是:那些未來極少可能被訪問的數(shù)據(jù)應(yīng)該被移除。
但有一個問題:redis和其他緩存系統(tǒng)不能夠預(yù)測未來。
LRU算法
緩存系統(tǒng)不能預(yù)測未來,原因是:那些很少再次被訪問的key也很有可能最近訪問相當(dāng)頻繁。如果經(jīng)常被訪問的模式不會突然改變,那么這是一種很有效的策略。然而,“最近經(jīng)常被訪問”似乎更隱晦地標(biāo)明一種 理念。這種算法被稱為LRU算法。最近訪問頻繁的key相比訪問少的key有更高的可能性。
舉個例子,這里有4個不同訪問周期的key,每一個“~”字符代表一秒,結(jié)尾的“|”表示當(dāng)前時刻。
A key每5秒請求一次,B周期是2秒,C、D都是10秒。
訪問頻率最高的是B,因為它的空閑時間最短,這意味著B是4個key中未來最有可能被訪問的key。
同樣的A和C目前的空閑時間是2s和6s也能很好地反映它們本身的周期。然而你可以看到不夠嚴(yán)謹(jǐn):D的訪問周期是10秒,但它卻是4個key中最近被訪問的。
當(dāng)然,在一個很長的運(yùn)行周期中,LRU算法能工作得很好。通常有一個更高訪問頻率的key當(dāng)然有一個更低的空閑周期。LRU算法淘汰最少被訪問key,那些有最大空閑周期的key。實現(xiàn)上也相當(dāng)容易,只需要額外跟蹤最近被訪問的key即可,有時甚至都需要:把所有我們想要淘汰的對象放到一個鏈表中,當(dāng)一個對象訪問就移除鏈表頭部元素,當(dāng)我們要淘汰元素是就直接淘汰鏈表尾部開始。
redis中的LRU:起因
最初,redis不支持LRU算法。當(dāng)內(nèi)存有效性成為一個必須被解決的問題時,后來才加上了。通過修改redis對象結(jié)構(gòu),在每個key對象增加24bit的空間。沒有額外的空間使用鏈表把所有對象放到一個鏈表中(大指針),因此需要實現(xiàn)得更加有效,不能因為key淘汰算法而讓整個服務(wù)改動太大。
24bits的對象已經(jīng)足夠去存儲當(dāng)前的unxi時間戳。這個表現(xiàn),被稱為“LRU 時鐘”,key元數(shù)據(jù)經(jīng)常被更新,所以它是一個有效的算法。
然后,有另一個更加復(fù)雜的問題需要解決:如何選擇訪問間隔最長的key,然后淘汰它。
redis內(nèi)部采用一個龐大的hash table來保存,添加另外一個數(shù)據(jù)結(jié)構(gòu)存儲時間間隔顯然不是一個好的選擇。然而我們希望能達(dá)到一個LRU本身是一個近似的,通過LRU算法本身來實現(xiàn)。
redis原始的淘汰算法簡單實現(xiàn):**當(dāng)需要淘汰一個key時,隨機(jī)選擇3個key,淘汰其中間隔時間最長的key。**基本上,我們隨機(jī)選擇key,淘汰key效果很好。后來隨機(jī)3個key改成一個配置項"N隨機(jī)key"。但把默認(rèn)值提高改成5個后效果大大提高。考慮到它的效果,你根本不用修改他。
然而,你可能會想這個算法如何有效執(zhí)行,你可以看到我們?nèi)绾螕v毀了很多有趣的數(shù)據(jù)。也許簡單的N key,我們會遇到很多好的決策,但是當(dāng)我們淘汰最好的,下一個周期又開始抓。
驗證規(guī)則第一條:用肉眼觀察你的算法
其中有一個觀點已經(jīng)應(yīng)用到Redis 3.0正式版中了。在redis2.8中一個LRU緩存經(jīng)常被使用在多個環(huán)境,用戶關(guān)于淘汰的沒有抱怨太多,但是很明顯我可以提高它,通過不僅僅是增加額外的空間,還有額外的CPU時間。
然而為了提高某項功能,你必須觀察它。有多個不同的方式去觀察LRU算法。你可以通過寫工具觀察,例如模擬不同的工作負(fù)載、校驗命中率和失誤率。
程序非常簡單:增加一些指定的keys,然后頻繁地訪問這些keys以至于每一個key都有一個下降的空閑時間。最終超過50%的keys被增加,一半的老key需要被淘汰。
一個完美理想的LRU實現(xiàn),應(yīng)該是沒有最新加的key被淘汰,而是淘汰最初的50%的老key。
規(guī)則二:不要丟棄重要信息
借助最新的可視化工具,我可以在嘗試新的方法觀察和測試幾分鐘。使用redis最明顯有效的提高算法就是,積累對立的垃圾信息在一個淘汰池中。
基本上,當(dāng)N keys算法被采用時,通常會分配一個很大的線程pool(默認(rèn)為16key),這個池按照空閑時間排序,所以只有當(dāng)有一個大于池中的一個或者池為空的時候,最新的key只會進(jìn)入到這個池中。
同時,一個新的redis-cli模式去測量LRU算法也增加了(看這個-lru-test選項)。
還有另外一個方式去檢驗LRU算法的好壞,通過一個冪等訪問模式。這個工具通常校驗用一個不同的測試,新算法工作工作效果好于真實世界負(fù)載。它也同樣使用流水線和每秒打印訪問日志,因此可以被使用不用為了基準(zhǔn)不同的思想,至少可以校驗和觀察明顯的速度回歸。
規(guī)則三、最少使用原則(LFU算法)
一切源于一個開放性問題:但你有多個redis 3.2數(shù)據(jù)庫時,而淘汰算法只能在本機(jī)選擇。因此,假如你全部空閑小的key都是DB0號機(jī)器,空閑時間長的key都是1號機(jī)器,redis每臺機(jī)器都會淘汰各自的key。一個更好的選擇當(dāng)然是先淘汰DB1,最后再淘汰DB0。
當(dāng)redis被當(dāng)作緩存使用時很少有情況被分成不同的db上,這不是一個好的處理方式。然而這也是我為什么我再一次修改淘汰代碼的原因。最終,我能夠修改緩存池包括數(shù)據(jù)庫id,使用單緩存池為多個db,代替多緩存池。這種實現(xiàn)很麻煩,但是通過優(yōu)化和修改代碼,最終它比普通實現(xiàn)要快到20%。
然而這時候,我對這個redis緩存淘汰算法的好奇心又被點燃。我想要提升它。我花費(fèi)了幾天想要提高LRU算法實現(xiàn):或許可以使用更大的緩存池?通過歷史時間選擇最合適被淘汰的key?
經(jīng)過一段時間,通過優(yōu)化我的工具,我理解到:LRU算法受限于數(shù)據(jù)庫中的數(shù)據(jù)樣本,有時可能相反的場景效果非常好,因此要想提高非常非常難。實際上,能通過展示不同算法的圖片上看這有點非常明顯:每個周期10個keys幾乎和理論的LRU算法表現(xiàn)一致。
當(dāng)原始算法很難提高時,我開始測試新的算法。 如果我們倒回到博客開始,我們說過LRU實際上有點嚴(yán)格。哪些key需要我們真正想要保留:將來有最大可能被訪問,最頻繁被訪問,而不是最近被訪問的key。
淘汰最少被訪問的key算法成為:LFU(Least Frequently Used),將來要被淘汰騰出新空間給新key。
理論上LFU的思想相當(dāng)簡單,只需要給每個key加一個訪問計數(shù)器。每次訪問就自增1,所以也就很容易知道哪些key被訪問更頻繁。
當(dāng)然,LFU也會帶起其他問題,不單單是針對redis,對于LFU實現(xiàn):
1、不能使用“移除頂部元素”的方式,keys必須要根據(jù)訪問計數(shù)器進(jìn)行排序。每訪問一次就得遍歷所有key找出訪問次數(shù)最少的key。
2、LFU不能僅僅是只增加每一訪問的計數(shù)器。正如我們所講的,訪問模式改變隨時變化,因此一個有高訪問次數(shù)的key,后面很可能沒有人繼續(xù)訪問它,因此我們的算法必須要適應(yīng)超時的情況。
在redis中,第一個問題很好解決:我們可以在LRU的方式一樣:隨機(jī)在緩存池中選舉,淘汰其中某項。第二個問題redis還是存在,因此一般對于LFU的思想必須使用一些方式進(jìn)行減少,或者定期把訪問計數(shù)器減半。
24位的LFU實現(xiàn)
LFU有它本身的實現(xiàn),在redis中我們使用自己的24bit來記錄LRU。
為了實現(xiàn)LFU僅僅需要在每個對象額外新增24bit:
1、一部分用于保存訪問計數(shù)器;
2、足夠用于決定什么時候?qū)⒂嫈?shù)器減半的信息;
我的解決方法是把24bit分成兩列:
16bits8bitslast decr timeLOG_C
16位記錄最后一次減半時間,那樣redis知道上一次減半時間,另外8bit作為訪問計數(shù)器。
你可能會想8位的計數(shù)器很快就會溢出,是的,相對于簡單計數(shù)器,我采用邏輯計數(shù)器。邏輯計數(shù)器的實現(xiàn):
基本上計數(shù)器的較大者,更小的可能計數(shù)器會增加:上面的代碼計算p位于0~1之間,但計數(shù)器增長時會越來越小,位于0-1的隨機(jī)數(shù)r,只會但滿足r<p時計數(shù)器才會加一。
你可以配置計數(shù)器增長的速率,如果使用默認(rèn)配置,會發(fā)生:
- 100次訪問后,計數(shù)器=10;
- 1000次訪問是是18;
- 10萬次訪問是142;
- 100萬次訪問后達(dá)到255,并不在繼續(xù)增長;
下面,讓我們看看計數(shù)器如果進(jìn)行衰減。16位的被儲存為unix時間戳保留到分鐘級別,redis會隨機(jī)掃描key填充到緩存池中,如果最后一個下降的時間大于N分鐘前(可配置化),如果計數(shù)器的值很大就減半,或者對于值小的就直接簡單減半。
這里又衍生出另外一個問題,就是新進(jìn)來的key是需要有機(jī)會被保留的。由于LFU新增是得分都是0,非常容易被選舉替換掉。在redis中,開始默認(rèn)值為5。這個初始值是根據(jù)增長數(shù)據(jù)和減半算法來估算的。模擬顯示得分小于5的key是首選。
代碼和性能
上面描述的算法已經(jīng)提交到一個非穩(wěn)定版的redis分支上。我最初的測試顯示:它在冪等模式下優(yōu)于LRU算法,測試情況是每個key使用用相同數(shù)量的內(nèi)存,然而真實世界的訪問可能會有很大不同。時間和空間都可能改變得很不同,所以我會很開心去學(xué)習(xí)觀察現(xiàn)實世界中LFU的性能如何,兩種方式在redis實現(xiàn)中對性能的改變。
因此,新增了一個OBJECT FREQ子命令,用于報告給定key的訪問計數(shù)器,不僅僅能有效提觀察一個計數(shù)器,而且還能調(diào)試LFU實現(xiàn)中的bug。
注意運(yùn)行中切換LRU和LFU,剛開始會隨機(jī)淘汰一些key,隨著24bit不能匹配上,然而慢慢會適應(yīng)。 還有幾種改進(jìn)實現(xiàn)的可能。Ben Manes發(fā)給我這篇感興趣的文章,描述了一種叫TinyLRU算法。鏈接
這篇文章包含一個非常厲害的觀點:相比于記錄當(dāng)前對象的訪問頻率,讓我們(概率性地)記錄全部對象的訪問頻率,看到了,這種方式我們甚至可以拒絕新key,同樣,我們相信這些key很可能得到很少的訪問,所以一點也不需要淘汰,如果淘汰一個key意味著降低命中/未命中率。
我的感覺這種技術(shù)雖然很感興趣GET/SET LFU緩存,但不適用與redis性質(zhì)的數(shù)據(jù)服務(wù)器:用戶期望keys被創(chuàng)建后至少存在幾毫秒。拒絕key的創(chuàng)建似乎在redis上就是一種錯誤。
然而,redis保留了LFU信息,當(dāng)一個key被覆蓋時,舉個例子:
24位的LFU計數(shù)器會從老的key復(fù)制到新對象中。
新的redis淘汰算法不穩(wěn)定版本還有以下幾個好消息:
1、跨DB策略。在過去的redis只是基于本地的選舉,現(xiàn)在修復(fù)為所有策略,不僅僅是LRU。
2、易變ttl策略。基于key預(yù)期淘汰存活時間,如今就像其他策略中的使用緩存池。
3、在緩存池中重用了sds對象,性能更好。
這篇博客比我預(yù)期要長,但是我希望它反映出一個見解:在創(chuàng)新和對于已經(jīng)存在的事物實現(xiàn)上,一種解決方案去解決一個特定問題,一個基礎(chǔ)工具。由開發(fā)人員以正確的方式使用它。許多redis的用戶把redis作為一個緩存的解決方案,因此提高淘汰策略這一塊經(jīng)常一次又一次被拿出來探討。
2.6對象
剛寫了redis主要的數(shù)據(jù)結(jié)構(gòu):
動態(tài)字符串、雙端鏈表、字典、壓縮列表、整數(shù)集合、跳表等
redis肯定不能直接使用這些數(shù)據(jù)結(jié)構(gòu)來實現(xiàn)數(shù)據(jù)庫,它用這些數(shù)據(jù)庫建立了一個對象系統(tǒng),包含:
字符串對象、列表對象、哈希對象、集合對象、有序集合對象
我們可以針對不同的使用場景,為對象設(shè)置多種分不同的數(shù)據(jù)結(jié)構(gòu)實現(xiàn),從而優(yōu)化對象在不同場景下的效率。
1)鍵值對
對于redis的鍵值對來說:key只有字符串類型,而v可以是各種類型,
我們習(xí)慣把“這個鍵所對應(yīng)的值是一個列表”表達(dá)為這是一個“列表鍵。
TYPE?命令的實現(xiàn)方式也與此類似, 當(dāng)我們對一個數(shù)據(jù)庫鍵執(zhí)行?TYPE?命令時, 命令返回的結(jié)果為數(shù)據(jù)庫鍵對應(yīng)的值對象的類型, 而不是鍵對象的類型:
# 鍵為字符串對象,值為列表對象redis> RPUSH numbers 1 3 5 (integer) 6redis> TYPE numbers list2)對象
我們看一下redis對象的組成:
typedef struct redisObject {// 類型unsigned type:4;// 編碼unsigned encoding:4;// 指向底層實現(xiàn)數(shù)據(jù)結(jié)構(gòu)的指針void *ptr;// ... } robj;通過?encoding?屬性來設(shè)定對象所使用的編碼, 而不是為特定類型的對象關(guān)聯(lián)一種固定的編碼, 極大地提升了 Redis 的靈活性和效率, 因為 Redis 可以根據(jù)不同的使用場景來為一個對象設(shè)置不同的編碼, 從而優(yōu)化對象在某一場景下的效率。
字符串對象
字符串對象的編碼可以是?int?、?raw?或者?embstr?。
如果一個字符串對象保存的是整數(shù)值, 并且這個整數(shù)值可以用?long?類型來表示, 那么字符串對象會將整數(shù)值保存在字符串對象結(jié)構(gòu)的?ptr屬性里面(將?void*?轉(zhuǎn)換成?long?), 并將字符串對象的編碼設(shè)置為?int?。
如果字符串對象保存的是一個字符串值, 并且這個字符串值的長度大于?39?字節(jié), 那么字符串對象將使用一個簡單動態(tài)字符串(SDS)來保存這個字符串值, 并將對象的編碼設(shè)置為?raw?。
如果字符串對象保存的是一個字符串值, 并且這個字符串值的長度小于等于?39?字節(jié), 那么字符串對象將使用?embstr?編碼的方式來保存這個字符串值。
embstr?編碼是專門用于保存短字符串的一種優(yōu)化編碼方式, 這種編碼和?raw?編碼一樣, 都使用?redisObject?結(jié)構(gòu)和?sdshdr?結(jié)構(gòu)來表示字符串對象,但?raw?編碼會調(diào)用兩次內(nèi)存分配函數(shù)來分別創(chuàng)建?redisObject?結(jié)構(gòu)和?sdshdr?結(jié)構(gòu),而?embstr?編碼則通過調(diào)用一次內(nèi)存分配函數(shù)來分配一塊連續(xù)的空間, 空間中依次包含?redisObject?和?sdshdr?兩個結(jié)構(gòu)。
?embstr?編碼有以下好處:
3)列表對象
列表對象的編碼可以是?ziplist?或者?linkedlist?。
當(dāng)列表對象可以同時滿足以下兩個條件時, 列表對象使用?ziplist?編碼:
不能滿足這兩個條件的列表對象需要使用?linkedlist?編碼。
4)哈希對象
哈希對象的編碼可以是?ziplist?或者?hashtable?。
當(dāng)哈希對象可以同時滿足以下兩個條件時, 哈希對象使用?ziplist?編碼:
不能滿足這兩個條件的哈希對象需要使用?hashtable?編碼。
5)集合對象
集合對象的編碼可以是?intset?或者?hashtable?。
當(dāng)集合對象可以同時滿足以下兩個條件時, 對象使用?intset?編碼:
不能滿足這兩個條件的集合對象需要使用?hashtable?編碼。
6)有序集合對象
有序集合的編碼可以是?ziplist?或者?skiplist?。
當(dāng)有序集合對象可以同時滿足以下兩個條件時, 對象使用?ziplist?編碼:
不能滿足以上兩個條件的有序集合對象將使用?skiplist?編碼。
這里多說兩句,各個語言的對象其實都差不多,底層實現(xiàn)也就那幾個,比如java中的容器,c++的STL。java的hashset就是一個哈希而已,hashmap就是k帶了一個v,而”有序的“Treemap使用了紅黑樹這種有平衡性的搜索二叉樹。
redis的有序集合并沒有再采取hash+紅黑樹的操作,而是把平衡樹換成了跳表,實際上性能真的沒差多少,甚至有時比紅黑樹有優(yōu)勢,比如跳表的性能較為平均,紅黑樹攢了很多次不平衡要調(diào)整可能會帶來資源需求的一個高峰,再加上跳表實現(xiàn)簡單的優(yōu)點,紅黑樹真的沒什么優(yōu)勢。
并且就算是真的想用一種帶平衡性的搜索樹,現(xiàn)在競賽也是用的華人之光發(fā)明的SB樹。
有序集合的優(yōu)點就是它的有序操作,比如拿最大最小值,紅黑樹時間o(logN),而哈希表只能一個一個遍歷。缺點在于插入一個值的時間也是o(logN),跳表也是。而哈希表插入數(shù)是o(1).
要了解底層和這些優(yōu)缺點
?
?
《三天給你聊清楚redis》第2天看看redis怎么被搞出來的(22036字)
三、單機(jī)實現(xiàn)
3.1、數(shù)據(jù)庫概述
redis服務(wù)器將所有數(shù)據(jù)庫都保存在redis/redisServer中,數(shù)組db存放所有數(shù)據(jù)庫,每一項是一個redisdb結(jié)構(gòu)。dbnum代表數(shù)據(jù)庫數(shù)量。
客戶端有一個指針指向當(dāng)前數(shù)據(jù)庫,可以切換,也就是移動指針。
3.1.1鍵空間
現(xiàn)在稍微介紹一下redisdb結(jié)構(gòu),它的字典保存了所有鍵值對
鍵空間的鍵也就是數(shù)據(jù)庫的鍵, 每個鍵都是一個字符串對象。
鍵空間的值也就是數(shù)據(jù)庫的值, 每個值可以是字符串對象、列表對象、哈希表對象、集合對象、有序集合對象
所有數(shù)據(jù)庫的操作,添加一個鍵值對, 刪除一個鍵值對, 獲取某個鍵值對, 等等,都是通過對鍵空間字典進(jìn)行操作來實現(xiàn)的。
3.1.2維護(hù)
讀寫鍵空間的時候,服務(wù)器會執(zhí)行一些額外操作,比如:
- 讀一個鍵后(讀操作寫操作都要對鍵讀取),?會根據(jù)鍵是否存在, 更新鍵空間命中(hit)次數(shù)或不命中(miss)次數(shù)。
- 讀取一個鍵后, 服務(wù)器會更新鍵的 LRU (最后一次使用)時間, 這個值可以用于計算鍵的閑置時間。
- 如果服務(wù)器在讀一個鍵時, 該鍵已經(jīng)過期, 服務(wù)器會刪除這個鍵, 然后執(zhí)行其他操作。
- 如果客戶使用?WATCH?監(jiān)視某個鍵,在對這個鍵進(jìn)行修改之后, 會將這個鍵記為臟(dirty),讓事務(wù)程序知到這個鍵被修改
- 服務(wù)器每次修改一個鍵之后, 都會對臟(dirty)鍵計數(shù)器的值增一, 這個計數(shù)器會觸發(fā)服務(wù)器的持久化以及復(fù)制操作執(zhí)行
- 如果服務(wù)器開啟了數(shù)據(jù)庫通知功能, 那么在對鍵進(jìn)行修改之后, 服務(wù)器將按配置發(fā)送相應(yīng)的數(shù)據(jù)庫通知。
3.1.3時間
用戶可以給某個鍵設(shè)置生存時間,過期時間是一個UNIX時間戳,到時間自動刪除這個鍵。
redisdb結(jié)構(gòu)的expires字典保存了所有的鍵的過期時間,我們稱這個字典為過期字典。
3.1.4三種過期鍵刪除策略
1)定時刪除:創(chuàng)建一個定時器,到時間立即執(zhí)行刪除操作(對內(nèi)存友好,因為能保證過期了立馬刪除,但是對cpu不友好)
2)惰性刪除:鍵過期不管,每次獲取鍵時檢查是否過期,過期就刪除(對cpu友好,但是只有在使用的時候才可能刪除,對內(nèi)存不友好)
3)定期刪除:隔一段時間檢查一次(具體算法決定檢查多少刪多少,需要合理設(shè)置)
3.1.5淘汰策略
當(dāng)Redis占用內(nèi)存超出最大限制 (maxmemory) 時,可采用如下策略 (maxmemory-policy) ,讓Redis淘汰一些數(shù)據(jù),以騰出空間繼續(xù)提供讀寫服務(wù) :
noeviction: 對可能導(dǎo)致增大內(nèi)存的命令返回錯誤 (大多數(shù)寫命令,DEL除外) ;
volatile-ttl: 在設(shè)置了過期時間的key中,選擇剩余壽命 (TTL) 最短的key,將其淘汰;
volatile-lru: 在設(shè)置了過期時間的key中,選擇最少使用的key (RU) ,將其淘汰;
volatile-random: 在設(shè)置了過期時間的key中,隨機(jī)選擇一些key,將其淘汰;
allkeys-1Lru: 在所有的key中,選擇最少使用的key (LRU) ,將其淘汰;
allkeys-random: 在所有的key中,隨機(jī)選擇一些key,將其淘汰;
?
3.2、持久化
因為redis是內(nèi)存數(shù)據(jù)庫,他把數(shù)據(jù)都存在內(nèi)存里,所以要想辦法實現(xiàn)持久化功能。
3.2.1、RDB
RDB持久化可以手動執(zhí)行,也可以配置定期執(zhí)行,可以把某個時間的數(shù)據(jù)狀態(tài)保存到RDB文件中,反之,我們可以用RDB文件還原數(shù)據(jù)庫狀態(tài)。
? ? 生成
有兩個命令可以生成RDB文件:
- SAVE?命令由服務(wù)器進(jìn)程直接執(zhí)行保存操作,所以該命令會阻塞服務(wù)器,服務(wù)器不能接受其他指令。
- BGSAVE?命令由子進(jìn)程執(zhí)行保存操作,所以該命令不會阻塞服務(wù)器,服務(wù)器可以接受其他指令。。
禁止BGSAVE和SAVE同時執(zhí)行,也就是說執(zhí)行其中一個就會拒絕另一個,這是為了避免父進(jìn)程和子進(jìn)程同時執(zhí)行兩個rdbsave,防止產(chǎn)生競爭條件。
? ? 載入
? ? RDB載入工作是服務(wù)器啟動時自動執(zhí)行的。
? ? 自動保存
用戶可以通過save選項設(shè)置多個保存條件,服務(wù)器狀態(tài)中會保存所有用?save?選項設(shè)置的保存條件,當(dāng)任意一個保存條件被滿足時,服務(wù)器會自動執(zhí)行?BGSAVE?命令。
比如
save 900 1
save 300 10
滿足:服務(wù)器在900秒之內(nèi)被修改至少一次或者300秒內(nèi)修改至少十次。就會執(zhí)行BGSAVE。
?
當(dāng)服務(wù)器啟動時,用戶可以通過指定配置文件或者傳入啟動參數(shù)來設(shè)置save選項,服務(wù)器會把條件放到一個結(jié)構(gòu)體里,結(jié)構(gòu)體有一個數(shù)組,保存了所有條件。
?
serverCron函數(shù)默認(rèn)100毫秒檢查一次,他會遍歷數(shù)組依次檢查,符合條件就會執(zhí)行BGSAVE。
? ? RDB文件結(jié)構(gòu)
一個完整 RDB 文件所包含的各個部分:
REDIS,長度5字節(jié), 保存著?"REDIS"?五個字符。 通過這五個字符, 可以在載入文件時, 快速檢查載入文件是否 RDB 文件。
db_version?,長度?4?字節(jié), 它的值是一個字符串表示的整數(shù), 這個整數(shù)記錄了 RDB 文件的版本號
databases?部分包含著零個或任意多個數(shù)據(jù)庫, 以及各個數(shù)據(jù)庫中的鍵值對數(shù)據(jù)
EOF?常量的長度為?1?字節(jié), 這個常量標(biāo)志著 RDB 文件正文內(nèi)容的結(jié)束
check_sum?是一個?8?字節(jié)長的無符號整數(shù), 保存著一個校驗和,以此來檢查 RDB 文件是否出錯或損壞
我并不想深入探究databases的組成。就是知道
- RDB 文件是一個經(jīng)過壓縮的二進(jìn)制文件,由多個部分組成。
- 對于不同類型的鍵值對, RDB 文件會使用不同的方式來保存它們即可。
3.2.2、AOF
AOF持久化是通過保存服務(wù)器執(zhí)行的命令來記錄狀態(tài)的。還原的時候再執(zhí)行一遍即可。
功能的實現(xiàn)可以分為命令追加、文件寫入、文件同步三個步驟。
?
當(dāng) AOF 持久化功能處于打開狀態(tài)時, 服務(wù)器在執(zhí)行完一個寫命令之后, 會以協(xié)議格式將被執(zhí)行的寫命令追加到服務(wù)器狀態(tài)的?aof_buf?緩沖區(qū)的末尾:
struct redisServer {// ...// AOF 緩沖區(qū)sds aof_buf;// ... };Redis 服務(wù)器進(jìn)程就是一個事件循環(huán)
循環(huán)中的文件事件負(fù)責(zé)接收客戶端的命令請求, 以及向客戶端發(fā)送命令回復(fù),
而時間事件則負(fù)責(zé)執(zhí)行像?serverCron?函數(shù)這樣需要定時運(yùn)行的函數(shù)。
因為服務(wù)器在處理文件事件時可能會執(zhí)行寫命令, 使得一些內(nèi)容被追加到?aof_buf?緩沖區(qū)里面, 所以在服務(wù)器每次結(jié)束一個事件循環(huán)之前, 它都會調(diào)用?flushAppendOnlyFile?函數(shù), 考慮是否需要將?aof_buf?緩沖區(qū)中的內(nèi)容寫入和保存到 AOF 文件里面, 這個過程可以用偽代碼表示:
def eventLoop():while True:# 處理文件事件,接收命令請求以及發(fā)送命令回復(fù)# 處理命令請求時可能會有新內(nèi)容被追加到 aof_buf 緩沖區(qū)中processFileEvents()# 處理時間事件processTimeEvents()# 考慮是否要將 aof_buf 中的內(nèi)容寫入和保存到 AOF 文件里面flushAppendOnlyFile()flushAppendOnlyFile?函數(shù)的行為由服務(wù)器配置的?appendfsync?選項的值來決定
值為?always?時, 服務(wù)器在每個事件循環(huán)都要將?aof_buf?緩沖區(qū)中的所有內(nèi)容寫入到 AOF 文件并且同步 AOF 文件, 所以?always?的效率最慢的一個, 但從安全性來說,?always?是最安全的, 因為即使出現(xiàn)故障停機(jī), AOF 持久化也只會丟失一個事件循環(huán)中所產(chǎn)生的命令數(shù)據(jù)。
值為?everysec?時, 服務(wù)器在每個事件循環(huán)都要將?aof_buf?緩沖區(qū)中的所有內(nèi)容寫入到 AOF 文件, 每隔超過一秒就要在子線程中對 AOF 文件進(jìn)行一次同步: 從效率上來講,?everysec?模式足夠快, 并且就算出現(xiàn)故障停機(jī), 數(shù)據(jù)庫也只丟失一秒鐘的命令數(shù)據(jù)。
值為?no?時, 服務(wù)器在每個事件循環(huán)都要將?aof_buf?緩沖區(qū)中的所有內(nèi)容寫入到 AOF 文件, 至于何時對 AOF 文件進(jìn)行同步, 則由操作系統(tǒng)控制。
因為處于?no?模式下的?flushAppendOnlyFile?調(diào)用無須執(zhí)行同步操作, 所以該模式下的 AOF 文件寫入速度總是最快的, 不過因為這種模式會在系統(tǒng)緩存中積累一段時間的寫入數(shù)據(jù), 所以該模式的單次同步時長通常是三種模式中時間最長的: 從平攤操作的角度來看,no?模式和?everysec?模式的效率類似, 當(dāng)出現(xiàn)故障停機(jī)時, 使用?no?模式的服務(wù)器將丟失上次同步 AOF 文件之后的所有寫命令數(shù)據(jù)。
? ? 重寫
AOF持久化是保存了一堆命令來恢復(fù)數(shù)據(jù)庫,隨著時間流逝,存的會越來越多,如果不加以控制,文件過大可能影響服務(wù)器甚至計算機(jī)。而且文件過大,恢復(fù)時需要時間也太長。
所以redis提供了重寫功能,寫出的新文件不會包含任何浪費(fèi)時間的冗余命令。
接下來,我們就介紹重寫的原理。
其實重寫不會對現(xiàn)有的AOF文件進(jìn)行讀取分析等操作,而是通過當(dāng)前服務(wù)器的狀態(tài)來實現(xiàn)。
# 假設(shè)服務(wù)器對鍵list執(zhí)行了以下命令s; 127.0.0.1:6379> RPUSH list "A" "B" (integer) 2 127.0.0.1:6379> RPUSH list "C" (integer) 3 127.0.0.1:6379> RPUSH list "D" "E" (integer) 5 127.0.0.1:6379> LPOP list "A" 127.0.0.1:6379> LPOP list "B" 127.0.0.1:6379> RPUSH list "F" "G" (integer) 5 127.0.0.1:6379> LRANGE list 0 -1 1) "C" 2) "D" 3) "E" 4) "F" 5) "G" 127.0.0.1:6379>當(dāng)前列表鍵list在數(shù)據(jù)庫中的值就為["C", "D", "E", "F", "G"]。要使用盡量少的命令來記錄list鍵的狀態(tài),最簡單的方式不是去讀取和分析現(xiàn)有AOF文件的內(nèi)容,,而是直接讀取list鍵在數(shù)據(jù)庫中的當(dāng)前值,然后用一條RPUSH list "C" "D" "E" "F" "G"代替前面的6條命令。
- 偽代碼表示如下
? ? AOF后臺重寫
aof_rewrite函數(shù)可以創(chuàng)建新的AOF文件,但是這個函數(shù)會進(jìn)行大量的寫入操作,所以調(diào)用這個函數(shù)的線程被長時間的阻塞,因為服務(wù)器使用單線程來處理命令請求;所以如果直接是服務(wù)器進(jìn)程調(diào)用AOF_REWRITE函數(shù)的話,那么重寫AOF期間,服務(wù)器將無法處理客戶端發(fā)送來的命令請求;
Redis不希望AOF重寫會造成服務(wù)器無法處理請求,所以將AOF重寫程序放到子進(jìn)程(后臺)里執(zhí)行。這樣處理的好處是:?
1)子進(jìn)程進(jìn)行AOF重寫期間,主進(jìn)程可以繼續(xù)處理命令請求;
2)子進(jìn)程帶有主進(jìn)程的數(shù)據(jù)副本,使用子進(jìn)程而不是線程,可以避免在鎖的情況下,保證數(shù)據(jù)的安全性。
還有一個問題,可能重寫的時候又有新的命令過來,造成信息不對等,所以redis設(shè)置了一個緩沖區(qū),重寫期間把命令放到重寫緩沖區(qū)。
?
? 總結(jié)
AOF重寫的目的是為了解決AOF文件體積膨脹的問題,使用更小的體積來保存數(shù)據(jù)庫狀態(tài),整個重寫過程基本上不影響Redis主進(jìn)程處理命令請求;
AOF重寫其實是一個有歧義的名字,實際上重寫工作是針對數(shù)據(jù)庫的當(dāng)前狀態(tài)來進(jìn)行的,重寫過程中不會讀寫、也不適用原來的AOF文件;
AOF可以由用戶手動觸發(fā),也可以由服務(wù)器自動觸發(fā)。
?
3.3、事件
redis服務(wù)器是一個事件驅(qū)動程序。
需要處理兩類事件:
1)文件事件:redis是通過套接字與客戶端或者其他服務(wù)器連接的,而文件事件就是服務(wù)器對套接字操作的抽象。
2)時間事件:服務(wù)器對一些定時操作的抽象。
3.3.1、文件事件
redis基于reactor模式開發(fā)了自己的網(wǎng)絡(luò)事件處理器,這個處理器被稱作文件事件處理器,它使用IO多路復(fù)用程序來同時監(jiān)聽多個套接字, 并根據(jù)套接字目前執(zhí)行的任務(wù)來為套接字關(guān)聯(lián)不同的事件處理器,當(dāng)被監(jiān)聽的套接字準(zhǔn)備好執(zhí)行連接應(yīng)答(accept)、讀取(read)、寫入(write)、關(guān)閉(close)等操作時, 與操作相對應(yīng)的文件事件就會產(chǎn)生, 這時文件事件處理器就會調(diào)用套接字之前關(guān)聯(lián)好的事件處理器來處理這些事件。
雖然文件事件處理器以單線程方式運(yùn)行, 但通過使用 I/O 多路復(fù)用程序來監(jiān)聽多個套接字, 文件事件處理器既實現(xiàn)了高性能的網(wǎng)絡(luò)通信模型, 又可以很好地與 Redis 服務(wù)器中其他同樣以單線程方式運(yùn)行的模塊進(jìn)行對接, 這保持了 Redis 內(nèi)部單線程設(shè)計的簡單性。
文件事件處理器的構(gòu)成:
I/O 多路復(fù)用程序負(fù)責(zé)監(jiān)聽多個套接字, 并向文件事件分派器傳送那些產(chǎn)生了事件的套接字。
?I/O 多路復(fù)用程序會把所有產(chǎn)生事件的套接字放到一個隊列, 以有序(sequentially)、同步(synchronously)、每次一個套接字的方式,向文件事件分派器傳送套接字。
I/O 多路復(fù)用程序可以監(jiān)聽多個套接字的?ae.h/AE_READABLE?事件和?ae.h/AE_WRITABLE?事件
1)當(dāng)套接字變得可讀時(客戶端對套接字執(zhí)行?write?操作,或者執(zhí)行?close?操作), 或者有新的可應(yīng)答(acceptable)套接字出現(xiàn)時(客戶端對服務(wù)器的監(jiān)聽套接字執(zhí)行?connect?操作), 套接字產(chǎn)生?AE_READABLE?事件。
2)當(dāng)套接字變得可寫時(客戶端對套接字執(zhí)行?read?操作), 套接字產(chǎn)生?AE_WRITABLE?事件。
如果一個套接字又可讀又可寫的話, 那么服務(wù)器將先讀套接字, 后寫套接字。
下面介紹各種處理器:
1)連接應(yīng)答處理器:服務(wù)器進(jìn)行初始化時, 程序會將連接應(yīng)答處理器和服務(wù)器監(jiān)聽套接字的?AE_READABLE?事件關(guān)聯(lián), 當(dāng)有客戶端連接(connect)服務(wù)器監(jiān)聽套接字的時候, 套接字就會產(chǎn)生?AE_READABLE?事件, 引發(fā)連接應(yīng)答處理器執(zhí)行, 并執(zhí)行相應(yīng)的套接字應(yīng)答操作。
2)命令請求處理器:客戶端連接到服務(wù)器后, 服務(wù)器會將客戶端套接字的?AE_READABLE?事件和命令請求處理器關(guān)聯(lián)起來, 當(dāng)客戶端發(fā)送命令請求時, 套接字就會產(chǎn)生?AE_READABLE?事件, 引發(fā)命令請求處理器執(zhí)行, 并執(zhí)行相應(yīng)的套接字讀入操作
3)命令回復(fù)處理器:服務(wù)器有命令回復(fù)需要傳送給客戶端, 服務(wù)器會將客戶端套接字的?AE_WRITABLE?事件和命令回復(fù)處理器關(guān)聯(lián)起來, 當(dāng)客戶端準(zhǔn)備好接收服務(wù)器傳回的命令回復(fù)時, 就會產(chǎn)生?AE_WRITABLE?事件, 引發(fā)命令回復(fù)處理器執(zhí)行, 并執(zhí)行相應(yīng)的套接字寫入操作。
一次完整的連接事件實例:
3.3.2、時間事件
redis時間事件可以分為兩類:定時事件、周期性事件,他們的特點就像他們的名字一樣。
而一個時間事件主要有三部分:
id:服務(wù)器為時間事件創(chuàng)建的全局唯一id,按時間遞增,越新的越大
when:unix時間戳,記錄到達(dá)時間
timeProc:時間事件處理器,是一個函數(shù),時間事件到達(dá)時,服務(wù)器就會調(diào)用處理器來處理事件。
目前版本的redis只使用周期性事件
來看看實現(xiàn):
服務(wù)器把所有時間事件放在一個鏈表中,每當(dāng)時間事件執(zhí)行器執(zhí)行時,它就遍歷鏈表,調(diào)用相應(yīng)的事件處理器。
但是注意:鏈表是無序的,不按when屬性來排序,當(dāng)時間事件執(zhí)行器運(yùn)行時,必須遍歷整個鏈表。但是,無序鏈表并不影響時間事件處理器的性能,因為在目前版本中,redis服務(wù)器只使用serverCron一個時間事件,就算在benchmark模式下也只有兩個事件,服務(wù)器幾乎是把鏈表退化成指針使用了。
?
3.3.3、事件的調(diào)度和執(zhí)行
?
文件事件和時間事件之間是合作關(guān)系, 服務(wù)器會輪流處理這兩種事件,對兩種事件的處理都是同步、有序、原子地進(jìn)行的,處理事件的過程中也不會進(jìn)行搶占,所以時間事件的實際處理時間通常會比設(shè)定的到達(dá)時間晚一些。
大概流程為:
是否關(guān)閉服務(wù)器?---->等待文件事件產(chǎn)生---->處理已經(jīng)產(chǎn)生的文件事件---->處理已經(jīng)達(dá)到的時間事件---->是否關(guān)閉服務(wù)器?........
?
3.4、客戶端
redis服務(wù)器是典型的一對多服務(wù)器,通過使用由IO多路復(fù)用技術(shù)實現(xiàn)的文件事件處理器,redis服務(wù)器使用了單線程單進(jìn)程的方式來處理請求。
3.4.1客戶端的屬性
- 描述符
客戶端狀態(tài)的?fd?屬性記錄了客戶端正在使用的套接字描述符:
typedef struct redisClient {// ...int fd;// ... } redisClient;- 偽客戶端fd?值為?-1?: 偽客戶端處理的命令請求來源于 AOF 文件或者 Lua 腳本, 而不是網(wǎng)絡(luò), 所以這種客戶端不需要套接字連接。
- 普通客戶端?fd?值為大于?-1?的整數(shù): 普通客戶端使用套接字來與服務(wù)器進(jìn)行通訊, 所以服務(wù)器會用?fd?屬性來記錄客戶端套接字的描述符。?
?
- 標(biāo)志
客戶端的標(biāo)志屬性?flags?記錄了客戶端的角色(role), 以及客戶端目前所處的狀態(tài):
typedef struct redisClient {// ...int flags;// ...} redisClient;flags?屬性的值可以是單個標(biāo)志:
flags = <flag>也可以是多個標(biāo)志的二進(jìn)制或, 比如:
flags = <flag1> | <flag2> | ...每個標(biāo)志使用一個常量表示, 一部分標(biāo)志記錄了客戶端的角色:
- 在主從服務(wù)器進(jìn)行復(fù)制操作時, 主服務(wù)器會成為從服務(wù)器的客戶端, 而從服務(wù)器也會成為主服務(wù)器的客戶端。?REDIS_MASTER?標(biāo)志表示客戶端代表的是一個主服務(wù)器,?REDIS_SLAVE?標(biāo)志表示客戶端代表的是一個從服務(wù)器。
- REDIS_LUA_CLIENT?標(biāo)識表示客戶端是專門用于處理 Lua 腳本里面包含的 Redis 命令的偽客戶端。
另一部分標(biāo)志記錄了客戶端目前所處的狀態(tài):
以下內(nèi)容為摘抄 REDIS_MONITOR?標(biāo)志表示客戶端正在執(zhí)行?MONITOR?命令。REDIS_UNIX_SOCKET?標(biāo)志表示服務(wù)器使用 UNIX 套接字來連接客戶端。REDIS_BLOCKED?標(biāo)志表示客戶端正在被?BRPOP?、?BLPOP?等命令阻塞。REDIS_UNBLOCKED?標(biāo)志表示客戶端已經(jīng)從?REDIS_BLOCKED?標(biāo)志所表示的阻塞狀態(tài)中脫離出來, 不再阻塞。?REDIS_UNBLOCKED?標(biāo)志只能在?REDIS_BLOCKED?標(biāo)志已經(jīng)打開的情況下使用。REDIS_MULTI?標(biāo)志表示客戶端正在執(zhí)行事務(wù)。REDIS_DIRTY_CAS?標(biāo)志表示事務(wù)使用?WATCH?命令監(jiān)視的數(shù)據(jù)庫鍵已經(jīng)被修改,? REDIS_DIRTY_EXEC?標(biāo)志表示事務(wù)在命令入隊時出現(xiàn)了錯誤, 以上兩個標(biāo)志都表示事務(wù)的安全性已經(jīng)被破壞, 只要這兩個標(biāo)記中的任意一個被打開,? EXEC?命令必然會執(zhí)行失敗。 這兩個標(biāo)志只能在客戶端打開了?REDIS_MULTI?標(biāo)志的情況下使用。REDIS_CLOSE_ASAP?標(biāo)志表示客戶端的輸出緩沖區(qū)大小超出了服務(wù)器允許的范圍, 服務(wù)器會在下一次執(zhí)行?serverCron?函數(shù)時關(guān)閉這個客戶端, 以免服務(wù)器的穩(wěn)定性受到這個客戶端影響。 積存在輸出緩沖區(qū)中的所有內(nèi)容會直接被釋放, 不會返回給客戶端。REDIS_CLOSE_AFTER_REPLY?標(biāo)志表示有用戶對這個客戶端執(zhí)行了?CLIENT_KILL?命令, 或者客戶端發(fā)送給服務(wù)器的命令請求中包含了錯誤的協(xié)議內(nèi)容。 服務(wù)器會將客戶端積存在輸出緩沖區(qū)中的所有內(nèi)容發(fā)送給客戶端, 然后關(guān)閉客戶端。REDIS_ASKING?標(biāo)志表示客戶端向集群節(jié)點(運(yùn)行在集群模式下的服務(wù)器)發(fā)送了?ASKING?命令。REDIS_FORCE_AOF?標(biāo)志強(qiáng)制服務(wù)器將當(dāng)前執(zhí)行的命令寫入到 AOF 文件里面, REDIS_FORCE_REPL?標(biāo)志強(qiáng)制主服務(wù)器將當(dāng)前執(zhí)行的命令復(fù)制給所有從服務(wù)器。 執(zhí)行?PUBSUB?命令會使客戶端打開?REDIS_FORCE_AOF?標(biāo)志, 執(zhí)行?SCRIPT_LOAD?命令會使客戶端打開? REDIS_FORCE_AOF標(biāo)志和?REDIS_FORCE_REPL?標(biāo)志。在主從服務(wù)器進(jìn)行命令傳播期間, 從服務(wù)器需要向主服務(wù)器發(fā)送?REPLICATION ACK?命令, 在發(fā)送這個命令之前, 從服務(wù)器必須打開主服務(wù)器對應(yīng)的客戶端的? REDIS_MASTER_FORCE_REPLY?標(biāo)志, 否則發(fā)送操作會被拒絕執(zhí)行。以上提到的所有標(biāo)志都定義在?redis.h?文件里面。
PUBSUB?命令和?SCRIPT?LOAD?命令的特殊性
通常情況下, Redis 只會將那些對數(shù)據(jù)庫進(jìn)行了修改的命令寫入到 AOF 文件, 并復(fù)制到各個從服務(wù)器: 如果一個命令沒有對數(shù)據(jù)庫進(jìn)行任何修改, 那么它就會被認(rèn)為是只讀命令, 這個命令不會被寫入到 AOF 文件, 也不會被復(fù)制到從服務(wù)器。
以上規(guī)則適用于絕大部分 Redis 命令, 但?PUBSUB?命令和?SCRIPT_LOAD?命令是其中的例外。
PUBSUB?命令雖然沒有修改數(shù)據(jù)庫, 但?PUBSUB?命令向頻道的所有訂閱者發(fā)送消息這一行為帶有副作用, 接收到消息的所有客戶端的狀態(tài)都會因為這個命令而改變。 因此, 服務(wù)器需要使用?REDIS_FORCE_AOF?標(biāo)志, 強(qiáng)制將這個命令寫入 AOF 文件, 這樣在將來載入 AOF 文件時, 服務(wù)器就可以再次執(zhí)行相同的?PUBSUB?命令, 并產(chǎn)生相同的副作用。
SCRIPT_LOAD?命令的與?PUBSUB?命令類似
3.4.2輸入緩沖區(qū)
客戶端狀態(tài)的輸入緩沖區(qū)用于保存客戶端發(fā)送的命令請求:
typedef struct redisClient {// ...sds querybuf;// ...} redisClient;?redisClient 實例:
3.4.3命令相關(guān)
在服務(wù)器將客戶端發(fā)送的命令請求保存到客戶端狀態(tài)的?querybuf?屬性之后, 服務(wù)器將對命令請求的內(nèi)容進(jìn)行分析, 并將得出的命令參數(shù)以及命令參數(shù)的個數(shù)分別保存到客戶端狀態(tài)的?argv?屬性和?argc?屬性:
typedef struct redisClient {// ...robj **argv;int argc;// ...} redisClient;argv?屬性是一個數(shù)組, 數(shù)組中的每個項都是一個字符串對象: 其中?argv[0]?是要執(zhí)行的命令, 而之后的其他項則是傳給命令的參數(shù)。
argc?屬性則負(fù)責(zé)記錄?argv?數(shù)組的長度。
3.3.4實現(xiàn)函數(shù)
?
當(dāng)服務(wù)器從協(xié)議內(nèi)容中分析并得出?argv?屬性和?argc?屬性的值之后, 服務(wù)器將根據(jù)項?argv[0]?的值, 在命令表中查找命令所對應(yīng)的命令實現(xiàn)函數(shù)。
(命令表是一個字典,字典的鍵是一個 SDS 結(jié)構(gòu), 保存了命令的名字, 字典的值是命令所對應(yīng)的?redisCommand?結(jié)構(gòu), 這個結(jié)構(gòu)保存了命令的實現(xiàn)函數(shù)、 命令的標(biāo)志、 命令應(yīng)該給定的參數(shù)個數(shù)、 命令的總執(zhí)行次數(shù)和總消耗時長等統(tǒng)計信息。)
3.3.5、輸出緩沖區(qū)
執(zhí)行命令所得的命令回復(fù)會被保存在客戶端狀態(tài)的輸出緩沖區(qū)里面, 每個客戶端都有兩個輸出緩沖區(qū):
- 固定大小的緩沖區(qū)用于保存那些長度比較小的回復(fù), 比如?OK?、簡短的字符串值、整數(shù)值、錯誤回復(fù),等等。
- 可變大小的緩沖區(qū)用于保存那些長度比較大的回復(fù), 比如一個非常長的字符串值, 一個由很多項組成的列表, 一個包含了很多元素的集合, 等等。
3.3.6、其它
客戶端狀態(tài)的?authenticated?屬性用于記錄客戶端是否通過了身份驗證,還有幾個和時間有關(guān)的屬性,敘述是一件挺無聊的事情,不再寫。
?
3.4、命令的執(zhí)行過程
3.4.1發(fā)送命令請求
當(dāng)用戶在客戶端中鍵入一個命令請求時, 客戶端會將這個命令請求轉(zhuǎn)換成協(xié)議格式, 然后通過連接到服務(wù)器的套接字, 將協(xié)議格式的命令請求發(fā)送給服務(wù)器。
3.4.2讀取命令請求
當(dāng)客戶端與服務(wù)器之間的連接套接字因為客戶端的寫入而變得可讀時, 服務(wù)器將調(diào)用命令請求處理器來執(zhí)行以下操作:
3.4.3命令執(zhí)行器:查找命令實現(xiàn)
命令執(zhí)行器要做的第一件事就是根據(jù)客戶端狀態(tài)的?argv[0]?參數(shù), 在命令表(command table)中查找參數(shù)所指定的命令, 并將找到的命令保存到客戶端狀態(tài)的?cmd?屬性里面。
命令表是一個字典, 字典的鍵是一個個命令名字,比如?"set"?、?"get"?、?"del"?,等等; 而字典的值是一個個?redisCommand?結(jié)構(gòu), 每個?redisCommand?結(jié)構(gòu)記錄了一個 Redis 命令的實現(xiàn)信息。
命令名字的大小寫不影響命令表的查找結(jié)果
因為命令表使用的是大小寫無關(guān)的查找算法, 無論輸入的命令名字是大寫、小寫或者混合大小寫, 只要命令的名字是正確的, 就能找到相應(yīng)的 redisCommand 結(jié)構(gòu)。
比如說, 無論用戶輸入的命令名字是 "SET" 、 "set" 、 "SeT" 又或者 "sEt" , 命令表返回的都是同一個 redisCommand 結(jié)構(gòu)。
redis> SET msg "hello world" OKredis> set msg "hello world" OKredis> SeT msg "hello world" OKredis> sEt msg "hello world" OK3.4.4命令執(zhí)行器:執(zhí)行預(yù)備操作
到目前為止, 服務(wù)器已經(jīng)將執(zhí)行命令所需的命令實現(xiàn)函數(shù)(保存在客戶端狀態(tài)的?cmd?屬性)、參數(shù)(保存在客戶端狀態(tài)的?argv?屬性)、參數(shù)個數(shù)(保存在客戶端狀態(tài)的?argc?屬性)都收集齊了, 但是在真正執(zhí)行命令之前, 程序還需要進(jìn)行一些預(yù)備操作, 從而確保命令可以正確、順利地被執(zhí)行, 這些操作包括:
- 檢查客戶端狀態(tài)的?cmd?指針是否指向?NULL?, 如果是的話, 那么說明用戶輸入的命令名字找不到相應(yīng)的命令實現(xiàn), 服務(wù)器不再執(zhí)行后續(xù)步驟, 并向客戶端返回一個錯誤。
- 根據(jù)客戶端?cmd?屬性指向的?redisCommand?結(jié)構(gòu)的?arity?屬性, 檢查命令請求所給定的參數(shù)個數(shù)是否正確, 當(dāng)參數(shù)個數(shù)不正確時, 不再執(zhí)行后續(xù)步驟, 直接向客戶端返回一個錯誤。 比如說, 如果?redisCommand?結(jié)構(gòu)的?arity?屬性的值為?-3?, 那么用戶輸入的命令參數(shù)個數(shù)必須大于等于?3?個才行。
- 檢查客戶端是否已經(jīng)通過了身份驗證, 未通過身份驗證的客戶端只能執(zhí)行?AUTH?命令, 如果未通過身份驗證的客戶端試圖執(zhí)行除?AUTH?命令之外的其他命令, 那么服務(wù)器將向客戶端返回一個錯誤。
- 如果服務(wù)器打開了?maxmemory?功能, 那么在執(zhí)行命令之前, 先檢查服務(wù)器的內(nèi)存占用情況, 并在有需要時進(jìn)行內(nèi)存回收, 從而使得接下來的命令可以順利執(zhí)行。 如果內(nèi)存回收失敗, 那么不再執(zhí)行后續(xù)步驟, 向客戶端返回一個錯誤。
- 如果服務(wù)器上一次執(zhí)行?BGSAVE?命令時出錯, 并且服務(wù)器打開了?stop-writes-on-bgsave-error?功能, 而且服務(wù)器即將要執(zhí)行的命令是一個寫命令, 那么服務(wù)器將拒絕執(zhí)行這個命令, 并向客戶端返回一個錯誤。
- 如果客戶端當(dāng)前正在用?SUBSCRIBE?命令訂閱頻道, 或者正在用?PSUBSCRIBE?命令訂閱模式, 那么服務(wù)器只會執(zhí)行客戶端發(fā)來的?SUBSCRIBE?、?PSUBSCRIBE?、?UNSUBSCRIBE?、?PUNSUBSCRIBE?四個命令, 其他別的命令都會被服務(wù)器拒絕。
- 如果服務(wù)器正在進(jìn)行數(shù)據(jù)載入, 那么客戶端發(fā)送的命令必須帶有?l?標(biāo)識(比如?INFO?、?SHUTDOWN?、?PUBLISH?,等等)才會被服務(wù)器執(zhí)行, 其他別的命令都會被服務(wù)器拒絕。
- 如果服務(wù)器因為執(zhí)行 Lua 腳本而超時并進(jìn)入阻塞狀態(tài), 那么服務(wù)器只會執(zhí)行客戶端發(fā)來的?SHUTDOWN nosave?命令和?SCRIPT KILL?命令, 其他別的命令都會被服務(wù)器拒絕。
- 如果客戶端正在執(zhí)行事務(wù), 那么服務(wù)器只會執(zhí)行客戶端發(fā)來的?EXEC?、?DISCARD?、?MULTI?、?WATCH?四個命令, 其他命令都會被放進(jìn)事務(wù)隊列中。
- 如果服務(wù)器打開了監(jiān)視器功能, 那么服務(wù)器會將要執(zhí)行的命令和參數(shù)等信息發(fā)送給監(jiān)視器。
當(dāng)完成了以上預(yù)備操作之后, 服務(wù)器就可以開始真正執(zhí)行命令了。
3.4.5命令執(zhí)行器:調(diào)用命令的實現(xiàn)函數(shù)
在前面的操作中, 服務(wù)器已經(jīng)將要執(zhí)行命令的實現(xiàn)保存到了客戶端狀態(tài)的?cmd?屬性里面, 并將命令的參數(shù)和參數(shù)個數(shù)分別保存到了客戶端狀態(tài)的?argv?屬性和?argc?屬性里面, 當(dāng)服務(wù)器決定要執(zhí)行命令時, 它只要執(zhí)行以下語句就可以了:
// client 是指向客戶端狀態(tài)的指針client->cmd->proc(client);因為執(zhí)行命令所需的實際參數(shù)都已經(jīng)保存到客戶端狀態(tài)的?argv?屬性里面了, 所以命令的實現(xiàn)函數(shù)只需要一個指向客戶端狀態(tài)的指針作為參數(shù)即可。
3.4.6命令執(zhí)行器:執(zhí)行后續(xù)工作
在執(zhí)行完實現(xiàn)函數(shù)之后, 服務(wù)器還需要執(zhí)行一些后續(xù)工作:
- 如果服務(wù)器開啟了慢查詢?nèi)罩竟δ?#xff0c; 那么慢查詢?nèi)罩灸K會檢查是否需要為剛剛執(zhí)行完的命令請求添加一條新的慢查詢?nèi)罩尽?/li>
- 根據(jù)剛剛執(zhí)行命令所耗費(fèi)的時長, 更新被執(zhí)行命令的?redisCommand?結(jié)構(gòu)的?milliseconds?屬性, 并將命令的?redisCommand?結(jié)構(gòu)的?calls?計數(shù)器的值增一。
- 如果服務(wù)器開啟了 AOF 持久化功能, 那么 AOF 持久化模塊會將剛剛執(zhí)行的命令請求寫入到 AOF 緩沖區(qū)里面。
- 如果有其他從服務(wù)器正在復(fù)制當(dāng)前這個服務(wù)器, 那么服務(wù)器會將剛剛執(zhí)行的命令傳播給所有從服務(wù)器。
當(dāng)以上操作都執(zhí)行完了之后, 服務(wù)器對于當(dāng)前命令的執(zhí)行到此就告一段落了, 之后服務(wù)器就可以繼續(xù)從文件事件處理器中取出并處理下一個命令請求了。
3.4.7將命令回復(fù)發(fā)送給客戶端
前面說過, 命令實現(xiàn)函數(shù)會將命令回復(fù)保存到客戶端的輸出緩沖區(qū)里面, 并為客戶端的套接字關(guān)聯(lián)命令回復(fù)處理器, 當(dāng)客戶端套接字變?yōu)榭蓪憼顟B(tài)時, 服務(wù)器就會執(zhí)行命令回復(fù)處理器, 將保存在客戶端輸出緩沖區(qū)中的命令回復(fù)發(fā)送給客戶端。
當(dāng)命令回復(fù)發(fā)送完畢之后, 回復(fù)處理器會清空客戶端狀態(tài)的輸出緩沖區(qū), 為處理下一個命令請求做好準(zhǔn)備。
3.4.8客戶端接收并打印命令回復(fù)
當(dāng)客戶端接收到協(xié)議格式的命令回復(fù)之后, 它會將這些回復(fù)轉(zhuǎn)換成人類可讀的格式, 并打印給用戶觀看(假設(shè)使用的是 Redis 自帶的?客戶端)
?
3.5、事務(wù)
Redis 事務(wù)可以一次執(zhí)行多個命令, 并且?guī)в幸韵氯齻€重要的保證:
- 批量操作在發(fā)送 EXEC 命令前被放入隊列緩存。
- 收到 EXEC 命令后進(jìn)入事務(wù)執(zhí)行,事務(wù)中任意命令執(zhí)行失敗,其余的命令依然被執(zhí)行。
- 在事務(wù)執(zhí)行過程,其他客戶端提交的命令請求不會插入到事務(wù)執(zhí)行命令序列中。
一個事務(wù)從開始到執(zhí)行會經(jīng)歷以下三個階段:
- 開始事務(wù)。
- 命令入隊。
- 執(zhí)行事務(wù)。
以下是一個事務(wù)的例子, 它先以?MULTI?開始一個事務(wù), 然后將多個命令入隊到事務(wù)中, 最后由?EXEC?命令觸發(fā)事務(wù), 一并執(zhí)行事務(wù)中的所有命令:
redis 127.0.0.1:6379> MULTI OKredis 127.0.0.1:6379> SET book-name "Mastering C++ in 21 days" QUEUEDredis 127.0.0.1:6379> GET book-name QUEUEDredis 127.0.0.1:6379> SADD tag "C++" "Programming" "Mastering Series" QUEUEDredis 127.0.0.1:6379> SMEMBERS tag QUEUEDredis 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"詳細(xì)介紹:
3.5.1事務(wù)開始
MULTI?命令的執(zhí)行標(biāo)志著事務(wù)的開始:
redis> MULTI OKMULTI?命令可以將執(zhí)行該命令的客戶端從非事務(wù)狀態(tài)切換至事務(wù)狀態(tài), 這一切換是通過在客戶端狀態(tài)的?flags?屬性中打開?REDIS_MULTI?標(biāo)識來完成的,?MULTI?命令的實現(xiàn)可以用以下偽代碼來表示:
def MULTI():# 打開事務(wù)標(biāo)識client.flags |= REDIS_MULTI# 返回 OK 回復(fù)replyOK()3.5.2命令入隊
當(dāng)一個客戶端處于非事務(wù)狀態(tài)時, 這個客戶端發(fā)送的命令會立即被服務(wù)器執(zhí)行:
redis> SET "name" "Practical Common Lisp" OKredis> GET "name" "Practical Common Lisp"redis> SET "author" "Peter Seibel" OKredis> GET "author" "Peter Seibel"與此不同的是, 當(dāng)一個客戶端切換到事務(wù)狀態(tài)之后, 服務(wù)器會根據(jù)這個客戶端發(fā)來的不同命令執(zhí)行不同的操作:
- 如果客戶端發(fā)送的命令為?EXEC?、?DISCARD?、?WATCH?、?MULTI?四個命令的其中一個, 那么服務(wù)器立即執(zhí)行這個命令。
- 與此相反, 如果客戶端發(fā)送的命令是?EXEC?、?DISCARD?、?WATCH?、?MULTI?四個命令以外的其他命令, 那么服務(wù)器并不立即執(zhí)行這個命令, 而是將這個命令放入一個事務(wù)隊列里面, 然后向客戶端返回?QUEUED?回復(fù)。
3.5.3事務(wù)隊列
每個 Redis 客戶端都有自己的事務(wù)狀態(tài), 這個事務(wù)狀態(tài)保存在客戶端狀態(tài)的?mstate?屬性里面:
typedef struct redisClient {// ...// 事務(wù)狀態(tài)multiState mstate; /* MULTI/EXEC state */// ...} redisClient;事務(wù)狀態(tài)包含一個事務(wù)隊列, 以及一個已入隊命令的計數(shù)器 (也可以說是事務(wù)隊列的長度):
typedef struct multiState {// 事務(wù)隊列,FIFO 順序multiCmd *commands;// 已入隊命令計數(shù)int count;} multiState;事務(wù)隊列是一個?multiCmd?類型的數(shù)組, 數(shù)組中的每個?multiCmd?結(jié)構(gòu)都保存了一個已入隊命令的相關(guān)信息, 包括指向命令實現(xiàn)函數(shù)的指針, 命令的參數(shù), 以及參數(shù)的數(shù)量:
typedef struct multiCmd {// 參數(shù)robj **argv;// 參數(shù)數(shù)量int argc;// 命令指針struct redisCommand *cmd;} multiCmd;事務(wù)隊列以先進(jìn)先出(FIFO)的方式保存入隊的命令: 較先入隊的命令會被放到數(shù)組的前面, 而較后入隊的命令則會被放到數(shù)組的后面。
舉個例子, 如果客戶端執(zhí)行以下命令:
redis> MULTI OKredis> SET "name" "Practical Common Lisp" QUEUEDredis> GET "name" QUEUEDredis> SET "author" "Peter Seibel" QUEUEDredis> GET "author" QUEUED那么服務(wù)器將為客戶端創(chuàng)建事務(wù)狀態(tài):
- 最先入隊的?SET?命令被放在了事務(wù)隊列的索引?0?位置上。
- 第二入隊的?GET?命令被放在了事務(wù)隊列的索引?1?位置上。
- 第三入隊的另一個?SET?命令被放在了事務(wù)隊列的索引?2?位置上。
- 最后入隊的另一個?GET?命令被放在了事務(wù)隊列的索引?3?位置上。
3.5.4執(zhí)行事務(wù)
當(dāng)一個處于事務(wù)狀態(tài)的客戶端向服務(wù)器發(fā)送?EXEC?命令時, 這個?EXEC?命令將立即被服務(wù)器執(zhí)行: 服務(wù)器會遍歷這個客戶端的事務(wù)隊列, 執(zhí)行隊列中保存的所有命令, 最后將執(zhí)行命令所得的結(jié)果全部返回給客戶端。
EXEC?命令的實現(xiàn)原理可以用以下偽代碼來描述:
def EXEC():# 創(chuàng)建空白的回復(fù)隊列reply_queue = []# 遍歷事務(wù)隊列中的每個項# 讀取命令的參數(shù),參數(shù)的個數(shù),以及要執(zhí)行的命令for argv, argc, cmd in client.mstate.commands:# 執(zhí)行命令,并取得命令的返回值reply = execute_command(cmd, argv, argc)# 將返回值追加到回復(fù)隊列末尾reply_queue.append(reply)# 移除 REDIS_MULTI 標(biāo)識,讓客戶端回到非事務(wù)狀態(tài)client.flags &= ~REDIS_MULTI# 清空客戶端的事務(wù)狀態(tài),包括:# 1)清零入隊命令計數(shù)器# 2)釋放事務(wù)隊列client.mstate.count = 0release_transaction_queue(client.mstate.commands)# 將事務(wù)的執(zhí)行結(jié)果返回給客戶端send_reply_to_client(client, reply_queue)3.5.5WATCH命令的實現(xiàn)
WATCH命令是一個樂觀鎖,它可以在EXEC命令執(zhí)行之前,監(jiān)視任意數(shù)量的數(shù)據(jù)庫鍵,并在EXEC執(zhí)行后,檢查被監(jiān)視的鍵是否至少有一個被修改,如果是,服務(wù)器拒絕執(zhí)行事務(wù),并向客戶端返回代表事務(wù)執(zhí)行失敗的回復(fù)。
/* 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 數(shù)據(jù)庫鍵空間,保存數(shù)據(jù)庫中所有的鍵值對*/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 已經(jīng)準(zhǔn)備好數(shù)據(jù)的阻塞狀態(tài)的key*/dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS 事物模塊,用于保存被WATCH命令所監(jiān)控的鍵*/// 當(dāng)內(nèi)存不足時,Redis會根據(jù)LRU算法回收一部分鍵所占的空間,而該eviction_pool是一個長為16數(shù)組,保存可能被回收的鍵// eviction_pool中所有鍵按照idle空轉(zhuǎn)時間,從小到大排序,每次回收空轉(zhuǎn)時間最長的鍵struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */// 數(shù)據(jù)庫IDint id; /* Database ID */// 鍵的平均過期時間long long avg_ttl; /* Average TTL, just for stats */ } redisDb;在每個代表數(shù)據(jù)庫的 server.h/redisDb?結(jié)構(gòu)類型中, 都保存了一個?watched_keys?字典, 字典的鍵是這個數(shù)據(jù)庫被監(jiān)視的鍵, 而字典的值則是一個鏈表, 鏈表中保存了所有監(jiān)視這個鍵的客戶端。比如說,以下字典就展示了一個?watched_keys?字典的例子:
每個key后掛著監(jiān)視自己的客戶端。
3.5.6監(jiān)控的觸發(fā)
在任何對數(shù)據(jù)庫鍵空間(key space)進(jìn)行修改的命令成功執(zhí)行之后 (比如?FLUSHDB?、?SET?、?DEL?、?LPUSH?、?SADD?、?ZREM?,諸如此類),?multi.c/touchWatchedKey?函數(shù)都會被調(diào)用 (修改命令會調(diào)用signalModifiedKey()函數(shù)來處理數(shù)據(jù)庫中的鍵被修改的情況,該函數(shù)直接調(diào)用touchWatchedKey()函數(shù))—— 它檢查數(shù)據(jù)庫的?watched_keys?字典, 看是否有客戶端在監(jiān)視已經(jīng)被命令修改的鍵, 如果有的話, 程序?qū)⑺斜O(jiān)視這個/這些被修改鍵的客戶端的?REDIS_DIRTY_CAS?選項打開:
?
3.5.7事務(wù)的ACID性質(zhì)
?在傳統(tǒng)的關(guān)系式數(shù)據(jù)庫中,常常用?ACID 性質(zhì)來檢驗事務(wù)功能的安全性。
redis事物總是具有前三個性質(zhì)。
a)原子性atomicity:redis事務(wù)保證事務(wù)中的命令要么全部執(zhí)行要不全部不執(zhí)行。
但是redis不同于傳統(tǒng)關(guān)系型數(shù)據(jù)庫,不支持回滾,即使出現(xiàn)了錯誤,事務(wù)也會繼續(xù)執(zhí)行下去。
因為redis作者認(rèn)為,這種復(fù)雜的機(jī)制和redis追求的簡單高效不符。并且,redis事務(wù)錯誤通常是編程錯誤,只會出現(xiàn)在開發(fā)環(huán)境中,而不會出現(xiàn)在實際生產(chǎn)環(huán)境中,所以沒必要支持回滾。
b)一致性consistency:redis事務(wù)可以保證命令失敗的情況下得以回滾,數(shù)據(jù)能恢復(fù)到?jīng)]有執(zhí)行之前的樣子,是保證一致性的,除非redis進(jìn)程意外終結(jié)。
Redis 的一致性問題可以分為三部分來討論:入隊錯誤、執(zhí)行錯誤、Redis 進(jìn)程被終結(jié)。
入隊錯誤
在命令入隊的過程中,如果客戶端向服務(wù)器發(fā)送了錯誤的命令,比如命令的參數(shù)數(shù)量不對,等等, 那么服務(wù)器將向客戶端返回一個出錯信息, 并且將客戶端的事務(wù)狀態(tài)設(shè)為?REDIS_DIRTY_EXEC?。
因此,帶有不正確入隊命令的事務(wù)不會被執(zhí)行,也不會影響數(shù)據(jù)庫的一致性。
執(zhí)行錯誤
如果命令在事務(wù)執(zhí)行的過程中發(fā)生錯誤,比如說,對一個不同類型的 key 執(zhí)行了錯誤的操作, 那么 Redis 只會將錯誤包含在事務(wù)的結(jié)果中, 這不會引起事務(wù)中斷或整個失敗,不會影響已執(zhí)行事務(wù)命令的結(jié)果,也不會影響后面要執(zhí)行的事務(wù)命令, 所以它對事務(wù)的一致性也沒有影響。
Redis 進(jìn)程被終結(jié)
如果 Redis 服務(wù)器進(jìn)程在執(zhí)行事務(wù)的過程中被其他進(jìn)程終結(jié),或者被管理員強(qiáng)制殺死,那么根據(jù) Redis 所使用的持久化模式,可能有以下情況出現(xiàn):
內(nèi)存模式:如果 Redis 沒有采取任何持久化機(jī)制,那么重啟之后的數(shù)據(jù)庫總是空白的,所以數(shù)據(jù)總是一致的。
RDB 模式:在執(zhí)行事務(wù)時,Redis 不會中斷事務(wù)去執(zhí)行保存 RDB 的工作,只有在事務(wù)執(zhí)行之后,保存 RDB 的工作才有可能開始。所以當(dāng) RDB 模式下的 Redis 服務(wù)器進(jìn)程在事務(wù)中途被殺死時,事務(wù)內(nèi)執(zhí)行的命令,不管成功了多少,都不會被保存到 RDB 文件里。恢復(fù)數(shù)據(jù)庫需要使用現(xiàn)有的 RDB 文件,而這個 RDB 文件的數(shù)據(jù)保存的是最近一次的數(shù)據(jù)庫快照(snapshot),所以它的數(shù)據(jù)可能不是最新的,但只要 RDB 文件本身沒有因為其他問題而出錯,那么還原后的數(shù)據(jù)庫就是一致的。
AOF 模式:因為保存 AOF 文件的工作在后臺線程進(jìn)行,所以即使是在事務(wù)執(zhí)行的中途,保存 AOF 文件的工作也可以繼續(xù)進(jìn)行,因此,根據(jù)事務(wù)語句是否被寫入并保存到 AOF 文件,有以下兩種情況發(fā)生:
1)如果事務(wù)語句未寫入到 AOF 文件,或 AOF 未被 SYNC 調(diào)用保存到磁盤,那么當(dāng)進(jìn)程被殺死之后,Redis 可以根據(jù)最近一次成功保存到磁盤的 AOF 文件來還原數(shù)據(jù)庫,只要 AOF 文件本身沒有因為其他問題而出錯,那么還原后的數(shù)據(jù)庫總是一致的,但其中的數(shù)據(jù)不一定是最新的。
2)如果事務(wù)的部分語句被寫入到 AOF 文件,并且 AOF 文件被成功保存,那么不完整的事務(wù)執(zhí)行信息就會遺留在 AOF 文件里,當(dāng)重啟 Redis 時,程序會檢測到 AOF 文件并不完整,Redis 會退出,并報告錯誤。需要使用 redis-check-aof 工具將部分成功的事務(wù)命令移除之后,才能再次啟動服務(wù)器。還原之后的數(shù)據(jù)總是一致的,而且數(shù)據(jù)也是最新的(直到事務(wù)執(zhí)行之前為止)。
?
c)隔離性Isolation:redis事務(wù)是嚴(yán)格遵守隔離性的,原因是redis是單進(jìn)程單線程模式,可以保證命令執(zhí)行過程中不會被其他客戶端命令打斷。
因為redis使用單線程執(zhí)行事務(wù),并且保證不會中斷,所以肯定有隔離性。
d)持久性Durability:持久性是指:當(dāng)一個事務(wù)執(zhí)行完畢,結(jié)果已經(jīng)保存在永久介質(zhì)里,比如硬盤,所以即使服務(wù)器后來停機(jī)了,結(jié)果也不會丟失
redis事務(wù)是不保證持久性的,這是因為redis持久化策略中不管是RDB還是AOF都是異步執(zhí)行的,不保證持久性是出于對性能的考慮。
3.5.8重點提煉
- 事務(wù)提供了一種將多個命令打包, 然后一次性、有序地執(zhí)行的機(jī)制。
- 多個命令會被入隊到事務(wù)隊列中, 然后按先進(jìn)先出(FIFO)的順序執(zhí)行。
- 事務(wù)在執(zhí)行過程中不會被中斷, 當(dāng)事務(wù)隊列中的所有命令都被執(zhí)行完畢之后, 事務(wù)才會結(jié)束。
- 帶有?WATCH?命令的事務(wù)會將客戶端和被監(jiān)視的鍵在數(shù)據(jù)庫的?watched_keys?字典中進(jìn)行關(guān)聯(lián), 當(dāng)鍵被修改時, 程序會將所有監(jiān)視被修改鍵的客戶端的?REDIS_DIRTY_CAS?標(biāo)志打開。
- 只有在客戶端的?REDIS_DIRTY_CAS?標(biāo)志未被打開時, 服務(wù)器才會執(zhí)行客戶端提交的事務(wù), 否則的話, 服務(wù)器將拒絕執(zhí)行客戶端提交的事務(wù)。
- Redis 的事務(wù)總是保證 ACID 中的原子性、一致性和隔離性, 當(dāng)服務(wù)器運(yùn)行在 AOF 持久化模式下, 并且?appendfsync?選項的值為?always?時, 事務(wù)也具有耐久性。
?
以上就是 Redis 客戶端和服務(wù)器執(zhí)行命令請求的整個過程了。
?
3.6、發(fā)布和訂閱
3.6.1頻道的訂閱和退訂
當(dāng)一個客戶端執(zhí)行?SUBSCRIBE?命令, 訂閱某個或某些頻道的時候, 這個客戶端與被訂閱頻道之間就建立起了一種訂閱關(guān)系。
Redis 將所有頻道的訂閱關(guān)系都保存在服務(wù)器狀態(tài)的?pubsub_channels?字典里面, 這個字典的鍵是某個被訂閱的頻道, 而鍵的值則是一個鏈表, 鏈表里面記錄了所有訂閱這個頻道的客戶端:
struct redisServer {// ...// 保存所有頻道的訂閱關(guān)系dict *pubsub_channels;// ...};每當(dāng)客戶端執(zhí)行?SUBSCRIBE?命令, 訂閱某個或某些頻道的時候, 服務(wù)器都會將客戶端與被訂閱的頻道在?pubsub_channels?字典中進(jìn)行關(guān)聯(lián)。
根據(jù)頻道是否已經(jīng)有其他訂閱者, 關(guān)聯(lián)操作分為兩種情況執(zhí)行:
- 如果頻道已經(jīng)有其他訂閱者, 那么它在?pubsub_channels?字典中必然有相應(yīng)的訂閱者鏈表, 程序唯一要做的就是將客戶端添加到訂閱者鏈表的末尾。
- 如果頻道還未有任何訂閱者, 那么它必然不存在于?pubsub_channels?字典, 程序首先要在?pubsub_channels?字典中為頻道創(chuàng)建一個鍵, 并將這個鍵的值設(shè)置為空鏈表, 然后再將客戶端添加到鏈表, 成為鏈表的第一個元素。
SUBSCRIBE?命令的實現(xiàn)可以用以下偽代碼來描述:
def subscribe(*all_input_channels):# 遍歷輸入的所有頻道for channel in all_input_channels:# 如果 channel 不存在于 pubsub_channels 字典(沒有任何訂閱者)# 那么在字典中添加 channel 鍵,并設(shè)置它的值為空鏈表if channel not in server.pubsub_channels:server.pubsub_channels[channel] = []# 將訂閱者添加到頻道所對應(yīng)的鏈表的末尾server.pubsub_channels[channel].append(client)?
UNSUBSCRIBE?命令的行為和?SUBSCRIBE?命令的行為正好相反 —— 當(dāng)一個客戶端退訂某個或某些頻道的時候, 服務(wù)器將從?pubsub_channels?中解除客戶端與被退訂頻道之間的關(guān)聯(lián):
- 程序會根據(jù)被退訂頻道的名字, 在?pubsub_channels?字典中找到頻道對應(yīng)的訂閱者鏈表, 然后從訂閱者鏈表中刪除退訂客戶端的信息。
- 如果刪除退訂客戶端之后, 頻道的訂閱者鏈表變成了空鏈表, 那么說明這個頻道已經(jīng)沒有任何訂閱者了, 程序?qū)?pubsub_channels?字典中刪除頻道對應(yīng)的鍵。
UNSUBSCRIBE?命令的實現(xiàn)可以用以下偽代碼來描述:
def unsubscribe(*all_input_channels):# 遍歷要退訂的所有頻道for channel in all_input_channels:# 在訂閱者鏈表中刪除退訂的客戶端server.pubsub_channels[channel].remove(client)# 如果頻道已經(jīng)沒有任何訂閱者了(訂閱者鏈表為空)# 那么將頻道從字典中刪除if len(server.pubsub_channels[channel]) == 0:server.pubsub_channels.remove(channel)3.6.2模式的訂閱和退訂
前面說過,服務(wù)器將所有頻道的訂閱關(guān)系保存起來,與此類似,服務(wù)器也將所有模式的訂閱關(guān)系存在了pubsub_Patterns屬性里。
struct redisServer {// ...// 保存所有頻道的訂閱關(guān)系list *pubsub_patterns;// ...};pubsub_Patterns屬性是一個鏈表,每個結(jié)點是被訂閱的模式,節(jié)點內(nèi)記錄了模式,節(jié)點內(nèi)的client屬性記錄了訂閱模式的客戶端。
typedef struct pubsubPattern{//訂閱模式的客戶端redisClient *client;//被訂閱的模式robj *pattern; }pubsubPattern;每當(dāng)客戶端執(zhí)行PSUBSCRIBE這個命令來訂閱某個或某些模式時,服務(wù)器會對每個被訂閱的模式執(zhí)行下面的操作:
1)新建一個pubsubPattern結(jié)構(gòu),設(shè)置好兩個屬性
2)將新節(jié)點加到pubsub_patterns尾部
偽代碼實現(xiàn):
def osubscribe(*all_input_patterns):#遍歷所有輸入的模式#記錄被訂閱的模式和對應(yīng)的客戶端pubsubPattern=create()pubsubPattern.client=clientpubsubPattern.pattern=pattern#插入鏈表末尾server.pub_patterns.append(pubsubPattern)模式退訂命令PUNSUBSCRIBE是PSUBSCRIBE的反操作
服務(wù)器將找到并刪除那些被退訂的模式
偽代碼如下:(我想吐槽一下這樣時間復(fù)雜度。。。沒有更好的辦法嗎?)
def osubscribe(*all_input_patterns):#遍歷所有退訂的模式for pattern in all_input_patterns:#遍歷每一個節(jié)點for pubsubPattern in server.pubsub_patterns:#如果客戶端和模式都相同if client==pubsubPattern.client:if pattern==pubsubPattern.pattern:#刪除server.pub_patterns.remove(pubsubPattern)3.6.3、發(fā)送消息
當(dāng)一個客戶端執(zhí)行PUBLISH<channel> <message>命令將消息發(fā)送給頻道時,服務(wù)器需要:
1)把消息發(fā)送給所有本頻道的訂閱者
具體做法就是去pubsub_channels字典找到本頻道的鏈表,也就是訂閱名單,然后發(fā)消息
2)將消息發(fā)給,包含本頻道的所有模式中的所有訂閱者
具體做法就是去pubsub_patterns查找包含本頻道的模式,并且把消息發(fā)送給訂閱它們的客戶端。
3.6.4、查看訂閱信息
redis2.8新增三個命令,用來查看頻道和模式的相關(guān)信息。
PUBLISH CHANNELS[pattern]用于返回服務(wù)器當(dāng)前被訂閱的頻道,pattern可寫可不寫,不寫就查看所有,否則查看與pattern匹配的對應(yīng)頻道
這個子命令是通過遍歷pubsub_channels字典實現(xiàn)的。
PUBLISH NUMSUB[CHANNEL-1 CHANNEL-2.....]返回這些頻道的訂閱者數(shù)量
這個子命令是通過遍歷pubsub_channels字典,查看對應(yīng)鏈表長度實現(xiàn)的。
PUBLISH NUMPAT返回被訂閱模式數(shù)量
這個子命令是通過返回pubsub_patterns的長度實現(xiàn)的。
總而言之,PUBSUB?命令的三個子命令都是通過讀取?pubsub_channels?字典和?pubsub_patterns?鏈表中的信息來實現(xiàn)的。
?
四、多機(jī)實現(xiàn)
4.1、舊版復(fù)制
Redis 的復(fù)制功能分為同步(sync)和命令傳播(command propagate)兩個操作:
- 同步操作用于將從服務(wù)器的數(shù)據(jù)庫狀態(tài)更新至主服務(wù)器當(dāng)前所處的數(shù)據(jù)庫狀態(tài)。
- 命令傳播操作用于在主服務(wù)器的數(shù)據(jù)庫狀態(tài)被修改, 導(dǎo)致主從服務(wù)器的數(shù)據(jù)庫狀態(tài)出現(xiàn)不一致時, 讓主從服務(wù)器的數(shù)據(jù)庫重新回到一致狀態(tài)。
同步
當(dāng)客戶端向從服務(wù)器發(fā)送?SLAVEOF?命令, 要求從服務(wù)器復(fù)制主服務(wù)器時, 從服務(wù)器首先需要執(zhí)行同步操作, 也即是, 將從服務(wù)器的數(shù)據(jù)庫狀態(tài)更新至主服務(wù)器當(dāng)前所處的數(shù)據(jù)庫狀態(tài)。
從服務(wù)器對主服務(wù)器的同步操作需要通過向主服務(wù)器發(fā)送?SYNC?命令來完成, 以下是?SYNC?命令的執(zhí)行步驟:
。
命令傳播
在同步操作執(zhí)行完畢之后, 主從服務(wù)器兩者的數(shù)據(jù)庫將達(dá)到一致狀態(tài), 但這種一致并不是一成不變的 —— 每當(dāng)主服務(wù)器執(zhí)行客戶端發(fā)送的寫命令時, 主服務(wù)器的數(shù)據(jù)庫就有可能會被修改, 并導(dǎo)致主從服務(wù)器狀態(tài)不再一致。
舉個例子, 假設(shè)一個主服務(wù)器和一個從服務(wù)器剛剛完成同步操作, 它們的數(shù)據(jù)庫都保存了相同的五個鍵?k1?至?k5
如果這時, 客戶端向主服務(wù)器發(fā)送命令?DEL?k3?, 那么主服務(wù)器在執(zhí)行完這個?DEL?命令之后, 主從服務(wù)器的數(shù)據(jù)庫將出現(xiàn)不一致: 主服務(wù)器的數(shù)據(jù)庫已經(jīng)不再包含鍵?k3?, 但這個鍵卻仍然包含在從服務(wù)器的數(shù)據(jù)庫里面
為了讓主從服務(wù)器再次回到一致狀態(tài), 主服務(wù)器需要對從服務(wù)器執(zhí)行命令傳播操作: 主服務(wù)器會將自己執(zhí)行的寫命令 —— 也即是造成主從服務(wù)器不一致的那條寫命令 —— 發(fā)送給從服務(wù)器執(zhí)行, 當(dāng)從服務(wù)器執(zhí)行了相同的寫命令之后, 主從服務(wù)器將再次回到一致狀態(tài)。
缺陷
。
其中可以明顯看出重新連接主服務(wù)器之后,SYNC命令創(chuàng)建包含k1-k10089的RDB文件。而事實上只需要再同步斷線后的k10087-k10089即可。SYNC的“全同步”對于從服務(wù)來說是不必要的。
? ? ? ? ? ?SYNC命令非常消耗資源,原因有三點:
1)主服務(wù)器執(zhí)行BGSAVE命令生成RDB文件,這個生成過程會大量消耗主服務(wù)器資源(CPU、內(nèi)存和磁盤I/O資源)
2)主服務(wù)器需要將自己生成的RBD文件發(fā)送給從從服務(wù)器,這個發(fā)送操作會消耗主從服務(wù)器大量的網(wǎng)絡(luò)資源(帶寬與流量)
3)接收到RDB文件你的從服務(wù)器需要載入RDB文件,載入期間從服務(wù)器會因為阻塞而導(dǎo)致沒辦法處理命令請求。
4.2新版復(fù)制
sync雖然解決了數(shù)據(jù)同步問題,但是在數(shù)據(jù)量比較大情況下,從庫斷線從來依然采用全量復(fù)制機(jī)制,無論是從數(shù)據(jù)恢復(fù)、寬帶占用來說,sync所帶來的問題還是很多的。于是redis從2.8開始,引入新的命令psync。
psync有兩種模式:完整重同步和部分重同步。
部分重同步主要依賴三個方面來實現(xiàn),依次介紹。
offset(復(fù)制偏移量):
主庫和從庫分別各自維護(hù)一個復(fù)制偏移量(可以使用info replication查看),用于標(biāo)識自己復(fù)制的情況:
在主庫中代表主節(jié)點向從節(jié)點傳遞的字節(jié)數(shù),在從庫中代表從庫同步的字節(jié)數(shù)。
每當(dāng)主庫向從節(jié)點發(fā)送N個字節(jié)數(shù)據(jù)時,主節(jié)點的offset增加N
從庫每收到主節(jié)點傳來的N個字節(jié)數(shù)據(jù)時,從庫的offset增加N。
因此offset總是不斷增大,這也是判斷主從數(shù)據(jù)是否同步的標(biāo)志,若主從的offset相同則表示數(shù)據(jù)同步量,不通則表示數(shù)據(jù)不同步。
replication backlog buffer(復(fù)制積壓緩沖區(qū)):
復(fù)制積壓緩沖區(qū)是一個固定長度的FIFO隊列,大小由配置參數(shù)repl-backlog-size指定,默認(rèn)大小1MB。
需要注意的是該緩沖區(qū)由master維護(hù)并且有且只有一個,所有slave共享此緩沖區(qū),其作用在于備份最近主庫發(fā)送給從庫的數(shù)據(jù)。
在主從命令傳播階段,主節(jié)點除了將寫命令發(fā)送給從節(jié)點外,還會發(fā)送一份到復(fù)制積壓緩沖區(qū),作為寫命令的備份。
?
除了存儲最近的寫命令,復(fù)制積壓緩沖區(qū)中還存儲了每個字節(jié)相應(yīng)的復(fù)制偏移量,由于復(fù)制積壓緩沖區(qū)固定大小先進(jìn)先出的隊列,所以它總是保存的是最近redis執(zhí)行的命令。
所以,重連服務(wù)器后,從服務(wù)器會發(fā)送自己的復(fù)制偏移量offset給主服務(wù)器,
如果offset偏移量之后的數(shù)據(jù)仍然存在于復(fù)制擠壓緩沖區(qū),就執(zhí)行部分重同步操作。
相反,執(zhí)行完整重同步操作。
run_id(服務(wù)器運(yùn)行的唯一ID)?
每個redis實例在啟動時候,都會隨機(jī)生成一個長度為40的唯一字符串來標(biāo)識當(dāng)前運(yùn)行的redis節(jié)點,查看此id可通過命令info server查看。
當(dāng)主從復(fù)制在初次復(fù)制時,主節(jié)點將自己的runid發(fā)送給從節(jié)點,從節(jié)點將這個runid保存起來,當(dāng)斷線重連時,從節(jié)點會將這個runid發(fā)送給主節(jié)點。主節(jié)點根據(jù)runid判斷能否進(jìn)行部分復(fù)制:
- 如果從節(jié)點保存的runid與主節(jié)點現(xiàn)在的runid相同,說明主從節(jié)點之前同步過,主節(jié)點會更具offset偏移量之后的數(shù)據(jù)判斷是否執(zhí)行部分復(fù)制,如果offset偏移量之后的數(shù)據(jù)仍然都在復(fù)制積壓緩沖區(qū)里,則執(zhí)行部分復(fù)制,否則執(zhí)行全量復(fù)制;
- 如果從節(jié)點保存的runid與主節(jié)點現(xiàn)在的runid不同,說明從節(jié)點在斷線前同步的redis節(jié)點并不是當(dāng)前的主節(jié)點,只能進(jìn)行全量復(fù)制;
?
psync流程:
復(fù)制
客戶端向服務(wù)器端發(fā)送:SLAVEOF
1、設(shè)置主服務(wù)器的地址和端口
存到masterhost和mastterport兩個屬性里之后,向客戶端發(fā)送ok,然后開始復(fù)制工作。
2、建立套接字鏈接
從服務(wù)器根據(jù)命令設(shè)置的地址和端口,創(chuàng)建鏈接,并且為這個套接字創(chuàng)建一個專門處理復(fù)制工作的文件事件處理器。
主服務(wù)器也會為套接字創(chuàng)建相應(yīng)的客戶端狀態(tài),并且把從服務(wù)器當(dāng)作一個客戶端來對待。
3、發(fā)送ping命令(檢查)
檢查套接字狀態(tài)是否正常
檢查主服務(wù)器是否能正確處理請求。(如果不能,就重連)
4、身份認(rèn)證
?
5、發(fā)送端口信息
從服務(wù)器向主服務(wù)器發(fā)送信息,主服務(wù)器記錄。
6、同步
從服務(wù)器向主服務(wù)器發(fā)送psync命令。(主服務(wù)器也成為從服務(wù)器的客戶端,因為主服務(wù)器會發(fā)送寫命令給從服務(wù)器)
7、命令傳播
完成同步后,進(jìn)入傳播階段,主服務(wù)器一直發(fā)送寫命令,從服務(wù)器一直接受,保證和主服務(wù)器一致。
心跳檢測
默認(rèn)一秒一次,從服務(wù)器向主服務(wù)器發(fā)送命令:REPLCONF ACK <offset>
三個作用:
檢測網(wǎng)絡(luò)連接狀態(tài):如果主服務(wù)器一秒沒收到命令,就說明出問題了
輔助實現(xiàn)min-slaves配置:min-slaves-to-write 3? ?min-slaves-max-log 10:當(dāng)從服務(wù)器小于3個或延遲都大于10,主服務(wù)器拒絕寫命令。
檢測命令丟失:如果命令丟失,主服務(wù)器會發(fā)現(xiàn)偏移量不一樣,然后它就會根據(jù)偏移量,去積壓緩沖區(qū)找到缺少的數(shù)據(jù)并發(fā)給從服務(wù)器。
4.3、哨兵
4.3.1什么是哨兵機(jī)制
Redis的哨兵(sentinel)?系統(tǒng)用于管理/多個?Redis?服務(wù)器,該系統(tǒng)執(zhí)行以下三個任務(wù):
·????????監(jiān)控:?哨兵(sentinel)?會不斷地檢查你的Master和Slave是否運(yùn)作正常。
·????????提醒:當(dāng)被監(jiān)控的某個?Redis出現(xiàn)問題時,?哨兵(sentinel)?可以通過?API?向管理員或者其他應(yīng)用程序發(fā)送通知。
·????????自動故障遷移:當(dāng)一個Master不能正常工作時,哨兵(sentinel)?會開始一次自動故障遷移操作,它會將失效Master的其中一個Slave升級為新的Master,?并讓失效Master的其他Slave改為復(fù)制新的Master;?當(dāng)客戶端試圖連接失效的Master時,集群也會向客戶端返回新Master的地址,使得集群可以使用Master代替失效Master。
例如下圖所示:
在Server1 掉線后:
升級Server2 為新的主服務(wù)器:
4.3.2、哨兵模式修改配置
實現(xiàn)步驟:
1.拷貝到etc目錄
cp sentinel.conf? /usr/local/redis/etc
2.修改sentinel.conf配置文件
sentinel monitor mymast? 192.168.110.133 6379 1? #主節(jié)點 名稱 IP 端口號 選舉次數(shù)
sentinel auth-pass mymaster 123456?
3. 修改心跳檢測 5000毫秒
sentinel down-after-milliseconds mymaster 5000
4.sentinel parallel-syncs mymaster 2 --- 做多多少合格節(jié)點
5. 啟動哨兵模式
./redis-server /usr/local/redis/etc/sentinel.conf --sentinel &
1)Sentinel(哨兵) 進(jìn)程是用于監(jiān)控 Redis 集群中 Master 主服務(wù)器工作的狀態(tài)
2)在 Master 主服務(wù)器發(fā)生故障的時候,可以實現(xiàn) Master 和 Slave 服務(wù)器的切換,保證系統(tǒng)的高可用(High Availability)
工作方式
1)每個 Sentinel(哨兵)進(jìn)程以每秒鐘一次的頻率向整個集群中的 Master 主服務(wù)器,Slave 從服務(wù)器以及其他 Sentinel(哨兵)進(jìn)程發(fā)送一個 PING 命令。
2. 如果一個實例(instance)距離最后一次有效回復(fù) PING 命令的時間超過 down-after-milliseconds 選項所指定的值, 則這個實例會被 Sentinel(哨兵)進(jìn)程標(biāo)記為主觀下線。
3. 如果一個 Master 主服務(wù)器被標(biāo)記為主觀下線,則正在監(jiān)視這個 Master 主服務(wù)器的所有 Sentinel(哨兵)進(jìn)程要以每秒一次的頻率確認(rèn) Master 主服務(wù)器的確進(jìn)入了主觀下線狀態(tài)。
4. 當(dāng)有足夠數(shù)量的 Sentinel(哨兵)進(jìn)程(大于等于配置文件指定的值)在指定的時間范圍內(nèi)確認(rèn) Master 主服務(wù)器進(jìn)入了主觀下線狀態(tài), 則Master 主服務(wù)器會被標(biāo)記為客觀下線(ODOWN)。
5. 在一般情況下, 每個 Sentinel(哨兵)進(jìn)程會以每 10 秒一次的頻率向集群中的所有Master 主服務(wù)器、Slave 從服務(wù)器發(fā)送 INFO 命令。
6. 當(dāng) Master 主服務(wù)器被 Sentinel(哨兵)進(jìn)程標(biāo)記為客觀下線時,Sentinel(哨兵)進(jìn)程向下線的 Master 主服務(wù)器的所有 Slave 從服務(wù)器發(fā)送 INFO 命令的頻率會從 10 秒一次改為每秒一次。
7. 若沒有足夠數(shù)量的 Sentinel(哨兵)進(jìn)程同意 Master 主服務(wù)器下線, Master 主服務(wù)器的客觀下線狀態(tài)就會被移除。若 Master 主服務(wù)器重新向 Sentinel(哨兵)進(jìn)程發(fā)送 PING 命令返回有效回復(fù),Master 主服務(wù)器的主觀下線狀態(tài)就會被移除。
哨兵(sentinel)?的一些設(shè)計思路和zookeeper非常類似
我們從啟動并初始化說起
4.3.3啟動并初始化 Sentinel
啟動一個 Sentinel 可以使用命令:
$ redis-sentinel /path/to/your/sentinel.conf或者命令:
$ redis-server /path/to/your/sentinel.conf --sentinel當(dāng)一個 Sentinel 啟動時, 它需要執(zhí)行以下步驟:
初始化服務(wù)器。
首先, 因為 Sentinel 本質(zhì)上只是一個運(yùn)行在特殊模式下的 Redis 服務(wù)器, 所以啟動 Sentinel 的第一步, 就是初始化一個普通的 Redis 服務(wù)器.
不過, 因為 Sentinel 執(zhí)行的工作和普通 Redis 服務(wù)器執(zhí)行的工作不同, 所以 Sentinel 的初始化過程和普通 Redis 服務(wù)器的初始化過程并不完全相同。
比如說, 普通服務(wù)器在初始化時會通過載入 RDB 文件或者 AOF 文件來還原數(shù)據(jù)庫狀態(tài), 但是因為 Sentinel 并不使用數(shù)據(jù)庫, 所以初始化 Sentinel 時就不會載入 RDB 文件或者 AOF 文件。
將普通 Redis 服務(wù)器使用的代碼替換成 Sentinel 專用代碼。
第二個步驟就是將一部分普通 Redis 服務(wù)器使用的代碼替換成 Sentinel 專用代碼。
比如說, 普通 Redis 服務(wù)器使用?redis.h/REDIS_SERVERPORT?常量的值作為服務(wù)器端口:
#define REDIS_SERVERPORT 6379而 Sentinel 則使用?sentinel.c/REDIS_SENTINEL_PORT?常量的值作為服務(wù)器端口:
#define REDIS_SENTINEL_PORT 26379為什么在 Sentinel 模式下, Redis 服務(wù)器不能執(zhí)行諸如?SET?、?DBSIZE?、?EVAL?等等這些命令 —— 因為服務(wù)器根本沒有在命令表中載入這些命令。
初始化 Sentinel 狀態(tài)。
在應(yīng)用了 Sentinel 的專用代碼之后, 接下來, 服務(wù)器會初始化一個?sentinel.c/sentinelState?結(jié)構(gòu)(后面簡稱“Sentinel 狀態(tài)”), 這個結(jié)構(gòu)保存了服務(wù)器中所有和 Sentinel 功能有關(guān)的狀態(tài) (服務(wù)器的一般狀態(tài)仍然由?redis.h/redisServer?結(jié)構(gòu)保存):
struct sentinelState {// 當(dāng)前紀(jì)元,用于實現(xiàn)故障轉(zhuǎn)移uint64_t current_epoch;// 保存了所有被這個 sentinel 監(jiān)視的主服務(wù)器// 字典的鍵是主服務(wù)器的名字// 字典的值則是一個指向 sentinelRedisInstance 結(jié)構(gòu)的指針dict *masters;// 是否進(jìn)入了 TILT 模式?int tilt;// 目前正在執(zhí)行的腳本的數(shù)量int running_scripts;// 進(jìn)入 TILT 模式的時間mstime_t tilt_start_time;// 最后一次執(zhí)行時間處理器的時間mstime_t previous_time;// 一個 FIFO 隊列,包含了所有需要執(zhí)行的用戶腳本list *scripts_queue;} sentinel;初始化 Sentinel 狀態(tài)的?masters?屬性
Sentinel 狀態(tài)中的?masters?字典記錄了所有被 Sentinel 監(jiān)視的主服務(wù)器的相關(guān)信息:
- 字典的鍵是被監(jiān)視主服務(wù)器的名字。
- 而字典的值則是被監(jiān)視主服務(wù)器對應(yīng)的?sentinel.c/sentinelRedisInstance?結(jié)構(gòu)。
每個?sentinelRedisInstance?結(jié)構(gòu)代表一個被 Sentinel 監(jiān)視的 Redis 服務(wù)器實例(instance), 這個實例可以是主服務(wù)器、從服務(wù)器、或者另外一個 Sentinel 。
實例結(jié)構(gòu)包含的屬性非常多, 以下代碼展示了一部分屬性
typedef struct sentinelRedisInstance {// 標(biāo)識值,記錄了實例的類型,以及該實例的當(dāng)前狀態(tài)int flags;// 實例的名字// 主服務(wù)器的名字由用戶在配置文件中設(shè)置// 從服務(wù)器以及 Sentinel 的名字由 Sentinel 自動設(shè)置// 格式為 ip:port ,例如 "127.0.0.1:26379"char *name;// 實例的運(yùn)行 IDchar *runid;// 配置紀(jì)元,用于實現(xiàn)故障轉(zhuǎn)移uint64_t config_epoch;// 實例的地址sentinelAddr *addr;// SENTINEL down-after-milliseconds 選項設(shè)定的值// 實例無響應(yīng)多少毫秒之后才會被判斷為主觀下線(subjectively down)mstime_t down_after_period;// SENTINEL monitor <master-name> <IP> <port> <quorum> 選項中的 quorum 參數(shù)// 判斷這個實例為客觀下線(objectively down)所需的支持投票數(shù)量int quorum;// SENTINEL parallel-syncs <master-name> <number> 選項的值// 在執(zhí)行故障轉(zhuǎn)移操作時,可以同時對新的主服務(wù)器進(jìn)行同步的從服務(wù)器數(shù)量int parallel_syncs;// SENTINEL failover-timeout <master-name> <ms> 選項的值// 刷新故障遷移狀態(tài)的最大時限mstime_t failover_timeout;// ...} sentinelRedisInstance;創(chuàng)建連向主服務(wù)器的網(wǎng)絡(luò)連接。
?Sentinel 將成為主服務(wù)器的客戶端, 它可以向主服務(wù)器發(fā)送命令, 并從命令回復(fù)中獲取相關(guān)的信息。
對于每個被 Sentinel 監(jiān)視的主服務(wù)器來說, Sentinel 會創(chuàng)建兩個連向主服務(wù)器的異步網(wǎng)絡(luò)連接:
- 一個是命令連接, 這個連接專門用于向主服務(wù)器發(fā)送命令, 并接收命令回復(fù)。
- 另一個是訂閱連接, 這個連接專門用于訂閱主服務(wù)器的?__sentinel__:hello?頻道。
接下來介紹 Sentinel 如何通過命令連接和訂閱連接與被監(jiān)視主服務(wù)器進(jìn)行通訊。
4.3.4、獲取服務(wù)器信息
sentinel默認(rèn)每十秒鐘發(fā)送一次INFO命令給主服務(wù)器,并獲取信息:
1)關(guān)于主服務(wù)器本身的信息
2)主服務(wù)器屬下所有從服務(wù)器信息
sentinel發(fā)現(xiàn)主服務(wù)器有新的從服務(wù)器時,會創(chuàng)建相應(yīng)的實例結(jié)構(gòu)和命令連接,訂閱連接
4.3.5、給服務(wù)器發(fā)送消息
4.3.6、主觀下線
指的是單個Sentinel實例對服務(wù)器做出的下線判斷,即單個sentinel認(rèn)為某個服務(wù)下線(有可能是接收不到訂閱,之間的網(wǎng)絡(luò)不通等等原因)。
如果服務(wù)器在down-after-milliseconds給定的毫秒數(shù)之內(nèi), 沒有返回 Sentinel 發(fā)送的 PING 命令的回復(fù), 或者返回一個錯誤, 那么 Sentinel 將這個服務(wù)器標(biāo)記為主觀下線(SDOWN )。
sentinel會以每秒一次的頻率向所有與其建立了命令連接的實例(master,從服務(wù),其他sentinel)發(fā)ping命令,通過判斷ping回復(fù)是有效回復(fù),還是無效回復(fù)來判斷實例時候在線(對該sentinel來說是“主觀在線”)。
sentinel配置文件中的down-after-milliseconds設(shè)置了判斷主觀下線的時間長度,如果實例在down-after-milliseconds毫秒內(nèi),返回的都是無效回復(fù),那么sentinel回認(rèn)為該實例已(主觀)下線,修改其flags狀態(tài)為SRI_S_DOWN。如果多個sentinel監(jiān)視一個服務(wù),有可能存在多個sentinel的down-after-milliseconds配置不同,這個在實際生產(chǎn)中要注意。
4.3.7、客觀下線
客觀下線(Objectively Down, 簡稱 ODOWN)指的是多個 Sentinel 實例在對同一個服務(wù)器做出 SDOWN 判斷, 并且通過 SENTINEL is-master-down-by-addr 命令互相交流之后, 得出的服務(wù)器下線判斷,然后開啟failover。
客觀下線就是說只有在足夠數(shù)量的 Sentinel 都將一個服務(wù)器標(biāo)記為主觀下線之后, 服務(wù)器才會被標(biāo)記為客觀下線(ODOWN)。
只有當(dāng)master被認(rèn)定為客觀下線時,才會發(fā)生故障遷移。
當(dāng)sentinel監(jiān)視的某個服務(wù)主觀下線后,sentinel會詢問其它監(jiān)視該服務(wù)的sentinel,看它們是否也認(rèn)為該服務(wù)主觀下線,接收到足夠數(shù)量(這個值可以配置)的sentinel判斷為主觀下線,既任務(wù)該服務(wù)客觀下線,并對其做故障轉(zhuǎn)移操作。
sentinel通過發(fā)送 SENTINEL is-master-down-by-addr ip port current_epoch runid
(ip:主觀下線的服務(wù)id,port:主觀下線的服務(wù)端口,current_epoch:sentinel的紀(jì)元,runid:*表示檢測服務(wù)下線狀態(tài),如果是sentinel 運(yùn)行id,表示用來選舉領(lǐng)頭sentinel)
來詢問其它sentinel是否同意服務(wù)下線。
一個sentinel接收另一個sentinel發(fā)來的is-master-down-by-addr后,提取參數(shù),根據(jù)ip和端口,檢測該服務(wù)時候在該sentinel主觀下線,并且回復(fù)is-master-down-by-addr,回復(fù)包含三個參數(shù):down_state(1表示已下線,0表示未下線),leader_runid(領(lǐng)頭sentinal id),leader_epoch(領(lǐng)頭sentinel紀(jì)元)。
sentinel接收到回復(fù)后,根據(jù)配置設(shè)置的下線最小數(shù)量,達(dá)到這個值,既認(rèn)為該服務(wù)客觀下線。
客觀下線條件只適用于主服務(wù)器: 對于任何其他類型的 Redis 實例, Sentinel 在將它們判斷為下線前不需要進(jìn)行協(xié)商, 所以從服務(wù)器或者其他 Sentinel 永遠(yuǎn)不會達(dá)到客觀下線條件。只要一個 Sentinel 發(fā)現(xiàn)某個主服務(wù)器進(jìn)入了客觀下線狀態(tài), 這個 Sentinel 就可能會被其他 Sentinel 推選出, 并對失效的主服務(wù)器執(zhí)行自動故障遷移操作。
4.3.8、選舉大哥sentinel
一個redis服務(wù)被判斷為客觀下線時,多個監(jiān)視該服務(wù)的sentinel協(xié)商,選舉一個領(lǐng)頭sentinel,對該redis服務(wù)進(jìn)行故障轉(zhuǎn)移操作。選舉領(lǐng)頭sentinel遵循以下規(guī)則:
1)所有的sentinel都有公平被選舉成領(lǐng)頭的資格。
2)所有的sentinel都只有一次將某個sentinel選舉成領(lǐng)頭的機(jī)會(在一輪選舉中),一旦選舉,不能更改。
3)先到先得,一旦當(dāng)前sentinel設(shè)置了領(lǐng)頭sentinel,以后要求設(shè)置sentinel為領(lǐng)頭請求都會被拒絕。
4)每個發(fā)現(xiàn)服務(wù)客觀下線的sentinel,都會要求其他sentinel將自己設(shè)置成領(lǐng)頭。
5)當(dāng)一個sentinel(源sentinel)向另一個sentinel(目sentinel)發(fā)送is-master-down-by-addr ip port current_epoch runid命令的時候,runid參數(shù)不是*,而是sentinel運(yùn)行id,就表示源sentinel要求目標(biāo)sentinel選舉其為領(lǐng)頭。
6)源sentinel會檢查目標(biāo)sentinel對其要求設(shè)置成領(lǐng)頭的回復(fù),如果回復(fù)的leader_runid和leader_epoch為源sentinel,表示目標(biāo)sentinel同意將源sentinel設(shè)置成領(lǐng)頭。
7)如果某個sentinel被半數(shù)以上的sentinel設(shè)置成領(lǐng)頭,那么該sentinel既為領(lǐng)頭。
8)如果在限定時間內(nèi),沒有選舉出領(lǐng)頭sentinel,暫定一段時間,再選舉。
為什么要選?
簡單來說,就是因為只能有一個sentinel節(jié)點去完成故障轉(zhuǎn)移。
sentinel is-master-down-by-addr這個命令有兩個作用,一是確認(rèn)下線判定,二是進(jìn)行領(lǐng)導(dǎo)者選舉。
過程:
1)每個做主觀下線的sentinel節(jié)點向其他sentinel節(jié)點發(fā)送上面那條命令,要求將它設(shè)置為領(lǐng)導(dǎo)者。
2)收到命令的sentinel節(jié)點如果還沒有同意過其他的sentinel發(fā)送的命令(還未投過票),那么就會同意,否則拒絕。
3)如果該sentinel節(jié)點發(fā)現(xiàn)自己的票數(shù)已經(jīng)過半且達(dá)到了quorum的值,就會成為領(lǐng)導(dǎo)者
4)如果這個過程出現(xiàn)多個sentinel成為領(lǐng)導(dǎo)者,則會等待一段時間重新選舉。
4.3.9、轉(zhuǎn)移
1)挑一個新的主服務(wù)器
2)把其它從服務(wù)器的主服務(wù)器改成新的
3)把之前的主服務(wù)器改為新主服務(wù)器的從服務(wù)器
4.3.10、怎么挑新的主服務(wù)器
1)刪除所有下線服務(wù)器
2)刪除五秒內(nèi)沒回復(fù)INOF命令的服務(wù)器
3)刪除數(shù)據(jù)舊的服務(wù)器(連接斷開超過down-after-millseconds*10)
4)根據(jù)優(yōu)先級,選出最高的。
4.3.11、重點提煉
?
- Sentinel 是一個特殊模式下的 Redis 服務(wù)器, 它使用了不同的命令表, 所以 Sentinel 能使用的命令和普通服務(wù)器不同。
- Sentinel 會讀入用戶指定的配置文件, 為每個要被監(jiān)視的主服務(wù)器創(chuàng)建相應(yīng)的實例結(jié)構(gòu), 并創(chuàng)建連向主服務(wù)器的命令連接和訂閱連接, 其中命令連接用于向主服務(wù)器發(fā)送命令請求, 而訂閱連接則用于接收指定頻道的消息。
- Sentinel 向主服務(wù)器發(fā)送?INFO?命令獲得屬下從服務(wù)器信息, 為這些從服務(wù)器創(chuàng)建實例結(jié)構(gòu)、命令連接和訂閱連接。
- 默認(rèn) Sentinel 十秒一次向被監(jiān)視的主服務(wù)器和從服務(wù)器發(fā)送?INFO?命令, 當(dāng)主服務(wù)器處于下線狀態(tài), 或者 Sentinel 正在對主服務(wù)器進(jìn)行故障轉(zhuǎn)移操作時, Sentinel 向從服務(wù)器發(fā)送?INFO?命令的頻率會改為每秒一次。
- 對于監(jiān)視同一個主服務(wù)器和從服務(wù)器的多個 Sentinel 來說, 它們會以每兩秒一次的頻率, 通過向被監(jiān)視服務(wù)器的?__sentinel__:hello?頻道發(fā)送消息來向其他 Sentinel 宣告自己的存在。
- 每個 Sentinel 也會從?__sentinel__:hello?頻道中接收其他 Sentinel 發(fā)來的信息, 并根據(jù)這些信息為其他 Sentinel 創(chuàng)建相應(yīng)的實例結(jié)構(gòu), 以及命令連接。
- Sentinel 只會與主服務(wù)器和從服務(wù)器創(chuàng)建命令連接和訂閱連接, Sentinel 與 Sentinel 之間則只創(chuàng)建命令連接。
- Sentinel 以每秒一次的頻率向?qū)嵗?#xff08;包括主服務(wù)器、從服務(wù)器、其他 Sentinel)發(fā)送?PING?命令, 并根據(jù)實例對?PING?命令的回復(fù)來判斷實例是否在線
- 當(dāng) Sentinel 將一個主服務(wù)器判斷為主觀下線時, 它會向同樣監(jiān)視這個主服務(wù)器的其他 Sentinel 進(jìn)行詢問, 看它們是否同意這個主服務(wù)器已經(jīng)進(jìn)入主觀下線狀態(tài)。
- 當(dāng) Sentinel 收集到足夠多的主觀下線投票之后, 它會將主服務(wù)器判斷為客觀下線, 并發(fā)起一次針對主服務(wù)器的故障轉(zhuǎn)移操作。
?
《三天給你聊清楚redis》第3天說說redis大概怎么用,和面試題(18000字)
五、實戰(zhàn)
5.1基礎(chǔ)實戰(zhàn)
?
5.1.1實戰(zhàn)點贊
點贊功能隨處可見,我們都知道點贊是一個非常高頻的操作,redis就非常適合做這種工作。
實現(xiàn)效果:
分析:三種類型:給帖子點贊,給評論點贊,給回復(fù)點贊
我們只實現(xiàn)查看點贊數(shù)量的話,只要一個int記錄一下就可以,但是我們之后還想查看點贊的人,所以要把每一個點贊的信息都記錄好,方便后面的功能繼續(xù)做出來。
思路:
點贊:把點贊的信息放進(jìn)去。
取消:把點贊的信息刪除。
在此之前,我們要封裝一個get到key的類,方便后面復(fù)用。
package com.now.community.community.util;public class RedisKeyUtil {private static final String SPLIT = ":";private static final String PREFIX_ENTITY_LIKE = "like:entity";private static final String PREFIX_USER_LIKE = "like:user";// 某個實體的贊// like:entity:entityType:entityId -> set(userId)public static String getEntityLikeKey(int entityType, int entityId) {return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId;}// 某個用戶的贊// like:user:userId -> intpublic static String getUserLikeKey(int userId) {return PREFIX_USER_LIKE + SPLIT + userId;} }點贊業(yè)務(wù):
// 點贊public void like(int userId, int entityType, int entityId, int entityUserId) {redisTemplate.execute(new SessionCallback() {@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId);operations.multi();if (isMember) {operations.opsForSet().remove(entityLikeKey, userId);} else {operations.opsForSet().add(entityLikeKey, userId);}return operations.exec();}});}我們要查找是否點贊,還有點贊數(shù)量,方便頁面顯示:
// 查詢某實體點贊的數(shù)量public long findEntityLikeCount(int entityType, int entityId) {String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);return redisTemplate.opsForSet().size(entityLikeKey);}// 查詢某人對某實體的點贊狀態(tài)public int findEntityLikeStatus(int userId, int entityType, int entityId) {String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;}點贊LikeController
@RequestMapping(path = "/like", method = RequestMethod.POST)@ResponseBodypublic String like(int entityType, int entityId,int entityUserId,int postId) {User user = hostHolder.getUser();// 點贊likeService.like(user.getId(), entityType, entityId,entityUserId);// 數(shù)量long likeCount = likeService.findEntityLikeCount(entityType, entityId);// 狀態(tài)int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);// 返回的結(jié)果Map<String, Object> map = new HashMap<>();map.put("likeCount", likeCount);map.put("likeStatus", likeStatus);return CommunityUtil.getJSONString(0, null, map);}?
?5.1.2實戰(zhàn)關(guān)注
效果:?
思路:很好想,把自己的粉絲和自己關(guān)注的人都存起來(set即可),做增刪改查。
package com.now.community.community.service;import com.now.community.community.entity.User; import com.now.community.community.util.CommunityConstant; import com.now.community.community.util.RedisKeyUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.SessionCallback; import org.springframework.stereotype.Service;import java.util.*;@Service public class FollowService implements CommunityConstant {@Autowiredprivate RedisTemplate redisTemplate;@Autowiredprivate UserService userService;public void follow(int userId, int entityType, int entityId) {redisTemplate.execute(new SessionCallback() {@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);operations.multi();operations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());operations.opsForZSet().add(followerKey, userId, System.currentTimeMillis());return operations.exec();}});}public void unfollow(int userId, int entityType, int entityId) {redisTemplate.execute(new SessionCallback() {@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);operations.multi();operations.opsForZSet().remove(followeeKey, entityId);operations.opsForZSet().remove(followerKey, userId);return operations.exec();}});}// 查詢關(guān)注的實體的數(shù)量public long findFolloweeCount(int userId, int entityType) {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);return redisTemplate.opsForZSet().zCard(followeeKey);}// 查詢實體的粉絲的數(shù)量public long findFollowerCount(int entityType, int entityId) {String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);return redisTemplate.opsForZSet().zCard(followerKey);}// 查詢當(dāng)前用戶是否已關(guān)注該實體public boolean hasFollowed(int userId, int entityType, int entityId) {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);return redisTemplate.opsForZSet().score(followeeKey, entityId) != null;}// 查詢某用戶關(guān)注的人public List<Map<String, Object>> findFollowees(int userId, int offset, int limit) {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);if (targetIds == null) {return null;}List<Map<String, Object>> list = new ArrayList<>();for (Integer targetId : targetIds) {Map<String, Object> map = new HashMap<>();User user = userService.findUserById(targetId);map.put("user", user);Double score = redisTemplate.opsForZSet().score(followeeKey, targetId);map.put("followTime", new Date(score.longValue()));list.add(map);}return list;}// 查詢某用戶的粉絲public List<Map<String, Object>> findFollowers(int userId, int offset, int limit) {String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId);Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1);if (targetIds == null) {return null;}List<Map<String, Object>> list = new ArrayList<>();for (Integer targetId : targetIds) {Map<String, Object> map = new HashMap<>();User user = userService.findUserById(targetId);map.put("user", user);Double score = redisTemplate.opsForZSet().score(followerKey, targetId);map.put("followTime", new Date(score.longValue()));list.add(map);}return list;} }??5.1.3實戰(zhàn)統(tǒng)計訪問量
過于簡單不解釋
?
??5.1.4實戰(zhàn)排行榜
?
const REDIS_TB_NAME='user:actId'; //表名 const REDIS_SEP=":"; //命名分隔符 const REDIS_FIELDS="username|regtime"; //表字段名稱 const REDIS_FIELD_RANK="rank"; //排行 const REDIS_FIELD_ID="id"; //表的自增ID //插入排行榜數(shù)據(jù) for($i=0;$i<RANK_REC_NUM;$i++) //填充數(shù)據(jù) {$redis_increase_id=$redis->get(REDIS_TB_NAME.REDIS_SEP.REDIS_FIELD_ID); //事務(wù)機(jī)制,插入用戶信息及排行信息,自增id$ret=$redis->multi() //開始事務(wù)->hMset(REDIS_TB_NAME.REDIS_SEP.$redis_increase_id,array($fields[0]=>"username".$redis_increase_id, $fields[1]=>(time()+intval(rand(0,1000))))) //username 用戶名 //regtime 注冊時間->Zadd(REDIS_TB_NAME.REDIS_SEP.REDIS_FIELD_RANK,intval(rand(1,100)),$redis_increase_id) //插入排行->incr(REDIS_TB_NAME.REDIS_SEP.REDIS_FIELD_ID) //自增id->exec(); //執(zhí)行事務(wù)if($ret==false) //插入失敗,重新插入{$i--;} } echo "插入".$i."條記錄成功<br>"; <table> <thead> <tr style="font-size:bold;color:red"><td>名次</td><td>分?jǐn)?shù)</td><td>姓名</td><td>注冊時間</td></tr> </thead> <tbody> <?phpconst REDIS_FIELDS="username|regtime"; //表字段名稱$fields=explode('|',REDIS_FIELDS);foreach($rank as $k=>$v){//echo REDIS_TB_NAME.REDIS_SEP.$k.REDIS_SEP.$fields[0];echo "<tr><td>$i</td><td>$v</td><td>".$redis->hget(REDIS_TB_NAME.REDIS_SEP.$k,$fields[0])."</td><td>".date("Y-m-d H:i:s",$redis->hget(REDIS_TB_NAME.REDIS_SEP.$k,$fields[1]))."</td></tr>";$i++;} } ?> </tbody> </table>Redis本身支持一些簡單的組合型的命令,比如以NX結(jié)尾命令都是判斷在這個值沒有時才進(jìn)行某個命令
????????Redis支持自定義的命令組合,通過MULTI和EXEC,將幾個命令組合起來執(zhí)行
????????如:插入排行數(shù)據(jù)和用戶信息,并自增id
$redis->multi() ->hmset("user:1",array("username"=>"hirryli","regtime"=>1234123483)) ->Zadd("user:rank",$scores,$userId) ->incr("user:id") ->exec();?
5.2實戰(zhàn)優(yōu)化小項目
這是我們之前項目的業(yè)務(wù)流程,做一下簡單介紹。
登錄:
用戶輸入賬號、密碼、驗證碼。我們先判斷用戶輸入的驗證碼是不是我們session存的驗證碼,然后去查賬號密碼是否正確。
如果登錄成功,發(fā)送給用戶一張憑證(ticket)。
登錄后
之后的每次請求,用戶攜帶ticket,服務(wù)器得到后,根據(jù)ticket去login_ticket表中查找登錄信息,并且根據(jù)登錄信息再查user表獲得更多的用戶信息。
使用Redis存儲驗證碼
- 驗證碼需要頻繁的訪問與刷新,對性能要求較高。
- 驗證碼不需永久保存,通常在很短的時間后就會失效。
- 分布式部署時,存在Session共享的問題。
我們重構(gòu)思路:進(jìn)入登錄頁面會訪問驗證碼方法,此方法會自動生成一個驗證碼和圖片,將驗證碼和圖片輸出給瀏覽器,并且下發(fā)一個cookies,這個cookies里面存的是一段隨機(jī)數(shù),這段隨機(jī)數(shù)作為key存在redis里面(之前是存session),value就是驗證碼,并設(shè)置一個過期時間;
//驗證碼@RequestMapping(path = "/kaptcha", method = RequestMethod.GET)public void getKaptcha(HttpServletResponse response/*, HttpSession session*/) {// 生成驗證碼String text = kaptchaProducer.createText();BufferedImage image = kaptchaProducer.createImage(text);// 將驗證碼存入session//session.setAttribute("kaptcha", text);//驗證碼的歸屬String owner= CommunityUtil.generateUUID();Cookie cookie=new Cookie("kaptchaOwner",owner);cookie.setMaxAge(60);cookie.setPath(contextPath);response.addCookie(cookie);//存入redisString redisKey= RedisKeyUtil.getKaptchaKey(owner);redisTemplate.opsForValue().set(redisKey,text,60, TimeUnit.SECONDS);// 將圖片輸出給瀏覽器response.setContentType("image/png");try {OutputStream os = response.getOutputStream();ImageIO.write(image, "png", os);} catch (IOException e) {logger.error("響應(yīng)驗證碼失敗:" + e.getMessage());}} @RequestMapping(path = "/login",method = RequestMethod.POST)public String login(String username,String password,String code,boolean rememberme,Model model,/*HttpSession session,*/HttpServletResponse response,@CookieValue("kaptchaOwner") String kaptchaOwner){// 檢查驗證碼//String kaptcha = (String) session.getAttribute("kaptcha");String kaptcha=null;if(StringUtils.isNotBlank(kaptchaOwner)){String redisKey=RedisKeyUtil.getKaptchaKey(kaptchaOwner);kaptcha=(String) redisTemplate.opsForValue().get(redisKey);}if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){model.addAttribute("codeMsg", "驗證碼不正確!");return "/site/login";}// 檢查賬號,密碼int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;Map<String, Object> map = userService.login(username, password, expiredSeconds);if (map.containsKey("ticket")) {Cookie cookie = new Cookie("ticket", map.get("ticket").toString());cookie.setPath(contextPath);cookie.setMaxAge(expiredSeconds);response.addCookie(cookie);return "redirect:/index";} else {...}}
使用Redis存儲登錄憑證
- 處理每次請求時,都要查詢用戶的登錄憑證,訪問的頻率非常高。
登錄時不存MySQL里,存redis里
public Map<String,Object> login(String username,String password,int expiredSeconds){Map<String,Object> map=new HashMap<>();// 生成登錄憑證LoginTicket loginTicket = new LoginTicket();loginTicket.setUserId(user.getId());loginTicket.setTicket(CommunityUtil.generateUUID());loginTicket.setStatus(0);loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));//loginTicketMapper.insertLoginTicket(loginTicket);String redisKey= RedisKeyUtil.getTicketKey(loginTicket.getTicket());redisTemplate.opsForValue().set(redisKey,loginTicket);...}查找
退出時也是改redis?
public void logout(String ticket) {//loginTicketMapper.updateStatus(ticket, 1);String redisKey= RedisKeyUtil.getTicketKey(ticket);LoginTicket loginTicket=(LoginTicket) redisTemplate.opsForValue().get(redisKey);loginTicket.setStatus(1);redisTemplate.opsForValue().set(redisKey,loginTicket);}
使用Redis緩存用戶信息
- 處理每次請求時,都要根據(jù)憑證查詢用戶信息,訪問的頻率非常高。
緩存用戶信息:因為會經(jīng)常根據(jù)userid來查詢user對象,所以使用redis來緩存提高服務(wù)器性能。使用redis的String類型,存入user對象,會自動將整個對象轉(zhuǎn)換成json字符串,同時設(shè)置過期時間;
取值:優(yōu)先從redis中取,取不到的時候從mysql中取,并將數(shù)據(jù)初始化到redis中
更新:更新的時候先更新mysql中的值,然后清除緩存數(shù)據(jù);
// 1.優(yōu)先從緩存中取值private User getCache(int userId) {String redisKey = RedisKeyUtil.getUserKey(userId);return (User) redisTemplate.opsForValue().get(redisKey);}// 2.取不到時初始化緩存數(shù)據(jù)private User initCache(int userId) {User user = userMapper.selectById(userId);String redisKey = RedisKeyUtil.getUserKey(userId);redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS);return user;}// 3.數(shù)據(jù)變更時清除緩存數(shù)據(jù)private void clearCache(int userId) {String redisKey = RedisKeyUtil.getUserKey(userId);redisTemplate.delete(redisKey);} public User findUserById(int id) { // return userMapper.selectById(id);User user = getCache(id);if (user == null) {user = initCache(id);}return user;} public int updateHeader(int userId, String headerUrl) {//return userMapper.updateHeader(userId, headerUrl);int rows=userMapper.updateHeader(userId, headerUrl);clearCache(userId);return rows;}?
?5.3討論一下為啥用redis解決會話?
什么是會話?
??會話可簡單理解為:用戶開一個瀏覽器,點擊多個超鏈接,訪問服務(wù)器多個web資源,然后關(guān)閉瀏覽器,整個過程稱之為一個會話。
?會話過程中要解決的一些問題?
–每個用戶不可避免各自會產(chǎn)生一些數(shù)據(jù),程序要想辦法為每個用戶保存這些數(shù)據(jù)。
–例如:用戶點擊超鏈接通過一個servlet購買了一個商品,程序應(yīng)該想辦法保存用戶購買的商品,以便于用戶點結(jié)帳servlet時,結(jié)帳servlet可以得到用戶購買的商品為用戶結(jié)帳。
?Cookie
–Cookie是客戶端技術(shù),程序把每個用戶的數(shù)據(jù)以cookie的形式寫給用戶各自的瀏覽器。當(dāng)用戶使用瀏覽器再去訪問服務(wù)器中的web資源時,就會帶著各自的數(shù)據(jù)去。這樣,web資源處理的就是用戶各自的數(shù)據(jù)了。
?HttpSession
–Session是服務(wù)器端技術(shù),利用這個技術(shù),服務(wù)器在運(yùn)行時可以為每一個用戶的瀏覽器創(chuàng)建一個其獨(dú)享的HttpSession對象,由于session為用戶瀏覽器獨(dú)享,所以用戶在訪問服務(wù)器的web資源時,可以把各自的數(shù)據(jù)放在各自的session中,當(dāng)用戶再去訪問服務(wù)器中的其它web資源時,其它web資源再從用戶各自的session中取出數(shù)據(jù)為用戶服務(wù)。
總結(jié):cookie存在客戶端,session存在服務(wù)器端
?通常結(jié)合使用。
?
我們先用sprintboot演示一下cookie和session操作
@RequestMapping(path = "/cookie/set",method = RequestMethod.GET)@ResponseBodypublic String setCookie(HttpServletResponse httpServletResponse){Cookie cookie=new Cookie("code", CommunityUtil.generateUUID());cookie.setPath("/community/alpha");cookie.setMaxAge(60*10);httpServletResponse.addCookie(cookie);return "set cookie";}@RequestMapping(path = "/cookie/get",method = RequestMethod.GET)@ResponseBodypublic String getCookie(@CookieValue("code") String code){System.out.println(code);return "get cookie";}@RequestMapping(path = "/session/set", method = RequestMethod.GET)@ResponseBodypublic String setSession(HttpSession session){session.setAttribute("id",1);session.setAttribute("name","Test");return "set session";}@RequestMapping(path = "/session/get", method = RequestMethod.GET)@ResponseBodypublic String getSession(HttpSession session) {System.out.println(session.getAttribute("id"));System.out.println(session.getAttribute("name"));return "get session";}隨著服務(wù)器要處理的請求越來越多,我們不得不分布式部署,減小服務(wù)器壓力。
為了負(fù)載均衡,我們一般采用nginx來分發(fā)請求給各個服務(wù)器處理
但是這樣session是無法共享的。
(粘性session)
你可以設(shè)置nginx的分配策略,下次同一個還讓同一個服務(wù)器來處理
但是很顯然,這就和分布式和nginx初衷違背了:負(fù)載很難保證均衡。
(同步session)
一臺服務(wù)器的session給所有服務(wù)器復(fù)制一份
第一,性能不好。第二,產(chǎn)生了一定的耦合
(專門session)
專門一臺服務(wù)器來解決,存session,其它服務(wù)器來這個服務(wù)器取session再用。
但是也有問題:你這個服務(wù)器掛了怎么辦?別的服務(wù)器都是依賴這個服務(wù)器工作的。我們分布式部署本來就是為了解決性能的瓶頸啊。
很容易想到,我們把那個處理session的服務(wù)器搞個集群:
更不行,想想就知道,本來就是為了解決分布式部署的問題,你把單獨(dú)解決session的服務(wù)器又搞集群,和之前有什么區(qū)別呢?還不如一個服務(wù)器存一份簡單呢。
(存數(shù)據(jù)庫)
可以,但是傳統(tǒng)的關(guān)系數(shù)據(jù)庫是存到硬盤里,速度太慢。
(nosql)
最終,我們的主流辦法使用nosql數(shù)據(jù)庫,比如redis,來解決這個問題的,如果有不同意見,歡迎討論。
?5.4插曲:RedLock小專欄
概念
Redis 官方站這篇文章提出了一種權(quán)威的基于 Redis 實現(xiàn)分布式鎖的方式名叫?Redlock,此種方式比原先的單節(jié)點的方法更安全。它可以保證以下特性:
單節(jié)點實現(xiàn)
SET resource_name my_random_value NX PX 30000
主要依靠上述命令,該命令僅當(dāng) Key 不存在時(NX保證)set 值,并且設(shè)置過期時間 3000ms (PX保證),值 my_random_value 必須是所有 client 和所有鎖請求發(fā)生期間唯一的,釋放鎖的邏輯是:
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1]) elsereturn 0 end上述實現(xiàn)可以避免釋放另一個client創(chuàng)建的鎖,如果只有 del 命令的話,那么如果 client1 拿到 lock1 之后因為某些操作阻塞了很長時間,此時 Redis 端 lock1 已經(jīng)過期了并且已經(jīng)被重新分配給了 client2,那么 client1 此時再去釋放這把鎖就會造成 client2 原本獲取到的鎖被 client1 無故釋放了,但現(xiàn)在為每個 client 分配一個 unique 的 string 值可以避免這個問題。至于如何去生成這個 unique string,方法很多隨意選擇一種就行了。
redlock算法
算法很易懂,起 5 個 master 節(jié)點,分布在不同的機(jī)房盡量保證可用性。為了獲得鎖,client 會進(jìn)行如下操作:
失敗重試
如果一個 client 申請鎖失敗了,那么它需要稍等一會在重試避免多個 client 同時申請鎖的情況,最好的情況是一個 client 需要幾乎同時向 5 個 master 發(fā)起鎖申請。另外就是如果 client 申請鎖失敗了它需要盡快在它曾經(jīng)申請到鎖的 master 上執(zhí)行 unlock 操作,便于其他 client 獲得這把鎖,避免這些鎖過期造成的時間浪費(fèi),當(dāng)然如果這時候網(wǎng)絡(luò)分區(qū)使得 client 無法聯(lián)系上這些 master,那么這種浪費(fèi)就是不得不付出的代價了。
放鎖
放鎖操作很簡單,就是依次釋放所有節(jié)點上的鎖就行了
性能、崩潰恢復(fù)
如果我們的節(jié)點沒有持久化機(jī)制,client 從 5 個 master 中的 3 個處獲得了鎖,然后其中一個重啟了,這是注意?整個環(huán)境中又出現(xiàn)了 3 個 master 可供另一個 client 申請同一把鎖!?違反了互斥性。如果我們開啟了 AOF 持久化那么情況會稍微好轉(zhuǎn)一些,因為 Redis 的過期機(jī)制是語義層面實現(xiàn)的,所以在 server 掛了的時候時間依舊在流逝,重啟之后鎖狀態(tài)不會受到污染。但是考慮斷電之后呢,AOF部分命令沒來得及刷回磁盤直接丟失了,除非我們配置刷回策略為 fsnyc = always,但這會損傷性能。解決這個問題的方法是,當(dāng)一個節(jié)點重啟之后,我們規(guī)定在 max TTL 期間它是不可用的,這樣它就不會干擾原本已經(jīng)申請到的鎖,等到它 crash 前的那部分鎖都過期了,環(huán)境不存在歷史鎖了,那么再把這個節(jié)點加進(jìn)來正常工作。
?
六、常見問題匯總
寫到這里,從原理到簡單的實戰(zhàn)就全部寫完了,這里匯總一些常用的以及面試常問的題目,希望幫助到大家。
1、什么是redis?
Redis 本質(zhì)上是一個 Key-Value 類型的內(nèi)存數(shù)據(jù)庫,? 整個數(shù)據(jù)庫加載在內(nèi)存當(dāng)中進(jìn)行操作, 定期通過異步操作把數(shù)據(jù)庫數(shù)據(jù) flush 到硬盤上進(jìn)行保存。
因為是純內(nèi)存操作, Redis 的性能非常出色, 每秒可以處理超過 10 萬次讀寫操作, 是已知性能
最快的 Key-Value DB。
Redis 的出色之處不僅僅是性能, Redis 最大的魅力是支持保存多種數(shù)據(jù)結(jié)構(gòu), 此外單個
value 的最大限制是 1GB, 不像 memcached 只能保存 1MB 的數(shù)據(jù), 因此 Redis 可以用
來實現(xiàn)很多有用的功能,比方說用他的 List 來做 FIFO 雙向鏈表,實現(xiàn)一個輕量級的高性 能
消息隊列服務(wù), 用他的 Set 可以做高性能的 tag 系統(tǒng)等等。
另外 Redis 也可以對存入的Key-Value 設(shè)置 expire 時間, 因此也可以被當(dāng)作一 個功能加強(qiáng)版的 memcached 來用。
Redis 的主要缺點是數(shù)據(jù)庫容量受到物理內(nèi)存的限制, 不能用作海量數(shù)據(jù)的高性能讀寫, 因此 Redis 適合的場景主要局限在較小數(shù)據(jù)量的高性能操作和運(yùn)算上
?2、相比 memcached 有哪些優(yōu)勢?
3、Redis 的全稱是什么?
Remote Dictionary Server。
4、支持哪幾種數(shù)據(jù)類型?
String、 List、 Set、 Sorted Set、 hashes
?
?
5、Redis 有哪幾種數(shù)據(jù)淘汰策略?
noeviction:返回錯誤當(dāng)內(nèi)存限制達(dá)到并且客戶端嘗試執(zhí)行會讓更多內(nèi)存被使用的命令(大
部分的寫入指令, 但 DEL 和幾個例外)
allkeys-lru: 嘗試回收最少使用的鍵(LRU), 使得新添加的數(shù)據(jù)有空間存放。
volatile-lru: 嘗試回收最少使用的鍵(LRU), 但僅限于在過期集合的鍵,使得新添加的數(shù)據(jù)
有空間存放。
allkeys-random: 回收隨機(jī)的鍵使得新添加的數(shù)據(jù)有空間存放。
volatile-random: 回收隨機(jī)的鍵使得新添加的數(shù)據(jù)有空間存放,但僅限于在過期集合的鍵。
volatile-ttl: 回收在過期集合的鍵, 并且優(yōu)先回收存活時間(TTL) 較短的鍵,使得新添加的
數(shù)據(jù)有空間存放
?
6、redis為什么采用跳表而不是紅黑樹
在做范圍查找的時候,平衡樹比skiplist操作要復(fù)雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續(xù)尋找其它不超過大值的節(jié)點。如果不對平衡樹進(jìn)行一定的改造,這里的中序遍歷并不容易實現(xiàn)。而在skiplist上進(jìn)行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進(jìn)行若干步的遍歷就可以實現(xiàn)。
平衡樹的插入和刪除操作可能引發(fā)子樹的調(diào)整,邏輯復(fù)雜,而skiplist的插入和刪除只需要修改相鄰節(jié)點的指針,操作簡單又快速。
從內(nèi)存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節(jié)點包含2個指針(分別指向左右子樹),而skiplist每個節(jié)點包含的指針數(shù)目平均為1/(1-p),具體取決于參數(shù)p的大小。如果像Redis里的實現(xiàn)一樣,取p=1/4,那么平均每個節(jié)點包含1.33個指針,比平衡樹更有優(yōu)勢。
查找單個key,skiplist和平衡樹的時間復(fù)雜度都為O(log n),大體相當(dāng);而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復(fù)雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結(jié)構(gòu),大都是基于哈希表實現(xiàn)的。
從算法實現(xiàn)難度上來比較,skiplist比平衡樹要簡單得多。
7、介紹一下HyperLogLog?
HyperLogLog 是一種概率數(shù)據(jù)結(jié)構(gòu),用來估算數(shù)據(jù)的基數(shù)。數(shù)據(jù)集可以是網(wǎng)站訪客的 IP 地址,E-mail 郵箱或者用戶 ID。
基數(shù)就是指一個集合中不同值的數(shù)目,比如 a, b, c, d 的基數(shù)就是 4,a, b, c, d, a 的基數(shù)還是 4。雖然 a 出現(xiàn)兩次,只會被計算一次。
使用 Redis 統(tǒng)計集合的基數(shù)一般有三種方法,分別是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前兩個數(shù)據(jù)結(jié)構(gòu)在集合的數(shù)量級增長時,所消耗的內(nèi)存會大大增加,但是 HyperLogLog 則不會。
Redis 的 HyperLogLog 通過犧牲準(zhǔn)確率來減少內(nèi)存空間的消耗,只需要12K內(nèi)存,在標(biāo)準(zhǔn)誤差0.81%的前提下,能夠統(tǒng)計2^64個數(shù)據(jù)。所以 HyperLogLog 是否適合在比如統(tǒng)計日活月活此類的對精度要不不高的場景。
這是一個很驚人的結(jié)果,以如此小的內(nèi)存來記錄如此大數(shù)量級的數(shù)據(jù)基數(shù)。
8、為什么 Redis 需要把所有數(shù)據(jù)放到內(nèi)存中?
Redis 為了達(dá)到最快的讀寫速度將數(shù)據(jù)都讀到內(nèi)存中, 并通過異步的方式將數(shù)據(jù)寫入磁盤。
所以 Redis 具有快速和數(shù)據(jù)持久化的特征。 如果不將數(shù)據(jù)放在內(nèi)存中, 磁盤 I/O 速度為嚴(yán)重
影響 Redis 的性能。 在內(nèi)存越來越便宜的今天, Redis 將會越來越受歡迎。
?
9、Redis支持的數(shù)據(jù)類型?
String字符串:
格式: set key value
string類型是二進(jìn)制安全的。意思是redis的string可以包含任何數(shù)據(jù)。比如jpg圖片或者序列化的對象 。
string類型是Redis最基本的數(shù)據(jù)類型,一個鍵最大能存儲512MB。
?
Hash(哈希)
格式: hmset name? key1 value1 key2 value2
Redis hash 是一個鍵值(key=>value)對集合。
Redis hash是一個string類型的field和value的映射表,hash特別適合用于存儲對象。
?
List(列表)
Redis 列表是簡單的字符串列表,按照插入順序排序。你可以添加一個元素到列表的頭部(左邊)或者尾部(右邊)
格式: lpush? name? value
在 key 對應(yīng) list 的頭部添加字符串元素
格式: rpush? name? value
在 key 對應(yīng) list 的尾部添加字符串元素
格式: lrem name? index
key 對應(yīng) list 中刪除 count 個和 value 相同的元素
格式: llen name??
返回 key 對應(yīng) list 的長度
?
Set(集合)
格式: sadd? name? value
Redis的Set是string類型的無序集合。
集合是通過哈希表實現(xiàn)的,所以添加,刪除,查找的復(fù)雜度都是O(1)。
?
zset(sorted set:有序集合)
格式: zadd? name score value
Redis zset 和 set 一樣也是string類型元素的集合,且不允許重復(fù)的成員。
不同的是每個元素都會關(guān)聯(lián)一個double類型的分?jǐn)?shù)。redis正是通過分?jǐn)?shù)來為集合中的成員進(jìn)行從小到大的排序。
zset的成員是唯一的,但分?jǐn)?shù)(score)卻可以重復(fù)。
?
10、 sds相對c的改進(jìn)?
??獲取長度:c字符串并不記錄自身長度,所以獲取長度只能遍歷一遍字符串,redis直接讀取len即可。
????緩沖區(qū)安全:c字符串容易造成緩沖區(qū)溢出,比如:程序員沒有分配足夠的空間就執(zhí)行拼接操作。而redis會先檢查sds的空間是否滿足所需要求,如果不滿足會自動擴(kuò)充。
????內(nèi)存分配:由于c不記錄字符串長度,對于包含了n個字符的字符串,底層總是一個長度n+1的數(shù)組,每一次長度變化,總是要對這個數(shù)組進(jìn)行一次內(nèi)存重新分配的操作。因為內(nèi)存分配涉及復(fù)雜算法并且可能需要執(zhí)行系統(tǒng)調(diào)用,所以它通常是比較耗時的操作。???
?
11、redis鏈表源碼?有什么特性?
?
雙端、無環(huán)、帶長度記錄、
多態(tài):使用?void*?指針來保存節(jié)點值, 可以通過?dup?、?free?、?match?為節(jié)點值設(shè)置類型特定函數(shù), 可以保存不同類型的值。
12、字典是如何實現(xiàn)的?
其實字典這種數(shù)據(jù)結(jié)構(gòu)也內(nèi)置在很多高級語言中,但是c語言沒有,所以redis自己實現(xiàn)了。
應(yīng)用也比較廣泛,比如redis的數(shù)據(jù)庫就是字典實現(xiàn)的。不僅如此,當(dāng)一個哈希鍵包含的鍵值對比較多,或者都是很長的字符串,redis就會用字典作為哈希鍵的底層實現(xiàn)。
13、LRU?redis里的具體實現(xiàn)?
LRU全稱是Least?Recently Used,即最近最久未使用的意思。
LRU算法的設(shè)計原則是:如果一個數(shù)據(jù)在最近一段時間沒有被訪問到,那么在將來它被訪問的可能性也很小。也就是說,當(dāng)限定的空間已存滿數(shù)據(jù)時,應(yīng)當(dāng)把最久沒有被訪問到的數(shù)據(jù)淘汰。
redis原始的淘汰算法簡單實現(xiàn):當(dāng)需要淘汰一個key時,隨機(jī)選擇3個key,淘汰其中間隔時間最長的key。**基本上,我們隨機(jī)選擇key,淘汰key效果很好。后來隨機(jī)3個key改成一個配置項"N隨機(jī)key"。但把默認(rèn)值提高改成5個后效果大大提高。考慮到它的效果,你根本不用修改他。
14、redis的持久化?
RDB持久化可以手動執(zhí)行,也可以配置定期執(zhí)行,可以把某個時間的數(shù)據(jù)狀態(tài)保存到RDB文件中,反之,我們可以用RDB文件還原數(shù)據(jù)庫狀態(tài)。
AOF持久化是通過保存服務(wù)器執(zhí)行的命令來記錄狀態(tài)的。還原的時候再執(zhí)行一遍即可。
?
15、如何選擇合適的持久化方式?
一般來說, 如果想達(dá)到足以媲美 PostgreSQL 的數(shù)據(jù)安全性, 你應(yīng)該同時使用兩種持久
化功能。 如果你非常關(guān)心你的數(shù)據(jù), 但仍然可以承受數(shù)分鐘以內(nèi)的數(shù)據(jù)丟失, 那么你可以
只使用 RDB 持久化。
有很多用戶都只使用 AOF 持久化, 但并不推薦這種方式: 因為定時生成 RDB 快照
(snapshot) 非常便于進(jìn)行數(shù)據(jù)庫備份, 并且 RDB 恢復(fù)數(shù)據(jù)集的速度也要比 AOF 恢復(fù)
的速度要快, 除此之外, 使用 RDB 還可以避免之前提到的 AOF 程序的 bug。
?
16、Redis 集群方案應(yīng)該怎么做? 都有哪些方案?
1.twemproxy, 大概概念是, 它類似于一個代理方式, 使用方法和普通 Redis 無任何區(qū)別,
設(shè) 置 好它 下 屬 的多 個 Redis 實 例 后, 使 用 時在 本 需 要 連接 Redis 的 地 方改 為 連接
twemproxy, 它會以一個代理的身份接收請求并使用一致性 hash 算法, 將請求轉(zhuǎn)接到具
體 Redis, 將結(jié)果再返回 twemproxy。 使用方式簡便(相對 Redis 只需修改連接端口), 對
舊項目擴(kuò)展的首選。 問題: twemproxy 自身單端口實例的壓力, 使用一致性 hash 后, 對
Redis 節(jié)點數(shù)量改變時候的計算值的改變, 數(shù)據(jù)無法自動移動到新的節(jié)點。
2. codis, 目前用的最多的集群方案, 基本和 twemproxy 一致的效果, 但它支持在 節(jié)點
數(shù)量改變情況下, 舊節(jié)點數(shù)據(jù)可恢復(fù)到新 hash 節(jié)點。
3. Redis cluster3.0 自帶的集群, 特點在于他的分布式算法不是一致性 hash, 而是 hash
槽的概念, 以及自身支持節(jié)點設(shè)置從節(jié)點。 具體看官方文檔介紹。
4.在業(yè)務(wù)代碼層實現(xiàn), 起幾個毫無關(guān)聯(lián)的 Redis 實例, 在代碼層, 對 key 進(jìn)行 hash 計算,
然后去對應(yīng)的 Redis 實例操作數(shù)據(jù)。 這種方式對 hash 層代碼要求比較高, 考慮部分包括,
節(jié)點失效后的替代算法方案, 數(shù)據(jù)震蕩后的自動腳本恢復(fù), 實例的監(jiān)控, 等等
MySQL 里有 2000w 數(shù)據(jù), Redis 中只存 20w 的數(shù)據(jù),
17、如何保證 Redis 中的數(shù)據(jù)都是熱點數(shù)據(jù)?
Redis 內(nèi)存數(shù)據(jù)集大小上升到一定大小的時候, 就會施行數(shù)據(jù)淘汰策略
18、Redis 有哪些適合的場景?
(1)、 會話緩存(Session Cache)
最常用的一種使用 Redis 的情景是會話緩存(session cache)。 用 Redis 緩存會話比其他
存儲(如 Memcached) 的優(yōu)勢在于: Redis 提供持久化。 當(dāng)維護(hù)一個不是嚴(yán)格要求一致性
的緩存時, 如果用戶的購物車信息全部丟失, 大部分人都會不高興的, 現(xiàn)在, 他們還會這樣
嗎?
幸運(yùn)的是, 隨著 Redis 這些年的改進(jìn), 很容易找到怎么恰當(dāng)?shù)氖褂?Redis 來緩存會話的文
檔。 甚至廣為人知的商業(yè)平臺 Magento 也提供 Redis 的插件。
(2)、 全頁緩存(FPC)
除基本的會話 token 之外, Redis 還提供很簡便的 FPC 平臺。 回到一致性問題, 即使重啟
了 Redis 實例, 因為有磁盤的持久化, 用戶也不會看到頁面加載速度的下降, 這是一個極
大改進(jìn), 類似 PHP 本地 FPC。
再次以 Magento 為例, Magento 提供一個插件來使用 Redis 作為全頁緩存后端。
此外, 對 WordPress 的用戶來說, Pantheon 有一個非常好的插件 wp-Redis, 這個插件
能幫助你以最快速度加載你曾瀏覽過的頁面。
(3)、 隊列
Reids 在內(nèi)存存儲引擎領(lǐng)域的一大優(yōu)點是提供 list 和 set 操作,這使得 Redis 能作為一個
很好的消息隊列平臺來使用。 Redis 作為隊列使用的操作, 就類似于本地程序語言(如
Python) 對 list 的 push/pop 操作。
如果你快速的在 Google 中搜索“Redis queues”, 你馬上就能找到大量的開源項目, 這些
項目的目的就是利用 Redis 創(chuàng)建非常好的后端工具, 以滿足各種隊列需求。 例如, Celery
有一個后臺就是使用 Redis 作為 broker, 你可以從這里去查看。
(4)、 排行榜/計數(shù)器
Redis在內(nèi)存中對數(shù)字進(jìn)行遞增或遞減的操作實現(xiàn)的非常好。集合(Set)和有序集合(Sorted
Set) 也使得我們在執(zhí)行這些操作的時候變的非常簡單, Redis 只是正好提供了這兩種數(shù)據(jù)
結(jié)構(gòu)。 所以, 我們要從排序集合中獲取到排名最靠前的 10 個用戶–我們稱之為
“user_scores”, 我們只需要像下面一樣執(zhí)行即可:
當(dāng)然, 這是假定你是根據(jù)你用戶的分?jǐn)?shù)做遞增的排序。 如果你想返回用戶及用戶的分?jǐn)?shù), 你
需要這樣執(zhí)行:
ZRANGE user_scores 0 10 WITHSCORES
Agora Games 就是一個很好的例子, 用 Ruby 實現(xiàn)的, 它的排行榜就是使用 Redis 來存儲
數(shù)據(jù)的, 你可以在這里看到。
(5)、 發(fā)布/訂閱
最后 是 Redis 的發(fā)布/訂閱功能。 發(fā)布/訂閱的使用場景確實非
常多。 我已看見人們在社交網(wǎng)絡(luò)連接中使用, 還可作為基于發(fā)布/訂閱的腳本觸發(fā)器, 甚至
用 Redis 的發(fā)布/訂閱功能來建立聊天系統(tǒng)。
19、說說 Redis 哈希槽的概念?
Redis 集群沒有使用一致性 hash,而是引入了哈希槽的概念, Redis 集群有 16384 個哈希槽,
每個 key 通過 CRC16 校驗后對 16384 取模來決定放置哪個槽, 集群的每個節(jié)點負(fù)責(zé)一部分
hash 槽
20、為什么Redis集群有16384個槽
(1)如果槽位為65536,發(fā)送心跳信息的消息頭達(dá)8k,發(fā)送的心跳包過于龐大。
如上所述,在消息頭中,最占空間的是myslots[CLUSTER_SLOTS/8]。 當(dāng)槽位為65536時,這塊的大小是:?65536÷8÷1024=8kb?因為每秒鐘,redis節(jié)點需要發(fā)送一定數(shù)量的ping消息作為心跳包,如果槽位為65536,這個ping消息的消息頭太大了,浪費(fèi)帶寬。
(2)redis的集群主節(jié)點數(shù)量基本不可能超過1000個。
如上所述,集群節(jié)點越多,心跳包的消息體內(nèi)攜帶的數(shù)據(jù)越多。如果節(jié)點過1000個,也會導(dǎo)致網(wǎng)絡(luò)擁堵。因此redis作者,不建議redis cluster節(jié)點數(shù)量超過1000個。 那么,對于節(jié)點數(shù)在1000以內(nèi)的redis cluster集群,16384個槽位夠用了。沒有必要拓展到65536個。
(3)槽位越小,節(jié)點少的情況下,壓縮比高
Redis主節(jié)點的配置信息中,它所負(fù)責(zé)的哈希槽是通過一張bitmap的形式來保存的,在傳輸過程中,會對bitmap進(jìn)行壓縮,但是如果bitmap的填充率slots / N很高的話(N表示節(jié)點數(shù)),bitmap的壓縮率就很低。 如果節(jié)點數(shù)很少,而哈希槽數(shù)量很多的話,bitmap的壓縮率就很低。
21、Redis 集群會有寫操作丟失嗎? 為什么?
Redis 并不能保證數(shù)據(jù)的強(qiáng)一致性, 這意味這在實際中集群在特定的條件下可能會丟失寫操
作。
?
22、Redis 集群方案應(yīng)該怎么做?都有哪些方案?
1.twemproxy,大概概念是,它類似于一個代理方式, 使用時在本需要連接 redis 的地方改為連接 twemproxy, 它會以一個代理的身份接收請求并使用一致性 hash 算法,將請求轉(zhuǎn)接到具體 redis,將結(jié)果再返回 twemproxy。
缺點: twemproxy 自身單端口實例的壓力,使用一致性 hash 后,對 redis 節(jié)點數(shù)量改變時候的計算值的改變,數(shù)據(jù)無法自動移動到新的節(jié)點。
2.codis,目前用的最多的集群方案,基本和 twemproxy 一致的效果,但它支持在 節(jié)點數(shù)量改變情況下,舊節(jié)點數(shù)據(jù)可恢復(fù)到新 hash 節(jié)點
3.redis cluster3.0 自帶的集群,特點在于他的分布式算法不是一致性 hash,而是 hash 槽的概念,以及自身支持節(jié)點設(shè)置從節(jié)點。具體看官方文檔介紹。
?
23、為什么要做 Redis 分區(qū)?
分區(qū)可以讓 Redis 管理更大的內(nèi)存, Redis 將可以使用所有機(jī)器的內(nèi)存。 如果沒有分區(qū), 你
最多只能使用一臺機(jī)器的內(nèi)存。 分區(qū)使 Redis 的計算能力通過簡單地增加計算機(jī)得到成倍提
升,Redis 的網(wǎng)絡(luò)帶寬也會隨著計算機(jī)和網(wǎng)卡的增加而成倍增長。
?
24、Redis 分區(qū)有什么缺點?
涉及多個 key 的操作通常不會被支持。 例如你不能對兩個集合求交集, 因為他們可能被存
儲到不同的 Redis 實例(實際上這種情況也有辦法, 但是不能直接使用交集指令)。
同時操作多個 key,則不能使用 Redis 事務(wù).
分區(qū)使用的粒度是key,不能使用一個非常長的排序key存儲一個數(shù)據(jù)集(The partitioning
granularity is the key, so it is not possible to shard a dataset with a single huge
key like a very big sorted set) .
當(dāng)使用分區(qū)的時候, 數(shù)據(jù)處理會非常復(fù)雜, 例如為了備份你必須從不同的 Redis 實例和主
機(jī)同時收集 RDB / AOF 文件。
分區(qū)時動態(tài)擴(kuò)容或縮容可能非常復(fù)雜。 Redis 集群在運(yùn)行時增加或者刪除 Redis 節(jié)點, 能
做到最大程度對用戶透明地數(shù)據(jù)再平衡,但其他一些客戶端分區(qū)或者代理分區(qū)方法則不支持
這種特性。 然而, 有一種預(yù)分片的技術(shù)也可以較好的解決這個問題。
?
25、Redis 與其他 key-value 存儲有什么不同?
Redis 有著更為復(fù)雜的數(shù)據(jù)結(jié)構(gòu)并且提供對他們的原子性操作,這是一個不同于其他數(shù)據(jù)庫
的進(jìn)化路徑。 Redis 的數(shù)據(jù)類型都是基于基本數(shù)據(jù)結(jié)構(gòu)的同時對程序員透明, 無需進(jìn)行額外
的抽象。
Redis 運(yùn)行在內(nèi)存中但是可以持久化到磁盤,所以在對不同數(shù)據(jù)集進(jìn)行高速讀寫時需要權(quán)衡
內(nèi)存, 應(yīng)為數(shù)據(jù)量不能大于硬件內(nèi)存。 在內(nèi)存數(shù)據(jù)庫方面的另一個優(yōu)點是, 相比在磁盤上
相同的復(fù)雜的數(shù)據(jù)結(jié)構(gòu), 在內(nèi)存中操作起來非常簡單, 這樣 Redis 可以做很多內(nèi)部復(fù)雜性
很強(qiáng)的事情。 同時, 在磁盤格式方面他們是緊湊的以追加的方式產(chǎn)生的, 因為他們并不需
要進(jìn)行隨機(jī)訪問
26、Redis 的內(nèi)存用完了會發(fā)生什么?
如果達(dá)到設(shè)置的上限, Redis 的寫命令會返回錯誤信息(但是讀命令還可以正常返回。) 或
者你可以將 Redis 當(dāng)緩存來使用配置淘汰機(jī)制,當(dāng) Redis 達(dá)到內(nèi)存上限時會沖刷掉舊的內(nèi)容。
?
27、Redis 是單線程的, 如何提高多核 CPU 的利用率?
可以在同一個服務(wù)器部署多個 Redis 的實例, 并把他們當(dāng)作不同的服務(wù)器來使用, 在某些時
候, 無論如何一個服務(wù)器是不夠的,
所以, 如果你想使用多個 CPU, 你可以考慮一下分片(shard)。
28、一個 Redis 實例最多能存放多少的 keys? List、 Set、Sorted Set 他們最多能存放多少元素?
理論上 Redis 可以處理多達(dá) 232 的 keys, 并且在實際中進(jìn)行了測試, 每個實例至少存放了 2億 5 千萬的 keys。 我們正在測試一些較大的值。
任何 list、 set、 和 sorted set 都可以放 232 個元素。
換句話說, Redis 的存儲極限是系統(tǒng)中的可用內(nèi)存值
?
29、修改配置不重啟 Redis 會實時生效嗎?
針對運(yùn)行實例, 有許多配置選項可以通過 CONFIG SET 命令進(jìn)行修改, 而無需執(zhí)行任何
形式的重啟。 從 Redis 2.2 開始, 可以從 AOF 切換到 RDB 的快照持久性或其他方式
而不需要重啟 Redis。 檢索 ‘CONFIG GET *’ 命令獲取更多信息。
但偶爾重新啟動是必須的, 如為升級 Redis 程序到新的版本, 或者當(dāng)你需要修改某些目前
CONFIG 命令還不支持的配置參數(shù)的時候
?
30、哨兵
Redis sentinel 是一個分布式系統(tǒng)中監(jiān)控 redis 主從服務(wù)器,并在主服務(wù)器下線時自動進(jìn)行故障轉(zhuǎn)移。其中三個特性:
監(jiān)控(Monitoring):??? Sentinel? 會不斷地檢查你的主服務(wù)器和從服務(wù)器是否運(yùn)作正常。
提醒(Notification): 被監(jiān)控的某個 Redis 服務(wù)器出現(xiàn)問題時, Sentinel 可以通過 API 向管理員或者其他應(yīng)用程序發(fā)送通知。
自動故障遷移(Automatic failover): 當(dāng)一個主服務(wù)器不能正常工作時, Sentinel 會開始一次自動故障遷移操作。
特點:
1、保證高可用
2、監(jiān)控各個節(jié)點
3、自動故障遷移
缺點:主從模式,切換需要時間丟數(shù)據(jù)
沒有解決 master 寫的壓力
?
31、緩存穿透
一般的緩存系統(tǒng),都是按照key去緩存查詢,如果不存在對應(yīng)的value,就去后端系統(tǒng)查找(比如DB)。
一些惡意的請求會故意查詢不存在的key,請求量很大,就會對后端系統(tǒng)造成很大的壓力。這就叫做緩存穿透。
?
如何避免?
1:對查詢結(jié)果為空的情況也進(jìn)行緩存,這樣,再次訪問時,緩存層會直接返回空值。緩存時間設(shè)置短一點,或者該key對應(yīng)的數(shù)據(jù)insert了之后清理緩存。
2:對一定不存在的key進(jìn)行過濾。具體請看布隆過濾器
?
32、緩存擊穿
是針對緩存中沒有但數(shù)據(jù)庫有的數(shù)據(jù)。
場景是,當(dāng)Key失效后,假如瞬間突然涌入大量的請求,來請求同一個Key,這些請求不會命中Redis,都會請求到DB,導(dǎo)致數(shù)據(jù)庫壓力過大,甚至扛不住,掛掉。
解決辦法
1、設(shè)置熱點Key,自動檢測熱點Key,將熱點Key的過期時間加大或者設(shè)置為永不過期,或者設(shè)置為邏輯上永不過期
2、加互斥鎖。當(dāng)發(fā)現(xiàn)沒有命中Redis,去查數(shù)據(jù)庫的時候,在執(zhí)行更新緩存的操作上加鎖,當(dāng)一個線程訪問時,其它線程等待,這個線程訪問過后,緩存中的數(shù)據(jù)會被重建,這樣其他線程就可以從緩存中取值。
?
33、緩存雪崩
是指大量Key同時失效,對這些Key的請求又會打到DB上,同樣會導(dǎo)致數(shù)據(jù)庫壓力過大甚至掛掉。
解決辦法
1)讓Key的失效時間分散開,可以在統(tǒng)一的失效時間上再加一個隨機(jī)值,或者使用更高級的算法分散失效時間。
2)構(gòu)建多個redis實例,個別節(jié)點掛了還有別的可以用。
3)多級緩存:比如增加本地緩存,減小redis壓力。
4)對存儲層增加限流措施,當(dāng)請求超出限制,提供降級服務(wù)(一般就是返回錯誤即可)
?
34、單線程的redis為什么這么快
(一)純內(nèi)存操作
(二)單線程操作,避免了頻繁的上下文切換
(三)采用了非阻塞I/O多路復(fù)用機(jī)制
(其實就是歷史遺留問題,非要吹的這么好。。。)
?
35、redis采用的刪除策略
?
redis采用的是定期刪除+惰性刪除策略。
36、為什么不用定時刪除策略?
定時刪除,用一個定時器來負(fù)責(zé)監(jiān)視key,過期則自動刪除。雖然內(nèi)存及時釋放,但是十分消耗CPU資源。在大并發(fā)請求下,CPU要將時間應(yīng)用在處理請求,而不是刪除key,因此沒有采用這一策略.
37、定期刪除+惰性刪除是如何工作的呢?
定期刪除,redis默認(rèn)每個100ms檢查,是否有過期的key,有過期key則刪除。需要說明的是,redis不是每個100ms將所有的key檢查一次,而是隨機(jī)抽取進(jìn)行檢查(如果每隔100ms,全部key進(jìn)行檢查,redis豈不是卡死)。因此,如果只采用定期刪除策略,會導(dǎo)致很多key到時間沒有刪除。
于是,惰性刪除派上用場。也就是說在你獲取某個key的時候,redis會檢查一下,這個key如果設(shè)置了過期時間那么是否過期了?如果過期了此時就會刪除。
?
38、為什么Redis的操作是原子性的,怎么保證原子性的?
對于Redis而言,命令的原子性指的是:一個操作的不可以再分,操作要么執(zhí)行,要么不執(zhí)行。
Redis的操作之所以是原子性的,是因為Redis是單線程的。
Redis本身提供的所有API都是原子操作,Redis中的事務(wù)其實是要保證批量操作的原子性。
多個命令在并發(fā)中也是原子性的嗎?
不一定, 將get和set改成單命令操作,incr 。使用Redis的事務(wù),或者使用Redis+Lua==的方式實現(xiàn).
?
39、消息隊列
不要使用redis去做消息隊列,這不是redis的設(shè)計目標(biāo)。但實在太多人使用redis去做去消息隊列,redis的作者看不下去。
kafka才好用
?
有問題隨時評論區(qū)提問
?
?七、開個腦洞
?最近又研究了一下redis,說實話,我越研究越覺得不靠譜,為啥會有nosql這種東西?
以下是我混亂的思考
未來架構(gòu)越來越復(fù)雜,saas做不了一致性交給redis做。
如果數(shù)據(jù)庫本身就有個redis呢?是不是我們就不需要這玩意了。
不對,使用場景還是有區(qū)別的,數(shù)據(jù)庫本身不就有buffer pool么。
一直在琢磨redis做緩存的必要性。
我要把。。
工作上的第三課,就是得考慮成本。之前我是純正的redis吹,但是mysql有緩存那些數(shù)據(jù)和redis速度差不了太多,而且沒有關(guān)系型數(shù)據(jù)庫記錄全面,最重要的是貴!
最重要的是貴!最重要的是貴!最重要的是貴!
我從一個幼稚的redis吹,變成了考慮更全面的人,看到了redis越來越多的缺點。希望,是我成長了吧。
全篇完。?
總結(jié)
以上是生活随笔為你收集整理的《这是全网最硬核redis总结,谁赞成,谁反对?》六万字大合集的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 华为OD机试题整理,已经写了参考代码
- 下一篇: gnfc——游戏增强现实语音通话系统