我攤牌了,這篇文章,值得99%的人收藏
此文后續會改為粉絲可見,所以喜歡的請提前關注和收藏,不迷路。
最近有五本我喜歡的redis實體新書,想要的去評論,我寫個隨機數抽獎包郵送給你。
?
?
那么,準備好了嗎?我們開始吧。
《三天給你聊清楚redis》第1天先嘮嘮redis是個啥(18629字)
一、入門
Redis是一款基于鍵值對的NoSQL數據庫,它的值支持多種數據結構:
字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等。
? Redis將所有的數據都存放在內存中,所以它的讀寫性能十分驚人,用作數據庫,緩存和消息代理。
Redis具有內置的復制,Lua腳本,LRU逐出,事務和不同級別的磁盤持久性,并通過Redis Sentinel和Redis Cluster自動分區提供了高可用性。
? Redis典型的應用場景包括:緩存、排行榜、計數器、社交網絡、消息隊列等
1.1NoSql入門概述
1)單機Mysql的美好時代
瓶頸:
?
- 數據庫總大小一臺機器硬盤內存放不下
- 數據的索引(B + tree)一個機器的運行內存放不下
- 訪問量(讀寫混合)一個實例不能承受
?
2)Memcached(緩存)+ MySql + 垂直拆分
通過緩存來緩解數據庫的壓力,優化數據庫的結構和索引
垂直拆分指的是:分成多個數據庫存儲數據(如:賣家庫與買家庫)
?
3)MySql主從復制讀寫分離
- 主從復制:主庫來一條數據,從庫立刻插入一條。
- 讀寫分離:讀取(從庫Master),寫(主庫Slave)
?
4)分表分庫+水平拆分+MySql集群
- 主庫的寫壓力出現瓶頸(行鎖InnoDB取代表鎖MyISAM)
- 分庫:根據業務相關緊耦合在同一個庫,對不同的數據讀寫進行分庫(如注冊信息等不常改動的冷庫與購物信息等熱門庫分開)
- 分表:切割表數據(例如90W條數據,id 1-30W的放在A庫,30W-60W的放在B庫,60W-90W的放在C庫)
?
MySql擴展的瓶頸
- 大數據下IO壓力大
- 表結構更改困難
常用的Nosql
Redis
memcache
Mongdb
以上幾種Nosql 請到各自的官網上下載并參考使用
Nosql 的核心功能點
KV(存儲)
Cache(緩存)
Persistence(持久化)
……
1.2redis的介紹和特點:
? ? ? ?問題:
? ? ? ? ? ? ? ?傳統數據庫:持久化存儲數據。
? ? ? ? ? ? ? ?solr索引庫:大量的數據的檢索。
? ? ? ? ? ? ? ?在實際開發中,高并發環境下,不同的用戶會需要相同的數據。因為每次請求,
? ? ? ? ? ? ? ?在后臺我們都會創建一個線程來處理,這樣造成,同樣的數據從數據庫中查詢了N次。
? ? ? ? ? ? ? ?而數據庫的查詢本身是IO操作,效率低,頻率高也不好。
? ? ? ? ? ? ? ?總而言之,一個網站總歸是有大量的數據是用戶共享的,但是如果每個用戶都去數據庫查詢
? ? ? ? ? ? ? ?效率就太低了。
? ? ? ?解決:
? ? ? ? ? ? ? ?將用戶共享數據緩存到服務器的內存中。 ? ? ? ?
? ? ? ?特點:
? ? ? ? ? ? ? ?1、基于鍵值對
? ? ? ? ? ? ? ?2、非關系型(redis)
? ? ? ? ? ? ? ? ? ? ? ?關系型數據庫:存儲了數據以及數據之間的關系,oracle,mysql
? ? ? ? ? ? ? ? ? ? ? ?非關系型數據庫:存儲了數據,redis,mdb.
? ? ? ? ? ? ? ?3、數據存儲在內存中,服務器關閉后,持久化到硬盤中
? ? ? ? ? ? ? ?4、支持主從同步
? ? ? ? ? ? ? ?實現了緩存數據和項目的解耦。
? ? ? ?redis存儲的數據特點:
? ? ? ? ? ? ? ?大量數據
? ? ? ? ? ? ? ?用戶共享數據
? ? ? ? ? ? ? ?數據不經常修改。
? ? ? ? ? ? ? ?查詢數據
? ? ? ?redis的應用場景:
? ? ? ? ? ? ? ?網站高并發的主頁數據
? ? ? ? ? ? ? ?網站數據的排名
? ? ? ? ? ? ? ?消息訂閱
1.3redis——數據結構和對象的使用介紹? ??
? ? ? ?
redis官網
微軟寫的windows下的redis
我們下載第一個
額案后基本一路默認就行了
安裝后,服務自動啟動,以后也不用自動啟動。
出現這個表示我們連接上了。
?
redis命令參考鏈接
1.3.1String
數據結構
struct sdshdr{//記錄buf數組中已使用字節的數量int len;//記錄buf數組中未使用的數量int free;//字節數組,用于保存字符串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>
應用場景
String是最常用的一種數據類型,普通的key/value存儲都可以歸為此類,value其實不僅是String,也可以是數字:比如想知道什么時候封鎖一個IP地址(訪問超過幾次)。INCRBY命令讓這些變得很容易,通過原子遞增保持計數。
1.3.2LIST
數據結構
typedef struct listNode{//前置節點struct listNode *prev;//后置節點struct listNode *next;//節點的值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"
應用場景
Redis list的應用場景非常多,也是Redis最重要的數據結構之一。
我們可以輕松地實現最新消息排行等功能。
Lists的另一個應用就是消息隊列,可以利用Lists的PUSH操作,將任務存在Lists中,然后工作線程再用POP操作將任務取出進行執行。
1.3.3HASH
數據結構
dictht是一個散列表結構,使用拉鏈法保存哈希沖突的dictEntry。
typedef struct dictht{//哈希表數組dictEntry **table;//哈希表大小unsigned long size;//哈希表大小掩碼,用于計算索引值unsigned long sizemask;//該哈希表已有節點的數量unsigned long used;
}typedef struct dictEntry{//鍵void *key;//值union{void *val;uint64_tu64;int64_ts64;}struct dictEntry *next;
}
Redis的字典dict中包含兩個哈希表dictht,這是為了方便進行rehash操作。在擴容時,將其中一個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操作并不是一次性完成、而是采用漸進式方式,目的是為了避免一次性執行過多的rehash操作給服務器帶來負擔。
漸進式rehash通過記錄dict的rehashidx完成,它從0開始,然后沒執行一次rehash例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],這一次會把 dict[0] 上 table[rehashidx] 的鍵值對 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
在 rehash 期間,每次對字典執行添加、刪除、查找或者更新操作時,都會執行一次漸進式 rehash。
采用漸進式rehash會導致字典中的數據分散在兩個dictht中,因此對字典的操作也會在兩個哈希表上進行。
例如查找時,先從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"
應用場景
Redis為集合提供了求交集、并集、差集等操作,故可以用來求共同好友等操作。
1.3.5ZSET
數據結構
typedef struct zskiplistNode{//后退指針struct zskiplistNode *backward;//分值double score;//成員對象robj *obj;//層struct zskiplistLever{//前進指針struct zskiplistNode *forward;//跨度unsigned int span;}lever[];}typedef struct zskiplist{//表頭節點跟表尾結點struct zskiplistNode *header, *tail;//表中節點的數量unsigned long length;//表中層數最大的節點的層數int lever;}
跳躍表,基于多指針有序鏈實現,可以看作多個有序鏈表。
與紅黑樹等平衡樹相比,跳躍表具有以下優點:
- 插入速度非常快速,因為不需要進行旋轉等操作來維持平衡性。
- 更容易實現。
- 支持無鎖操作。
常見操作
> 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"
應用場景
以某個條件為權重,比如按頂的次數排序
ZREVRANGE命令可以用來按照得分來獲取前100名的用戶,ZRANK可以用來獲取用戶排名,非常直接而且操作容易。
Redis sorted set的使用場景與set類似,區別是set不是自動有序的,而sorted set可以通過用戶額外提供一個優先級(score)的參數來為成員排序,并且是插入有序的,即自動排序。
?
redis命令參考鏈接
1.4Spring整合Redis
引入依賴
- spring-boot-starter-data-redis
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
配置Redis
- 配置數據庫參數
# RedisProperties
spring.redis.database=11#第11個庫,這個隨便
spring.redis.host=localhost
spring.redis.port=6379#端口
- 編寫配置類,構造RedisTemplate
這個springboot已經幫我們配了,但是默認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);// 設置key的序列化方式template.setKeySerializer(RedisSerializer.string());// 設置value的序列化方式template.setValueSerializer(RedisSerializer.json());// 設置hash的key的序列化方式template.setHashKeySerializer(RedisSerializer.string());// 設置hash的value的序列化方式template.setHashValueSerializer(RedisSerializer.json());template.afterPropertiesSet();return template;}}
訪問Redis
- redisTemplate.opsForValue()
- redisTemplate.opsForHash()
- redisTemplate.opsForList()
- redisTemplate.opsForSet()
- redisTemplate.opsForZSet()
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class RedisTests {@Autowiredprivate RedisTemplate redisTemplate;@Testpublic void testStrings() {String redisKey = "test:count";redisTemplate.opsForValue().set(redisKey, 1);System.out.println(redisTemplate.opsForValue().get(redisKey));System.out.println(redisTemplate.opsForValue().increment(redisKey));System.out.println(redisTemplate.opsForValue().decrement(redisKey));}@Testpublic void testHashes() {String redisKey = "test:user";redisTemplate.opsForHash().put(redisKey, "id", 1);redisTemplate.opsForHash().put(redisKey, "username", "zhangsan");System.out.println(redisTemplate.opsForHash().get(redisKey, "id"));System.out.println(redisTemplate.opsForHash().get(redisKey, "username"));}@Testpublic void testLists() {String redisKey = "test:ids";redisTemplate.opsForList().leftPush(redisKey, 101);redisTemplate.opsForList().leftPush(redisKey, 102);redisTemplate.opsForList().leftPush(redisKey, 103);System.out.println(redisTemplate.opsForList().size(redisKey));System.out.println(redisTemplate.opsForList().index(redisKey, 0));System.out.println(redisTemplate.opsForList().range(redisKey, 0, 2));System.out.println(redisTemplate.opsForList().leftPop(redisKey));System.out.println(redisTemplate.opsForList().leftPop(redisKey));System.out.println(redisTemplate.opsForList().leftPop(redisKey));}@Testpublic void testSets() {String redisKey = "test:teachers";redisTemplate.opsForSet().add(redisKey, "劉備", "關羽", "張飛", "趙云", "諸葛亮");System.out.println(redisTemplate.opsForSet().size(redisKey));System.out.println(redisTemplate.opsForSet().pop(redisKey));System.out.println(redisTemplate.opsForSet().members(redisKey));}@Testpublic void testSortedSets() {String redisKey = "test:students";redisTemplate.opsForZSet().add(redisKey, "唐僧", 80);redisTemplate.opsForZSet().add(redisKey, "悟空", 90);redisTemplate.opsForZSet().add(redisKey, "八戒", 50);redisTemplate.opsForZSet().add(redisKey, "沙僧", 70);redisTemplate.opsForZSet().add(redisKey, "白龍馬", 60);System.out.println(redisTemplate.opsForZSet().zCard(redisKey));System.out.println(redisTemplate.opsForZSet().score(redisKey, "八戒"));System.out.println(redisTemplate.opsForZSet().reverseRank(redisKey, "八戒"));System.out.println(redisTemplate.opsForZSet().reverseRange(redisKey, 0, 2));}@Testpublic void testKeys() {redisTemplate.delete("test:user");System.out.println(redisTemplate.hasKey("test:user"));redisTemplate.expire("test:students", 10, TimeUnit.SECONDS);}
}
這樣還是稍微有點麻煩,我們其實可以綁定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());}
二、數據結構原理總結
這部分在我看來是最有意思的,我們有必要了解底層數據結構的實現,這也是我最感興趣的。
比如,你知道redis中的字符串怎么實現的嗎?為什么這么實現?
你知道redis壓縮列表是什么算法嗎?
你知道redis為什么拋棄了紅黑樹反而采用了跳表這種新的數據結構嗎?
你知道hyperloglog為什么用如此小的空間就可以有這么好的統計性能和準確性嗎?
你知道布隆過濾器為什么這么有效嗎?有沒有數學證明過?
你是否還能很快寫出來快排?或者不斷優化性能的排序?是不是只會調庫了甚至庫函數怎么實現的都不知道?真的就是快排?
包括數據庫,持久化,處理事件、客戶端服務端、事務的實現、發布和訂閱等功能的實現,也需要了解。
2.1數據結構和對象的實現
- 1) 字符串
redis并未使用傳統的c語言字符串表示,它自己構建了一種簡單的動態字符串抽象類型。
在redis里,c語言字符串只會作為字符串字面量出現,用在無需修改的地方。
當需要一個可以被修改的字符串時,redis就會使用自己實現的SDS(simple dynamic string)。比如在redis數據庫里,包含字符串的鍵值對底層都是SDS實現的,不止如此,SDS還被用作緩沖區(buffer):比如AOF模塊中的AOF緩沖區以及客戶端狀態中的輸入緩沖區。
下面來具體看一下sds的實現:
struct sdshdr
{int len;//buf已使用字節數量(保存的字符串長度)int free;//未使用的字節數量char buf[];//用來保存字符串的字節數組
};
sds遵循c中字符串以'\0'結尾的慣例,這一字節的空間不算在len之內。
這樣的好處是,我們可以直接重用c中的一部分函數。比如printf;
? ? sds相對c的改進
? ? 獲取長度:c字符串并不記錄自身長度,所以獲取長度只能遍歷一遍字符串,redis直接讀取len即可。
? ? 緩沖區安全:c字符串容易造成緩沖區溢出,比如:程序員沒有分配足夠的空間就執行拼接操作。而redis會先檢查sds的空間是否滿足所需要求,如果不滿足會自動擴充。
? ? 內存分配:由于c不記錄字符串長度,對于包含了n個字符的字符串,底層總是一個長度n+1的數組,每一次長度變化,總是要對這個數組進行一次內存重新分配的操作。因為內存分配涉及復雜算法并且可能需要執行系統調用,所以它通常是比較耗時的操作。? ?
? ? redis內存分配:
1、空間預分配:如果修改后大小小于1MB,程序分配和len大小一樣的未使用空間,如果修改后大于1MB,程序分配? 1MB的未使用空間。修改長度時檢查,夠的話就直接使用未使用空間,不用再分配。?
2、惰性空間釋放:字符串縮短時不需要釋放空間,用free記錄即可,留作以后使用。
? ? 二進制安全
c字符串除了末尾外,不能包含空字符,否則程序讀到空字符會誤以為是結尾,這就限制了c字符串只能保存文本,二進制文件就不能保存了。
而redis字符串都是二進制安全的,因為有len來記錄長度。
- 2) 鏈表
作為一種常用數據結構,鏈表內置在很多高級語言中,因為c并沒有,所以redis實現了自己的鏈表。
鏈表在redis也有一定的應用,比如列表鍵的底層實現之一就是鏈表。(當列表鍵包含大量元素或者元素都是很長的字符串時)
發布與訂閱、慢查詢、監視器等功能也用到了鏈表。
具體實現:
//redis的節點使用了雙向鏈表結構
typedef struct listNode {// 前置節點struct listNode *prev;// 后置節點struct listNode *next;// 節點的值void *value;
} listNode;
//其實學過數據結構的應該都實現過
typedef struct list {// 表頭節點listNode *head;// 表尾節點listNode *tail;// 鏈表所包含的節點數量unsigned long len;// 節點值復制函數void *(*dup)(void *ptr);// 節點值釋放函數void (*free)(void *ptr);// 節點值對比函數int (*match)(void *ptr, void *key);
} list;
總結一下redis鏈表特性:
雙端、無環、帶長度記錄、
多態:使用?void*
?指針來保存節點值, 可以通過?dup
?、?free
?、?match
?為節點值設置類型特定函數, 可以保存不同類型的值。
- 3)字典
其實字典這種數據結構也內置在很多高級語言中,但是c語言沒有,所以redis自己實現了。
應用也比較廣泛,比如redis的數據庫就是字典實現的。不僅如此,當一個哈希鍵包含的鍵值對比較多,或者都是很長的字符串,redis就會用字典作為哈希鍵的底層實現。
來看看具體是實現:
//redis的字典使用哈希表作為底層實現
typedef struct dictht {// 哈希表數組dictEntry **table;// 哈希表大小unsigned long size;// 哈希表大小掩碼,用于計算索引值// 總是等于 size - 1unsigned long sizemask;// 該哈希表已有節點的數量unsigned long used;} dictht;
table
?是一個數組, 數組中的每個元素都是一個指向dictEntry
?結構的指針, 每個?dictEntry
?結構保存著一個鍵值對。
圖為一個大小為4的空哈希表。
我們接著就來看dictEntry的實現:
typedef struct dictEntry {// 鍵void *key;// 值union {void *val;uint64_t u64;int64_t s64;} v;// 指向下個哈希表節點,形成鏈表struct dictEntry *next;
} dictEntry;
(v可以是一個指針, 或者是一個?uint64_t
?整數, 又或者是一個?int64_t
?整數。)
next就是解決鍵沖突問題的,沖突了就掛后面,這個學過數據結構的應該都知道吧,不說了。
?
下面我們來說字典是怎么實現的了。
typedef struct dict {// 類型特定函數dictType *type;// 私有數據void *privdata;// 哈希表dictht ht[2];// rehash 索引int rehashidx; //* rehashing not in progress if rehashidx == -1
} dict;
type
?和?privdata
?是對不同類型的鍵值對, 為創建多態字典而設置的:
type
?指向?dictType
?, 每個?dictType
?保存了用于操作特定類型鍵值對的函數, 可以為用途不同的字典設置不同的類型特定函數。
而?privdata
?屬性則保存了需要傳給那些類型特定函數的可選參數。
而dictType就暫時不展示了,不重要而且字有點多。。。還是講有意思的東西吧
? ? rehash(重新散列)
隨著我們不斷的操作,哈希表保存的鍵值可能會增多或者減少,為了讓哈希表的負載因子維持在合理的范圍內,有時需要對哈希表進行合理的擴展或者收縮。?一般情況下, 字典只使用?ht[0]
?哈希表,?ht[1]
?哈希表只會在對?ht[0]
?哈希表進行 rehash 時使用。
redis字典哈希rehash的步驟如下:
1)為ht[1]分配合理空間:如果是擴展操作,大小為第一個大于等于ht[0]*used*2的,2的n次冪。
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?如果是收縮操作,大小為第一個大于等于ht[0]*used的,2的n次冪。
2)將ht[0]中的數據rehash到ht[1]上。
3)釋放ht[0],將ht[1]設置為ht[0],ht[1]創建空表,為下次做準備。
? ? 漸進rehash
數據量特別大時,rehash可能對服務器造成影響。為了避免,服務器不是一次性rehash的,而是分多次。
我們維持一個變量rehashidx,設置為0,代表rehash開始,然后開始rehash,在這期間,每個對字典的操作,程序都會把索引rehashidx上的數據移動到ht[1]。
隨著操作不斷執行,最終我們會完成rehash,設置rehashidx為-1.
需要注意:rehash過程中,每一次增刪改查也是在兩個表進行的。
- 4)整數集合
整數集合(intset)是 Redis 用于保存整數值的集合抽象數據結構, 可以保存?int16_t
?、?int32_t
?、?int64_t
?的整數值, 并且保證集合中不會出現重復元素。
實現較為簡單:
typedef struct intset {// 編碼方式uint32_t encoding;// 集合包含的元素數量uint32_t length;// 保存元素的數組int8_t contents[];
} intset;
各個項在數組中從小到大有序地排列, 并且數組中不包含任何重復項。
雖然?intset
?結構將?contents
?屬性聲明為?int8_t
?類型的數組, 但實際上?contents
?數組并不保存任何?int8_t
?類型的值 ——?contents
?數組的真正類型取決于?encoding
?屬性的值:
如果?encoding
?屬性的值為?INTSET_ENC_INT16
?, 那么?contents
?就是一個?int16_t
?類型的數組, 數組里的每個項都是一個?int16_t
?類型的整數值 (最小值為?-32,768
?,最大值為?32,767
?)。
如果?encoding
?屬性的值為?INTSET_ENC_INT32
?, 那么?contents
?就是一個?int32_t
?類型的數組, 數組里的每個項都是一個?int32_t
?類型的整數值 (最小值為?-2,147,483,648
?,最大值為?2,147,483,647
?)。
如果?encoding
?屬性的值為?INTSET_ENC_INT64
?, 那么?contents
?就是一個?int64_t
?類型的數組, 數組里的每個項都是一個?int64_t
?類型的整數值 (最小值為?-9,223,372,036,854,775,808
?,最大值為?9,223,372,036,854,775,807
?)。
? ? 升級
c語言是靜態類型語言,不允許不同類型保存在一個數組。這樣第一,靈活性較差,第二,有時會用掉不必要的內存
比如用long long儲存1
為了提高整數集合的靈活性和節約內存,我們引入升級策略。
當我們要將一個新元素添加到集合里, 并且新元素類型比集合現有元素的類型都要長時, 集合需要先進行升級。
分為三步進行:
- 根據新元素的類型, 擴展整數集合底層數組的空間大小, 并為新元素分配空間。
- 將底層數組現有的所有元素都轉換成與新元素相同的類型, 并將類型轉換后的元素放置到正確的位上
- 將新元素添加到底層數組里面。
因為每次添加新元素都可能會引起升級, 每次升級都要對已有元素類型轉換, 所以添加新元素的時間復雜度為?O(N)?。
因為引發升級的新元素比原數據都長,所以要么他是最大的,要么他是最小的。我們把它放在開頭或結尾即可。
?
? ? 降級
略略略,不管你們信不信,整數集合不支持降級操作。。我也不知道為啥
- 5)壓縮列表
壓縮列表是列表鍵和哈希鍵的底層實現之一。
當一個列表鍵只包含少量列表項,并且列表項都是小整數或者短字符串,redis就會用壓縮列表做列表鍵底層實現。
壓縮列表是 Redis 為了節約內存而開發的, 由一系列特殊編碼的連續內存塊組成的順序型(sequential)數據結構。
一個壓縮列表可以包含任意多個節點(entry), 每個節點可以保存一個字節數組或者一個整數值。
具體實現:
具體說一下entry:
由三個部分組成:
1、previous_entry_length:記錄上一個節點的長度,這樣我們就可以從最后一路遍歷到開頭。
2、encoding:記錄了content所保存的數據類型和長度。(具體編碼不寫了,不重要)
3、content:保存節點值,可以是字節數組或整數。(具體怎么壓縮的等我搞明白再補)
? ? 連鎖更新
前面說過, 每個節點的?previous_entry_length
?屬性都記錄了前一個節點的長度:
- 如果前一節點的長度<?
254
?KB, 那么?previous_entry_length
?需要用?1
?字節長的空間 - 如果前一節點的長度>=
254
?KB, 那么?previous_entry_length
?需要用?5
?字節長的空間
現在, 考慮這樣一種情況: 在一個壓縮列表中, 有多個連續的、長度介于?250
?字節到?253
?字節之間的節點 ,這時, 如果我們將一個長度大于等于?254
?字節的新節點?new
?設置為壓縮列表的表頭節點。。。。
然后腦補一下,就會導致連鎖擴大每個節點的空間對吧?e(i)因為e(i-1)的擴大而擴大,i+1也是如此,以此類推。。。
?
刪除節點同樣會導致連鎖更新。
這個事情只是想說明一個問題:插入刪除操作的最壞時間復雜度其實是o(n*n),因為每更新一個節點都要o(n)。
但是,也不用太過擔心,因為這種特殊情況并不多見,這些命令的平均復雜度依舊是o(n)。
?
2.2 跳表專欄
2.2.1跳表是啥
為什么選擇了跳表而不是紅黑樹?
跳表是個啥東西請看這個文章。
我們知道,節點插入時隨機出一個層數,僅僅依靠一個簡單的隨機數操作而構建出來的多層鏈表結構,能保證它有一個良好的查找性能嗎?為了回答這個疑問,我們需要分析skiplist的統計性能。
在分析之前,我們還需要著重指出的是,執行插入操作時計算隨機數的過程,是一個很關鍵的過程,它對skiplist的統計特性有著很重要的影響。這并不是一個普通的服從均勻分布的隨機數,它的計算過程如下:
- 首先,每個節點肯定都有第1層指針(每個節點都在第1層鏈表里)。
- 如果一個節點有第i層(i>=1)指針(即節點已經在第1層到第i層鏈表中),那么它有第(i+1)層指針的概率為p。
- 節點最大的層數不允許超過一個最大值,記為MaxLevel。
這個計算隨機層數的偽碼如下所示:
randomLevel()
level := 1
// random()返回一個[0...1)的隨機數
while random() < p and level < MaxLevel do
level := level + 1
return level
randomLevel()的偽碼中包含兩個參數,一個是p,一個是MaxLevel。在Redis的skiplist實現中,這兩個參數的取值為:
p = 1/4
MaxLevel = 32
2.2.2skiplist的算法性能分析
在這一部分,我們來簡單分析一下skiplist的時間復雜度和空間復雜度,以便對于skiplist的性能有一個直觀的了解。如果你不是特別偏執于算法的性能分析,那么可以暫時跳過這一小節的內容。
我們先來計算一下每個節點所包含的平均指針數目(概率期望)。節點包含的指針數目,相當于這個算法在空間上的額外開銷(overhead),可以用來度量空間復雜度。
根據前面randomLevel()的偽碼,我們很容易看出,產生越高的節點層數,概率越低。定量的分析如下:
- 節點層數至少為1。而大于1的節點層數,滿足一個概率分布。
- 節點層數恰好等于1的概率為1-p。
- 節點層數大于等于2的概率為p,而節點層數恰好等于2的概率為p(1-p)。
- 節點層數大于等于3的概率為p^2,而節點層數恰好等于3的概率為p^2(1-p)。
- 節點層數大于等于4的概率為p^3,而節點層數恰好等于4的概率為p^3(1-p)。
- ......
因此,一個節點的平均層數(也即包含的平均指針數目),計算如下:
現在很容易計算出:
- 當p=1/2時,每個節點所包含的平均指針數目為2;
- 當p=1/4時,每個節點所包含的平均指針數目為1.33。這也是Redis里的skiplist實現在空間上的開銷。
接下來,為了分析時間復雜度,我們計算一下skiplist的平均查找長度。查找長度指的是查找路徑上跨越的跳數,而查找過程中的比較次數就等于查找長度加1。以前面圖中標出的查找23的查找路徑為例,從左上角的頭結點開始,一直到結點22,查找長度為6。
為了計算查找長度,這里我們需要利用一點小技巧。我們注意到,每個節點插入的時候,它的層數是由隨機函數randomLevel()計算出來的,而且隨機的計算不依賴于其它節點,每次插入過程都是完全獨立的。所以,從統計上來說,一個skiplist結構的形成與節點的插入順序無關。
這樣的話,為了計算查找長度,我們可以將查找過程倒過來看,從右下方第1層上最后到達的那個節點開始,沿著查找路徑向左向上回溯,類似于爬樓梯的過程。我們假設當回溯到某個節點的時候,它才被插入,這雖然相當于改變了節點的插入順序,但從統計上不影響整個skiplist的形成結構。
現在假設我們從一個層數為i的節點x出發,需要向左向上攀爬k層。這時我們有兩種可能:
- 如果節點x有第(i+1)層指針,那么我們需要向上走。這種情況概率為p。
- 如果節點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
這個結果的意思是,我們每爬升1個層級,需要在查找路徑上走1/p步。而我們總共需要攀爬的層級數等于整個skiplist的總層數-1。
那么接下來我們需要分析一下當skiplist中有n個節點的時候,它的總層數的概率均值是多少。這個問題直觀上比較好理解。根據節點的層數隨機算法,容易得出:
- 第1層鏈表固定有n個節點;
- 第2層鏈表平均有n*p個節點;
- 第3層鏈表平均有n*p^2個節點;
- ...
所以,從第1層到最高層,各層鏈表的平均節點數是一個指數遞減的等比數列。容易推算出,總層數的均值為log1/pn,而最高層的平均節點數為1/p。
綜上,粗略來計算的話,平均查找長度約等于:
- C(log1/pn-1)=(log1/pn-1)/p
即,平均時間復雜度為O(log n)。
當然,這里的時間復雜度分析還是比較粗略的。比如,沿著查找路徑向左向上回溯的時候,可能先到達左側頭結點,然后沿頭結點一路向上;還可能先到達最高層的節點,然后沿著最高層鏈表一路向左。但這些細節不影響平均時間復雜度的最后結果。另外,這里給出的時間復雜度只是一個概率平均值,但實際上計算一個精細的概率分布也是有可能的。
詳情還請參見William Pugh的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
2.2.3skiplist與平衡樹、哈希表的比較
- skiplist和各種平衡樹(如AVL、紅黑樹等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做單個key的查找,不適宜做范圍查找。所謂范圍查找,指的是查找那些大小在指定的兩個值之間的所有節點。
- 在做范圍查找的時候,平衡樹比skiplist操作要復雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這里的中序遍歷并不容易實現。而在skiplist上進行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進行若干步的遍歷就可以實現。
- 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯復雜,而skiplist的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
- 從內存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指針(分別指向左右子樹),而skiplist每個節點包含的指針數目平均為1/(1-p),具體取決于參數p的大小。如果像Redis里的實現一樣,取p=1/4,那么平均每個節點包含1.33個指針,比平衡樹更有優勢。
- 查找單個key,skiplist和平衡樹的時間復雜度都為O(log n),大體相當;而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結構,大都是基于哈希表實現的。
- 從算法實現難度上來比較,skiplist比平衡樹要簡單得多。
2.2.4Redis中的skiplist和經典有何不同
- 分數(score)允許重復,即skiplist的key允許重復。這在最開始介紹的經典skiplist中是不允許的。
- 在比較時,不僅比較分數(相當于skiplist的key),還比較數據本身。在Redis的skiplist實現中,數據本身的內容唯一標識這份數據,而不是由key來唯一標識。另外,當多個元素分數相同的時候,還需要根據數據內容來進字典排序。
- 第1層鏈表不是一個單向鏈表,而是一個雙向鏈表。這是為了方便以倒序方式獲取一個范圍內的元素。
- 在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)它們的記憶力不是很強。基本上由你決定。更改有關節點具有給定數量級別的概率的參數將使內存密集度低于btree。
2)排序集通常是許多Zrange或Zrevrange操作的目標,即作為鏈表遍歷跳過列表。通過此操作,跳過列表的緩存區域性至少與其他類型的平衡樹一樣好。
3)它們易于實現、調試等。例如,由于跳過列表的簡單性,我收到了一個補丁(已經在redis master中),其中包含在o(log(n))中實現zrank的擴展跳過列表。它只需要對代碼稍作修改。
2.3HyperLogLog 專欄
HyperLogLog 是一種概率數據結構,用來估算數據的基數。數據集可以是網站訪客的 IP 地址,E-mail 郵箱或者用戶 ID。
基數就是指一個集合中不同值的數目,比如 a, b, c, d 的基數就是 4,a, b, c, d, a 的基數還是 4。雖然 a 出現兩次,只會被計算一次。
使用 Redis 統計集合的基數一般有三種方法,分別是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前兩個數據結構在集合的數量級增長時,所消耗的內存會大大增加,但是 HyperLogLog 則不會。
Redis 的 HyperLogLog 通過犧牲準確率來減少內存空間的消耗,只需要12K內存,在標準誤差0.81%的前提下,能夠統計2^64個數據。所以 HyperLogLog 是否適合在比如統計日活月活此類的對精度要不不高的場景。
這是一個很驚人的結果,以如此小的內存來記錄如此大數量級的數據基數。下面我們就帶大家來深入了解一下 HyperLogLog 的使用,基礎原理,源碼實現和具體的試驗數據分析。
2.3.1HyperLogLog 在 Redis 中的使用
Redis 提供了?PFADD
?、?PFCOUNT
?和?PFMERGE
?三個命令來供用戶使用 HyperLogLog。
PFADD
?用于向 HyperLogLog 添加元素。
> PFADD visitors alice bob carol(integer) 1> PFCOUNT visitors(integer) 3
如果 HyperLogLog 估計的近似基數在?PFADD
?命令執行之后出現了變化, 那么命令返回 1 , 否則返回 0 。 如果命令執行時給定的鍵不存在, 那么程序將先創建一個空的 HyperLogLog 結構, 然后再執行命令。
PFCOUNT
?命令會給出 HyperLogLog 包含的近似基數。在計算出基數后,?PFCOUNT
?會將值存儲在 HyperLogLog 中進行緩存,知道下次?PFADD
?執行成功前,就都不需要再次進行基數的計算。
PFMERGE
?將多個 HyperLogLog 合并為一個 HyperLogLog , 合并后的 HyperLogLog 的基數接近于所有輸入 HyperLogLog 的并集基數。
> PFADD customers alice dan(integer) 1> PFMERGE everyone visitors customersOK> PFCOUNT everyone(integer) 4
2.3.2內存消耗對比實驗
我們下面就來通過實驗真實對比一下下面三種數據結構的內存消耗,HashMap、BitMap 和 HyperLogLog。
我們首先使用 Lua 腳本向 Redis 對應的數據結構中插入一定數量的數,然后執行 bgsave 命令,最后使用 redis-rdb-tools 的 rdb 的命令查看各個鍵所占的內存大小。
下面是 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 三種數據結構中插入了一千萬個數,然后使用?rdb
?命令查看各個結構內存消耗。
我們進行了兩輪實驗,分別插入一萬數字和一千萬數字,三種數據結構消耗的內存統計如下所示。
從表中可以明顯看出,一萬數量級時 BitMap 消耗內存最小, 一千萬數量級時 HyperLogLog 消耗內存最小,但是總體來看,HyperLogLog 消耗的內存都是 14392 字節,可見 HyperLogLog 在內存消耗方面有自己的獨到之處。
2.3.3基本原理
HyperLogLog 是一種概率數據結構,它使用概率算法來統計集合的近似基數。而它算法的最本源則是伯努利過程。
伯努利過程就是一個拋硬幣實驗的過程。拋一枚正常硬幣,落地可能是正面,也可能是反面,二者的概率都是 1/2 。伯努利過程就是一直拋硬幣,直到落地時出現正面位置,并記錄下拋擲次數k。比如說,拋一次硬幣就出現正面了,此時 k 為 1; 第一次拋硬幣是反面,則繼續拋,直到第三次才出現正面,此時 k 為 3。
對于 n 次伯努利過程,我們會得到 n 個出現正面的投擲次數值 k1, k2 ... kn , 其中這里的最大值是k_max。
根據一頓數學推導,我們可以得出一個結論: 2^{k_ max} 來作為n的估計值。也就是說你可以根據最大投擲次數近似的推算出進行了幾次伯努利過程。
下面,我們就來講解一下 HyperLogLog 是如何模擬伯努利過程,并最終統計集合基數的。
HyperLogLog 在添加元素時,會通過Hash函數,將元素轉為64位比特串,例如輸入5,便轉為101(省略前面的0,下同)。這些比特串就類似于一次拋硬幣的伯努利過程。比特串中,0 代表了拋硬幣落地是反面,1 代表拋硬幣落地是正面,如果一個數據最終被轉化了 10010000,那么從低位往高位看,我們可以認為,這串比特串可以代表一次伯努利過程,首次出現 1 的位數為5,就是拋了5次才出現正面。
所以 HyperLogLog 的基本思想是利用集合中數字的比特串第一個 1 出現位置的最大值來預估整體基數,但是這種預估方法存在較大誤差,為了改善誤差情況,HyperLogLog中引入分桶平均的概念,計算 m 個桶的調和平均值。
Redis 中 HyperLogLog 一共分了 2^14 個桶,也就是 16384 個桶。每個桶中是一個 6 bit 的數組。
HyperLogLog 將上文所說的 64 位比特串的低 14 位單獨拿出,它的值就對應桶的序號,然后將剩下 50 位中第一次出現 1 的位置值設置到桶中。50位中出現1的位置值最大為50,所以每個桶中的 6 位數組正好可以表示該值。
在設置前,要設置進桶的值是否大于桶中的舊值,如果大于才進行設置,否則不進行設置。
此時為了性能考慮,是不會去統計當前的基數的,而是將 HyperLogLog 頭的 card 屬性中的標志位置為 1,表示下次進行 pfcount 操作的時候,當前的緩存值已經失效了,需要重新統計緩存值。在后面 pfcount 流程的時候,發現這個標記為失效,就會去重新統計新的基數,放入基數緩存。
在計算近似基數時,就分別計算每個桶中的值,帶入到上文的 DV 公式中,進行調和平均和結果修正,就能得到估算的基數值。
2.3.4HyperLogLog 具體對象
我們首先來看一下 HyperLogLog 對象的定義
struct hllhdr {char magic[4]; /* 魔法值 "HYLL" */uint8_t encoding; /* 密集結構或者稀疏結構 HLL_DENSE or HLL_SPARSE. */uint8_t notused[3]; /* 保留位, 全為0. */uint8_t card[8]; /* 基數大小的緩存 */uint8_t registers[]; /* 數據字節數組 */};
HyperLogLog 對象中的?registers
?數組就是桶,它有兩種存儲結構,分別為密集存儲結構和稀疏存儲結構,兩種結構只涉及存儲和桶的表現形式,從中我們可以看到 Redis 對節省內存極致地追求。
我們先看相對簡單的密集存儲結構,它也是十分的簡單明了,既然要有 2^14 個 6 bit的桶,那么我就真使用足夠多的?uint8_t
?字節去表示,只是此時會涉及到字節位置和桶的轉換,因為字節有 8 位,而桶只需要 6 位。
所以我們需要將桶的序號轉換成對應的字節偏移量 offsetbytes 和其內部的位數偏移量 offsetbits。需要注意的是小端字節序,高位在右側,需要進行倒轉。
當 offset_bits 小于等于2時,說明一個桶就在該字節內,只需要進行倒轉就能得到桶的值。
?offset_bits 大于 2 ,則說明一個桶分布在兩個字節內,此時需要將兩個字節的內容都進行倒置,然后再進行拼接得到桶的值。
Redis 為了方便表達稀疏存儲,它將上面三種字節表示形式分別賦予了一條指令。
-
ZERO : 一字節,表示連續多少個桶計數為0,前兩位為標志00,后6位表示有多少個桶,最大為64。
-
XZERO : 兩個字節,表示連續多少個桶計數為0,前兩位為標志01,后14位表示有多少個桶,最大為16384。
-
VAL : 一字節,表示連續多少個桶的計數為多少,前一位為標志1,四位表示連桶內計數,所以最大表示桶的計數為32。后兩位表示連續多少個桶。
?
Redis從稀疏存儲轉換到密集存儲的條件是:
-
任意一個計數值從 32 變成 33,因為 VAL 指令已經無法容納,它能表示的計數值最大為 32
-
稀疏存儲占用的總字節數超過 3000 字節,這個閾值可以通過 hllsparsemax_bytes 參數進行調整。
2.4LRU專欄
2.4.1LRU介紹和代碼實現
LRU全稱是Least?Recently Used,即最近最久未使用的意思。
LRU算法的設計原則是:如果一個數據在最近一段時間沒有被訪問到,那么在將來它被訪問的可能性也很小。也就是說,當限定的空間已存滿數據時,應當把最久沒有被訪問到的數據淘汰。(這一段是找的,讓大家理解一下什么是LRU)。
?
說一下我們什么時候見到過LRU:其實老師們肯定都給大家舉過這么個例子:你在圖書館,你把書架子里的書拿到桌子上。。但是桌子是有限的,你有時候不得不把一些書放回去。這就相當于內存和硬盤。這個例子都說過吧?
LRU就是記錄你最長時間沒看過的書,就把它放回去。在cache那里見過吧
?
然后最近在研究redis,又看到了這個LRU,所以就想寫一下吧。
題目:設計一個結構,這個結構可以查詢K-V,但是容量有限,當存不下的時候就要把用的年代最久遠的那個東西扔掉。
其實思路很簡單,我們維護一個雙向鏈表即可,get也就是使用了,我們就把把它提到最安全的位置。新來的KV就依次放即可。
我們就先寫這個雙向鏈表結構
先寫節點結構:
public static class Node<V> {public V value;public Node<V> last;//前public Node<V> next;//后public Node(V value) {this.value = value;}}
然后寫雙向鏈表結構: 我們沒必要把鏈表操作都寫了,分析一下,我們只有三個操作:
1、加節點
2、使用了某個節點就把它調到尾,代表優先級最高
3、把優先級最低的移除,也就是去頭部
(不會的,翻我之前的鏈表操作都有寫)
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;//注意讓本節點前指針指向舊尾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;}}
鏈表操作封裝完了就要實現這個結構了。
具體思路代碼注釋
public static class MyCache<K, V> {//為了kv or vk都能查private HashMap<K, Node<V>> keyNodeMap;private HashMap<Node<V>, K> nodeKeyMap;//用來做優先級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);//我們認為放入舊key也是使用過} else {Node<V> newNode = new Node<V>(value);this.keyNodeMap.put(key, newNode);this.nodeKeyMap.put(newNode, key);this.nodeList.addNode(newNode);//加進去if (this.keyNodeMap.size() == this.capacity + 1) {this.removeMostUnusedCache();//放不下就去掉優先級最低的}}}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算法改進
redis通常使用緩存,是使用一種固定最大內存的使用。當數據達到可使用的最大固定內存時,我們需要通過移除老數據來獲取空間。redis作為緩存是否有效的重要標志是如何尋找一種好的策略:刪除即將需要使用的數據是一種糟糕的策略,而刪除那些很少再次請求的數據則是一種好的策略。
在其他的緩存組件還有個命中率,僅僅表示讀請求的比例。訪問一個緩存中的keys通常不是分布式的。然而訪問經常變化,這意味著不經常訪問,相反,有些keys一旦不流行可能會轉向最經常訪問的keys。 因此,通常一個緩存系統應該盡可能保留那些未來最有可能被訪問的keys。針對keys淘汰的策略是:那些未來極少可能被訪問的數據應該被移除。
但有一個問題:redis和其他緩存系統不能夠預測未來。
LRU算法
緩存系統不能預測未來,原因是:那些很少再次被訪問的key也很有可能最近訪問相當頻繁。如果經常被訪問的模式不會突然改變,那么這是一種很有效的策略。然而,“最近經常被訪問”似乎更隱晦地標明一種 理念。這種算法被稱為LRU算法。最近訪問頻繁的key相比訪問少的key有更高的可能性。
舉個例子,這里有4個不同訪問周期的key,每一個“~”字符代表一秒,結尾的“|”表示當前時刻。
~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~|
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|
~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~|
~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D|
A key每5秒請求一次,B周期是2秒,C、D都是10秒。
訪問頻率最高的是B,因為它的空閑時間最短,這意味著B是4個key中未來最有可能被訪問的key。
同樣的A和C目前的空閑時間是2s和6s也能很好地反映它們本身的周期。然而你可以看到不夠嚴謹:D的訪問周期是10秒,但它卻是4個key中最近被訪問的。
當然,在一個很長的運行周期中,LRU算法能工作得很好。通常有一個更高訪問頻率的key當然有一個更低的空閑周期。LRU算法淘汰最少被訪問key,那些有最大空閑周期的key。實現上也相當容易,只需要額外跟蹤最近被訪問的key即可,有時甚至都需要:把所有我們想要淘汰的對象放到一個鏈表中,當一個對象訪問就移除鏈表頭部元素,當我們要淘汰元素是就直接淘汰鏈表尾部開始。
redis中的LRU:起因
最初,redis不支持LRU算法。當內存有效性成為一個必須被解決的問題時,后來才加上了。通過修改redis對象結構,在每個key對象增加24bit的空間。沒有額外的空間使用鏈表把所有對象放到一個鏈表中(大指針),因此需要實現得更加有效,不能因為key淘汰算法而讓整個服務改動太大。
24bits的對象已經足夠去存儲當前的unxi時間戳。這個表現,被稱為“LRU 時鐘”,key元數據經常被更新,所以它是一個有效的算法。
然后,有另一個更加復雜的問題需要解決:如何選擇訪問間隔最長的key,然后淘汰它。
redis內部采用一個龐大的hash table來保存,添加另外一個數據結構存儲時間間隔顯然不是一個好的選擇。然而我們希望能達到一個LRU本身是一個近似的,通過LRU算法本身來實現。
redis原始的淘汰算法簡單實現:**當需要淘汰一個key時,隨機選擇3個key,淘汰其中間隔時間最長的key。**基本上,我們隨機選擇key,淘汰key效果很好。后來隨機3個key改成一個配置項"N隨機key"。但把默認值提高改成5個后效果大大提高。考慮到它的效果,你根本不用修改他。
然而,你可能會想這個算法如何有效執行,你可以看到我們如何搗毀了很多有趣的數據。也許簡單的N key,我們會遇到很多好的決策,但是當我們淘汰最好的,下一個周期又開始抓。
驗證規則第一條:用肉眼觀察你的算法
其中有一個觀點已經應用到Redis 3.0正式版中了。在redis2.8中一個LRU緩存經常被使用在多個環境,用戶關于淘汰的沒有抱怨太多,但是很明顯我可以提高它,通過不僅僅是增加額外的空間,還有額外的CPU時間。
然而為了提高某項功能,你必須觀察它。有多個不同的方式去觀察LRU算法。你可以通過寫工具觀察,例如模擬不同的工作負載、校驗命中率和失誤率。
程序非常簡單:增加一些指定的keys,然后頻繁地訪問這些keys以至于每一個key都有一個下降的空閑時間。最終超過50%的keys被增加,一半的老key需要被淘汰。
一個完美理想的LRU實現,應該是沒有最新加的key被淘汰,而是淘汰最初的50%的老key。
規則二:不要丟棄重要信息
借助最新的可視化工具,我可以在嘗試新的方法觀察和測試幾分鐘。使用redis最明顯有效的提高算法就是,積累對立的垃圾信息在一個淘汰池中。
基本上,當N keys算法被采用時,通常會分配一個很大的線程pool(默認為16key),這個池按照空閑時間排序,所以只有當有一個大于池中的一個或者池為空的時候,最新的key只會進入到這個池中。
同時,一個新的redis-cli模式去測量LRU算法也增加了(看這個-lru-test選項)。
還有另外一個方式去檢驗LRU算法的好壞,通過一個冪等訪問模式。這個工具通常校驗用一個不同的測試,新算法工作工作效果好于真實世界負載。它也同樣使用流水線和每秒打印訪問日志,因此可以被使用不用為了基準不同的思想,至少可以校驗和觀察明顯的速度回歸。
規則三、最少使用原則(LFU算法)
一切源于一個開放性問題:但你有多個redis 3.2數據庫時,而淘汰算法只能在本機選擇。因此,假如你全部空閑小的key都是DB0號機器,空閑時間長的key都是1號機器,redis每臺機器都會淘汰各自的key。一個更好的選擇當然是先淘汰DB1,最后再淘汰DB0。
當redis被當作緩存使用時很少有情況被分成不同的db上,這不是一個好的處理方式。然而這也是我為什么我再一次修改淘汰代碼的原因。最終,我能夠修改緩存池包括數據庫id,使用單緩存池為多個db,代替多緩存池。這種實現很麻煩,但是通過優化和修改代碼,最終它比普通實現要快到20%。
然而這時候,我對這個redis緩存淘汰算法的好奇心又被點燃。我想要提升它。我花費了幾天想要提高LRU算法實現:或許可以使用更大的緩存池?通過歷史時間選擇最合適被淘汰的key?
經過一段時間,通過優化我的工具,我理解到:LRU算法受限于數據庫中的數據樣本,有時可能相反的場景效果非常好,因此要想提高非常非常難。實際上,能通過展示不同算法的圖片上看這有點非常明顯:每個周期10個keys幾乎和理論的LRU算法表現一致。
當原始算法很難提高時,我開始測試新的算法。 如果我們倒回到博客開始,我們說過LRU實際上有點嚴格。哪些key需要我們真正想要保留:將來有最大可能被訪問,最頻繁被訪問,而不是最近被訪問的key。
淘汰最少被訪問的key算法成為:LFU(Least Frequently Used),將來要被淘汰騰出新空間給新key。
理論上LFU的思想相當簡單,只需要給每個key加一個訪問計數器。每次訪問就自增1,所以也就很容易知道哪些key被訪問更頻繁。
當然,LFU也會帶起其他問題,不單單是針對redis,對于LFU實現:
1、不能使用“移除頂部元素”的方式,keys必須要根據訪問計數器進行排序。每訪問一次就得遍歷所有key找出訪問次數最少的key。
2、LFU不能僅僅是只增加每一訪問的計數器。正如我們所講的,訪問模式改變隨時變化,因此一個有高訪問次數的key,后面很可能沒有人繼續訪問它,因此我們的算法必須要適應超時的情況。
在redis中,第一個問題很好解決:我們可以在LRU的方式一樣:隨機在緩存池中選舉,淘汰其中某項。第二個問題redis還是存在,因此一般對于LFU的思想必須使用一些方式進行減少,或者定期把訪問計數器減半。
24位的LFU實現
LFU有它本身的實現,在redis中我們使用自己的24bit來記錄LRU。
為了實現LFU僅僅需要在每個對象額外新增24bit:
1、一部分用于保存訪問計數器;
2、足夠用于決定什么時候將計數器減半的信息;
我的解決方法是把24bit分成兩列:
16bits8bitslast decr timeLOG_C
16位記錄最后一次減半時間,那樣redis知道上一次減半時間,另外8bit作為訪問計數器。
你可能會想8位的計數器很快就會溢出,是的,相對于簡單計數器,我采用邏輯計數器。邏輯計數器的實現:
uint8_t LFULogIncr(uint8_t counter) {if (counter == 255) return 255;double r = (double)rand()/RAND_MAX;double baseval = counter - LFU_INIT_VAL;if (baseval < 0) baseval = 0;double p = 1.0/(baseval*server.lfu_log_factor+1);if (r < p) counter++;return counter;}
基本上計數器的較大者,更小的可能計數器會增加:上面的代碼計算p位于0~1之間,但計數器增長時會越來越小,位于0-1的隨機數r,只會但滿足r<p時計數器才會加一。
你可以配置計數器增長的速率,如果使用默認配置,會發生:
- 100次訪問后,計數器=10;
- 1000次訪問是是18;
- 10萬次訪問是142;
- 100萬次訪問后達到255,并不在繼續增長;
下面,讓我們看看計數器如果進行衰減。16位的被儲存為unix時間戳保留到分鐘級別,redis會隨機掃描key填充到緩存池中,如果最后一個下降的時間大于N分鐘前(可配置化),如果計數器的值很大就減半,或者對于值小的就直接簡單減半。
這里又衍生出另外一個問題,就是新進來的key是需要有機會被保留的。由于LFU新增是得分都是0,非常容易被選舉替換掉。在redis中,開始默認值為5。這個初始值是根據增長數據和減半算法來估算的。模擬顯示得分小于5的key是首選。
代碼和性能
上面描述的算法已經提交到一個非穩定版的redis分支上。我最初的測試顯示:它在冪等模式下優于LRU算法,測試情況是每個key使用用相同數量的內存,然而真實世界的訪問可能會有很大不同。時間和空間都可能改變得很不同,所以我會很開心去學習觀察現實世界中LFU的性能如何,兩種方式在redis實現中對性能的改變。
因此,新增了一個OBJECT FREQ子命令,用于報告給定key的訪問計數器,不僅僅能有效提觀察一個計數器,而且還能調試LFU實現中的bug。
注意運行中切換LRU和LFU,剛開始會隨機淘汰一些key,隨著24bit不能匹配上,然而慢慢會適應。 還有幾種改進實現的可能。Ben Manes發給我這篇感興趣的文章,描述了一種叫TinyLRU算法。鏈接
這篇文章包含一個非常厲害的觀點:相比于記錄當前對象的訪問頻率,讓我們(概率性地)記錄全部對象的訪問頻率,看到了,這種方式我們甚至可以拒絕新key,同樣,我們相信這些key很可能得到很少的訪問,所以一點也不需要淘汰,如果淘汰一個key意味著降低命中/未命中率。
我的感覺這種技術雖然很感興趣GET/SET LFU緩存,但不適用與redis性質的數據服務器:用戶期望keys被創建后至少存在幾毫秒。拒絕key的創建似乎在redis上就是一種錯誤。
然而,redis保留了LFU信息,當一個key被覆蓋時,舉個例子:
SET oldkey some_new_value
24位的LFU計數器會從老的key復制到新對象中。
新的redis淘汰算法不穩定版本還有以下幾個好消息:
1、跨DB策略。在過去的redis只是基于本地的選舉,現在修復為所有策略,不僅僅是LRU。
2、易變ttl策略。基于key預期淘汰存活時間,如今就像其他策略中的使用緩存池。
3、在緩存池中重用了sds對象,性能更好。
這篇博客比我預期要長,但是我希望它反映出一個見解:在創新和對于已經存在的事物實現上,一種解決方案去解決一個特定問題,一個基礎工具。由開發人員以正確的方式使用它。許多redis的用戶把redis作為一個緩存的解決方案,因此提高淘汰策略這一塊經常一次又一次被拿出來探討。
2.6對象
剛寫了redis主要的數據結構:
動態字符串、雙端鏈表、字典、壓縮列表、整數集合、跳表等
redis肯定不能直接使用這些數據結構來實現數據庫,它用這些數據庫建立了一個對象系統,包含:
字符串對象、列表對象、哈希對象、集合對象、有序集合對象
我們可以針對不同的使用場景,為對象設置多種分不同的數據結構實現,從而優化對象在不同場景下的效率。
1)鍵值對
對于redis的鍵值對來說:key只有字符串類型,而v可以是各種類型,
我們習慣把“這個鍵所對應的值是一個列表”表達為這是一個“列表鍵。
TYPE?命令的實現方式也與此類似, 當我們對一個數據庫鍵執行?TYPE?命令時, 命令返回的結果為數據庫鍵對應的值對象的類型, 而不是鍵對象的類型:
# 鍵為字符串對象,值為列表對象redis> RPUSH numbers 1 3 5
(integer) 6redis> TYPE numbers
list
2)對象
我們看一下redis對象的組成:
typedef struct redisObject {// 類型unsigned type:4;// 編碼unsigned encoding:4;// 指向底層實現數據結構的指針void *ptr;// ...
} robj;
通過?encoding
?屬性來設定對象所使用的編碼, 而不是為特定類型的對象關聯一種固定的編碼, 極大地提升了 Redis 的靈活性和效率, 因為 Redis 可以根據不同的使用場景來為一個對象設置不同的編碼, 從而優化對象在某一場景下的效率。
字符串對象
字符串對象的編碼可以是?int
?、?raw
?或者?embstr
?。
如果一個字符串對象保存的是整數值, 并且這個整數值可以用?long
?類型來表示, 那么字符串對象會將整數值保存在字符串對象結構的?ptr
屬性里面(將?void*
?轉換成?long
?), 并將字符串對象的編碼設置為?int
?。
如果字符串對象保存的是一個字符串值, 并且這個字符串值的長度大于?39
?字節, 那么字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串值, 并將對象的編碼設置為?raw
?。
如果字符串對象保存的是一個字符串值, 并且這個字符串值的長度小于等于?39
?字節, 那么字符串對象將使用?embstr
?編碼的方式來保存這個字符串值。
embstr
?編碼是專門用于保存短字符串的一種優化編碼方式, 這種編碼和?raw
?編碼一樣, 都使用?redisObject
?結構和?sdshdr
?結構來表示字符串對象,但?raw
?編碼會調用兩次內存分配函數來分別創建?redisObject
?結構和?sdshdr
?結構,而?embstr
?編碼則通過調用一次內存分配函數來分配一塊連續的空間, 空間中依次包含?redisObject
?和?sdshdr
?兩個結構。
?embstr
?編碼有以下好處:
embstr
?編碼創建刪除字符串對象只需操作一次內存- 因為數據都保存在一塊連續的內存, 所以這種編碼的字符串對象比?
raw
?編碼字符串對象能更好地利用緩存帶來的優勢。
3)列表對象
列表對象的編碼可以是?ziplist
?或者?linkedlist
?。
當列表對象可以同時滿足以下兩個條件時, 列表對象使用?ziplist
?編碼:
- 列表對象保存的所有字符串元素的長度都小于?
64
?字節; - 列表對象保存的元素數量小于?
512
?個;
不能滿足這兩個條件的列表對象需要使用?linkedlist
?編碼。
4)哈希對象
哈希對象的編碼可以是?ziplist
?或者?hashtable
?。
當哈希對象可以同時滿足以下兩個條件時, 哈希對象使用?ziplist
?編碼:
- 哈希對象保存的所有鍵值對的鍵和值的字符串長度都小于?
64
?字節; - 哈希對象保存的鍵值對數量小于?
512
?個;
不能滿足這兩個條件的哈希對象需要使用?hashtable
?編碼。
5)集合對象
集合對象的編碼可以是?intset
?或者?hashtable
?。
當集合對象可以同時滿足以下兩個條件時, 對象使用?intset
?編碼:
- 集合對象保存的所有元素都是整數值;
- 集合對象保存的元素數量不超過?
512
?個;
不能滿足這兩個條件的集合對象需要使用?hashtable
?編碼。
6)有序集合對象
有序集合的編碼可以是?ziplist
?或者?skiplist
?。
當有序集合對象可以同時滿足以下兩個條件時, 對象使用?ziplist
?編碼:
- 有序集合保存的元素數量小于?
128
?個; - 有序集合保存的所有元素成員的長度都小于?
64
?字節;
不能滿足以上兩個條件的有序集合對象將使用?skiplist
?編碼。
這里多說兩句,各個語言的對象其實都差不多,底層實現也就那幾個,比如java中的容器,c++的STL。java的hashset就是一個哈希而已,hashmap就是k帶了一個v,而”有序的“Treemap使用了紅黑樹這種有平衡性的搜索二叉樹。
redis的有序集合并沒有再采取hash+紅黑樹的操作,而是把平衡樹換成了跳表,實際上性能真的沒差多少,甚至有時比紅黑樹有優勢,比如跳表的性能較為平均,紅黑樹攢了很多次不平衡要調整可能會帶來資源需求的一個高峰,再加上跳表實現簡單的優點,紅黑樹真的沒什么優勢。
并且就算是真的想用一種帶平衡性的搜索樹,現在競賽也是用的華人之光發明的SB樹。
有序集合的優點就是它的有序操作,比如拿最大最小值,紅黑樹時間o(logN),而哈希表只能一個一個遍歷。缺點在于插入一個值的時間也是o(logN),跳表也是。而哈希表插入數是o(1).
要了解底層和這些優缺點
?
?
《三天給你聊清楚redis》第2天看看redis怎么被搞出來的(22036字)
三、單機實現
3.1、數據庫概述
redis服務器將所有數據庫都保存在redis/redisServer中,數組db存放所有數據庫,每一項是一個redisdb結構。dbnum代表數據庫數量。
客戶端有一個指針指向當前數據庫,可以切換,也就是移動指針。
3.1.1鍵空間
現在稍微介紹一下redisdb結構,它的字典保存了所有鍵值對
鍵空間的鍵也就是數據庫的鍵, 每個鍵都是一個字符串對象。
鍵空間的值也就是數據庫的值, 每個值可以是字符串對象、列表對象、哈希表對象、集合對象、有序集合對象
所有數據庫的操作,添加一個鍵值對, 刪除一個鍵值對, 獲取某個鍵值對, 等等,都是通過對鍵空間字典進行操作來實現的。
3.1.2維護
讀寫鍵空間的時候,服務器會執行一些額外操作,比如:
- 讀一個鍵后(讀操作寫操作都要對鍵讀取),?會根據鍵是否存在, 更新鍵空間命中(hit)次數或不命中(miss)次數。
- 讀取一個鍵后, 服務器會更新鍵的 LRU (最后一次使用)時間, 這個值可以用于計算鍵的閑置時間。
- 如果服務器在讀一個鍵時, 該鍵已經過期, 服務器會刪除這個鍵, 然后執行其他操作。
- 如果客戶使用?WATCH?監視某個鍵,在對這個鍵進行修改之后, 會將這個鍵記為臟(dirty),讓事務程序知到這個鍵被修改
- 服務器每次修改一個鍵之后, 都會對臟(dirty)鍵計數器的值增一, 這個計數器會觸發服務器的持久化以及復制操作執行
- 如果服務器開啟了數據庫通知功能, 那么在對鍵進行修改之后, 服務器將按配置發送相應的數據庫通知。
3.1.3時間
用戶可以給某個鍵設置生存時間,過期時間是一個UNIX時間戳,到時間自動刪除這個鍵。
redisdb結構的expires字典保存了所有的鍵的過期時間,我們稱這個字典為過期字典。
3.1.4三種過期鍵刪除策略
1)定時刪除:創建一個定時器,到時間立即執行刪除操作(對內存友好,因為能保證過期了立馬刪除,但是對cpu不友好)
2)惰性刪除:鍵過期不管,每次獲取鍵時檢查是否過期,過期就刪除(對cpu友好,但是只有在使用的時候才可能刪除,對內存不友好)
3)定期刪除:隔一段時間檢查一次(具體算法決定檢查多少刪多少,需要合理設置)
3.1.5淘汰策略
當Redis占用內存超出最大限制 (maxmemory) 時,可采用如下策略 (maxmemory-policy) ,讓Redis淘汰一些數據,以騰出空間繼續提供讀寫服務 :
noeviction: 對可能導致增大內存的命令返回錯誤 (大多數寫命令,DEL除外) ;
volatile-ttl: 在設置了過期時間的key中,選擇剩余壽命 (TTL) 最短的key,將其淘汰;
volatile-lru: 在設置了過期時間的key中,選擇最少使用的key (RU) ,將其淘汰;
volatile-random: 在設置了過期時間的key中,隨機選擇一些key,將其淘汰;
allkeys-1Lru: 在所有的key中,選擇最少使用的key (LRU) ,將其淘汰;
allkeys-random: 在所有的key中,隨機選擇一些key,將其淘汰;
?
3.2、持久化
因為redis是內存數據庫,他把數據都存在內存里,所以要想辦法實現持久化功能。
3.2.1、RDB
RDB持久化可以手動執行,也可以配置定期執行,可以把某個時間的數據狀態保存到RDB文件中,反之,我們可以用RDB文件還原數據庫狀態。
? ? 生成
有兩個命令可以生成RDB文件:
- SAVE?命令由服務器進程直接執行保存操作,所以該命令會阻塞服務器,服務器不能接受其他指令。
- BGSAVE?命令由子進程執行保存操作,所以該命令不會阻塞服務器,服務器可以接受其他指令。。
禁止BGSAVE和SAVE同時執行,也就是說執行其中一個就會拒絕另一個,這是為了避免父進程和子進程同時執行兩個rdbsave,防止產生競爭條件。
? ? 載入
? ? RDB載入工作是服務器啟動時自動執行的。
? ? 自動保存
用戶可以通過save選項設置多個保存條件,服務器狀態中會保存所有用?save
?選項設置的保存條件,當任意一個保存條件被滿足時,服務器會自動執行?BGSAVE?命令。
比如
save 900 1
save 300 10
滿足:服務器在900秒之內被修改至少一次或者300秒內修改至少十次。就會執行BGSAVE。
?
當服務器啟動時,用戶可以通過指定配置文件或者傳入啟動參數來設置save選項,服務器會把條件放到一個結構體里,結構體有一個數組,保存了所有條件。
?
serverCron函數默認100毫秒檢查一次,他會遍歷數組依次檢查,符合條件就會執行BGSAVE。
? ? RDB文件結構
一個完整 RDB 文件所包含的各個部分:
REDIS,
長度5
字節, 保存著?"REDIS"
?五個字符。 通過這五個字符, 可以在載入文件時, 快速檢查載入文件是否 RDB 文件。
db_version
?,長度?4
?字節, 它的值是一個字符串表示的整數, 這個整數記錄了 RDB 文件的版本號
databases
?部分包含著零個或任意多個數據庫, 以及各個數據庫中的鍵值對數據
EOF
?常量的長度為?1
?字節, 這個常量標志著 RDB 文件正文內容的結束
check_sum
?是一個?8
?字節長的無符號整數, 保存著一個校驗和,以此來檢查 RDB 文件是否出錯或損壞
我并不想深入探究databases的組成。就是知道
- RDB 文件是一個經過壓縮的二進制文件,由多個部分組成。
- 對于不同類型的鍵值對, RDB 文件會使用不同的方式來保存它們即可。
3.2.2、AOF
AOF持久化是通過保存服務器執行的命令來記錄狀態的。還原的時候再執行一遍即可。
功能的實現可以分為命令追加、文件寫入、文件同步三個步驟。
?
當 AOF 持久化功能處于打開狀態時, 服務器在執行完一個寫命令之后, 會以協議格式將被執行的寫命令追加到服務器狀態的?aof_buf
?緩沖區的末尾:
struct redisServer {// ...// AOF 緩沖區sds aof_buf;// ...
};
Redis 服務器進程就是一個事件循環
循環中的文件事件負責接收客戶端的命令請求, 以及向客戶端發送命令回復,
而時間事件則負責執行像?serverCron
?函數這樣需要定時運行的函數。
因為服務器在處理文件事件時可能會執行寫命令, 使得一些內容被追加到?aof_buf
?緩沖區里面, 所以在服務器每次結束一個事件循環之前, 它都會調用?flushAppendOnlyFile
?函數, 考慮是否需要將?aof_buf
?緩沖區中的內容寫入和保存到 AOF 文件里面, 這個過程可以用偽代碼表示:
def eventLoop():while True:# 處理文件事件,接收命令請求以及發送命令回復# 處理命令請求時可能會有新內容被追加到 aof_buf 緩沖區中processFileEvents()# 處理時間事件processTimeEvents()# 考慮是否要將 aof_buf 中的內容寫入和保存到 AOF 文件里面flushAppendOnlyFile()
flushAppendOnlyFile
?函數的行為由服務器配置的?appendfsync
?選項的值來決定
值為?always
?時, 服務器在每個事件循環都要將?aof_buf
?緩沖區中的所有內容寫入到 AOF 文件并且同步 AOF 文件, 所以?always
?的效率最慢的一個, 但從安全性來說,?always
?是最安全的, 因為即使出現故障停機, AOF 持久化也只會丟失一個事件循環中所產生的命令數據。
值為?everysec
?時, 服務器在每個事件循環都要將?aof_buf
?緩沖區中的所有內容寫入到 AOF 文件, 每隔超過一秒就要在子線程中對 AOF 文件進行一次同步: 從效率上來講,?everysec
?模式足夠快, 并且就算出現故障停機, 數據庫也只丟失一秒鐘的命令數據。
值為?no
?時, 服務器在每個事件循環都要將?aof_buf
?緩沖區中的所有內容寫入到 AOF 文件, 至于何時對 AOF 文件進行同步, 則由操作系統控制。
因為處于?no
?模式下的?flushAppendOnlyFile
?調用無須執行同步操作, 所以該模式下的 AOF 文件寫入速度總是最快的, 不過因為這種模式會在系統緩存中積累一段時間的寫入數據, 所以該模式的單次同步時長通常是三種模式中時間最長的: 從平攤操作的角度來看,no
?模式和?everysec
?模式的效率類似, 當出現故障停機時, 使用?no
?模式的服務器將丟失上次同步 AOF 文件之后的所有寫命令數據。
? ? 重寫
AOF持久化是保存了一堆命令來恢復數據庫,隨著時間流逝,存的會越來越多,如果不加以控制,文件過大可能影響服務器甚至計算機。而且文件過大,恢復時需要時間也太長。
所以redis提供了重寫功能,寫出的新文件不會包含任何浪費時間的冗余命令。
接下來,我們就介紹重寫的原理。
其實重寫不會對現有的AOF文件進行讀取分析等操作,而是通過當前服務器的狀態來實現。
# 假設服務器對鍵list執行了以下命令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>
當前列表鍵list在數據庫中的值就為["C", "D", "E", "F", "G"]。要使用盡量少的命令來記錄list鍵的狀態,最簡單的方式不是去讀取和分析現有AOF文件的內容,,而是直接讀取list鍵在數據庫中的當前值,然后用一條RPUSH list "C" "D" "E" "F" "G"代替前面的6條命令。
- 偽代碼表示如下
def AOF_REWRITE(tmp_tile_name):f = create(tmp_tile_name)# 遍歷所有數據庫for db in redisServer.db:# 如果數據庫為空,那么跳過這個數據庫if db.is_empty(): continue# 寫入 SELECT 命令,用于切換數據庫f.write_command("SELECT " + db.number)# 遍歷所有鍵for key in db:# 如果鍵帶有過期時間,并且已經過期,那么跳過這個鍵if key.have_expire_time() and key.is_expired(): continueif key.type == String:# 用 SET key value 命令來保存字符串鍵value = get_value_from_string(key)f.write_command("SET " + key + value)elif key.type == List:# 用 RPUSH key item1 item2 ... itemN 命令來保存列表鍵item1, item2, ..., itemN = get_item_from_list(key)f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)elif key.type == Set:# 用 SADD key member1 member2 ... memberN 命令來保存集合鍵member1, member2, ..., memberN = get_member_from_set(key)f.write_command("SADD " + key + member1 + member2 + ... + memberN)elif key.type == Hash:# 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令來保存哈希鍵field1, value1, field2, value2, ..., fieldN, valueN =\get_field_and_value_from_hash(key)f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\... + fieldN + valueN)elif key.type == SortedSet:# 用 ZADD key score1 member1 score2 member2 ... scoreN memberN# 命令來保存有序集鍵score1, member1, score2, member2, ..., scoreN, memberN = \get_score_and_member_from_sorted_set(key)f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\... + scoreN + memberN)else:raise_type_error()# 如果鍵帶有過期時間,那么用 EXPIREAT key time 命令來保存鍵的過期時間if key.have_expire_time():f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())# 關閉文件f.close()
? ? AOF后臺重寫
aof_rewrite函數可以創建新的AOF文件,但是這個函數會進行大量的寫入操作,所以調用這個函數的線程被長時間的阻塞,因為服務器使用單線程來處理命令請求;所以如果直接是服務器進程調用AOF_REWRITE函數的話,那么重寫AOF期間,服務器將無法處理客戶端發送來的命令請求;
Redis不希望AOF重寫會造成服務器無法處理請求,所以將AOF重寫程序放到子進程(后臺)里執行。這樣處理的好處是:?
1)子進程進行AOF重寫期間,主進程可以繼續處理命令請求;
2)子進程帶有主進程的數據副本,使用子進程而不是線程,可以避免在鎖的情況下,保證數據的安全性。
還有一個問題,可能重寫的時候又有新的命令過來,造成信息不對等,所以redis設置了一個緩沖區,重寫期間把命令放到重寫緩沖區。
?
? 總結
AOF重寫的目的是為了解決AOF文件體積膨脹的問題,使用更小的體積來保存數據庫狀態,整個重寫過程基本上不影響Redis主進程處理命令請求;
AOF重寫其實是一個有歧義的名字,實際上重寫工作是針對數據庫的當前狀態來進行的,重寫過程中不會讀寫、也不適用原來的AOF文件;
AOF可以由用戶手動觸發,也可以由服務器自動觸發。
?
3.3、事件
redis服務器是一個事件驅動程序。
需要處理兩類事件:
1)文件事件:redis是通過套接字與客戶端或者其他服務器連接的,而文件事件就是服務器對套接字操作的抽象。
2)時間事件:服務器對一些定時操作的抽象。
3.3.1、文件事件
redis基于reactor模式開發了自己的網絡事件處理器,這個處理器被稱作文件事件處理器,它使用IO多路復用程序來同時監聽多個套接字, 并根據套接字目前執行的任務來為套接字關聯不同的事件處理器,當被監聽的套接字準備好執行連接應答(accept)、讀取(read)、寫入(write)、關閉(close)等操作時, 與操作相對應的文件事件就會產生, 這時文件事件處理器就會調用套接字之前關聯好的事件處理器來處理這些事件。
雖然文件事件處理器以單線程方式運行, 但通過使用 I/O 多路復用程序來監聽多個套接字, 文件事件處理器既實現了高性能的網絡通信模型, 又可以很好地與 Redis 服務器中其他同樣以單線程方式運行的模塊進行對接, 這保持了 Redis 內部單線程設計的簡單性。
文件事件處理器的構成:
I/O 多路復用程序負責監聽多個套接字, 并向文件事件分派器傳送那些產生了事件的套接字。
?I/O 多路復用程序會把所有產生事件的套接字放到一個隊列, 以有序(sequentially)、同步(synchronously)、每次一個套接字的方式,向文件事件分派器傳送套接字。
I/O 多路復用程序可以監聽多個套接字的?ae.h/AE_READABLE
?事件和?ae.h/AE_WRITABLE
?事件
1)當套接字變得可讀時(客戶端對套接字執行?write
?操作,或者執行?close
?操作), 或者有新的可應答(acceptable)套接字出現時(客戶端對服務器的監聽套接字執行?connect
?操作), 套接字產生?AE_READABLE
?事件。
2)當套接字變得可寫時(客戶端對套接字執行?read
?操作), 套接字產生?AE_WRITABLE
?事件。
如果一個套接字又可讀又可寫的話, 那么服務器將先讀套接字, 后寫套接字。
下面介紹各種處理器:
1)連接應答處理器:服務器進行初始化時, 程序會將連接應答處理器和服務器監聽套接字的?AE_READABLE
?事件關聯, 當有客戶端連接(connect
)服務器監聽套接字的時候, 套接字就會產生?AE_READABLE
?事件, 引發連接應答處理器執行, 并執行相應的套接字應答操作。
2)命令請求處理器:客戶端連接到服務器后, 服務器會將客戶端套接字的?AE_READABLE
?事件和命令請求處理器關聯起來, 當客戶端發送命令請求時, 套接字就會產生?AE_READABLE
?事件, 引發命令請求處理器執行, 并執行相應的套接字讀入操作
3)命令回復處理器:服務器有命令回復需要傳送給客戶端, 服務器會將客戶端套接字的?AE_WRITABLE
?事件和命令回復處理器關聯起來, 當客戶端準備好接收服務器傳回的命令回復時, 就會產生?AE_WRITABLE
?事件, 引發命令回復處理器執行, 并執行相應的套接字寫入操作。
一次完整的連接事件實例:
3.3.2、時間事件
redis時間事件可以分為兩類:定時事件、周期性事件,他們的特點就像他們的名字一樣。
而一個時間事件主要有三部分:
id:服務器為時間事件創建的全局唯一id,按時間遞增,越新的越大
when:unix時間戳,記錄到達時間
timeProc:時間事件處理器,是一個函數,時間事件到達時,服務器就會調用處理器來處理事件。
目前版本的redis只使用周期性事件
來看看實現:
服務器把所有時間事件放在一個鏈表中,每當時間事件執行器執行時,它就遍歷鏈表,調用相應的事件處理器。
但是注意:鏈表是無序的,不按when屬性來排序,當時間事件執行器運行時,必須遍歷整個鏈表。但是,無序鏈表并不影響時間事件處理器的性能,因為在目前版本中,redis服務器只使用serverCron一個時間事件,就算在benchmark模式下也只有兩個事件,服務器幾乎是把鏈表退化成指針使用了。
?
3.3.3、事件的調度和執行
?
文件事件和時間事件之間是合作關系, 服務器會輪流處理這兩種事件,對兩種事件的處理都是同步、有序、原子地進行的,處理事件的過程中也不會進行搶占,所以時間事件的實際處理時間通常會比設定的到達時間晚一些。
大概流程為:
是否關閉服務器?---->等待文件事件產生---->處理已經產生的文件事件---->處理已經達到的時間事件---->是否關閉服務器?........
?
3.4、客戶端
redis服務器是典型的一對多服務器,通過使用由IO多路復用技術實現的文件事件處理器,redis服務器使用了單線程單進程的方式來處理請求。
3.4.1客戶端的屬性
- 描述符
客戶端狀態的?fd
?屬性記錄了客戶端正在使用的套接字描述符:
typedef struct redisClient {// ...int fd;// ...
} redisClient;
- 偽客戶端
fd
?值為?-1
?: 偽客戶端處理的命令請求來源于 AOF 文件或者 Lua 腳本, 而不是網絡, 所以這種客戶端不需要套接字連接。 - 普通客戶端?
fd
?值為大于?-1
?的整數: 普通客戶端使用套接字來與服務器進行通訊, 所以服務器會用?fd
?屬性來記錄客戶端套接字的描述符。?
?
- 標志
客戶端的標志屬性?flags
?記錄了客戶端的角色(role), 以及客戶端目前所處的狀態:
typedef struct redisClient {// ...int flags;// ...} redisClient;
flags
?屬性的值可以是單個標志:
flags = <flag>
也可以是多個標志的二進制或, 比如:
flags = <flag1> | <flag2> | ...
每個標志使用一個常量表示, 一部分標志記錄了客戶端的角色:
- 在主從服務器進行復制操作時, 主服務器會成為從服務器的客戶端, 而從服務器也會成為主服務器的客戶端。?
REDIS_MASTER
?標志表示客戶端代表的是一個主服務器,?REDIS_SLAVE
?標志表示客戶端代表的是一個從服務器。 REDIS_LUA_CLIENT
?標識表示客戶端是專門用于處理 Lua 腳本里面包含的 Redis 命令的偽客戶端。
另一部分標志記錄了客戶端目前所處的狀態:
以下內容為摘抄
REDIS_MONITOR?標志表示客戶端正在執行?MONITOR?命令。REDIS_UNIX_SOCKET?標志表示服務器使用 UNIX 套接字來連接客戶端。REDIS_BLOCKED?標志表示客戶端正在被?BRPOP?、?BLPOP?等命令阻塞。REDIS_UNBLOCKED?標志表示客戶端已經從?REDIS_BLOCKED?標志所表示的阻塞狀態中脫離出來,
不再阻塞。?REDIS_UNBLOCKED?標志只能在?REDIS_BLOCKED?標志已經打開的情況下使用。REDIS_MULTI?標志表示客戶端正在執行事務。REDIS_DIRTY_CAS?標志表示事務使用?WATCH?命令監視的數據庫鍵已經被修改,?
REDIS_DIRTY_EXEC?標志表示事務在命令入隊時出現了錯誤,
以上兩個標志都表示事務的安全性已經被破壞, 只要這兩個標記中的任意一個被打開,?
EXEC?命令必然會執行失敗。
這兩個標志只能在客戶端打開了?REDIS_MULTI?標志的情況下使用。REDIS_CLOSE_ASAP?標志表示客戶端的輸出緩沖區大小超出了服務器允許的范圍,
服務器會在下一次執行?serverCron?函數時關閉這個客戶端,
以免服務器的穩定性受到這個客戶端影響。
積存在輸出緩沖區中的所有內容會直接被釋放, 不會返回給客戶端。REDIS_CLOSE_AFTER_REPLY?標志表示有用戶對這個客戶端執行了?CLIENT_KILL?命令,
或者客戶端發送給服務器的命令請求中包含了錯誤的協議內容。
服務器會將客戶端積存在輸出緩沖區中的所有內容發送給客戶端, 然后關閉客戶端。REDIS_ASKING?標志表示客戶端向集群節點(運行在集群模式下的服務器)發送了?ASKING?命令。REDIS_FORCE_AOF?標志強制服務器將當前執行的命令寫入到 AOF 文件里面,
REDIS_FORCE_REPL?標志強制主服務器將當前執行的命令復制給所有從服務器。
執行?PUBSUB?命令會使客戶端打開?REDIS_FORCE_AOF?標志,
執行?SCRIPT_LOAD?命令會使客戶端打開?
REDIS_FORCE_AOF標志和?REDIS_FORCE_REPL?標志。在主從服務器進行命令傳播期間, 從服務器需要向主服務器發送?REPLICATION ACK?命令,
在發送這個命令之前, 從服務器必須打開主服務器對應的客戶端的?
REDIS_MASTER_FORCE_REPLY?標志, 否則發送操作會被拒絕執行。
以上提到的所有標志都定義在?redis.h
?文件里面。
PUBSUB
?命令和?SCRIPT?LOAD
?命令的特殊性
通常情況下, Redis 只會將那些對數據庫進行了修改的命令寫入到 AOF 文件, 并復制到各個從服務器: 如果一個命令沒有對數據庫進行任何修改, 那么它就會被認為是只讀命令, 這個命令不會被寫入到 AOF 文件, 也不會被復制到從服務器。
以上規則適用于絕大部分 Redis 命令, 但?PUBSUB?命令和?SCRIPT_LOAD?命令是其中的例外。
PUBSUB?命令雖然沒有修改數據庫, 但?PUBSUB?命令向頻道的所有訂閱者發送消息這一行為帶有副作用, 接收到消息的所有客戶端的狀態都會因為這個命令而改變。 因此, 服務器需要使用?REDIS_FORCE_AOF
?標志, 強制將這個命令寫入 AOF 文件, 這樣在將來載入 AOF 文件時, 服務器就可以再次執行相同的?PUBSUB?命令, 并產生相同的副作用。
SCRIPT_LOAD?命令的與?PUBSUB?命令類似
3.4.2輸入緩沖區
客戶端狀態的輸入緩沖區用于保存客戶端發送的命令請求:
typedef struct redisClient {// ...sds querybuf;// ...} redisClient;
?redisClient 實例:
3.4.3命令相關
在服務器將客戶端發送的命令請求保存到客戶端狀態的?querybuf
?屬性之后, 服務器將對命令請求的內容進行分析, 并將得出的命令參數以及命令參數的個數分別保存到客戶端狀態的?argv
?屬性和?argc
?屬性:
typedef struct redisClient {// ...robj **argv;int argc;// ...} redisClient;
argv
?屬性是一個數組, 數組中的每個項都是一個字符串對象: 其中?argv[0]
?是要執行的命令, 而之后的其他項則是傳給命令的參數。
argc
?屬性則負責記錄?argv
?數組的長度。
3.3.4實現函數
?
當服務器從協議內容中分析并得出?argv
?屬性和?argc
?屬性的值之后, 服務器將根據項?argv[0]
?的值, 在命令表中查找命令所對應的命令實現函數。
(命令表是一個字典,字典的鍵是一個 SDS 結構, 保存了命令的名字, 字典的值是命令所對應的?redisCommand
?結構, 這個結構保存了命令的實現函數、 命令的標志、 命令應該給定的參數個數、 命令的總執行次數和總消耗時長等統計信息。)
3.3.5、輸出緩沖區
執行命令所得的命令回復會被保存在客戶端狀態的輸出緩沖區里面, 每個客戶端都有兩個輸出緩沖區:
- 固定大小的緩沖區用于保存那些長度比較小的回復, 比如?
OK
?、簡短的字符串值、整數值、錯誤回復,等等。 - 可變大小的緩沖區用于保存那些長度比較大的回復, 比如一個非常長的字符串值, 一個由很多項組成的列表, 一個包含了很多元素的集合, 等等。
3.3.6、其它
客戶端狀態的?authenticated
?屬性用于記錄客戶端是否通過了身份驗證,還有幾個和時間有關的屬性,敘述是一件挺無聊的事情,不再寫。
?
3.4、命令的執行過程
3.4.1發送命令請求
當用戶在客戶端中鍵入一個命令請求時, 客戶端會將這個命令請求轉換成協議格式, 然后通過連接到服務器的套接字, 將協議格式的命令請求發送給服務器。
3.4.2讀取命令請求
當客戶端與服務器之間的連接套接字因為客戶端的寫入而變得可讀時, 服務器將調用命令請求處理器來執行以下操作:
- 讀取套接字中協議格式的命令請求, 并將其保存到客戶端狀態的輸入緩沖區里面。
- 對輸入緩沖區中的命令請求進行分析, 提取出命令請求中包含的命令參數, 以及命令參數的個數, 然后分別將參數和參數個數保存到客戶端狀態的?
argv
?屬性和?argc
?屬性里面。 - 調用命令執行器, 執行客戶端指定的命令。
3.4.3命令執行器:查找命令實現
命令執行器要做的第一件事就是根據客戶端狀態的?argv[0]
?參數, 在命令表(command table)中查找參數所指定的命令, 并將找到的命令保存到客戶端狀態的?cmd
?屬性里面。
命令表是一個字典, 字典的鍵是一個個命令名字,比如?"set"
?、?"get"
?、?"del"
?,等等; 而字典的值是一個個?redisCommand
?結構, 每個?redisCommand
?結構記錄了一個 Redis 命令的實現信息。
命令名字的大小寫不影響命令表的查找結果
因為命令表使用的是大小寫無關的查找算法, 無論輸入的命令名字是大寫、小寫或者混合大小寫, 只要命令的名字是正確的, 就能找到相應的 redisCommand 結構。
比如說, 無論用戶輸入的命令名字是 "SET" 、 "set" 、 "SeT" 又或者 "sEt" , 命令表返回的都是同一個 redisCommand 結構。
redis> SET msg "hello world"
OKredis> set msg "hello world"
OKredis> SeT msg "hello world"
OKredis> sEt msg "hello world"
OK
3.4.4命令執行器:執行預備操作
到目前為止, 服務器已經將執行命令所需的命令實現函數(保存在客戶端狀態的?cmd
?屬性)、參數(保存在客戶端狀態的?argv
?屬性)、參數個數(保存在客戶端狀態的?argc
?屬性)都收集齊了, 但是在真正執行命令之前, 程序還需要進行一些預備操作, 從而確保命令可以正確、順利地被執行, 這些操作包括:
- 檢查客戶端狀態的?
cmd
?指針是否指向?NULL
?, 如果是的話, 那么說明用戶輸入的命令名字找不到相應的命令實現, 服務器不再執行后續步驟, 并向客戶端返回一個錯誤。 - 根據客戶端?
cmd
?屬性指向的?redisCommand
?結構的?arity
?屬性, 檢查命令請求所給定的參數個數是否正確, 當參數個數不正確時, 不再執行后續步驟, 直接向客戶端返回一個錯誤。 比如說, 如果?redisCommand
?結構的?arity
?屬性的值為?-3
?, 那么用戶輸入的命令參數個數必須大于等于?3
?個才行。 - 檢查客戶端是否已經通過了身份驗證, 未通過身份驗證的客戶端只能執行?AUTH?命令, 如果未通過身份驗證的客戶端試圖執行除?AUTH?命令之外的其他命令, 那么服務器將向客戶端返回一個錯誤。
- 如果服務器打開了?
maxmemory
?功能, 那么在執行命令之前, 先檢查服務器的內存占用情況, 并在有需要時進行內存回收, 從而使得接下來的命令可以順利執行。 如果內存回收失敗, 那么不再執行后續步驟, 向客戶端返回一個錯誤。 - 如果服務器上一次執行?BGSAVE?命令時出錯, 并且服務器打開了?
stop-writes-on-bgsave-error
?功能, 而且服務器即將要執行的命令是一個寫命令, 那么服務器將拒絕執行這個命令, 并向客戶端返回一個錯誤。 - 如果客戶端當前正在用?SUBSCRIBE?命令訂閱頻道, 或者正在用?PSUBSCRIBE?命令訂閱模式, 那么服務器只會執行客戶端發來的?SUBSCRIBE?、?PSUBSCRIBE?、?UNSUBSCRIBE?、?PUNSUBSCRIBE?四個命令, 其他別的命令都會被服務器拒絕。
- 如果服務器正在進行數據載入, 那么客戶端發送的命令必須帶有?
l
?標識(比如?INFO?、?SHUTDOWN?、?PUBLISH?,等等)才會被服務器執行, 其他別的命令都會被服務器拒絕。 - 如果服務器因為執行 Lua 腳本而超時并進入阻塞狀態, 那么服務器只會執行客戶端發來的?SHUTDOWN nosave?命令和?SCRIPT KILL?命令, 其他別的命令都會被服務器拒絕。
- 如果客戶端正在執行事務, 那么服務器只會執行客戶端發來的?EXEC?、?DISCARD?、?MULTI?、?WATCH?四個命令, 其他命令都會被放進事務隊列中。
- 如果服務器打開了監視器功能, 那么服務器會將要執行的命令和參數等信息發送給監視器。
當完成了以上預備操作之后, 服務器就可以開始真正執行命令了。
3.4.5命令執行器:調用命令的實現函數
在前面的操作中, 服務器已經將要執行命令的實現保存到了客戶端狀態的?cmd
?屬性里面, 并將命令的參數和參數個數分別保存到了客戶端狀態的?argv
?屬性和?argc
?屬性里面, 當服務器決定要執行命令時, 它只要執行以下語句就可以了:
// client 是指向客戶端狀態的指針client->cmd->proc(client);
因為執行命令所需的實際參數都已經保存到客戶端狀態的?argv
?屬性里面了, 所以命令的實現函數只需要一個指向客戶端狀態的指針作為參數即可。
3.4.6命令執行器:執行后續工作
在執行完實現函數之后, 服務器還需要執行一些后續工作:
- 如果服務器開啟了慢查詢日志功能, 那么慢查詢日志模塊會檢查是否需要為剛剛執行完的命令請求添加一條新的慢查詢日志。
- 根據剛剛執行命令所耗費的時長, 更新被執行命令的?
redisCommand
?結構的?milliseconds
?屬性, 并將命令的?redisCommand
?結構的?calls
?計數器的值增一。 - 如果服務器開啟了 AOF 持久化功能, 那么 AOF 持久化模塊會將剛剛執行的命令請求寫入到 AOF 緩沖區里面。
- 如果有其他從服務器正在復制當前這個服務器, 那么服務器會將剛剛執行的命令傳播給所有從服務器。
當以上操作都執行完了之后, 服務器對于當前命令的執行到此就告一段落了, 之后服務器就可以繼續從文件事件處理器中取出并處理下一個命令請求了。
3.4.7將命令回復發送給客戶端
前面說過, 命令實現函數會將命令回復保存到客戶端的輸出緩沖區里面, 并為客戶端的套接字關聯命令回復處理器, 當客戶端套接字變為可寫狀態時, 服務器就會執行命令回復處理器, 將保存在客戶端輸出緩沖區中的命令回復發送給客戶端。
當命令回復發送完畢之后, 回復處理器會清空客戶端狀態的輸出緩沖區, 為處理下一個命令請求做好準備。
3.4.8客戶端接收并打印命令回復
當客戶端接收到協議格式的命令回復之后, 它會將這些回復轉換成人類可讀的格式, 并打印給用戶觀看(假設使用的是 Redis 自帶的?客戶端)
?
3.5、事務
Redis 事務可以一次執行多個命令, 并且帶有以下三個重要的保證:
- 批量操作在發送 EXEC 命令前被放入隊列緩存。
- 收到 EXEC 命令后進入事務執行,事務中任意命令執行失敗,其余的命令依然被執行。
- 在事務執行過程,其他客戶端提交的命令請求不會插入到事務執行命令序列中。
一個事務從開始到執行會經歷以下三個階段:
- 開始事務。
- 命令入隊。
- 執行事務。
以下是一個事務的例子, 它先以?MULTI?開始一個事務, 然后將多個命令入隊到事務中, 最后由?EXEC?命令觸發事務, 一并執行事務中的所有命令:
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"
詳細介紹:
3.5.1事務開始
MULTI?命令的執行標志著事務的開始:
redis> MULTI
OK
MULTI?命令可以將執行該命令的客戶端從非事務狀態切換至事務狀態, 這一切換是通過在客戶端狀態的?flags
?屬性中打開?REDIS_MULTI
?標識來完成的,?MULTI?命令的實現可以用以下偽代碼來表示:
def MULTI():# 打開事務標識client.flags |= REDIS_MULTI# 返回 OK 回復replyOK()
3.5.2命令入隊
當一個客戶端處于非事務狀態時, 這個客戶端發送的命令會立即被服務器執行:
redis> SET "name" "Practical Common Lisp"
OKredis> GET "name"
"Practical Common Lisp"redis> SET "author" "Peter Seibel"
OKredis> GET "author"
"Peter Seibel"
與此不同的是, 當一個客戶端切換到事務狀態之后, 服務器會根據這個客戶端發來的不同命令執行不同的操作:
- 如果客戶端發送的命令為?EXEC?、?DISCARD?、?WATCH?、?MULTI?四個命令的其中一個, 那么服務器立即執行這個命令。
- 與此相反, 如果客戶端發送的命令是?EXEC?、?DISCARD?、?WATCH?、?MULTI?四個命令以外的其他命令, 那么服務器并不立即執行這個命令, 而是將這個命令放入一個事務隊列里面, 然后向客戶端返回?
QUEUED
?回復。
3.5.3事務隊列
每個 Redis 客戶端都有自己的事務狀態, 這個事務狀態保存在客戶端狀態的?mstate
?屬性里面:
typedef struct redisClient {// ...// 事務狀態multiState mstate; /* MULTI/EXEC state */// ...} redisClient;
事務狀態包含一個事務隊列, 以及一個已入隊命令的計數器 (也可以說是事務隊列的長度):
typedef struct multiState {// 事務隊列,FIFO 順序multiCmd *commands;// 已入隊命令計數int count;} multiState;
事務隊列是一個?multiCmd
?類型的數組, 數組中的每個?multiCmd
?結構都保存了一個已入隊命令的相關信息, 包括指向命令實現函數的指針, 命令的參數, 以及參數的數量:
typedef struct multiCmd {// 參數robj **argv;// 參數數量int argc;// 命令指針struct redisCommand *cmd;} multiCmd;
事務隊列以先進先出(FIFO)的方式保存入隊的命令: 較先入隊的命令會被放到數組的前面, 而較后入隊的命令則會被放到數組的后面。
舉個例子, 如果客戶端執行以下命令:
redis> MULTI
OKredis> SET "name" "Practical Common Lisp"
QUEUEDredis> GET "name"
QUEUEDredis> SET "author" "Peter Seibel"
QUEUEDredis> GET "author"
QUEUED
那么服務器將為客戶端創建事務狀態:
- 最先入隊的?SET?命令被放在了事務隊列的索引?
0
?位置上。 - 第二入隊的?GET?命令被放在了事務隊列的索引?
1
?位置上。 - 第三入隊的另一個?SET?命令被放在了事務隊列的索引?
2
?位置上。 - 最后入隊的另一個?GET?命令被放在了事務隊列的索引?
3
?位置上。
3.5.4執行事務
當一個處于事務狀態的客戶端向服務器發送?EXEC?命令時, 這個?EXEC?命令將立即被服務器執行: 服務器會遍歷這個客戶端的事務隊列, 執行隊列中保存的所有命令, 最后將執行命令所得的結果全部返回給客戶端。
EXEC?命令的實現原理可以用以下偽代碼來描述:
def EXEC():# 創建空白的回復隊列reply_queue = []# 遍歷事務隊列中的每個項# 讀取命令的參數,參數的個數,以及要執行的命令for argv, argc, cmd in client.mstate.commands:# 執行命令,并取得命令的返回值reply = execute_command(cmd, argv, argc)# 將返回值追加到回復隊列末尾reply_queue.append(reply)# 移除 REDIS_MULTI 標識,讓客戶端回到非事務狀態client.flags &= ~REDIS_MULTI# 清空客戶端的事務狀態,包括:# 1)清零入隊命令計數器# 2)釋放事務隊列client.mstate.count = 0release_transaction_queue(client.mstate.commands)# 將事務的執行結果返回給客戶端send_reply_to_client(client, reply_queue)
3.5.5WATCH命令的實現
WATCH命令是一個樂觀鎖,它可以在EXEC命令執行之前,監視任意數量的數據庫鍵,并在EXEC執行后,檢查被監視的鍵是否至少有一個被修改,如果是,服務器拒絕執行事務,并向客戶端返回代表事務執行失敗的回復。
/* Redis database representation. There are multiple databases identified* by integers from 0 (the default database) up to the max configured* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {dict *dict; /* The keyspace for this DB 數據庫鍵空間,保存數據庫中所有的鍵值對*/dict *expires; /* Timeout of keys with a timeout set 保存過期時間*/dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */dict *ready_keys; /* Blocked keys that received a PUSH 已經準備好數據的阻塞狀態的key*/dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS 事物模塊,用于保存被WATCH命令所監控的鍵*/// 當內存不足時,Redis會根據LRU算法回收一部分鍵所占的空間,而該eviction_pool是一個長為16數組,保存可能被回收的鍵// eviction_pool中所有鍵按照idle空轉時間,從小到大排序,每次回收空轉時間最長的鍵struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */// 數據庫IDint id; /* Database ID */// 鍵的平均過期時間long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
在每個代表數據庫的 server.h/redisDb
?結構類型中, 都保存了一個?watched_keys
?字典, 字典的鍵是這個數據庫被監視的鍵, 而字典的值則是一個鏈表, 鏈表中保存了所有監視這個鍵的客戶端。比如說,以下字典就展示了一個?watched_keys
?字典的例子:
每個key后掛著監視自己的客戶端。
3.5.6監控的觸發
在任何對數據庫鍵空間(key space)進行修改的命令成功執行之后 (比如?FLUSHDB?、?SET?、?DEL?、?LPUSH?、?SADD?、?ZREM?,諸如此類),?multi.c/touchWatchedKey?函數都會被調用 (修改命令會調用signalModifiedKey()函數來處理數據庫中的鍵被修改的情況,該函數直接調用touchWatchedKey()函數)—— 它檢查數據庫的?watched_keys?字典, 看是否有客戶端在監視已經被命令修改的鍵, 如果有的話, 程序將所有監視這個/這些被修改鍵的客戶端的?REDIS_DIRTY_CAS?選項打開:
?
/* "Touch" a key, so that if this key is being WATCHed by some client the* next EXEC will fail. */
// Touch 一個 key,如果該key正在被監視,那么客戶端會執行EXEC失敗
void touchWatchedKey(redisDb *db, robj *key) {list *clients;listIter li;listNode *ln;// 字典為空,沒有任何鍵被監視if (dictSize(db->watched_keys) == 0) return;// 獲取所有監視這個鍵的客戶端 clients = dictFetchValue(db->watched_keys, key);// 沒找到返回if (!clients) return;/* Mark all the clients watching this key as CLIENT_DIRTY_CAS *//* Check if we are already watching for this key */// 遍歷所有客戶端,打開他們的 REDIS_DIRTY_CAS 標識listRewind(clients,&li);while((ln = listNext(&li))) {client *c = listNodeValue(ln);// 設置CLIENT_DIRTY_CAS標識c->flags |= CLIENT_DIRTY_CAS;}
}
3.5.7事務的ACID性質
?在傳統的關系式數據庫中,常常用?ACID 性質來檢驗事務功能的安全性。
redis事物總是具有前三個性質。
a)原子性atomicity:redis事務保證事務中的命令要么全部執行要不全部不執行。
但是redis不同于傳統關系型數據庫,不支持回滾,即使出現了錯誤,事務也會繼續執行下去。
因為redis作者認為,這種復雜的機制和redis追求的簡單高效不符。并且,redis事務錯誤通常是編程錯誤,只會出現在開發環境中,而不會出現在實際生產環境中,所以沒必要支持回滾。
b)一致性consistency:redis事務可以保證命令失敗的情況下得以回滾,數據能恢復到沒有執行之前的樣子,是保證一致性的,除非redis進程意外終結。
Redis 的一致性問題可以分為三部分來討論:入隊錯誤、執行錯誤、Redis 進程被終結。
入隊錯誤
在命令入隊的過程中,如果客戶端向服務器發送了錯誤的命令,比如命令的參數數量不對,等等, 那么服務器將向客戶端返回一個出錯信息, 并且將客戶端的事務狀態設為?REDIS_DIRTY_EXEC?。
因此,帶有不正確入隊命令的事務不會被執行,也不會影響數據庫的一致性。
執行錯誤
如果命令在事務執行的過程中發生錯誤,比如說,對一個不同類型的 key 執行了錯誤的操作, 那么 Redis 只會將錯誤包含在事務的結果中, 這不會引起事務中斷或整個失敗,不會影響已執行事務命令的結果,也不會影響后面要執行的事務命令, 所以它對事務的一致性也沒有影響。
Redis 進程被終結
如果 Redis 服務器進程在執行事務的過程中被其他進程終結,或者被管理員強制殺死,那么根據 Redis 所使用的持久化模式,可能有以下情況出現:
內存模式:如果 Redis 沒有采取任何持久化機制,那么重啟之后的數據庫總是空白的,所以數據總是一致的。
RDB 模式:在執行事務時,Redis 不會中斷事務去執行保存 RDB 的工作,只有在事務執行之后,保存 RDB 的工作才有可能開始。所以當 RDB 模式下的 Redis 服務器進程在事務中途被殺死時,事務內執行的命令,不管成功了多少,都不會被保存到 RDB 文件里。恢復數據庫需要使用現有的 RDB 文件,而這個 RDB 文件的數據保存的是最近一次的數據庫快照(snapshot),所以它的數據可能不是最新的,但只要 RDB 文件本身沒有因為其他問題而出錯,那么還原后的數據庫就是一致的。
AOF 模式:因為保存 AOF 文件的工作在后臺線程進行,所以即使是在事務執行的中途,保存 AOF 文件的工作也可以繼續進行,因此,根據事務語句是否被寫入并保存到 AOF 文件,有以下兩種情況發生:
1)如果事務語句未寫入到 AOF 文件,或 AOF 未被 SYNC 調用保存到磁盤,那么當進程被殺死之后,Redis 可以根據最近一次成功保存到磁盤的 AOF 文件來還原數據庫,只要 AOF 文件本身沒有因為其他問題而出錯,那么還原后的數據庫總是一致的,但其中的數據不一定是最新的。
2)如果事務的部分語句被寫入到 AOF 文件,并且 AOF 文件被成功保存,那么不完整的事務執行信息就會遺留在 AOF 文件里,當重啟 Redis 時,程序會檢測到 AOF 文件并不完整,Redis 會退出,并報告錯誤。需要使用 redis-check-aof 工具將部分成功的事務命令移除之后,才能再次啟動服務器。還原之后的數據總是一致的,而且數據也是最新的(直到事務執行之前為止)。
?
c)隔離性Isolation:redis事務是嚴格遵守隔離性的,原因是redis是單進程單線程模式,可以保證命令執行過程中不會被其他客戶端命令打斷。
因為redis使用單線程執行事務,并且保證不會中斷,所以肯定有隔離性。
d)持久性Durability:持久性是指:當一個事務執行完畢,結果已經保存在永久介質里,比如硬盤,所以即使服務器后來停機了,結果也不會丟失
redis事務是不保證持久性的,這是因為redis持久化策略中不管是RDB還是AOF都是異步執行的,不保證持久性是出于對性能的考慮。
3.5.8重點提煉
- 事務提供了一種將多個命令打包, 然后一次性、有序地執行的機制。
- 多個命令會被入隊到事務隊列中, 然后按先進先出(FIFO)的順序執行。
- 事務在執行過程中不會被中斷, 當事務隊列中的所有命令都被執行完畢之后, 事務才會結束。
- 帶有?WATCH?命令的事務會將客戶端和被監視的鍵在數據庫的?
watched_keys
?字典中進行關聯, 當鍵被修改時, 程序會將所有監視被修改鍵的客戶端的?REDIS_DIRTY_CAS
?標志打開。 - 只有在客戶端的?
REDIS_DIRTY_CAS
?標志未被打開時, 服務器才會執行客戶端提交的事務, 否則的話, 服務器將拒絕執行客戶端提交的事務。 - Redis 的事務總是保證 ACID 中的原子性、一致性和隔離性, 當服務器運行在 AOF 持久化模式下, 并且?
appendfsync
?選項的值為?always
?時, 事務也具有耐久性。
?
以上就是 Redis 客戶端和服務器執行命令請求的整個過程了。
?
3.6、發布和訂閱
3.6.1頻道的訂閱和退訂
當一個客戶端執行?SUBSCRIBE?命令, 訂閱某個或某些頻道的時候, 這個客戶端與被訂閱頻道之間就建立起了一種訂閱關系。
Redis 將所有頻道的訂閱關系都保存在服務器狀態的?pubsub_channels
?字典里面, 這個字典的鍵是某個被訂閱的頻道, 而鍵的值則是一個鏈表, 鏈表里面記錄了所有訂閱這個頻道的客戶端:
struct redisServer {// ...// 保存所有頻道的訂閱關系dict *pubsub_channels;// ...};
每當客戶端執行?SUBSCRIBE?命令, 訂閱某個或某些頻道的時候, 服務器都會將客戶端與被訂閱的頻道在?pubsub_channels
?字典中進行關聯。
根據頻道是否已經有其他訂閱者, 關聯操作分為兩種情況執行:
- 如果頻道已經有其他訂閱者, 那么它在?
pubsub_channels
?字典中必然有相應的訂閱者鏈表, 程序唯一要做的就是將客戶端添加到訂閱者鏈表的末尾。 - 如果頻道還未有任何訂閱者, 那么它必然不存在于?
pubsub_channels
?字典, 程序首先要在?pubsub_channels
?字典中為頻道創建一個鍵, 并將這個鍵的值設置為空鏈表, 然后再將客戶端添加到鏈表, 成為鏈表的第一個元素。
SUBSCRIBE?命令的實現可以用以下偽代碼來描述:
def subscribe(*all_input_channels):# 遍歷輸入的所有頻道for channel in all_input_channels:# 如果 channel 不存在于 pubsub_channels 字典(沒有任何訂閱者)# 那么在字典中添加 channel 鍵,并設置它的值為空鏈表if channel not in server.pubsub_channels:server.pubsub_channels[channel] = []# 將訂閱者添加到頻道所對應的鏈表的末尾server.pubsub_channels[channel].append(client)
?
UNSUBSCRIBE?命令的行為和?SUBSCRIBE?命令的行為正好相反 —— 當一個客戶端退訂某個或某些頻道的時候, 服務器將從?pubsub_channels
?中解除客戶端與被退訂頻道之間的關聯:
- 程序會根據被退訂頻道的名字, 在?
pubsub_channels
?字典中找到頻道對應的訂閱者鏈表, 然后從訂閱者鏈表中刪除退訂客戶端的信息。 - 如果刪除退訂客戶端之后, 頻道的訂閱者鏈表變成了空鏈表, 那么說明這個頻道已經沒有任何訂閱者了, 程序將從?
pubsub_channels
?字典中刪除頻道對應的鍵。
UNSUBSCRIBE?命令的實現可以用以下偽代碼來描述:
def unsubscribe(*all_input_channels):# 遍歷要退訂的所有頻道for channel in all_input_channels:# 在訂閱者鏈表中刪除退訂的客戶端server.pubsub_channels[channel].remove(client)# 如果頻道已經沒有任何訂閱者了(訂閱者鏈表為空)# 那么將頻道從字典中刪除if len(server.pubsub_channels[channel]) == 0:server.pubsub_channels.remove(channel)
3.6.2模式的訂閱和退訂
前面說過,服務器將所有頻道的訂閱關系保存起來,與此類似,服務器也將所有模式的訂閱關系存在了pubsub_Patterns屬性里。
struct redisServer {// ...// 保存所有頻道的訂閱關系list *pubsub_patterns;// ...};
pubsub_Patterns屬性是一個鏈表,每個結點是被訂閱的模式,節點內記錄了模式,節點內的client屬性記錄了訂閱模式的客戶端。
typedef struct pubsubPattern{//訂閱模式的客戶端redisClient *client;//被訂閱的模式robj *pattern;
}pubsubPattern;
每當客戶端執行PSUBSCRIBE這個命令來訂閱某個或某些模式時,服務器會對每個被訂閱的模式執行下面的操作:
1)新建一個pubsubPattern結構,設置好兩個屬性
2)將新節點加到pubsub_patterns尾部
偽代碼實現:
def osubscribe(*all_input_patterns):#遍歷所有輸入的模式#記錄被訂閱的模式和對應的客戶端pubsubPattern=create()pubsubPattern.client=clientpubsubPattern.pattern=pattern#插入鏈表末尾server.pub_patterns.append(pubsubPattern)
模式退訂命令PUNSUBSCRIBE是PSUBSCRIBE的反操作
服務器將找到并刪除那些被退訂的模式
偽代碼如下:(我想吐槽一下這樣時間復雜度。。。沒有更好的辦法嗎?)
def osubscribe(*all_input_patterns):#遍歷所有退訂的模式for pattern in all_input_patterns:#遍歷每一個節點for pubsubPattern in server.pubsub_patterns:#如果客戶端和模式都相同if client==pubsubPattern.client:if pattern==pubsubPattern.pattern:#刪除server.pub_patterns.remove(pubsubPattern)
3.6.3、發送消息
當一個客戶端執行PUBLISH<channel> <message>命令將消息發送給頻道時,服務器需要:
1)把消息發送給所有本頻道的訂閱者
具體做法就是去pubsub_channels字典找到本頻道的鏈表,也就是訂閱名單,然后發消息
2)將消息發給,包含本頻道的所有模式中的所有訂閱者
具體做法就是去pubsub_patterns查找包含本頻道的模式,并且把消息發送給訂閱它們的客戶端。
3.6.4、查看訂閱信息
redis2.8新增三個命令,用來查看頻道和模式的相關信息。
PUBLISH CHANNELS[pattern]用于返回服務器當前被訂閱的頻道,pattern可寫可不寫,不寫就查看所有,否則查看與pattern匹配的對應頻道
這個子命令是通過遍歷pubsub_channels字典實現的。
PUBLISH NUMSUB[CHANNEL-1 CHANNEL-2.....]返回這些頻道的訂閱者數量
這個子命令是通過遍歷pubsub_channels字典,查看對應鏈表長度實現的。
PUBLISH NUMPAT返回被訂閱模式數量
這個子命令是通過返回pubsub_patterns的長度實現的。
總而言之,PUBSUB?命令的三個子命令都是通過讀取?pubsub_channels
?字典和?pubsub_patterns
?鏈表中的信息來實現的。
?
四、多機實現
4.1、舊版復制
Redis 的復制功能分為同步(sync)和命令傳播(command propagate)兩個操作:
- 同步操作用于將從服務器的數據庫狀態更新至主服務器當前所處的數據庫狀態。
- 命令傳播操作用于在主服務器的數據庫狀態被修改, 導致主從服務器的數據庫狀態出現不一致時, 讓主從服務器的數據庫重新回到一致狀態。
同步
當客戶端向從服務器發送?SLAVEOF?命令, 要求從服務器復制主服務器時, 從服務器首先需要執行同步操作, 也即是, 將從服務器的數據庫狀態更新至主服務器當前所處的數據庫狀態。
從服務器對主服務器的同步操作需要通過向主服務器發送?SYNC?命令來完成, 以下是?SYNC?命令的執行步驟:
- 從服務器向主服務器發送?SYNC?命令。
- 收到?SYNC?命令的主服務器執行?BGSAVE?命令, 在后臺生成一個 RDB 文件, 并使用一個緩沖區記錄從現在開始執行的所有寫命令。
- 當主服務器的?BGSAVE?命令執行完畢時, 主服務器會將?BGSAVE?命令生成的 RDB 文件發送給從服務器, 從服務器接收并載入這個 RDB 文件, 將自己的數據庫狀態更新至主服務器執行?BGSAVE?命令時的數據庫狀態。
- 主服務器將記錄在緩沖區里面的所有寫命令發送給從服務器, 從服務器執行這些寫命令, 將自己的數據庫狀態更新至主服務器數據庫當前所處的狀態。
。
命令傳播
在同步操作執行完畢之后, 主從服務器兩者的數據庫將達到一致狀態, 但這種一致并不是一成不變的 —— 每當主服務器執行客戶端發送的寫命令時, 主服務器的數據庫就有可能會被修改, 并導致主從服務器狀態不再一致。
舉個例子, 假設一個主服務器和一個從服務器剛剛完成同步操作, 它們的數據庫都保存了相同的五個鍵?k1
?至?k5
如果這時, 客戶端向主服務器發送命令?DEL?k3
?, 那么主服務器在執行完這個?DEL?命令之后, 主從服務器的數據庫將出現不一致: 主服務器的數據庫已經不再包含鍵?k3
?, 但這個鍵卻仍然包含在從服務器的數據庫里面
為了讓主從服務器再次回到一致狀態, 主服務器需要對從服務器執行命令傳播操作: 主服務器會將自己執行的寫命令 —— 也即是造成主從服務器不一致的那條寫命令 —— 發送給從服務器執行, 當從服務器執行了相同的寫命令之后, 主從服務器將再次回到一致狀態。
缺陷
。
其中可以明顯看出重新連接主服務器之后,SYNC命令創建包含k1-k10089的RDB文件。而事實上只需要再同步斷線后的k10087-k10089即可。SYNC的“全同步”對于從服務來說是不必要的。
? ? ? ? ? ?SYNC命令非常消耗資源,原因有三點:
1)主服務器執行BGSAVE命令生成RDB文件,這個生成過程會大量消耗主服務器資源(CPU、內存和磁盤I/O資源)
2)主服務器需要將自己生成的RBD文件發送給從從服務器,這個發送操作會消耗主從服務器大量的網絡資源(帶寬與流量)
3)接收到RDB文件你的從服務器需要載入RDB文件,載入期間從服務器會因為阻塞而導致沒辦法處理命令請求。
4.2新版復制
sync雖然解決了數據同步問題,但是在數據量比較大情況下,從庫斷線從來依然采用全量復制機制,無論是從數據恢復、寬帶占用來說,sync所帶來的問題還是很多的。于是redis從2.8開始,引入新的命令psync。
psync有兩種模式:完整重同步和部分重同步。
部分重同步主要依賴三個方面來實現,依次介紹。
offset(復制偏移量):
主庫和從庫分別各自維護一個復制偏移量(可以使用info replication查看),用于標識自己復制的情況:
在主庫中代表主節點向從節點傳遞的字節數,在從庫中代表從庫同步的字節數。
每當主庫向從節點發送N個字節數據時,主節點的offset增加N
從庫每收到主節點傳來的N個字節數據時,從庫的offset增加N。
因此offset總是不斷增大,這也是判斷主從數據是否同步的標志,若主從的offset相同則表示數據同步量,不通則表示數據不同步。
replication backlog buffer(復制積壓緩沖區):
復制積壓緩沖區是一個固定長度的FIFO隊列,大小由配置參數repl-backlog-size指定,默認大小1MB。
需要注意的是該緩沖區由master維護并且有且只有一個,所有slave共享此緩沖區,其作用在于備份最近主庫發送給從庫的數據。
在主從命令傳播階段,主節點除了將寫命令發送給從節點外,還會發送一份到復制積壓緩沖區,作為寫命令的備份。
?
除了存儲最近的寫命令,復制積壓緩沖區中還存儲了每個字節相應的復制偏移量,由于復制積壓緩沖區固定大小先進先出的隊列,所以它總是保存的是最近redis執行的命令。
所以,重連服務器后,從服務器會發送自己的復制偏移量offset給主服務器,
如果offset偏移量之后的數據仍然存在于復制擠壓緩沖區,就執行部分重同步操作。
相反,執行完整重同步操作。
run_id(服務器運行的唯一ID)?
每個redis實例在啟動時候,都會隨機生成一個長度為40的唯一字符串來標識當前運行的redis節點,查看此id可通過命令info server查看。
當主從復制在初次復制時,主節點將自己的runid發送給從節點,從節點將這個runid保存起來,當斷線重連時,從節點會將這個runid發送給主節點。主節點根據runid判斷能否進行部分復制:
- 如果從節點保存的runid與主節點現在的runid相同,說明主從節點之前同步過,主節點會更具offset偏移量之后的數據判斷是否執行部分復制,如果offset偏移量之后的數據仍然都在復制積壓緩沖區里,則執行部分復制,否則執行全量復制;
- 如果從節點保存的runid與主節點現在的runid不同,說明從節點在斷線前同步的redis節點并不是當前的主節點,只能進行全量復制;
?
psync流程:
復制
客戶端向服務器端發送:SLAVEOF
1、設置主服務器的地址和端口
存到masterhost和mastterport兩個屬性里之后,向客戶端發送ok,然后開始復制工作。
2、建立套接字鏈接
從服務器根據命令設置的地址和端口,創建鏈接,并且為這個套接字創建一個專門處理復制工作的文件事件處理器。
主服務器也會為套接字創建相應的客戶端狀態,并且把從服務器當作一個客戶端來對待。
3、發送ping命令(檢查)
檢查套接字狀態是否正常
檢查主服務器是否能正確處理請求。(如果不能,就重連)
4、身份認證
?
5、發送端口信息
從服務器向主服務器發送信息,主服務器記錄。
6、同步
從服務器向主服務器發送psync命令。(主服務器也成為從服務器的客戶端,因為主服務器會發送寫命令給從服務器)
7、命令傳播
完成同步后,進入傳播階段,主服務器一直發送寫命令,從服務器一直接受,保證和主服務器一致。
心跳檢測
默認一秒一次,從服務器向主服務器發送命令:REPLCONF ACK <offset>
三個作用:
檢測網絡連接狀態:如果主服務器一秒沒收到命令,就說明出問題了
輔助實現min-slaves配置:min-slaves-to-write 3? ?min-slaves-max-log 10:當從服務器小于3個或延遲都大于10,主服務器拒絕寫命令。
檢測命令丟失:如果命令丟失,主服務器會發現偏移量不一樣,然后它就會根據偏移量,去積壓緩沖區找到缺少的數據并發給從服務器。
4.3、哨兵
4.3.1什么是哨兵機制
Redis的哨兵(sentinel)?系統用于管理/多個?Redis?服務器,該系統執行以下三個任務:
·????????監控:?哨兵(sentinel)?會不斷地檢查你的Master和Slave是否運作正常。
·????????提醒:當被監控的某個?Redis出現問題時,?哨兵(sentinel)?可以通過?API?向管理員或者其他應用程序發送通知。
·????????自動故障遷移:當一個Master不能正常工作時,哨兵(sentinel)?會開始一次自動故障遷移操作,它會將失效Master的其中一個Slave升級為新的Master,?并讓失效Master的其他Slave改為復制新的Master;?當客戶端試圖連接失效的Master時,集群也會向客戶端返回新Master的地址,使得集群可以使用Master代替失效Master。
例如下圖所示:
在Server1 掉線后:
升級Server2 為新的主服務器:
4.3.2、哨兵模式修改配置
實現步驟:
1.拷貝到etc目錄
cp sentinel.conf? /usr/local/redis/etc
2.修改sentinel.conf配置文件
sentinel monitor mymast? 192.168.110.133 6379 1? #主節點 名稱 IP 端口號 選舉次數
sentinel auth-pass mymaster 123456?
3. 修改心跳檢測 5000毫秒
sentinel down-after-milliseconds mymaster 5000
4.sentinel parallel-syncs mymaster 2 --- 做多多少合格節點
5. 啟動哨兵模式
./redis-server /usr/local/redis/etc/sentinel.conf --sentinel &
1)Sentinel(哨兵) 進程是用于監控 Redis 集群中 Master 主服務器工作的狀態
2)在 Master 主服務器發生故障的時候,可以實現 Master 和 Slave 服務器的切換,保證系統的高可用(High Availability)
工作方式
1)每個 Sentinel(哨兵)進程以每秒鐘一次的頻率向整個集群中的 Master 主服務器,Slave 從服務器以及其他 Sentinel(哨兵)進程發送一個 PING 命令。
2. 如果一個實例(instance)距離最后一次有效回復 PING 命令的時間超過 down-after-milliseconds 選項所指定的值, 則這個實例會被 Sentinel(哨兵)進程標記為主觀下線。
3. 如果一個 Master 主服務器被標記為主觀下線,則正在監視這個 Master 主服務器的所有 Sentinel(哨兵)進程要以每秒一次的頻率確認 Master 主服務器的確進入了主觀下線狀態。
4. 當有足夠數量的 Sentinel(哨兵)進程(大于等于配置文件指定的值)在指定的時間范圍內確認 Master 主服務器進入了主觀下線狀態, 則Master 主服務器會被標記為客觀下線(ODOWN)。
5. 在一般情況下, 每個 Sentinel(哨兵)進程會以每 10 秒一次的頻率向集群中的所有Master 主服務器、Slave 從服務器發送 INFO 命令。
6. 當 Master 主服務器被 Sentinel(哨兵)進程標記為客觀下線時,Sentinel(哨兵)進程向下線的 Master 主服務器的所有 Slave 從服務器發送 INFO 命令的頻率會從 10 秒一次改為每秒一次。
7. 若沒有足夠數量的 Sentinel(哨兵)進程同意 Master 主服務器下線, Master 主服務器的客觀下線狀態就會被移除。若 Master 主服務器重新向 Sentinel(哨兵)進程發送 PING 命令返回有效回復,Master 主服務器的主觀下線狀態就會被移除。
哨兵(sentinel)?的一些設計思路和zookeeper非常類似
我們從啟動并初始化說起
4.3.3啟動并初始化 Sentinel
啟動一個 Sentinel 可以使用命令:
$ redis-sentinel /path/to/your/sentinel.conf
或者命令:
$ redis-server /path/to/your/sentinel.conf --sentinel
當一個 Sentinel 啟動時, 它需要執行以下步驟:
初始化服務器。
首先, 因為 Sentinel 本質上只是一個運行在特殊模式下的 Redis 服務器, 所以啟動 Sentinel 的第一步, 就是初始化一個普通的 Redis 服務器.
不過, 因為 Sentinel 執行的工作和普通 Redis 服務器執行的工作不同, 所以 Sentinel 的初始化過程和普通 Redis 服務器的初始化過程并不完全相同。
比如說, 普通服務器在初始化時會通過載入 RDB 文件或者 AOF 文件來還原數據庫狀態, 但是因為 Sentinel 并不使用數據庫, 所以初始化 Sentinel 時就不會載入 RDB 文件或者 AOF 文件。
將普通 Redis 服務器使用的代碼替換成 Sentinel 專用代碼。
第二個步驟就是將一部分普通 Redis 服務器使用的代碼替換成 Sentinel 專用代碼。
比如說, 普通 Redis 服務器使用?redis.h/REDIS_SERVERPORT
?常量的值作為服務器端口:
#define REDIS_SERVERPORT 6379
而 Sentinel 則使用?sentinel.c/REDIS_SENTINEL_PORT
?常量的值作為服務器端口:
#define REDIS_SENTINEL_PORT 26379
為什么在 Sentinel 模式下, Redis 服務器不能執行諸如?SET?、?DBSIZE?、?EVAL?等等這些命令 —— 因為服務器根本沒有在命令表中載入這些命令。
初始化 Sentinel 狀態。
在應用了 Sentinel 的專用代碼之后, 接下來, 服務器會初始化一個?sentinel.c/sentinelState
?結構(后面簡稱“Sentinel 狀態”), 這個結構保存了服務器中所有和 Sentinel 功能有關的狀態 (服務器的一般狀態仍然由?redis.h/redisServer
?結構保存):
struct sentinelState {// 當前紀元,用于實現故障轉移uint64_t current_epoch;// 保存了所有被這個 sentinel 監視的主服務器// 字典的鍵是主服務器的名字// 字典的值則是一個指向 sentinelRedisInstance 結構的指針dict *masters;// 是否進入了 TILT 模式?int tilt;// 目前正在執行的腳本的數量int running_scripts;// 進入 TILT 模式的時間mstime_t tilt_start_time;// 最后一次執行時間處理器的時間mstime_t previous_time;// 一個 FIFO 隊列,包含了所有需要執行的用戶腳本list *scripts_queue;} sentinel;
初始化 Sentinel 狀態的?masters
?屬性
Sentinel 狀態中的?masters
?字典記錄了所有被 Sentinel 監視的主服務器的相關信息:
- 字典的鍵是被監視主服務器的名字。
- 而字典的值則是被監視主服務器對應的?
sentinel.c/sentinelRedisInstance
?結構。
每個?sentinelRedisInstance
?結構代表一個被 Sentinel 監視的 Redis 服務器實例(instance), 這個實例可以是主服務器、從服務器、或者另外一個 Sentinel 。
實例結構包含的屬性非常多, 以下代碼展示了一部分屬性
typedef struct sentinelRedisInstance {// 標識值,記錄了實例的類型,以及該實例的當前狀態int flags;// 實例的名字// 主服務器的名字由用戶在配置文件中設置// 從服務器以及 Sentinel 的名字由 Sentinel 自動設置// 格式為 ip:port ,例如 "127.0.0.1:26379"char *name;// 實例的運行 IDchar *runid;// 配置紀元,用于實現故障轉移uint64_t config_epoch;// 實例的地址sentinelAddr *addr;// SENTINEL down-after-milliseconds 選項設定的值// 實例無響應多少毫秒之后才會被判斷為主觀下線(subjectively down)mstime_t down_after_period;// SENTINEL monitor <master-name> <IP> <port> <quorum> 選項中的 quorum 參數// 判斷這個實例為客觀下線(objectively down)所需的支持投票數量int quorum;// SENTINEL parallel-syncs <master-name> <number> 選項的值// 在執行故障轉移操作時,可以同時對新的主服務器進行同步的從服務器數量int parallel_syncs;// SENTINEL failover-timeout <master-name> <ms> 選項的值// 刷新故障遷移狀態的最大時限mstime_t failover_timeout;// ...} sentinelRedisInstance;
創建連向主服務器的網絡連接。
?Sentinel 將成為主服務器的客戶端, 它可以向主服務器發送命令, 并從命令回復中獲取相關的信息。
對于每個被 Sentinel 監視的主服務器來說, Sentinel 會創建兩個連向主服務器的異步網絡連接:
- 一個是命令連接, 這個連接專門用于向主服務器發送命令, 并接收命令回復。
- 另一個是訂閱連接, 這個連接專門用于訂閱主服務器的?
__sentinel__:hello
?頻道。
為什么有兩個連接?在 Redis 目前的發布與訂閱功能中, 被發送的信息都不會保存在Redis 服務器里面, 如果在信息發送時, 想要接收信息的客戶
端不在線或者斷線, 那么這個客戶端就會丟失這條信息。因此, 為了不丟失 __sentinel__:hello 頻道的任何信息,
Sentinel 必須專門用一個訂閱連接來接收該頻道的信息。而另一方面, 除了訂閱頻道之外, Sentinel 還又必須向主服務
器發送命令, 以此來與主服務器進行通訊, 所以 Sentinel 還
必須向主服務器創建命令連接。并且因為 Sentinel 需要與多個實例創建多個網絡連接, 所以Sentinel 使用的是異步連接。
接下來介紹 Sentinel 如何通過命令連接和訂閱連接與被監視主服務器進行通訊。
4.3.4、獲取服務器信息
sentinel默認每十秒鐘發送一次INFO命令給主服務器,并獲取信息:
1)關于主服務器本身的信息
2)主服務器屬下所有從服務器信息
sentinel發現主服務器有新的從服務器時,會創建相應的實例結構和命令連接,訂閱連接
4.3.5、給服務器發送消息
4.3.6、主觀下線
指的是單個Sentinel實例對服務器做出的下線判斷,即單個sentinel認為某個服務下線(有可能是接收不到訂閱,之間的網絡不通等等原因)。
如果服務器在down-after-milliseconds給定的毫秒數之內, 沒有返回 Sentinel 發送的 PING 命令的回復, 或者返回一個錯誤, 那么 Sentinel 將這個服務器標記為主觀下線(SDOWN )。
sentinel會以每秒一次的頻率向所有與其建立了命令連接的實例(master,從服務,其他sentinel)發ping命令,通過判斷ping回復是有效回復,還是無效回復來判斷實例時候在線(對該sentinel來說是“主觀在線”)。
sentinel配置文件中的down-after-milliseconds設置了判斷主觀下線的時間長度,如果實例在down-after-milliseconds毫秒內,返回的都是無效回復,那么sentinel回認為該實例已(主觀)下線,修改其flags狀態為SRI_S_DOWN。如果多個sentinel監視一個服務,有可能存在多個sentinel的down-after-milliseconds配置不同,這個在實際生產中要注意。
4.3.7、客觀下線
客觀下線(Objectively Down, 簡稱 ODOWN)指的是多個 Sentinel 實例在對同一個服務器做出 SDOWN 判斷, 并且通過 SENTINEL is-master-down-by-addr 命令互相交流之后, 得出的服務器下線判斷,然后開啟failover。
客觀下線就是說只有在足夠數量的 Sentinel 都將一個服務器標記為主觀下線之后, 服務器才會被標記為客觀下線(ODOWN)。
只有當master被認定為客觀下線時,才會發生故障遷移。
當sentinel監視的某個服務主觀下線后,sentinel會詢問其它監視該服務的sentinel,看它們是否也認為該服務主觀下線,接收到足夠數量(這個值可以配置)的sentinel判斷為主觀下線,既任務該服務客觀下線,并對其做故障轉移操作。
sentinel通過發送 SENTINEL is-master-down-by-addr ip port current_epoch runid
(ip:主觀下線的服務id,port:主觀下線的服務端口,current_epoch:sentinel的紀元,runid:*表示檢測服務下線狀態,如果是sentinel 運行id,表示用來選舉領頭sentinel)
來詢問其它sentinel是否同意服務下線。
一個sentinel接收另一個sentinel發來的is-master-down-by-addr后,提取參數,根據ip和端口,檢測該服務時候在該sentinel主觀下線,并且回復is-master-down-by-addr,回復包含三個參數:down_state(1表示已下線,0表示未下線),leader_runid(領頭sentinal id),leader_epoch(領頭sentinel紀元)。
sentinel接收到回復后,根據配置設置的下線最小數量,達到這個值,既認為該服務客觀下線。
客觀下線條件只適用于主服務器: 對于任何其他類型的 Redis 實例, Sentinel 在將它們判斷為下線前不需要進行協商, 所以從服務器或者其他 Sentinel 永遠不會達到客觀下線條件。只要一個 Sentinel 發現某個主服務器進入了客觀下線狀態, 這個 Sentinel 就可能會被其他 Sentinel 推選出, 并對失效的主服務器執行自動故障遷移操作。
4.3.8、選舉大哥sentinel
一個redis服務被判斷為客觀下線時,多個監視該服務的sentinel協商,選舉一個領頭sentinel,對該redis服務進行故障轉移操作。選舉領頭sentinel遵循以下規則:
1)所有的sentinel都有公平被選舉成領頭的資格。
2)所有的sentinel都只有一次將某個sentinel選舉成領頭的機會(在一輪選舉中),一旦選舉,不能更改。
3)先到先得,一旦當前sentinel設置了領頭sentinel,以后要求設置sentinel為領頭請求都會被拒絕。
4)每個發現服務客觀下線的sentinel,都會要求其他sentinel將自己設置成領頭。
5)當一個sentinel(源sentinel)向另一個sentinel(目sentinel)發送is-master-down-by-addr ip port current_epoch runid命令的時候,runid參數不是*,而是sentinel運行id,就表示源sentinel要求目標sentinel選舉其為領頭。
6)源sentinel會檢查目標sentinel對其要求設置成領頭的回復,如果回復的leader_runid和leader_epoch為源sentinel,表示目標sentinel同意將源sentinel設置成領頭。
7)如果某個sentinel被半數以上的sentinel設置成領頭,那么該sentinel既為領頭。
8)如果在限定時間內,沒有選舉出領頭sentinel,暫定一段時間,再選舉。
為什么要選?
簡單來說,就是因為只能有一個sentinel節點去完成故障轉移。
sentinel is-master-down-by-addr這個命令有兩個作用,一是確認下線判定,二是進行領導者選舉。
過程:
1)每個做主觀下線的sentinel節點向其他sentinel節點發送上面那條命令,要求將它設置為領導者。
2)收到命令的sentinel節點如果還沒有同意過其他的sentinel發送的命令(還未投過票),那么就會同意,否則拒絕。
3)如果該sentinel節點發現自己的票數已經過半且達到了quorum的值,就會成為領導者
4)如果這個過程出現多個sentinel成為領導者,則會等待一段時間重新選舉。
4.3.9、轉移
1)挑一個新的主服務器
2)把其它從服務器的主服務器改成新的
3)把之前的主服務器改為新主服務器的從服務器
4.3.10、怎么挑新的主服務器
1)刪除所有下線服務器
2)刪除五秒內沒回復INOF命令的服務器
3)刪除數據舊的服務器(連接斷開超過down-after-millseconds*10)
4)根據優先級,選出最高的。
4.3.11、重點提煉
?
- Sentinel 是一個特殊模式下的 Redis 服務器, 它使用了不同的命令表, 所以 Sentinel 能使用的命令和普通服務器不同。
- Sentinel 會讀入用戶指定的配置文件, 為每個要被監視的主服務器創建相應的實例結構, 并創建連向主服務器的命令連接和訂閱連接, 其中命令連接用于向主服務器發送命令請求, 而訂閱連接則用于接收指定頻道的消息。
- Sentinel 向主服務器發送?INFO?命令獲得屬下從服務器信息, 為這些從服務器創建實例結構、命令連接和訂閱連接。
- 默認 Sentinel 十秒一次向被監視的主服務器和從服務器發送?INFO?命令, 當主服務器處于下線狀態, 或者 Sentinel 正在對主服務器進行故障轉移操作時, Sentinel 向從服務器發送?INFO?命令的頻率會改為每秒一次。
- 對于監視同一個主服務器和從服務器的多個 Sentinel 來說, 它們會以每兩秒一次的頻率, 通過向被監視服務器的?
__sentinel__:hello
?頻道發送消息來向其他 Sentinel 宣告自己的存在。 - 每個 Sentinel 也會從?
__sentinel__:hello
?頻道中接收其他 Sentinel 發來的信息, 并根據這些信息為其他 Sentinel 創建相應的實例結構, 以及命令連接。 - Sentinel 只會與主服務器和從服務器創建命令連接和訂閱連接, Sentinel 與 Sentinel 之間則只創建命令連接。
- Sentinel 以每秒一次的頻率向實例(包括主服務器、從服務器、其他 Sentinel)發送?PING?命令, 并根據實例對?PING?命令的回復來判斷實例是否在線
- 當 Sentinel 將一個主服務器判斷為主觀下線時, 它會向同樣監視這個主服務器的其他 Sentinel 進行詢問, 看它們是否同意這個主服務器已經進入主觀下線狀態。
- 當 Sentinel 收集到足夠多的主觀下線投票之后, 它會將主服務器判斷為客觀下線, 并發起一次針對主服務器的故障轉移操作。
?
《三天給你聊清楚redis》第3天說說redis大概怎么用,和面試題(18000字)
五、實戰
5.1基礎實戰
?
5.1.1實戰點贊
點贊功能隨處可見,我們都知道點贊是一個非常高頻的操作,redis就非常適合做這種工作。
實現效果:
分析:三種類型:給帖子點贊,給評論點贊,給回復點贊
我們只實現查看點贊數量的話,只要一個int記錄一下就可以,但是我們之后還想查看點贊的人,所以要把每一個點贊的信息都記錄好,方便后面的功能繼續做出來。
思路:
點贊:把點贊的信息放進去。
取消:把點贊的信息刪除。
在此之前,我們要封裝一個get到key的類,方便后面復用。
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;}
}
點贊業務:
// 點贊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();}});}
我們要查找是否點贊,還有點贊數量,方便頁面顯示:
// 查詢某實體點贊的數量public long findEntityLikeCount(int entityType, int entityId) {String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);return redisTemplate.opsForSet().size(entityLikeKey);}// 查詢某人對某實體的點贊狀態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);// 數量long likeCount = likeService.findEntityLikeCount(entityType, entityId);// 狀態int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);// 返回的結果Map<String, Object> map = new HashMap<>();map.put("likeCount", likeCount);map.put("likeStatus", likeStatus);return CommunityUtil.getJSONString(0, null, map);}
?
?5.1.2實戰關注
效果:?
思路:很好想,把自己的粉絲和自己關注的人都存起來(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();}});}// 查詢關注的實體的數量public long findFolloweeCount(int userId, int entityType) {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);return redisTemplate.opsForZSet().zCard(followeeKey);}// 查詢實體的粉絲的數量public long findFollowerCount(int entityType, int entityId) {String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);return redisTemplate.opsForZSet().zCard(followerKey);}// 查詢當前用戶是否已關注該實體public boolean hasFollowed(int userId, int entityType, int entityId) {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);return redisTemplate.opsForZSet().score(followeeKey, entityId) != null;}// 查詢某用戶關注的人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實戰統計訪問量
過于簡單不解釋
?
??5.1.4實戰排行榜
?
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
//插入排行榜數據
for($i=0;$i<RANK_REC_NUM;$i++) //填充數據
{$redis_increase_id=$redis->get(REDIS_TB_NAME.REDIS_SEP.REDIS_FIELD_ID); //事務機制,插入用戶信息及排行信息,自增id$ret=$redis->multi() //開始事務->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(); //執行事務if($ret==false) //插入失敗,重新插入{$i--;}
}
echo "插入".$i."條記錄成功<br>";
<table>
<thead>
<tr style="font-size:bold;color:red"><td>名次</td><td>分數</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結尾命令都是判斷在這個值沒有時才進行某個命令
????????Redis支持自定義的命令組合,通過MULTI和EXEC,將幾個命令組合起來執行
????????如:插入排行數據和用戶信息,并自增id
$redis->multi()
->hmset("user:1",array("username"=>"hirryli","regtime"=>1234123483))
->Zadd("user:rank",$scores,$userId)
->incr("user:id")
->exec();
?
5.2實戰優化小項目
這是我們之前項目的業務流程,做一下簡單介紹。
登錄:
用戶輸入賬號、密碼、驗證碼。我們先判斷用戶輸入的驗證碼是不是我們session存的驗證碼,然后去查賬號密碼是否正確。
如果登錄成功,發送給用戶一張憑證(ticket)。
登錄后
之后的每次請求,用戶攜帶ticket,服務器得到后,根據ticket去login_ticket表中查找登錄信息,并且根據登錄信息再查user表獲得更多的用戶信息。
使用Redis存儲驗證碼
- 驗證碼需要頻繁的訪問與刷新,對性能要求較高。
- 驗證碼不需永久保存,通常在很短的時間后就會失效。
- 分布式部署時,存在Session共享的問題。
我們重構思路:進入登錄頁面會訪問驗證碼方法,此方法會自動生成一個驗證碼和圖片,將驗證碼和圖片輸出給瀏覽器,并且下發一個cookies,這個cookies里面存的是一段隨機數,這段隨機數作為key存在redis里面(之前是存session),value就是驗證碼,并設置一個過期時間;
//驗證碼@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("響應驗證碼失敗:" + 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緩存用戶信息
- 處理每次請求時,都要根據憑證查詢用戶信息,訪問的頻率非常高。
緩存用戶信息:因為會經常根據userid來查詢user對象,所以使用redis來緩存提高服務器性能。使用redis的String類型,存入user對象,會自動將整個對象轉換成json字符串,同時設置過期時間;
取值:優先從redis中取,取不到的時候從mysql中取,并將數據初始化到redis中
更新:更新的時候先更新mysql中的值,然后清除緩存數據;
// 1.優先從緩存中取值private User getCache(int userId) {String redisKey = RedisKeyUtil.getUserKey(userId);return (User) redisTemplate.opsForValue().get(redisKey);}// 2.取不到時初始化緩存數據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.數據變更時清除緩存數據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解決會話?
什么是會話?
??會話可簡單理解為:用戶開一個瀏覽器,點擊多個超鏈接,訪問服務器多個web資源,然后關閉瀏覽器,整個過程稱之為一個會話。
?會話過程中要解決的一些問題?
–每個用戶不可避免各自會產生一些數據,程序要想辦法為每個用戶保存這些數據。
–例如:用戶點擊超鏈接通過一個servlet購買了一個商品,程序應該想辦法保存用戶購買的商品,以便于用戶點結帳servlet時,結帳servlet可以得到用戶購買的商品為用戶結帳。
?Cookie
–Cookie是客戶端技術,程序把每個用戶的數據以cookie的形式寫給用戶各自的瀏覽器。當用戶使用瀏覽器再去訪問服務器中的web資源時,就會帶著各自的數據去。這樣,web資源處理的就是用戶各自的數據了。
?HttpSession
–Session是服務器端技術,利用這個技術,服務器在運行時可以為每一個用戶的瀏覽器創建一個其獨享的HttpSession對象,由于session為用戶瀏覽器獨享,所以用戶在訪問服務器的web資源時,可以把各自的數據放在各自的session中,當用戶再去訪問服務器中的其它web資源時,其它web資源再從用戶各自的session中取出數據為用戶服務。
總結:cookie存在客戶端,session存在服務器端
?通常結合使用。
?
我們先用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";}
隨著服務器要處理的請求越來越多,我們不得不分布式部署,減小服務器壓力。
為了負載均衡,我們一般采用nginx來分發請求給各個服務器處理
但是這樣session是無法共享的。
(粘性session)
你可以設置nginx的分配策略,下次同一個還讓同一個服務器來處理
但是很顯然,這就和分布式和nginx初衷違背了:負載很難保證均衡。
(同步session)
一臺服務器的session給所有服務器復制一份
第一,性能不好。第二,產生了一定的耦合
(專門session)
專門一臺服務器來解決,存session,其它服務器來這個服務器取session再用。
但是也有問題:你這個服務器掛了怎么辦?別的服務器都是依賴這個服務器工作的。我們分布式部署本來就是為了解決性能的瓶頸啊。
很容易想到,我們把那個處理session的服務器搞個集群:
更不行,想想就知道,本來就是為了解決分布式部署的問題,你把單獨解決session的服務器又搞集群,和之前有什么區別呢?還不如一個服務器存一份簡單呢。
(存數據庫)
可以,但是傳統的關系數據庫是存到硬盤里,速度太慢。
(nosql)
最終,我們的主流辦法使用nosql數據庫,比如redis,來解決這個問題的,如果有不同意見,歡迎討論。
?5.4插曲:RedLock小專欄
概念
Redis 官方站這篇文章提出了一種權威的基于 Redis 實現分布式鎖的方式名叫?Redlock,此種方式比原先的單節點的方法更安全。它可以保證以下特性:
- 安全特性:互斥訪問,即永遠只有一個 client 能拿到鎖
- 避免死鎖:最終 client 都可能拿到鎖,不會出現死鎖的情況,即使原本鎖住某資源的 client crash 了或者出現了網絡分區
- 容錯性:只要大部分 Redis 節點存活就可以正常提供服務
單節點實現
SET resource_name my_random_value NX PX 30000
主要依靠上述命令,該命令僅當 Key 不存在時(NX保證)set 值,并且設置過期時間 3000ms (PX保證),值 my_random_value 必須是所有 client 和所有鎖請求發生期間唯一的,釋放鎖的邏輯是:
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
上述實現可以避免釋放另一個client創建的鎖,如果只有 del 命令的話,那么如果 client1 拿到 lock1 之后因為某些操作阻塞了很長時間,此時 Redis 端 lock1 已經過期了并且已經被重新分配給了 client2,那么 client1 此時再去釋放這把鎖就會造成 client2 原本獲取到的鎖被 client1 無故釋放了,但現在為每個 client 分配一個 unique 的 string 值可以避免這個問題。至于如何去生成這個 unique string,方法很多隨意選擇一種就行了。
redlock算法
算法很易懂,起 5 個 master 節點,分布在不同的機房盡量保證可用性。為了獲得鎖,client 會進行如下操作:
- 得到當前的時間,微秒單位
- 嘗試順序地在 5 個實例上申請鎖,當然需要使用相同的 key 和 random value,這里一個 client 需要合理設置與 master 節點溝通的 timeout 大小,避免長時間和一個 fail 了的節點浪費時間
- 當 client 在大于等于 3 個 master 上成功申請到鎖的時候,且它會計算申請鎖消耗了多少時間,這部分消耗的時間采用獲得鎖的當下時間減去第一步獲得的時間戳得到,如果鎖的持續時長(lock validity time)比流逝的時間多的話,那么鎖就真正獲取到了。
- 如果鎖申請到了,那么鎖真正的 lock validity time 應該是 origin(lock validity time) - 申請鎖期間流逝的時間
- 如果 client 申請鎖失敗了,那么它就會在少部分申請成功鎖的 master 節點上執行釋放鎖的操作,重置狀態
失敗重試
如果一個 client 申請鎖失敗了,那么它需要稍等一會在重試避免多個 client 同時申請鎖的情況,最好的情況是一個 client 需要幾乎同時向 5 個 master 發起鎖申請。另外就是如果 client 申請鎖失敗了它需要盡快在它曾經申請到鎖的 master 上執行 unlock 操作,便于其他 client 獲得這把鎖,避免這些鎖過期造成的時間浪費,當然如果這時候網絡分區使得 client 無法聯系上這些 master,那么這種浪費就是不得不付出的代價了。
放鎖
放鎖操作很簡單,就是依次釋放所有節點上的鎖就行了
性能、崩潰恢復
如果我們的節點沒有持久化機制,client 從 5 個 master 中的 3 個處獲得了鎖,然后其中一個重啟了,這是注意?整個環境中又出現了 3 個 master 可供另一個 client 申請同一把鎖!?違反了互斥性。如果我們開啟了 AOF 持久化那么情況會稍微好轉一些,因為 Redis 的過期機制是語義層面實現的,所以在 server 掛了的時候時間依舊在流逝,重啟之后鎖狀態不會受到污染。但是考慮斷電之后呢,AOF部分命令沒來得及刷回磁盤直接丟失了,除非我們配置刷回策略為 fsnyc = always,但這會損傷性能。解決這個問題的方法是,當一個節點重啟之后,我們規定在 max TTL 期間它是不可用的,這樣它就不會干擾原本已經申請到的鎖,等到它 crash 前的那部分鎖都過期了,環境不存在歷史鎖了,那么再把這個節點加進來正常工作。
?
六、常見問題匯總
寫到這里,從原理到簡單的實戰就全部寫完了,這里匯總一些常用的以及面試常問的題目,希望幫助到大家。
1、什么是redis?
Redis 本質上是一個 Key-Value 類型的內存數據庫,? 整個數據庫加載在內存當中進行操作, 定期通過異步操作把數據庫數據 flush 到硬盤上進行保存。
因為是純內存操作, Redis 的性能非常出色, 每秒可以處理超過 10 萬次讀寫操作, 是已知性能
最快的 Key-Value DB。
Redis 的出色之處不僅僅是性能, Redis 最大的魅力是支持保存多種數據結構, 此外單個
value 的最大限制是 1GB, 不像 memcached 只能保存 1MB 的數據, 因此 Redis 可以用
來實現很多有用的功能,比方說用他的 List 來做 FIFO 雙向鏈表,實現一個輕量級的高性 能
消息隊列服務, 用他的 Set 可以做高性能的 tag 系統等等。
另外 Redis 也可以對存入的Key-Value 設置 expire 時間, 因此也可以被當作一 個功能加強版的 memcached 來用。
Redis 的主要缺點是數據庫容量受到物理內存的限制, 不能用作海量數據的高性能讀寫, 因此 Redis 適合的場景主要局限在較小數據量的高性能操作和運算上
?2、相比 memcached 有哪些優勢?
- redis支持更豐富的數據類型(支持更復雜的應用場景):Redis不僅僅支持簡單的k/v類型的數據,同時還提供list,set,zset,hash等數據結構的存儲。memcache支持簡單的數據類型,String。
- Redis支持數據的持久化,可以將內存中的數據保持在磁盤中,重啟的時候可以再次加載進行使用,而Memecache把數據全部存在內存之中。
- 集群模式:memcached沒有原生的集群模式,需要依靠客戶端來實現往集群中分片寫入數據;但是 redis 目前是原生支持 cluster 模式的.
- Memcached是多線程,非阻塞IO復用的網絡模型;Redis使用單線程的多路 IO 復用模型。
3、Redis 的全稱是什么?
Remote Dictionary Server。
4、支持哪幾種數據類型?
String、 List、 Set、 Sorted Set、 hashes
?
?
5、Redis 有哪幾種數據淘汰策略?
noeviction:返回錯誤當內存限制達到并且客戶端嘗試執行會讓更多內存被使用的命令(大
部分的寫入指令, 但 DEL 和幾個例外)
allkeys-lru: 嘗試回收最少使用的鍵(LRU), 使得新添加的數據有空間存放。
volatile-lru: 嘗試回收最少使用的鍵(LRU), 但僅限于在過期集合的鍵,使得新添加的數據
有空間存放。
allkeys-random: 回收隨機的鍵使得新添加的數據有空間存放。
volatile-random: 回收隨機的鍵使得新添加的數據有空間存放,但僅限于在過期集合的鍵。
volatile-ttl: 回收在過期集合的鍵, 并且優先回收存活時間(TTL) 較短的鍵,使得新添加的
數據有空間存放
?
6、redis為什么采用跳表而不是紅黑樹
在做范圍查找的時候,平衡樹比skiplist操作要復雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這里的中序遍歷并不容易實現。而在skiplist上進行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進行若干步的遍歷就可以實現。
平衡樹的插入和刪除操作可能引發子樹的調整,邏輯復雜,而skiplist的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
從內存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指針(分別指向左右子樹),而skiplist每個節點包含的指針數目平均為1/(1-p),具體取決于參數p的大小。如果像Redis里的實現一樣,取p=1/4,那么平均每個節點包含1.33個指針,比平衡樹更有優勢。
查找單個key,skiplist和平衡樹的時間復雜度都為O(log n),大體相當;而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復雜度接近O(1),性能更高一些。所以我們平常使用的各種Map或dictionary結構,大都是基于哈希表實現的。
從算法實現難度上來比較,skiplist比平衡樹要簡單得多。
7、介紹一下HyperLogLog?
HyperLogLog 是一種概率數據結構,用來估算數據的基數。數據集可以是網站訪客的 IP 地址,E-mail 郵箱或者用戶 ID。
基數就是指一個集合中不同值的數目,比如 a, b, c, d 的基數就是 4,a, b, c, d, a 的基數還是 4。雖然 a 出現兩次,只會被計算一次。
使用 Redis 統計集合的基數一般有三種方法,分別是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前兩個數據結構在集合的數量級增長時,所消耗的內存會大大增加,但是 HyperLogLog 則不會。
Redis 的 HyperLogLog 通過犧牲準確率來減少內存空間的消耗,只需要12K內存,在標準誤差0.81%的前提下,能夠統計2^64個數據。所以 HyperLogLog 是否適合在比如統計日活月活此類的對精度要不不高的場景。
這是一個很驚人的結果,以如此小的內存來記錄如此大數量級的數據基數。
8、為什么 Redis 需要把所有數據放到內存中?
Redis 為了達到最快的讀寫速度將數據都讀到內存中, 并通過異步的方式將數據寫入磁盤。
所以 Redis 具有快速和數據持久化的特征。 如果不將數據放在內存中, 磁盤 I/O 速度為嚴重
影響 Redis 的性能。 在內存越來越便宜的今天, Redis 將會越來越受歡迎。
?
9、Redis支持的數據類型?
String字符串:
格式: set key value
string類型是二進制安全的。意思是redis的string可以包含任何數據。比如jpg圖片或者序列化的對象 。
string類型是Redis最基本的數據類型,一個鍵最大能存儲512MB。
?
Hash(哈希)
格式: hmset name? key1 value1 key2 value2
Redis hash 是一個鍵值(key=>value)對集合。
Redis hash是一個string類型的field和value的映射表,hash特別適合用于存儲對象。
?
List(列表)
Redis 列表是簡單的字符串列表,按照插入順序排序。你可以添加一個元素到列表的頭部(左邊)或者尾部(右邊)
格式: lpush? name? value
在 key 對應 list 的頭部添加字符串元素
格式: rpush? name? value
在 key 對應 list 的尾部添加字符串元素
格式: lrem name? index
key 對應 list 中刪除 count 個和 value 相同的元素
格式: llen name??
返回 key 對應 list 的長度
?
Set(集合)
格式: sadd? name? value
Redis的Set是string類型的無序集合。
集合是通過哈希表實現的,所以添加,刪除,查找的復雜度都是O(1)。
?
zset(sorted set:有序集合)
格式: zadd? name score value
Redis zset 和 set 一樣也是string類型元素的集合,且不允許重復的成員。
不同的是每個元素都會關聯一個double類型的分數。redis正是通過分數來為集合中的成員進行從小到大的排序。
zset的成員是唯一的,但分數(score)卻可以重復。
?
10、 sds相對c的改進?
??獲取長度:c字符串并不記錄自身長度,所以獲取長度只能遍歷一遍字符串,redis直接讀取len即可。
????緩沖區安全:c字符串容易造成緩沖區溢出,比如:程序員沒有分配足夠的空間就執行拼接操作。而redis會先檢查sds的空間是否滿足所需要求,如果不滿足會自動擴充。
????內存分配:由于c不記錄字符串長度,對于包含了n個字符的字符串,底層總是一個長度n+1的數組,每一次長度變化,總是要對這個數組進行一次內存重新分配的操作。因為內存分配涉及復雜算法并且可能需要執行系統調用,所以它通常是比較耗時的操作。???
?
11、redis鏈表源碼?有什么特性?
?
雙端、無環、帶長度記錄、
多態:使用?void*
?指針來保存節點值, 可以通過?dup
?、?free
?、?match
?為節點值設置類型特定函數, 可以保存不同類型的值。
12、字典是如何實現的?
其實字典這種數據結構也內置在很多高級語言中,但是c語言沒有,所以redis自己實現了。
應用也比較廣泛,比如redis的數據庫就是字典實現的。不僅如此,當一個哈希鍵包含的鍵值對比較多,或者都是很長的字符串,redis就會用字典作為哈希鍵的底層實現。
13、LRU?redis里的具體實現?
LRU全稱是Least?Recently Used,即最近最久未使用的意思。
LRU算法的設計原則是:如果一個數據在最近一段時間沒有被訪問到,那么在將來它被訪問的可能性也很小。也就是說,當限定的空間已存滿數據時,應當把最久沒有被訪問到的數據淘汰。
redis原始的淘汰算法簡單實現:當需要淘汰一個key時,隨機選擇3個key,淘汰其中間隔時間最長的key。**基本上,我們隨機選擇key,淘汰key效果很好。后來隨機3個key改成一個配置項"N隨機key"。但把默認值提高改成5個后效果大大提高。考慮到它的效果,你根本不用修改他。
14、redis的持久化?
RDB持久化可以手動執行,也可以配置定期執行,可以把某個時間的數據狀態保存到RDB文件中,反之,我們可以用RDB文件還原數據庫狀態。
AOF持久化是通過保存服務器執行的命令來記錄狀態的。還原的時候再執行一遍即可。
?
15、如何選擇合適的持久化方式?
一般來說, 如果想達到足以媲美 PostgreSQL 的數據安全性, 你應該同時使用兩種持久
化功能。 如果你非常關心你的數據, 但仍然可以承受數分鐘以內的數據丟失, 那么你可以
只使用 RDB 持久化。
有很多用戶都只使用 AOF 持久化, 但并不推薦這種方式: 因為定時生成 RDB 快照
(snapshot) 非常便于進行數據庫備份, 并且 RDB 恢復數據集的速度也要比 AOF 恢復
的速度要快, 除此之外, 使用 RDB 還可以避免之前提到的 AOF 程序的 bug。
?
16、Redis 集群方案應該怎么做? 都有哪些方案?
1.twemproxy, 大概概念是, 它類似于一個代理方式, 使用方法和普通 Redis 無任何區別,
設 置 好它 下 屬 的多 個 Redis 實 例 后, 使 用 時在 本 需 要 連接 Redis 的 地 方改 為 連接
twemproxy, 它會以一個代理的身份接收請求并使用一致性 hash 算法, 將請求轉接到具
體 Redis, 將結果再返回 twemproxy。 使用方式簡便(相對 Redis 只需修改連接端口), 對
舊項目擴展的首選。 問題: twemproxy 自身單端口實例的壓力, 使用一致性 hash 后, 對
Redis 節點數量改變時候的計算值的改變, 數據無法自動移動到新的節點。
2. codis, 目前用的最多的集群方案, 基本和 twemproxy 一致的效果, 但它支持在 節點
數量改變情況下, 舊節點數據可恢復到新 hash 節點。
3. Redis cluster3.0 自帶的集群, 特點在于他的分布式算法不是一致性 hash, 而是 hash
槽的概念, 以及自身支持節點設置從節點。 具體看官方文檔介紹。
4.在業務代碼層實現, 起幾個毫無關聯的 Redis 實例, 在代碼層, 對 key 進行 hash 計算,
然后去對應的 Redis 實例操作數據。 這種方式對 hash 層代碼要求比較高, 考慮部分包括,
節點失效后的替代算法方案, 數據震蕩后的自動腳本恢復, 實例的監控, 等等
MySQL 里有 2000w 數據, Redis 中只存 20w 的數據,
17、如何保證 Redis 中的數據都是熱點數據?
Redis 內存數據集大小上升到一定大小的時候, 就會施行數據淘汰策略
18、Redis 有哪些適合的場景?
(1)、 會話緩存(Session Cache)
最常用的一種使用 Redis 的情景是會話緩存(session cache)。 用 Redis 緩存會話比其他
存儲(如 Memcached) 的優勢在于: Redis 提供持久化。 當維護一個不是嚴格要求一致性
的緩存時, 如果用戶的購物車信息全部丟失, 大部分人都會不高興的, 現在, 他們還會這樣
嗎?
幸運的是, 隨著 Redis 這些年的改進, 很容易找到怎么恰當的使用 Redis 來緩存會話的文
檔。 甚至廣為人知的商業平臺 Magento 也提供 Redis 的插件。
(2)、 全頁緩存(FPC)
除基本的會話 token 之外, Redis 還提供很簡便的 FPC 平臺。 回到一致性問題, 即使重啟
了 Redis 實例, 因為有磁盤的持久化, 用戶也不會看到頁面加載速度的下降, 這是一個極
大改進, 類似 PHP 本地 FPC。
再次以 Magento 為例, Magento 提供一個插件來使用 Redis 作為全頁緩存后端。
此外, 對 WordPress 的用戶來說, Pantheon 有一個非常好的插件 wp-Redis, 這個插件
能幫助你以最快速度加載你曾瀏覽過的頁面。
(3)、 隊列
Reids 在內存存儲引擎領域的一大優點是提供 list 和 set 操作,這使得 Redis 能作為一個
很好的消息隊列平臺來使用。 Redis 作為隊列使用的操作, 就類似于本地程序語言(如
Python) 對 list 的 push/pop 操作。
如果你快速的在 Google 中搜索“Redis queues”, 你馬上就能找到大量的開源項目, 這些
項目的目的就是利用 Redis 創建非常好的后端工具, 以滿足各種隊列需求。 例如, Celery
有一個后臺就是使用 Redis 作為 broker, 你可以從這里去查看。
(4)、 排行榜/計數器
Redis在內存中對數字進行遞增或遞減的操作實現的非常好。集合(Set)和有序集合(Sorted
Set) 也使得我們在執行這些操作的時候變的非常簡單, Redis 只是正好提供了這兩種數據
結構。 所以, 我們要從排序集合中獲取到排名最靠前的 10 個用戶–我們稱之為
“user_scores”, 我們只需要像下面一樣執行即可:
當然, 這是假定你是根據你用戶的分數做遞增的排序。 如果你想返回用戶及用戶的分數, 你
需要這樣執行:
ZRANGE user_scores 0 10 WITHSCORES
Agora Games 就是一個很好的例子, 用 Ruby 實現的, 它的排行榜就是使用 Redis 來存儲
數據的, 你可以在這里看到。
(5)、 發布/訂閱
最后 是 Redis 的發布/訂閱功能。 發布/訂閱的使用場景確實非
常多。 我已看見人們在社交網絡連接中使用, 還可作為基于發布/訂閱的腳本觸發器, 甚至
用 Redis 的發布/訂閱功能來建立聊天系統。
19、說說 Redis 哈希槽的概念?
Redis 集群沒有使用一致性 hash,而是引入了哈希槽的概念, Redis 集群有 16384 個哈希槽,
每個 key 通過 CRC16 校驗后對 16384 取模來決定放置哪個槽, 集群的每個節點負責一部分
hash 槽
20、為什么Redis集群有16384個槽
(1)如果槽位為65536,發送心跳信息的消息頭達8k,發送的心跳包過于龐大。
如上所述,在消息頭中,最占空間的是myslots[CLUSTER_SLOTS/8]
。 當槽位為65536時,這塊的大小是:?65536÷8÷1024=8kb
?因為每秒鐘,redis節點需要發送一定數量的ping消息作為心跳包,如果槽位為65536,這個ping消息的消息頭太大了,浪費帶寬。
(2)redis的集群主節點數量基本不可能超過1000個。
如上所述,集群節點越多,心跳包的消息體內攜帶的數據越多。如果節點過1000個,也會導致網絡擁堵。因此redis作者,不建議redis cluster節點數量超過1000個。 那么,對于節點數在1000以內的redis cluster集群,16384個槽位夠用了。沒有必要拓展到65536個。
(3)槽位越小,節點少的情況下,壓縮比高
Redis主節點的配置信息中,它所負責的哈希槽是通過一張bitmap的形式來保存的,在傳輸過程中,會對bitmap進行壓縮,但是如果bitmap的填充率slots / N很高的話(N表示節點數),bitmap的壓縮率就很低。 如果節點數很少,而哈希槽數量很多的話,bitmap的壓縮率就很低。
21、Redis 集群會有寫操作丟失嗎? 為什么?
Redis 并不能保證數據的強一致性, 這意味這在實際中集群在特定的條件下可能會丟失寫操
作。
?
22、Redis 集群方案應該怎么做?都有哪些方案?
1.twemproxy,大概概念是,它類似于一個代理方式, 使用時在本需要連接 redis 的地方改為連接 twemproxy, 它會以一個代理的身份接收請求并使用一致性 hash 算法,將請求轉接到具體 redis,將結果再返回 twemproxy。
缺點: twemproxy 自身單端口實例的壓力,使用一致性 hash 后,對 redis 節點數量改變時候的計算值的改變,數據無法自動移動到新的節點。
2.codis,目前用的最多的集群方案,基本和 twemproxy 一致的效果,但它支持在 節點數量改變情況下,舊節點數據可恢復到新 hash 節點
3.redis cluster3.0 自帶的集群,特點在于他的分布式算法不是一致性 hash,而是 hash 槽的概念,以及自身支持節點設置從節點。具體看官方文檔介紹。
?
23、為什么要做 Redis 分區?
分區可以讓 Redis 管理更大的內存, Redis 將可以使用所有機器的內存。 如果沒有分區, 你
最多只能使用一臺機器的內存。 分區使 Redis 的計算能力通過簡單地增加計算機得到成倍提
升,Redis 的網絡帶寬也會隨著計算機和網卡的增加而成倍增長。
?
24、Redis 分區有什么缺點?
涉及多個 key 的操作通常不會被支持。 例如你不能對兩個集合求交集, 因為他們可能被存
儲到不同的 Redis 實例(實際上這種情況也有辦法, 但是不能直接使用交集指令)。
同時操作多個 key,則不能使用 Redis 事務.
分區使用的粒度是key,不能使用一個非常長的排序key存儲一個數據集(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) .
當使用分區的時候, 數據處理會非常復雜, 例如為了備份你必須從不同的 Redis 實例和主
機同時收集 RDB / AOF 文件。
分區時動態擴容或縮容可能非常復雜。 Redis 集群在運行時增加或者刪除 Redis 節點, 能
做到最大程度對用戶透明地數據再平衡,但其他一些客戶端分區或者代理分區方法則不支持
這種特性。 然而, 有一種預分片的技術也可以較好的解決這個問題。
?
25、Redis 與其他 key-value 存儲有什么不同?
Redis 有著更為復雜的數據結構并且提供對他們的原子性操作,這是一個不同于其他數據庫
的進化路徑。 Redis 的數據類型都是基于基本數據結構的同時對程序員透明, 無需進行額外
的抽象。
Redis 運行在內存中但是可以持久化到磁盤,所以在對不同數據集進行高速讀寫時需要權衡
內存, 應為數據量不能大于硬件內存。 在內存數據庫方面的另一個優點是, 相比在磁盤上
相同的復雜的數據結構, 在內存中操作起來非常簡單, 這樣 Redis 可以做很多內部復雜性
很強的事情。 同時, 在磁盤格式方面他們是緊湊的以追加的方式產生的, 因為他們并不需
要進行隨機訪問
26、Redis 的內存用完了會發生什么?
如果達到設置的上限, Redis 的寫命令會返回錯誤信息(但是讀命令還可以正常返回。) 或
者你可以將 Redis 當緩存來使用配置淘汰機制,當 Redis 達到內存上限時會沖刷掉舊的內容。
?
27、Redis 是單線程的, 如何提高多核 CPU 的利用率?
可以在同一個服務器部署多個 Redis 的實例, 并把他們當作不同的服務器來使用, 在某些時
候, 無論如何一個服務器是不夠的,
所以, 如果你想使用多個 CPU, 你可以考慮一下分片(shard)。
28、一個 Redis 實例最多能存放多少的 keys? List、 Set、Sorted Set 他們最多能存放多少元素?
理論上 Redis 可以處理多達 232 的 keys, 并且在實際中進行了測試, 每個實例至少存放了 2億 5 千萬的 keys。 我們正在測試一些較大的值。
任何 list、 set、 和 sorted set 都可以放 232 個元素。
換句話說, Redis 的存儲極限是系統中的可用內存值
?
29、修改配置不重啟 Redis 會實時生效嗎?
針對運行實例, 有許多配置選項可以通過 CONFIG SET 命令進行修改, 而無需執行任何
形式的重啟。 從 Redis 2.2 開始, 可以從 AOF 切換到 RDB 的快照持久性或其他方式
而不需要重啟 Redis。 檢索 ‘CONFIG GET *’ 命令獲取更多信息。
但偶爾重新啟動是必須的, 如為升級 Redis 程序到新的版本, 或者當你需要修改某些目前
CONFIG 命令還不支持的配置參數的時候
?
30、哨兵
Redis sentinel 是一個分布式系統中監控 redis 主從服務器,并在主服務器下線時自動進行故障轉移。其中三個特性:
監控(Monitoring):??? Sentinel? 會不斷地檢查你的主服務器和從服務器是否運作正常。
提醒(Notification): 被監控的某個 Redis 服務器出現問題時, Sentinel 可以通過 API 向管理員或者其他應用程序發送通知。
自動故障遷移(Automatic failover): 當一個主服務器不能正常工作時, Sentinel 會開始一次自動故障遷移操作。
特點:
1、保證高可用
2、監控各個節點
3、自動故障遷移
缺點:主從模式,切換需要時間丟數據
沒有解決 master 寫的壓力
?
31、緩存穿透
一般的緩存系統,都是按照key去緩存查詢,如果不存在對應的value,就去后端系統查找(比如DB)。
一些惡意的請求會故意查詢不存在的key,請求量很大,就會對后端系統造成很大的壓力。這就叫做緩存穿透。
?
如何避免?
1:對查詢結果為空的情況也進行緩存,這樣,再次訪問時,緩存層會直接返回空值。緩存時間設置短一點,或者該key對應的數據insert了之后清理緩存。
2:對一定不存在的key進行過濾。具體請看布隆過濾器
?
32、緩存擊穿
是針對緩存中沒有但數據庫有的數據。
場景是,當Key失效后,假如瞬間突然涌入大量的請求,來請求同一個Key,這些請求不會命中Redis,都會請求到DB,導致數據庫壓力過大,甚至扛不住,掛掉。
解決辦法
1、設置熱點Key,自動檢測熱點Key,將熱點Key的過期時間加大或者設置為永不過期,或者設置為邏輯上永不過期
2、加互斥鎖。當發現沒有命中Redis,去查數據庫的時候,在執行更新緩存的操作上加鎖,當一個線程訪問時,其它線程等待,這個線程訪問過后,緩存中的數據會被重建,這樣其他線程就可以從緩存中取值。
?
33、緩存雪崩
是指大量Key同時失效,對這些Key的請求又會打到DB上,同樣會導致數據庫壓力過大甚至掛掉。
解決辦法
1)讓Key的失效時間分散開,可以在統一的失效時間上再加一個隨機值,或者使用更高級的算法分散失效時間。
2)構建多個redis實例,個別節點掛了還有別的可以用。
3)多級緩存:比如增加本地緩存,減小redis壓力。
4)對存儲層增加限流措施,當請求超出限制,提供降級服務(一般就是返回錯誤即可)
?
34、單線程的redis為什么這么快
(一)純內存操作
(二)單線程操作,避免了頻繁的上下文切換
(三)采用了非阻塞I/O多路復用機制
(其實就是歷史遺留問題,非要吹的這么好。。。)
?
35、redis采用的刪除策略
?
redis采用的是定期刪除+惰性刪除策略。
36、為什么不用定時刪除策略?
定時刪除,用一個定時器來負責監視key,過期則自動刪除。雖然內存及時釋放,但是十分消耗CPU資源。在大并發請求下,CPU要將時間應用在處理請求,而不是刪除key,因此沒有采用這一策略.
37、定期刪除+惰性刪除是如何工作的呢?
定期刪除,redis默認每個100ms檢查,是否有過期的key,有過期key則刪除。需要說明的是,redis不是每個100ms將所有的key檢查一次,而是隨機抽取進行檢查(如果每隔100ms,全部key進行檢查,redis豈不是卡死)。因此,如果只采用定期刪除策略,會導致很多key到時間沒有刪除。
于是,惰性刪除派上用場。也就是說在你獲取某個key的時候,redis會檢查一下,這個key如果設置了過期時間那么是否過期了?如果過期了此時就會刪除。
?
38、為什么Redis的操作是原子性的,怎么保證原子性的?
對于Redis而言,命令的原子性指的是:一個操作的不可以再分,操作要么執行,要么不執行。
Redis的操作之所以是原子性的,是因為Redis是單線程的。
Redis本身提供的所有API都是原子操作,Redis中的事務其實是要保證批量操作的原子性。
多個命令在并發中也是原子性的嗎?
不一定, 將get和set改成單命令操作,incr 。使用Redis的事務,或者使用Redis+Lua==的方式實現.
?
39、消息隊列
不要使用redis去做消息隊列,這不是redis的設計目標。但實在太多人使用redis去做去消息隊列,redis的作者看不下去。
kafka才好用
?
有問題隨時評論區提問
?
?七、開個腦洞
?最近又研究了一下redis,說實話,我越研究越覺得不靠譜,為啥會有nosql這種東西?
以下是我混亂的思考
未來架構越來越復雜,saas做不了一致性交給redis做。
如果數據庫本身就有個redis呢?是不是我們就不需要這玩意了。
不對,使用場景還是有區別的,數據庫本身不就有buffer pool么。
一直在琢磨redis做緩存的必要性。
我要把。。
工作上的第三課,就是得考慮成本。之前我是純正的redis吹,但是mysql有緩存那些數據和redis速度差不了太多,而且沒有關系型數據庫記錄全面,最重要的是貴!
最重要的是貴!最重要的是貴!最重要的是貴!
我從一個幼稚的redis吹,變成了考慮更全面的人,看到了redis越來越多的缺點。希望,是我成長了吧。
全篇完。?