這一年以來,寫了太多的業(yè)務(wù)代碼漫试。是時候要總結(jié)一下自己的積累了贮缅。本文是redis深度歷險的讀書筆記凭豪,做個記錄以及分享給大家汁蝶。
docker
redis
redis插件加載
數(shù)據(jù)結(jié)構(gòu)
字符串
字符串是一個字符數(shù)組
常見用途就是信息JSON序列化成為字符串之后,存入redis窃躲,取信息會經(jīng)過一次反序列化
字符串是動態(tài)字符串计贰,可以進行擴容;當(dāng)字符串長度小于1m的時候蒂窒,擴容是加倍躁倒。當(dāng)大于1m,每次擴容就增加1m洒琢。字符串最大的長度為512m
使用字符串計數(shù)的時候秧秉,單個值的最大值為signed long,超過就會報錯
-
常用命令
set name a get name exists a del a mset a 1 b 2 mget a b expire a 5 setex a 5 1 setnx a 1 incr a incrby a 10
列表
-
相當(dāng)于java中的LinkedList,一個雙向鏈表。插入和刪除操作快器腋,但是索引定位很慢钞诡。
這里幫助大家回憶一下普通節(jié)點刪除操作 p.pre.next = p.next p.next.pre = p.pre p = null 不需要對節(jié)點進行移動
-
列表可以作為隊列板祝,右進左出即可
rpush books java python golang kotlin 4 llen books 4 lpop books java
-
列表可以作為棧,右進右出即可。就是,調(diào)整了隊列使用的方向
rpush books java python golang kotlin 4 llen books 4 rpop books kotlin
-
使用隊列會存在的慢操作有g(shù)et(index)方法汪厨,需要遍歷整個鏈表,性能為O(n)
rpush books java python golang kotlin 4 lindex 1 # 時間為O(n) lindex books 1 "python" lrange books 0 -1 "java" "python" "golang" "kotlin" ltrim books 1 -1 # 設(shè)置長度為3的數(shù)組愉择,區(qū)間外的全部被清除 OK lrange books 0 -1 # 獲取所有元素 "python" "golang" "kotlin" llen books 3 ltrim books 1 0 # 刪除所有元素 OK
快速列表:在列表元素較少的時候劫乱,會使用一塊連續(xù)的內(nèi)存,叫ziplist(壓縮列表)锥涕。當(dāng)數(shù)據(jù)量較多的時候衷戈,就要變成quicklist(快速列表),多個ziplist使用雙向指針串起來使用。
// 快速列表
struct quicklist {
quicklistNode* head;
quicklistNode* tail;
long count; // 元素總數(shù)
int nodes; // ziplist 節(jié)點的個數(shù)
int compressDepth; // LZF 算法壓縮深度
...
}
// 快速列表節(jié)點
struct quicklistNode {
quicklistNode* prev;
quicklistNode* next;
ziplist* zl; // 指向壓縮列表
int32 size; // ziplist 的字節(jié)總數(shù)
int16 count; // ziplist 中的元素數(shù)量
int2 encoding; // 存儲形式 2bit层坠,原生字節(jié)數(shù)組還是 LZF 壓縮存儲
}
struct ziplist_compressed {
int32 size;
byte[] compressed_data;
}
struct ziplist {
...
}
Hash字典
內(nèi)部實現(xiàn)結(jié)構(gòu)就是數(shù)組+鏈表的方式進行實現(xiàn)殖妇。相同hash值的元素使用鏈表串聯(lián)起來。java的鏈表數(shù)量超過6個會變成紅黑樹窿春,不知道redis的會不會有這個查詢優(yōu)化拉一。
redis里面的值就只能是字符串,便于比較旧乞。不像java的hashmap可以存儲對象蔚润,equals方法的編寫可謂五花八門
-
java的hashmap在進行rehash的時候,需要一次性進行rehash尺栖,是個耗時操作嫡纠,多線程還會出現(xiàn)環(huán)。redis的是漸進式rehash延赌。rehash的時候除盏,會保留兩個hash結(jié)構(gòu),查詢時候會查詢兩個hash結(jié)構(gòu)挫以,在后續(xù)的定時任務(wù)和hash指令操作的時候者蠕,漸進地將舊的內(nèi)容遷移到新的hash結(jié)構(gòu)中。當(dāng)內(nèi)容遷移完畢掐松,刪除舊的hash結(jié)構(gòu)
hset books java "think in java" 1 hset books golang "concurrency in go" 1 hset books python "python cookbook" 1 hgetall books # 返回所有entryies踱侣,key和value間隔返回 "java" "think in java" "golang" "concurrency in go" "python" "python cookbook" hlen books 3 hget books java "think in java" hset books java "my java book" 0 hmset books java "java" golang "golang" ok hset user-lixinkun age 25 1 hincrby user-lixinkun 1 26
set
-
相當(dāng)于java的hashset,確保唯一性
sadd books java 1 sadd books java 0 sadd books python golang smembers books java golang python #順序可能被打亂,輸出結(jié)果是無序的
Zset 有序列表
類似于java的sortedset和hashmap的結(jié)合體大磺。具備set的唯一性原理抡句,也可以給每個值賦一個score,代表它的排序權(quán)重杠愧。它的內(nèi)部實現(xiàn)是一個跳躍列表待榔。貌似java也有這個數(shù)據(jù)結(jié)構(gòu)
內(nèi)部排序通過skiplist實現(xiàn)。zset要支持睡覺的插入和刪除流济,不能使用數(shù)組來表示锐锣。普通的鏈表數(shù)據(jù)結(jié)構(gòu),有新元素插入的時候袭灯,需要進行定位刺下,這樣才能保證是有序的。通常會通過二分查找來確定位置稽荧,但是只有數(shù)組才支持快速查找橘茉。這時候就需要一個多層級的列表結(jié)構(gòu),最下面一層的元素都是串聯(lián)起來的姨丈。某個元素存在于所有的層畅卓。定位插入點的時候,先在頂層進行定位蟋恬,然后下潛到下一層翁潘,然后一直到最底下一層。redia采用隨機策略來決定元素可以兼職到第幾層歼争。L0層的概率是100%拜马,L1的是50%渗勘。這樣去類推
-
數(shù)據(jù)結(jié)構(gòu),具體介紹鏈接redis跳躍列表介紹
/* ZSETs use a specialized version of Skiplists */ typedef struct zskiplistNode { //成員對象 robj *obj; //分值 double score; //后退指針 struct zskiplistNode *backward; //層 struct zskiplistLevel { struct zskiplistNode *forward;//前進指針 unsigned int span;//跨度 } level[]; } zskiplistNode; typedef struct zskiplist { //表頭節(jié)點和表尾節(jié)點 struct zskiplistNode *header, *tail; //表中節(jié)點的的數(shù)量 unsigned long length; //表中層數(shù)最大的節(jié)點層數(shù) int level; } zskiplist;
容器的通用規(guī)則
create if not exists.如果容器不存在,則先創(chuàng)建容器俩莽。
drop if no elements.如果容器沒有元素了旺坠,就立即刪除該容器,釋放內(nèi)存扮超。
容器的過期時間是針對整個對象的取刃,hash的過期是整個進行過期,而不是單個元素
分布式鎖
對于不是原子性的操作出刷,就需要通過分布式鎖來保證只有一個線程可以獲取到鎖璧疗。具體使用的是setnx和del命令。鎖處理完了以后馁龟,就要被釋放崩侠。中間需要解決的問題就是,如果業(yè)務(wù)邏輯執(zhí)行之間坷檩,出現(xiàn)了異常啦膜,可能導(dǎo)致del無法執(zhí)行,整個業(yè)務(wù)癱瘓淌喻。
或許僧家,可以加一個過期時間,5s以后自動釋放裸删。但是邏輯還有問題的八拱,如果expire的時候,命令丟失了涯塔,也會死鎖肌稻。出現(xiàn)這種問題的原因就是setnx和expire兩個命令不是原子性的。
在redis2.8的版本中匕荸,作者加入了set指令的擴展餐廚爹谭,setnx和expire指令可以一起執(zhí)行了,指令是原子性的榛搔。
加鎖
set lock:book true ex 5 nx
//釋放鎖
del lock:book
超時問題是redis分布式鎖不能解決的問題诺凡,如果加鎖和釋放鎖之間的邏輯太長,超出了鎖的超時時限践惑,那么第二個線程可能獲得redis分布式鎖腹泌。所以,redis分布式鎖不要用于較長時間的任務(wù)
可重入性尔觉。java里面有一個ReentrantLock凉袱。Redis分布式鎖如果要支持重入,需要對客戶端的set方法進行封裝,使用線程的ThreadLocal遍歷存儲當(dāng)前持有鎖的計數(shù)专甩。
下面代碼redis分布式鎖的使用钟鸵,這里鏈接是錯誤案例介紹redis分布式鎖使用與錯誤示例
//錯誤使用就是使用setnx和expire兩個指令
/**
* 嘗試獲取分布式鎖
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請求標(biāo)識
* @param expireTime 超期時間
* @return 是否獲取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
- java版本的可重入鎖,核心就是用threadlocal去記錄每個線程,某個key的鎖計數(shù)
public class RedisWithReentrantLock{
//線程鎖計數(shù)器8
private ThreadLocal<Map<String,Integer>> lockers = new ThreadLocal<>();
private Jedis jedis;
//內(nèi)部使用的lock
private boolean _lock(String key) {
return jedis.set(key, "", "nx", "ex", 5L) != null;
}
//內(nèi)部使用的unlock
private void _unlock(String key) {
jedis.del(key);
}
//獲取計數(shù)器
private Map<String, Integer> currentLockers() {
Map<String, Integer> refs = lockers.get();
if (refs != null) {
return refs;
}
lockers.set(new HashMap<>());
return lockers.get();
}
//可重入加鎖
public boolean lock(String key) {
Map<String, Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if (refCnt != null) {
refs.put(key, refCnt + 1);
return true;
}
boolean ok = _lock(key);
if (!ok) {
return false;
}
refs.put(key, 1);
return true;
}
//可重入釋放鎖
public boolean unlock(String key) {
Map<String, Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if (refCnt == null) {
return false;
}
refCnt = -1;
if (refCnt > 0) {
refs.put(key, refCnt);
} else {
refs.remove(key);
_unlock(key);
}
return true;
}
}
redis的消息隊列涤躲,只做特性介紹携添,公司實際使用rocketmq
使用簡單,只有一個消費者
沒有ack保證篓叶,如果對消息可靠性有極高的要求,推薦使用rocketmq的事務(wù)消息
使用list作為異步消息的發(fā)送結(jié)構(gòu)羞秤,rpush缸托,rpop
延時隊列的實現(xiàn)
- 延時隊列可以使用zset實現(xiàn),將消息序列化成為一個字符串瘾蛋,消息的到期時間為score俐镐。然后使用多個線程去輪詢這個隊列。使用多線程是為了保障可用性哺哼。一個線程掛了佩抹,還有其他線程可以進行處理
位圖,節(jié)約存儲空間
平時進行開發(fā)的時候取董,要對一些bool類型的數(shù)據(jù)進行存取棍苹,如用戶一年的簽到記錄。如果使用key/value方式進行存取茵汰,就要記錄365個枢里,當(dāng)用戶量上億的時候,需要的存儲結(jié)構(gòu)是非常驚人的蹂午。
redis提供了位圖數(shù)據(jù)結(jié)構(gòu)栏豺,每個用戶每天的簽到就只占據(jù)一個位。365天大概需要46個字節(jié)豆胸,大大節(jié)約空間
位圖就是byte數(shù)組奥洼,可以使用getbit和setbit進行存儲,如果字節(jié)是不可以打印字符,redis會展示其16進制晚胡。實際來操作一波
零存整取的方式灵奖,一個個bit去設(shè)置,然后一次性拿出值
通過python獲取字母`h`的hash值為0b1101000
setbit s 1 1
setbit s 2 1
setbit s 4 1
get s
"h" # 這樣就得到了h的值
零存零取的方式估盘,就是對單個bit進行操作,沒有設(shè)置的位就是默認(rèn)為0
set bit w 1 1
get bit w 0
0
整存零取
set w h
getbit w 1
1
不可打印字符
setbit x 0 1
setbit x 1 1
get x
"\xc0"
- 位圖的統(tǒng)計和查找:redis提供了對位圖的基本統(tǒng)計算
set w hello
ok
bitcount w
21
bitcount w 0 0
3
bitcount w 0 1 # 統(tǒng)計1的數(shù)量
7
bitpos w 0 # 第一個0
0
bitpos w 1 1 1 # 第二個字符算起桑寨,第一個1位
魔術(shù)指令bitfield
bitfield w get u4 0
高級數(shù)據(jù)結(jié)構(gòu)
HyberLogLog,提供不精確的統(tǒng)計數(shù)據(jù)
頁面的uv,不能使用一個key的incr忿檩,因為要進行去重
使用zset也可以做到這個尉尾,但是消耗空間太大
HyperLogLog的標(biāo)準(zhǔn)誤差是0.81%。對于不精確的統(tǒng)計燥透,可以使用這個數(shù)據(jù)結(jié)構(gòu)
提供pdadd沙咏,pfcount兩個指令,pf是Philippe Flajolet教授的名字縮寫辨图,這個數(shù)據(jù)結(jié)構(gòu)是他發(fā)明的。這個數(shù)據(jù)結(jié)構(gòu)占用的內(nèi)存是12k肢藐,使用了2^14個桶去進行計算
pfadd book java
1
pfadd book java
0
pfadd book python
1
pfadd book python
pfcount book
2
布隆過濾器Bloom Filter
這個數(shù)據(jù)結(jié)構(gòu)主要實現(xiàn)去重的功能
場景使用:廣告推送的時候故河,如果我們看過了一條廣告,下一條推送的時候吆豹,要對看過的廣告進行去重鱼的。如果使用關(guān)系型數(shù)據(jù)庫,每次推送都要去數(shù)據(jù)庫查詢痘煤,這樣數(shù)據(jù)庫很難抗住這個壓力凑阶。況且,瀏覽記錄會隨著時間線性增長衷快,空間也跟不上
布隆過濾器會有一定的誤判概率宙橱。這可以比喻成為一個不精確的set結(jié)構(gòu),調(diào)用contains的時候蘸拔,有可能誤判师郑。如果說這個值不存在,那就肯定不存在调窍。如果說這個值存在宝冕,優(yōu)肯是誤判,可能邓萨,兩者長得相像猬仁?這樣的話,對于實際業(yè)務(wù)也沒有影響先誉,我們可以保證推送給用戶的都是最新的內(nèi)容湿刽,只有極少數(shù)用戶沒看過的內(nèi)容會被誤判。經(jīng)過測試褐耳,誤判率大概在1%左右诈闺。調(diào)整參數(shù)可以優(yōu)化這個誤判率
布隆過濾器作為redis的一個插件,需要版本為4.0以后铃芦,才能使用雅镊。
基本指令為bf.add,bf.exists刃滓,bf.madd(批量)仁烹,安裝docker,然后去重新安裝redis咧虎,帶這個插件的版本卓缰。centos7安裝docker方法,安裝好以后再安裝插件,使用docker啟動redis
用戶可以通過bf.reserve命令顯式制定錯誤率征唬,key和初始空間捌显。錯誤率越低,占用空間越大总寒。初始值越大扶歪,空間越大,所以摄闸,在使用的時候善镰,需要準(zhǔn)確預(yù)估一下元素的大小。
布隆過濾器是一個大型的位數(shù)組年枕。向布隆過濾器添加key的時候炫欺,會使用多個hash函數(shù)對key進行hash,算得一個整數(shù)索引画切。然后對位數(shù)組的長度取模運算得到一個位置,每個hash函數(shù)都得到一個不同的位置囱怕,再把這幾個位置都設(shè)置為1霍弹,完成了add操作。巧妙地共享可多個key的存儲空間娃弓。
為何會出現(xiàn)誤判呢典格?就是由于判斷key是否存在的時候,如果某個位是0台丛,那就一定不存在耍缴。但是,如果各個位置都是1的話挽霉,也不能說明是存在的防嗡,有可能是其他key存在導(dǎo)致。如果位數(shù)組比較稀疏侠坎,出現(xiàn)誤判的概率就低了蚁趁。所以,使用的時候实胸,不要讓實際的元素大于初始值他嫡。如果大于的時候,要對布隆過濾器進行重建庐完。
set存儲的是所有的元素钢属,布隆過濾器是存儲元素的指紋(hash)∶徘空間節(jié)省還是比較明顯的.
docker加載插件的命令淆党,docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest
docker exec -it redis-redisbloom bash
bf.add book java
1
bf.add book java #再次加入java,就被排出了
0
bf.exists book python
0
redis限流
- 后端屏蔽用戶的過于頻繁的請求,比如1秒內(nèi)不能請求超過5次宁否。這個限流存在一個滑動時間窗口窒升,zset的score圈出一個時間窗口,我們保留這個時間窗口慕匠,對于窗口外面的數(shù)據(jù)饱须,則可以統(tǒng)統(tǒng)砍掉。zset的value可以為毫秒時間戳台谊。
public class SimpleRateLimiter{
private Jedis jedis;
//用戶請求是否允許
//使用樂觀鎖
//value保證唯一性就好
public boolean isActionAllow(String userId, String actionKey, int period, int maxCount) {
String key = String.format("hist:%s:%s", userId, actionKey);
long now = System.currentTimeMillis();
Pipeline pipe = jedis.pipelined();
pipe.multi();
pipe.zadd(key, now, "", now);
//截取
pipe.zremrangeByScore(key, 0, now - period * 1000);
Response<Long> count = pipe.zcard(key);
pipe.expire(key, period + 1);
pipe.exec();
pipe.close();
return count.get() <= maxCount;
}
}
- 漏斗限流蓉媳,靈感來源于漏斗,就是漏斗有一個容量锅铅,也有一個消耗速率酪呻。當(dāng)灌水速率小于漏水速率,漏斗超過容量以后盐须。灌水行為就要丟棄或者暫停
//單機版的漏斗限流
public class FunnelRateLimiter{
class Funnel{
//容量
int capacity;
//漏水速率
float leakingRate;
//剩余空間
int leftQuota;
//上次觸發(fā)時間
long leakingTs;
}
//....省略構(gòu)造方法
void makeSpace() {
long now = System.currentTimeMillis();
long deltaTs = nowTs - leakingTs;
int deltaQuota = (int)(deltaTs * leakingRate);
//如果時間間隔太久玩荠,可能會導(dǎo)致int溢出
if (deltaQuota < 0) {
//重設(shè)
this.leftQuota = capacity;
this.leakingTs = 0;
return;
}
if (deltaQuota < 1) {
return;
}
this.leftQuota += leftQuota;
this.leakingTs = nowTs;
if (this.leftQuota > this.capacity) {
this.leftQuota = this.capacity;
}
//是否可以接收請求
boolean watering(int quota) {
makeSpace();
if (leftQuota >= quota) {
this.leftQuota -= quota;
return true;
}
return false;
}
}
private Map<String, Funnel> funnels = new HashMap<>();
public boolean isActionAllow(String userId, String actionKey, int capacity, fload leakingRate) {
String key = String.format("funnels:%s:%s", userId, actionKey);
Funnel funnel = funnels.get(key);
if (funnel == null) {
funnel = new Funnel(capacity, leakingRate);
funnels.put(key, funnel);
}
return funnel.watering(1);
}
}
- 這里是redis提供的分布式限流,redis4.0提供了redis-cell贼邓。這是一個差價阶冈,需要去github下載redis-cell的源碼解壓以后。進入redis-cli后塑径,執(zhí)行redis的 module load命令即可加載
wget https://github.com/brandur/redis-cell/releases/download/v0.2.4/redis-cell-v0.2.4-x86_64-unknown-linux-gnu.tar.gz
tar -xzvf redis-cell-v0.2.4-x86_64-unknown-linux-gnu.tar.gz
解壓后得到libredis_cess.so
redis-cli
module load /data/libredis_cell.so
我這里是放到了data目錄下面女坑,運行redis后加載就行了
//各個參數(shù) key 容量 計算漏斗速率 可選參數(shù)
cl.throttle <key> <max_burst> <count per period> <period> [<quantity>]
cl.throttle book 15 30 60 1 # 這個表示容量為15,2秒一個漏水速率(30/60)
0 # 0 允許 -1 拒絕
16 # 漏斗容量
15 # 剩余空間
-1 # 當(dāng)?shù)谝粋€參數(shù)為-1師,這里的值表示需要經(jīng)過多少時間以后才能再試
2 # 多久時間以后统舀,漏斗可以完全空出來