Redis 技術整理

紫邪情發表於2023-11-03

認識Redis

Redis官網:https://redis.io/

Redis誕生於2009年全稱是Remote Dictionary Server 遠端詞典伺服器,是一個基於記憶體的鍵值型NoSQL資料庫

特徵

  • 鍵值(key-value)型,value支援多種不同資料結構,功能豐富
  • 單執行緒,每個命令具備原子性
  • 低延遲,速度快(基於記憶體.IO多路複用.良好的編碼)
  • 支援資料持久化
  • 支援主從叢集、分片叢集

NoSQL可以翻譯做Not Only SQL(不僅僅是SQL),或者是No SQL(非SQL的)資料庫。是相對於傳統關係型資料庫而言,有很大差異的一種特殊的資料庫,因此也稱之為非關係型資料庫

關係型資料是結構化的,即有嚴格要求,而NoSQL則對資料庫格式沒有嚴格約束,往往形式鬆散,自由

可以是鍵值型:

也可以是文件型:

甚至可以是圖格式:

在事務方面:

  1. 傳統關係型資料庫能滿足事務ACID的原則
  2. 非關係型資料庫往往不支援事務,或者不能嚴格保證ACID的特性,只能實現基本的一致性

除了上面說的,在儲存方式.擴充套件性.查詢效能上關係型與非關係型也都有著顯著差異,總結如下:

  • 儲存方式
    • 關係型資料庫基於磁碟進行儲存,會有大量的磁碟IO,對效能有一定影響
    • 非關係型資料庫,他們的操作更多的是依賴於記憶體來操作,記憶體的讀寫速度會非常快,效能自然會好一些
  • 擴充套件性
    • 關係型資料庫叢集模式一般是主從,主從資料一致,起到資料備份的作用,稱為垂直擴充套件。
    • 非關係型資料庫可以將資料拆分,儲存在不同機器上,可以儲存海量資料,解決記憶體大小有限的問題。稱為水平擴充套件。
    • 關係型資料庫因為表之間存在關聯關係,如果做水平擴充套件會給資料查詢帶來很多麻煩

安裝Redis

企業都是基於Linux伺服器來部署專案,而且Redis官方也沒有提供Windows版本的安裝包

本文選擇的Linux版本為CentOS 7

單機安裝

  1. 安裝需要的依賴
yum install -y gcc tcl
  1. 上傳壓縮包並解壓
tar -zxf redis-7.0.12.tar.gz
  1. 進入解壓的redis目錄
cd redis-7.0.12
  1. 編譯並安裝
make && make install

預設的安裝路徑是在 /usr/local/bin目錄下

image-20230723232738946

該目錄已經預設配置到環境變數,因此可以在任意目錄下執行這些命令。其中:

  • redis-cli:是redis提供的命令列客戶端
  • redis-server:是redis的服務端啟動指令碼
  • redis-sentinel:是redis的哨兵啟動指令碼

啟動Redis

redis的啟動方式有很多種,例如:

  • 預設啟動
  • 指定配置啟動
  • 開機自啟

預設啟動

安裝完成後,在任意目錄輸入redis-server命令即可啟動Redis:

redis-server

這種啟動屬於“前臺啟動”,會阻塞整個會話視窗,視窗關閉或者按下CTRL + C則Redis停止

指定配置啟動

如果要讓Redis以“後臺”方式啟動,則必須修改Redis配置檔案,就在之前解壓的redis安裝包下(/usr/local/src/redis-6.2.6),名字叫redis.conf

修改redis.conf檔案中的一些配置:可以先複製一份再修改

# 允許訪問的地址,預設是127.0.0.1,會導致只能在本地訪問。修改為0.0.0.0則可以在任意IP訪問,生產環境不要設定為0.0.0.0
bind 0.0.0.0
# 守護程式,修改為yes後即可後臺執行
daemonize yes
# 密碼,設定後訪問Redis必須輸入密碼
requirepass 072413

Redis的其它常見配置:

# 監聽的埠
port 6379
# 工作目錄,預設是當前目錄,也就是執行redis-server時的命令,日誌、持久化等檔案會儲存在這個目錄
dir .
# 資料庫數量,設定為1,代表只使用1個庫,預設有16個庫,編號0~15
databases 1
# 設定redis能夠使用的最大記憶體
maxmemory 512mb
# 日誌檔案,預設為空,不記錄日誌,可以指定日誌檔名
logfile "redis.log"

啟動Redis:

# 進入redis安裝目錄 
cd /opt/redis-6.2.13
# 啟動
redis-server redis.conf

停止服務:

# 利用redis-cli來執行 shutdown 命令,即可停止 Redis 服務,
# 因為之前配置了密碼,因此需要透過 -u 來指定密碼
redis-cli -u password shutdown

開機自啟

可以透過配置來實現開機自啟。

首先,新建一個系統服務檔案:

vim /etc/systemd/system/redis.service

內容如下:

[Unit]
Description=redis-server
After=network.target

[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /opt/redis-7.0.12/redis.conf
PrivateTmp=true

[Install]
WantedBy=multi-user.target

然後過載系統服務:

systemctl daemon-reload

現在,我們可以用下面這組命令來操作redis了:

# 啟動
systemctl start redis
# 停止
systemctl stop redis
# 重啟
systemctl restart redis
# 檢視狀態
systemctl status redis

執行下面的命令,可以讓redis開機自啟:

systemctl enable redis

主從叢集安裝

主:具有讀寫操作

從:只有讀操作

image-20210630111505799

  1. 修改redis.conf檔案
# 開啟RDB
# save ""
save 3600 1
save 300 100
save 60 10000

# 關閉AOF
appendonly no
  1. 將上面的redis.conf檔案複製到不同地方
# 方式一:逐個複製
cp /usr/local/bin/redis-7.0.12/redis.conf /tmp/redis-7001
cp /usr/local/bin/redis-7.0.12/redis.conf /tmp/redis-7002
cp /usr/local/bin/redis-7.0.12/redis.conf /tmp/redis-7003

# 方式二:管道組合命令,一鍵複製
echo redis-7001 redis-7002 redis-7003 | xargs -t -n 1 cp /usr/local/bin/redis-7.0.12/redis.conf
  1. 修改各自的埠、rdb目錄改為自己的目錄
sed -i -e 's/6379/7001/g' -e 's/dir .\//dir \/tmp\/redis-7001\//g' redis-7001/redis.conf
sed -i -e 's/6379/7002/g' -e 's/dir .\//dir \/tmp\/redis-7002\//g' redis-7002/redis.conf
sed -i -e 's/6379/7003/g' -e 's/dir .\//dir \/tmp\/redis-7003\//g' redis-7003/redis.conf
  1. 修改每個redis節點的IP宣告。虛擬機器本身有多個IP,為了避免將來混亂,需要在redis.conf檔案中指定每一個例項的繫結ip資訊,格式如下:
# redis例項的宣告 IP
replica-announce-ip IP地址


# 逐一執行
sed -i '1a replica-announce-ip 192.168.150.101' redis-7001/redis.conf
sed -i '1a replica-announce-ip 192.168.150.101' redis-7002/redis.conf
sed -i '1a replica-announce-ip 192.168.150.101' redis-7003/redis.conf

# 或者一鍵修改
printf '%s\n' redis-7001 redis-7002 redis-7003 | xargs -I{} -t sed -i '1a replica-announce-ip 192.168.150.101' {}/redis.conf
  1. 啟動
# 第1個
redis-server redis-7001/redis.conf
# 第2個
redis-server redis-7002/redis.conf
# 第3個
redis-server redis-7003/redis.conf


# 一鍵停止
printf '%s\n' redis-7001 redis-7002 redis-7003 | xargs -I{} -t redis-cli -p {} shutdown
  1. 開啟主從關係:配置主從可以使用replicaof 或者slaveof(5.0以前)命令

永久配置:在redis.conf中新增一行配置

slaveof <masterip> <masterport>

臨時配置:使用redis-cli客戶端連線到redis服務,執行slaveof命令(重啟後失效)

# 5.0以後新增命令replicaof,與salveof效果一致
slaveof <masterip> <masterport>

解除安裝Redis

  1. 檢視redis是否啟動
ps aux | grep redis
  1. 若啟動,則殺死程式
kill -9 PID

image-20230724175911257

  1. 停止服務
redis-cli shutdown
  1. 檢視/usr/local/lib目錄中是否有與Redis相關的檔案
ll /usr/local/bin/redis-*

# 有的話就刪掉

rm -rf /usr/local/bin/redis-*

Redis客戶端工具

命令列客戶端

Redis安裝完成後就自帶了命令列客戶端:redis-cli,使用方式如下:

redis-cli [options] [commonds]

其中常見的options有:

  • -h 127.0.0.1:指定要連線的redis節點的IP地址,預設是127.0.0.1
  • -p 6379:指定要連線的redis節點的埠,預設是6379
  • -a 072413:指定redis的訪問密碼

其中的commonds就是Redis的操作命令,例如:

  • ping:與redis服務端做心跳測試,服務端正常會返回pong

不指定commond時,會進入redis-cli的互動控制檯:

image-20230724180838657

圖形化客戶端

地址:https://github.com/uglide/RedisDesktopManager

不過該倉庫提供的是RedisDesktopManager的原始碼,並未提供windows安裝包。

在下面這個倉庫可以找到安裝包:https://github.com/lework/RedisDesktopManager-Windows/releases

下載之後,解壓、安裝

image-20230724182022549

image-20230724182209444

Redis預設有16個倉庫,編號從0至15. 透過配置檔案可以設定倉庫數量,但是不超過16,並且不能自定義倉庫名稱。

如果是基於redis-cli連線Redis服務,可以透過select命令來選擇資料庫

# 選擇 0號庫
select 0

Redis常見命令 / 物件

Redis是一個key-value的資料庫,key一般是String型別,不過value的型別多種多樣:

1652887393157

查命令的官網: https://redis.io/commands

在互動介面使用 help 命令查詢:

help [command]

image-20230725122759107

通用命令

通用指令是部分資料型別都可以使用的指令,常見的有:

  • KEYS:檢視符合模板的所有key。在生產環境下,不推薦使用keys 命令,因為這個命令在key過多的情況下,效率不高
  • DEL:刪除一個指定的key
  • EXISTS:判斷key是否存在
  • EXPIRE:給一個key設定有效期,有效期到期時該key會被自動刪除。記憶體非常寶貴,對於一些資料,我們應當給他一些過期時間,當過期時間到了之後,他就會自動被刪除
    • 當使用EXPIRE給key設定的有效期過期了,那麼此時查詢出來的TTL結果就是-2
    • 如果沒有設定過期時間,那麼TTL返回值就是-1
  • TTL:檢視一個KEY的剩餘有效期

image-20230725123855605

String命令

使用場景:

  1. 驗證碼儲存
  2. 不易變動的物件儲存
  3. 簡單鎖的儲存

String型別,也就是字串型別,是Redis中最簡單的儲存型別

其value是字串,不過根據字串的格式不同,又可以分為3類:

  • string:普通字串
  • int:整數型別,可以做自增.自減操作
  • float:浮點型別,可以做自增.自減操作

1652890121291

String的常見命令有:

  • SET:新增或者修改已經存在的一個String型別的鍵值對,對於SET,若key不存在則為新增,存在則為修改

  • GET:根據key獲取String型別的value

  • MSET:批次新增多個String型別的鍵值對

  • MGET:根據多個key獲取多個String型別的value

  • INCR:讓一個整型的key自增1

  • INCRBY:讓一個整型的key自增並指定步長

    • incrby num 2 讓num值自增2
    • 也可以使用負數,是為減法,如:incrby num -2 讓num值-2。此種類似 DECR 命令,而DECR是每次-1
  • INCRBYFLOAT:讓一個浮點型別的數字自增並指定步長

  • SETNX:新增一個String型別的鍵值對(key不存在為新增,存在則不執行)

  • SETEX:新增一個String型別的鍵值對,並且指定有效期

注:以上命令除了INCRBYFLOAT 都是常用命令

key問題

key的設計

Redis沒有類似MySQL中的Table的概念,我們該如何區分不同型別的key?

可以透過給key新增字首加以區分,不過這個字首不是隨便加的,有一定的規範

Redis的key允許有多個單詞形成層級結構,多個單詞之間用:隔開,格式如下:

1652941631682

這個格式並非固定,也可以根據自己的需求來刪除或新增詞條

如專案名稱叫 automation,有user和product兩種不同型別的資料,我們可以這樣定義key:

  • user相關的key:automation:user:1

  • product相關的key:automation:product:1

同時還需要滿足:

  • key的長度最好別超過44位元組(3.0版本是39位元組)
  • key中別包含特殊字元

BigKey問題

BigKey通常“以Key的大小和Key中成員的數量來綜合判定”,例如:

  • Key本身的資料量過大:一個String型別的Key,它的值為5 MB
  • Key中的成員數過多:一個ZSET型別的Key,它的成員數量為10,000個
  • Key中成員的資料量過大:一個Hash型別的Key,它的成員數量雖然只有1,000個但這些成員的Value(值)總大小為100 MB

判定元素大小的方式

MEMORY USAGE key	# 檢視某個key的記憶體大小,不建議使用:因為此命令對CPU使用率較高


# 衡量值 或 值的個數
STRLEN key		# string結構 某key的長度
LLEN key		# list集合 某key的值的個數
.............

推薦值

  • 單個key的value小於10KB
  • 對於集合型別的key,建議元素數量小於1000

BigKey的危害

  1. 網路阻塞:對BigKey執行讀請求時,少量的QPS就可能導致頻寬使用率被佔滿,導致Redis例項,乃至所在物理機變慢
  2. 資料傾斜:BigKey所在的Redis例項記憶體使用率遠超其他例項,無法使資料分片的記憶體資源達到均衡
  3. Redis阻塞:對元素較多的hash、list、zset等做運算會耗時較舊,使主執行緒被阻塞
  4. CPU壓力:對BigKey的資料序列化和反序列化會導致CPU的使用率飆升,影響Redis例項和本機其它應用

如何發現BigKey

  1. redis-cli --bigkeys 命令redis-cli -a 密碼 --bigkeys

此命令可以遍歷分析所有key,並返回Key的整體統計資訊與每個資料的Top1的key

不足:返回的是記憶體大小是TOP1的key,而此key不一定是BigKey,同時TOP2、3.......的key也不一定就不是BigKey

  1. scan命令掃描:]每次會返回2個元素,第一個是下一次迭代的游標(cursor),第一次游標會設定為0,當最後一次scan 返回的游標等於0時,表示整個scan遍歷結束了,第二個返回的是List,一個匹配的key的陣列
127.0.0.1:7001> help SCAN

SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
summary: Incrementally iterate the keys space
since: 2.8.0
group: generic

自定義程式碼來判定是否為BigKey

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.ScanResult;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class JedisTest {
    private final static int STR_MAX_LEN = 10 * 1024;
    private final static int HASH_MAX_LEN = 500;
    
    private Jedis jedis;

    @BeforeEach
    void setUp() {
        // 1.建立連線
        jedis = new Jedis("192.168.146.100", 6379);
        // 2.設定密碼
        jedis.auth("072413");
        // 3.選擇庫
        jedis.select(0);
    }

    @Test
    void testScan() {
        int maxLen = 0;
        long len = 0;

        String cursor = "0";
        do {
            // 掃描並獲取一部分key
            ScanResult<String> result = jedis.scan(cursor);
            // 記錄cursor
            cursor = result.getCursor();
            List<String> list = result.getResult();
            if (list == null || list.isEmpty()) {
                break;
            }
            // 遍歷
            for (String key : list) {
                // 判斷key的型別
                String type = jedis.type(key);
                switch (type) {
                    case "string":
                        len = jedis.strlen(key);
                        maxLen = STR_MAX_LEN;
                        break;
                    case "hash":
                        len = jedis.hlen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "list":
                        len = jedis.llen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "set":
                        len = jedis.scard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "zset":
                        len = jedis.zcard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    default:
                        break;
                }
                if (len >= maxLen) {
                    System.out.printf("Found big key : %s, type: %s, length or size: %d %n", key, type, len);
                }
            }
        } while (!cursor.equals("0"));
    }
    
    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }
}
  1. 第三方工具
  • 利用第三方工具,如 Redis-Rdb-Tools 分析RDB快照檔案,全面分析記憶體使用情況
  1. 網路監控
  • 自定義工具,監控進出Redis的網路資料,超出預警值時主動告警
  • 一般阿里雲搭建的雲伺服器就有相關監控頁面

image-20220521140415785

如何刪除BigKey

BigKey記憶體佔用較多,即便是刪除這樣的key也需要耗費很長時間,導致Redis主執行緒阻塞,引發一系列問題

  1. redis 3.0 及以下版本:如果是集合型別,則遍歷BigKey的元素,先逐個刪除子元素,最後刪除BigKey

image-20220521140621204

  1. Redis 4.0以後:使用非同步刪除的命令 unlink
127.0.0.1:7001> help UNLINK

UNLINK key [key ...]
summary: Delete a key asynchronously in another thread. Otherwise it is just as DEL, but non blocking.
since: 4.0.0
group: generic

解決BigKey問題

上一節是刪除BigKey,但是資料最終還是未解決

要解決BigKey:

  1. 選擇合適的資料結構(String、Hash、List、Set、ZSet、Stream、GEO、HyperLogLog、BitMap)
  2. 將大資料拆為小資料,具體根據業務來

如:一個物件放在hash中,hash底層會使用ZipList壓縮,但entry數量超過500時(看具體redis版本),會使用雜湊表而不是ZipList

# 檢視redis的entry數量
[root@zixq ~]# redis-cli
127.0.0.1:7001> config get hash-max-ziplist-entries


# 修改redis的entry數量		別太離譜即可
config set hash-max-ziplist-entries

因此:一個hash的key中若是field-value約束在一定的entry以內即可,超出的就用另一個hash的key來儲存,具體實現以業務來做

Hash命令

使用場景:

  1. 易改變物件的儲存
  2. 分散式鎖的儲存(Redisson分散式鎖的實現原理)

這個在工作中使用頻率很高

Hash型別,也叫雜湊,其value是一個無序字典,類似於Java中的HashMap結構。

String結構是將物件序列化為JSON字串後儲存,當需要修改物件某個欄位時很不方便:

1652941995945

Hash結構可以將物件中的每個欄位獨立儲存,可以針對單個欄位做CRUD:

1652942027719

Hash型別的常見命令

  • HSET key field value:新增或者修改hash型別key的field的值。同理:操作不存在資料是為新增,存在則為修改

  • HGET key field:獲取一個hash型別key的field的值

  • HMSET:批次新增多個hash型別key的field的值

  • HMGET:批次獲取多個hash型別key的field的值

  • HGETALL:獲取一個hash型別的key中的所有的field和value

  • HKEYS:獲取一個hash型別的key中的所有的field

  • HINCRBY:讓一個hash型別key的field的value值自增並指定步長

  • HSETNX:新增一個hash型別的key的field值,前提是這個field不存在,否則不執行

List命令 - 命令規律開始變化

Redis中的List型別與Java中的LinkedList類似,可以看做是一個雙向連結串列結構。既可以支援正向檢索,也可以支援反向檢索。

特徵也與LinkedList類似:

  • 有序
  • 元素可以重複
  • 插入和刪除快
  • 查詢速度一般

使用場景:

  • 朋友圈點贊列表
  • 評論列表

List的常見命令有:

  • LPUSH key element ... :向列表左側插入一個或多個元素
  • LPOP key:移除並返回列表左側的第一個元素,沒有則返回nil
  • RPUSH key element ... :向列表右側插入一個或多個元素
  • RPOP key:移除並返回列表右側的第一個元素
  • LRANGE key star end:返回一段角標範圍內的所有元素
  • BLPOP和BRPOP:與LPOP和RPOP類似,只不過在沒有元素時等待指定時間,而不是直接返回nil

1652943604992

Set命令

Redis的Set結構與Java中的HashSet類似,可以看做是一個value為null的HashMap。因為也是一個hash表,因此具備與HashSet類似的特徵:

  • 無序
  • 元素不可重複
  • 查詢快
  • 支援交集.並集.差集等功能

使用場景:

  • 一人一次的業務。如:某商品一個使用者只能買一次
  • 共同擁有的業務,如:關注、取關與共同關注

Set型別的常見命令

  • SADD key member ... :向set中新增一個或多個元素
  • SREM key member ... :移除set中的指定元素
  • SCARD key:返回set中元素的個數
  • SISMEMBER key member:判斷一個元素是否存在於set中
  • SMEMBERS:獲取set中的所有元素
  • SINTER key1 key2 ... :求key1與key2的交集
  • SDIFF key1 key2 ... :求key1與key2的差集
  • SUNION key1 key2 ..:求key1和key2的並集

SortedSet / ZSet 命令

Redis的SortedSet是一個可排序的set集合,與Java中的TreeSet有些類似,但底層資料結構卻差別很大。SortedSet中的每一個元素都帶有一個score屬性,可以基於score屬性對元素排序,底層的實現是一個跳錶(SkipList)加 hash表。

SortedSet具備下列特性:

  • 可排序
  • 元素不重複
  • 查詢速度快

使用場景:

  • 排行榜

SortedSet的常見命令有:

  • ZADD key score member:新增一個或多個元素到sorted set ,如果已經存在則更新其score值
  • ZREM key member:刪除sorted set中的一個指定元素
  • ZSCORE key member : 獲取sorted set中的指定元素的score值
  • ZRANK key member:獲取sorted set 中的指定元素的排名
  • ZCARD key:獲取sorted set中的元素個數
  • ZCOUNT key min max:統計score值在給定範圍內的所有元素的個數
  • ZINCRBY key increment member:讓sorted set中的指定元素自增,步長為指定的increment值
  • ZRANGE key min max:按照score排序後,獲取指定排名範圍內的元素
  • ZRANGEBYSCORE key min max:按照score排序後,獲取指定score範圍內的元素
  • ZDIFF.ZINTER.ZUNION:求差集.交集.並集

注意:所有的排名預設都是升序,如果要降序則在命令的Z後面新增REV即可,例如:

  • 升序獲取sorted set 中的指定元素的排名:ZRANK key member
  • 降序獲取sorted set 中的指定元素的排名:ZREVRANK key memeber

Stream命令

基於Stream的訊息佇列-消費者組

消費者組(Consumer Group):將多個消費者劃分到一個組中,監聽同一個佇列。具備下列特點:

1653577801668

  1. 建立消費者組
XGROUP CREATE key groupName ID [MKSTREAM]
  • key:佇列名稱
  • groupName:消費者組名稱
  • ID:起始ID標示,$代表佇列中最後一個訊息,0則代表佇列中第一個訊息
  • MKSTREAM:佇列不存在時自動建立佇列
  1. 刪除指定的消費者組
XGROUP DESTORY key groupName
  1. 給指定的消費者組新增消費者
XGROUP CREATECONSUMER key groupname consumername
  1. 刪除消費者組中的指定消費者
XGROUP DELCONSUMER key groupname consumername
  1. 從消費者組讀取訊息
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • group:消費組名稱

  • consumer:消費者名稱,如果消費者不存在,會自動建立一個消費者

  • count:本次查詢的最大數量

  • BLOCK milliseconds:當沒有訊息時最長等待時間

  • NOACK:無需手動ACK,獲取到訊息後自動確認

  • STREAMS key:指定佇列名稱

  • ID:獲取訊息的起始ID:

    • ">":從下一個未消費的訊息開始
    • 其它:根據指定id從pending-list中獲取已消費但未確認的訊息,例如0,是從pending-list中的第一個訊息開始

STREAM型別訊息佇列的XREADGROUP命令特點:

  • 訊息可回溯
  • 可以多消費者爭搶訊息,加快消費速度
  • 可以阻塞讀取
  • 沒有訊息漏讀的風險
  • 有訊息確認機制,保證訊息至少被消費一次

消費者監聽訊息的基本思路:

1653578211854

GEO命令

GEO就是Geolocation的簡寫形式,代表地理座標。Redis在3.2版本中加入了對GEO的支援,允許儲存地理座標資訊,幫助我們根據經緯度來檢索資料

常見的命令有:

  • GEOADD:新增一個地理空間資訊,包含:經度(longitude)、緯度(latitude)、值(member)
  • GEODIST:計算指定的兩個點之間的距離並返回
  • GEOHASH:將指定member的座標轉為hash字串形式並返回
  • GEOPOS:返回指定member的座標
  • GEORADIUS:指定圓心、半徑,找到該圓內包含的所有member,並按照與圓心之間的距離排序後返回。6.以後已廢棄
  • GEOSEARCH:在指定範圍內搜尋member,並按照與指定點之間的距離排序後返回。範圍可以是圓形或矩形。6.2.新功能
  • GEOSEARCHSTORE:與GEOSEARCH功能一致,不過可以把結果儲存到一個指定的key。 6.2.新功能

BitMap命令

bit:指的就是bite,二進位制,裡面的內容就是非0即1咯

map:就是說將適合使用0或1的業務進行關聯。如:1為簽到、0為未簽到,這樣就直接使用某bite就可表示出一個使用者一個月的簽到情況,減少記憶體花銷了

BitMap底層是基於String實現的,因此:在Java中BitMap相關的操作封裝到了redis的String操作中

BitMap的操作命令有:

  • SETBIT:向指定位置(offset)存入一個0或1
  • GETBIT :獲取指定位置(offset)的bit值
  • BITCOUNT :統計BitMap中值為1的bit位的數量
  • BITFIELD :操作(查詢、修改、自增)BitMap中bit陣列中的指定位置(offset)的值
  • BITFIELD_RO :獲取BitMap中bit陣列,並以十進位制形式返回
  • BITOP :將多個BitMap的結果做位運算(與 、或、異或)
  • BITPOS :查詢bit陣列中指定範圍內第一個0或1出現的位置

HyperLogLog 命令

UV:全稱Unique Visitor,也叫獨立訪客量,是指透過網際網路訪問、瀏覽這個網頁的自然人。1天內同一個使用者多次訪問該網站,只記錄1次

PV:全稱Page View,也叫頁面訪問量或點選量,使用者每訪問網站的一個頁面,記錄1次PV,使用者多次開啟頁面,則記錄多次PV。往往用來衡量網站的流量

Hyperloglog(HLL)是從Loglog演算法派生的機率演算法,用於確定非常大的集合的基數,而不需要儲存其所有值

相關演算法原理大家可以參考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基於string結構實現的,單個HLL的記憶體永遠小於16kb。作為代價,其測量結果是機率性的,有小於0.81%的誤差,同時此結構自帶去重

常用命令如下:目前也只有這些命令

image-20230820183246556

PubSub 釋出訂閱

PubSub(釋出訂閱)是Redis2.0版本引入的訊息傳遞模型。顧名思義,消費者可以訂閱一個或多個channel,生產者向對應channel傳送訊息後,所有訂閱者都能收到相關訊息

  • SUBSCRIBE channel [channel] :訂閱一個或多個頻道
  • PUBLISH channel msg :向一個頻道傳送訊息
  • PSUBSCRIBE pattern[pattern] :訂閱與pattern格式匹配的所有頻道。pattern支援的萬用字元如下:
?			表示 一個 字元			如:h?llo 則可以為 hallo、hxllo
*			表示 0個或N個 字元		   如:h*llo 則可以為 hllo、heeeello.........
[ae]		表示 是a或e都行			如:h[ae]llo 則可以為  hello、hallo

優點:

  • 採用釋出訂閱模型,支援多生產、多消費

缺點:

  • 不支援資料持久化
  • 無法避免訊息丟失
  • 訊息堆積有上限,超出時資料丟失

Java操作:Jedis

官網:https://redis.io/docs/clients/

其中Java客戶端也包含很多:

image-20220609102817435

標記為❤的就是推薦使用的Java客戶端,包括:

  • Jedis和Lettuce:這兩個主要是提供了“Redis命令對應的API”,方便我們操作Redis,而SpringDataRedis又對這兩種做了抽象和封裝
  • Redisson:是在Redis基礎上實現了分散式的可伸縮的Java資料結構,例如Map.Queue等,而且支援跨程式的同步機制:Lock.Semaphore等待,比較適合用來實現特殊的功能需求

入門Jedis

建立Maven專案

  1. 依賴
<!--jedis-->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.7.0</version>
</dependency>
  1. 測試:其他型別如Hash、Set、List、SortedSet和下面String是一樣的用法
package com.zixieqing.redis;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import redis.clients.jedis.Jedis;

/**
 * jedis操作redis:redis的命令就是jedis對應的API
 *
 * @author : ZiXieqing
 */

public class QuickStartTest {
    private Jedis jedis;

    @Before
    public void setUp() throws Exception {
        jedis = new Jedis("host", 6379);
        // 設定密碼
        jedis.auth("072413");
        // 設定庫
        jedis.select(0);
    }

    @After
    public void tearDown() throws Exception {
        if (null != jedis) jedis.close();
    }

    /**
     * String型別
     */
    @Test
    public void stringTest() {
        // 新增key-value
        String result = jedis.set("name", "zixieqing");
        System.out.println("result = " + result);

        // 透過key獲取value
        String value = jedis.get("name");
        System.out.println("value = " + value);

        // 批次新增或修改
        String mset = jedis.mset("age", "18", "sex", "girl");
        System.out.println("mset = " + mset);
        System.out.println("jedis.keys() = " + jedis.keys("*"));

        // 給key自增並指定步長
        long incrBy = jedis.incrBy("age", 5L);
        System.out.println("incrBy = " + incrBy);

        // 若key不存在,則新增,存在則不執行
        long setnx = jedis.setnx("city", "hangzhou");
        System.out.println("setnx = " + setnx);

        // 新增key-value,並指定有效期
        String setex = jedis.setex("job", 10L, "Java");
        System.out.println("setex = " + setex);

        // 獲取key的有效期
        long ttl = jedis.ttl("job");
        System.out.println("ttl = " + ttl);
    }
}

連線池

Jedis本身是執行緒不安全的,並且頻繁的建立和銷燬連線會有效能損耗,推薦使用Jedis連線池代替Jedis的直連方式

package com.zixieqing.redis.util;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.time.Duration;

/**
 * Jedis連線池
 *
 * @author : ZiXieqing
 */

public class JedisConnectionFactory {
    private static JedisPool jedisPool;

    static {
        // 設定連線池
        JedisPoolConfig PoolConfig = new JedisPoolConfig();
        PoolConfig.setMaxTotal(30);
        PoolConfig.setMaxIdle(30);
        PoolConfig.setMinIdle(0);
        PoolConfig.setMaxWait(Duration.ofSeconds(1));

        /*
            設定連結物件
            JedisPool(GenericObjectPoolConfig<Jedis> poolConfig, String host, int port, int timeout, String password)
         */
        jedisPool = new JedisPool(PoolConfig, "192.168.46.128", 6379, 1000, "072413");
    }

    public static Jedis getJedis() {
        return jedisPool.getResource();
    }
}

Java操作:SpringDataRedis

SpringData是Spring中資料操作的模組,包含對各種資料庫的整合,其中對Redis的整合模組就叫做SpringDataRedis

官網:https://spring.io/projects/spring-data-redis

  • 提供了對不同Redis客戶端的整合(Lettuce和Jedis)
  • 提供了RedisTemplate統一API來操作Redis
  • 支援Redis的釋出訂閱模型
  • 支援Redis哨兵和Redis叢集
  • 支援基於JDK.JSON、字串、Spring物件的資料序列化及反序列化
  • 支援基於Redis的JDKCollection實現

SpringDataRedis中提供了RedisTemplate工具類,其中封裝了各種對Redis的操作。並且將不同資料型別的操作API封裝到了不同的型別中:

1652976773295

入門SpringDataRedis

建立SpringBoot專案

  1. pom.xml配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.9.RELEASE</version>
        <relativePath/>
    </parent>

    <groupId>com.zixieqing</groupId>
    <artifactId>02-spring-data-redis</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>02-spring-data-redis</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>8</java.version>
    </properties>

    <dependencies>
        <!--redis依賴-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--common-pool-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--Jackson依賴-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
  1. YAML檔案配置
spring:
  redis:
    host: 192.168.46.128
    port: 6379
    password: "072413"
    jedis:
      pool:
        max-active: 100 # 最大連線數
        max-idle: 100 # 最大空閒數
        min-idle: 0 # 最小空閒數
        max-wait: 5 # 最大連結等待時間 單位:ms
  1. 測試:其他如Hash、List、Set、SortedSet的方法和下面String差不多
package com.zixieqing.springdataredis;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

@SpringBootTest(classes = App.class)
class ApplicationTests {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * SpringDataRedis操作redis:String型別  其他型別都是同理操作
     *
     * String:opsForValue
     * Hash:opsForHash
     * List:opsForList
     * Set:opsForSet
     * SortedSet:opsForZSet
     */
    @Test
    void stringTest() {
        // 新增key-value
        redisTemplate.opsForValue().set("name", "紫邪情");

        // 根據key獲取value
        String getName = Objects.requireNonNull(redisTemplate.opsForValue().get("name")).toString();
        System.out.println("getName = " + getName);

        // 新增key-value 並 指定有效期
        redisTemplate.opsForValue().set("job", "Java", 10L, TimeUnit.SECONDS);
        String getJob = Objects.requireNonNull(redisTemplate.opsForValue().get("job")).toString();
        System.out.println("getJob = " + getJob);

        // 就是 setnx 命令,key不存在則新增,存在則不執行
        redisTemplate.opsForValue().setIfAbsent("city", "杭州");
        redisTemplate.opsForValue().setIfAbsent("info", "臉皮厚,欠揍", 10L, TimeUnit.SECONDS);

        ArrayList<String> keys = new ArrayList<>();
        keys.add("name");
        keys.add("job");
        keys.add("city");
        keys.add("info");
        redisTemplate.delete(keys);
    }
}

資料序列化

RedisTemplate可以接收Object型別作為值寫入Redis:

只不過寫入前會把Object序列化為位元組形式,預設是採用JDK序列化,得到的結果是這樣的:

image-20230725220208536

缺點:

  • 可讀性差
  • 記憶體佔用較大

Jackson序列化

我們可以自定義RedisTemplate的序列化方式,程式碼如下:

package com.zixieqing.springdataredis.config;

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.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;

/**
 * redis自定義序列化方式
 *
 * @author : ZiXieqing
 */

@Configuration
public class RedisSerializeConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
        // 建立RedisTemplate物件
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 設定連線工廠
        template.setConnectionFactory(connectionFactory);
        // 建立JSON序列化工具
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        // 設定Key的序列化
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        // 設定Value的序列化
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);
        // 返回
        return template;
    }
}

這裡採用了JSON序列化來代替預設的JDK序列化方式。最終結果如圖:

image-20230725221131734

整體可讀性有了很大提升,並且能將Java物件自動的序列化為JSON字串,並且查詢時能自動把JSON反序列化為Java物件

不過,其中記錄了序列化時對應的class名稱,目的是為了查詢時實現自動反序列化。這會帶來額外的記憶體開銷。

StringRedisTemplate

儘管JSON的序列化方式可以滿足我們的需求,但依然存在一些問題

image-20230725221320439

為了在反序列化時知道物件的型別,JSON序列化器會將類的class型別寫入json結果中,存入Redis,會帶來額外的記憶體開銷。

為了減少記憶體的消耗,我們可以採用手動序列化的方式,換句話說,就是不借助預設的序列化器,而是我們自己來控制序列化的動作,同時,我們只採用String的序列化器,這樣,在儲存value時,我們就不需要在記憶體中多儲存資料,從而節約我們的記憶體空間

1653054744832

這種用法比較普遍,因此SpringDataRedis就提供了RedisTemplate的子類:StringRedisTemplate,它的key和value的序列化方式預設就是String方式

image-20230725224017233

image-20230725223754743

使用示例:

package com.zixieqing.springdataredis;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zixieqing.springdataredis.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

@Slf4j
@SpringBootTest(classes = App.class)
class ApplicationTests {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // 是jackson中的
    private final ObjectMapper mapper = new ObjectMapper();

    /**
     * 使用StringRedisTemplate操作Redis 和 序列化與反序列化
     *
     * 操作redis和String型別一樣的
     */
    @Test
    void serializeTest() throws JsonProcessingException {
        User user = new User();
        user.setName("zixieqing")
                .setJob("Java");

        // 序列化
        String userStr = mapper.writeValueAsString(user);
        stringRedisTemplate.opsForValue().set("com:zixieqing:springdataredis:user", userStr);

        // 反序列化
        String userStr2 = stringRedisTemplate.opsForValue().get("com:zixieqing:springdataredis:user");
        User user2 = mapper.readValue(userStr2, User.class);

        log.info("反序列化結果:{}", user2);
    }
}

image-20230725225419447

快取更新策略

快取更新是redis為了節約記憶體而設計出來的一個東西,主要是因為記憶體資料寶貴,當我們向redis插入太多資料,此時就可能會導致快取中的資料過多,所以redis會對部分資料進行淘汰

image-20230729132118455

記憶體淘汰:redis自動進行,當redis記憶體達到我們們設定的max-memery的時候,會自動觸發淘汰機制,淘汰掉一些不重要的資料(可以自己設定策略方式)

超時剔除:當我們給redis設定了過期時間ttl之後,redis會將超時的資料進行刪除,方便我們們繼續使用快取

主動更新:我們可以手動呼叫方法把快取刪掉,通常用於解決快取和資料庫不一致問題

業務場景:先說結論,後面分析這些結論是怎麼來的

  1. 低一致性需求:使用Redis自帶的記憶體淘汰機制
  2. 高一致性需求:主動更新,並以超時剔除作為兜底方案
    • 讀操作:
      • 快取命中則直接返回
      • 快取未命中則查詢資料庫,並寫入快取,設定超時時間
    • 寫操作:
      • 先寫資料庫,然後再刪除快取
      • 要確保資料庫與快取操作的原子性(單體系統寫庫操作和刪除快取操作放入一個事務;分散式系統使用分散式事務管理這二者)

主動更新策略:資料庫與快取不一致問題

由於我們的快取的資料來源來自於資料庫,而資料庫的資料是會發生變化的。因此,如果當資料庫中資料發生變化,而快取卻沒有同步,此時就會有一致性問題存在,其後果是:

使用者使用快取中的過時資料,就會產生類似多執行緒資料安全問題,從而影響業務,產品口碑等;怎麼解決呢?有如下幾種方案

image-20230729133340137

Cache Aside Pattern 人工編碼方式:快取呼叫者在更新完資料庫後再去更新快取,也稱之為雙寫方案。這種由我們自己編寫,所以可控,因此此種方式勝出

Read/Write Through Pattern : 由系統本身完成,資料庫與快取的問題交由系統本身去處理

Write Behind Caching Pattern :呼叫者只操作快取,其他執行緒去非同步處理資料庫,實現最終一致

Cache Aside 人工編碼 解決資料庫與快取不一致

由上一節知道資料庫與快取不一致的解決方案是 Cache Aside 人工編碼,但是這個玩意兒需要考慮幾個問題:

  1. 刪除快取還是更新快取?

    • 更新快取:每次更新資料庫都更新快取,無效寫操作較多

    • 刪除快取:更新資料庫時讓快取失效,查詢時再更新快取(勝出)

  2. 如何保證快取與資料庫的操作的同時成功或失敗?

    • 單體系統,將快取與資料庫操作放在一個事務
    • 分散式系統,利用TCC等分散式事務方案
  3. 先操作快取還是先運算元據庫?

    • 先刪除快取,再運算元據庫

    • 先運算元據庫,再刪除快取(勝出)

為什麼是先運算元據庫,再刪除快取?

運算元據庫和操作快取在“序列”情況下沒什麼太大區別,問題不大,但是:在“併發”情況下,二者就有區別,就會產生資料庫與快取資料不一致的問題

先看“先刪除快取,再運算元據庫”:

先刪快取再更新資料庫

再看“先運算元據庫,再刪除快取”:redis操作幾乎是微秒級,所以下圖執行緒1會很快完成,然後執行緒2業務一般都慢一點,所以快取中能極快地更新成資料庫中的最新資料,因此這種方式雖也會發生資料不一致,但機率很小(資料庫操作一般不會在微秒級別內完成)

先運算元據庫,再刪除快取

因此:勝出的是“先運算元據庫,再刪除快取”

1653323595206

快取穿透及解決方式

快取穿透:指客戶端請求的資料在快取中和資料庫中都不存在。這樣快取永遠不會生效,這些請求都會打到資料庫

場景:如別人模仿id,然後發起大量請求,而這些id對應的資料redis中沒有,然後全跑去查庫,資料庫壓力就會增大,導致資料庫扛不住而崩掉

解決方式

  1. 快取空物件:就是快取和資料庫中都沒有時,直接放個空物件到快取中,並設定有效期即可

    • 優點:實現簡單,維護方便

    • 缺點:

      • 額外的記憶體消耗
      • 可能造成短期的不一致。一開始redis和資料庫都沒有,後面新增了資料,而此資料的id可能恰好對上,這樣redis中存的這id的資料還是空物件
  2. 布隆過濾:採用的是雜湊思想來解決這個問題,透過一個龐大的二進位制陣列,用雜湊思想去判斷當前這個要查詢的資料是否存在,如果布隆過濾器判斷存在,則放行,這個請求會去訪問redis,哪怕此時redis中的資料過期了,但是資料庫中一定存在這個資料,在資料庫中查詢出來這個資料後,再將其放入到redis中,假設布隆過濾器判斷這個資料不存在,則直接返回

    • 優點:記憶體佔用較少,沒有多餘key

    • 缺點:

      • 實現複雜

      • 存在誤判可能。布隆過濾器判斷存在,可資料庫中不一定真存在,因它採用的是雜湊演算法,就會產生雜湊衝突

  3. 增加主鍵id的複雜度,從而提前做好基礎資料校驗

  4. 做使用者許可權認證

  5. 做熱點引數限流

空物件和布隆過濾的架構如下:左為空物件,右為布隆過濾

1653326156516

快取空物件示例:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result findShopById(Long id) {
        String cacheKey = CACHE_SHOP_KEY + id;
        // 查 redis
        Map<Object, Object> shopMap = stringRedisTemplate.opsForHash().entries(cacheKey);
        // 有則返回 同時需要看是否命中的是:空物件
        if (!shopMap.isEmpty()) {
            return Result.ok(JSONUtil.toJsonStr(shopMap));
        }

        // 無則查庫
        Shop shop = getById(id);
        // 庫中無
        if (null == shop) {
            // 向 redis 中放入 空物件,且設定有效期
            Map<String, String> hashMap = new HashMap<>(16);
            hashMap.put("", "");
            stringRedisTemplate.opsForHash().putAll(cacheKey, hashMap);
            // CACHE_NULL_TTL = 2L
            stringRedisTemplate.expire(cacheKey, CACHE_NULL_TTL, TimeUnit.MINUTES);
            
            return Result.fail("商鋪不存在");
        }
        // 庫中有	BeanUtil 使用的是hutool工具
        // 這步意思:因為Shop例項類中欄位型別不是均為String,因此需要將欄位值轉成String,否則存入Redis時會發生 造型異常
        Map<String, Object> shopMapData = BeanUtil.beanToMap(shop, new HashMap<>(16),
                CopyOptions.create()
                        .ignoreNullValue()
                        .setIgnoreError(false)
                        .setFieldValueEditor((filedKey, filedValue) -> filedValue = filedValue + "")
        );
        // 寫入 redis
        stringRedisTemplate.opsForHash().putAll(cacheKey, shopMapData);
        // 設定有效期    CACHE_SHOP_TTL = 30L
        stringRedisTemplate.expire(cacheKey, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 返回客戶端
        return Result.ok(JSONUtil.toJsonStr(shop));
    }
}

快取雪崩及解決方式

快取雪崩:指在同一時段大量的快取key同時失效 或 Redis服務當機,導致大量請求到達資料庫,帶來巨大壓力

1653327884526

解決方案

  1. 給不同的Key的TTL新增隨機值
  2. 利用Redis叢集提高服務的可用性
  3. 給快取業務新增降級限流策略
  4. 給業務新增多級快取

快取擊穿及解決方式

快取擊穿問題也叫熱點Key問題,就是一個被高併發訪問並且快取重建業務較複雜的key突然失效了,無數的請求訪問會在瞬間給資料庫帶來巨大的衝擊

1653328022622

常見的解決方案有兩種:

  1. 互斥鎖
  2. 邏輯過期

1653357522914

互斥鎖 - 保一致

互斥鎖:保一致性,會讓執行緒阻塞,有死鎖風險

本質:利用了String的setnx指令;key不存在則新增,存在則不操作

1653328288627

示例:下列邏輯該封裝則封裝即可

public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShopById(Long id) {
        String cacheKey = CACHE_SHOP_KEY + id;
        // 查 redis
        Map<Object, Object> shopMap = stringRedisTemplate.opsForHash().entries(cacheKey);

        // redis 中有責返回
        if (!shopMap.isEmpty()) {
            Shop shop = BeanUtil.fillBeanWithMap(shopMap, new Shop(), false);
            return Result.ok(shop);
        }

        Shop shop = null;

        try {
            // 無則獲取 互斥鎖
            Boolean res = stringRedisTemplate
                    .opsForValue()
                    .setIfAbsent(LOCK_SHOP_KEY + id, UUID.randomUUID().toString(true), LOCK_SHOP_TTL, TimeUnit.MINUTES);
            boolean flag = BooleanUtil.isTrue(res);

            // 獲取失敗則等一會兒再試
            if (!flag) {
                Thread.sleep(20);
                return queryShopById(id);
            }

            // 獲取鎖成功則查 redis 此時有沒有,從而減少快取重建
            Map<Object, Object> shopMa = stringRedisTemplate.opsForHash().entries(cacheKey);

            // redis 中有責返回
            if (!shopMa.isEmpty()) {
                shop = BeanUtil.fillBeanWithMap(shopMa, new Shop(), false);
                return Result.ok(shop);
            }
            // 有則返回,無則查庫
            shop = getById(id);

            // 庫中無
            if (null == shop) {
                // 向 redis放入 空值,並設定有效期
                Map<String, String> hashMap = new HashMap<>(16);
                hashMap.put("", "");
                stringRedisTemplate.opsForHash().putAll(cacheKey, hashMap);
                stringRedisTemplate.expire(cacheKey, 2L, TimeUnit.MINUTES);

                return Result.fail("無此資料");
            }

            // 庫中有則寫入 redis,並設定有效期
            Map<String, Object> sMap = BeanUtil.beanToMap(shop, new HashMap<>(16),
                    CopyOptions.create()
                            .ignoreNullValue()
                            .setIgnoreError(false)
                            .setFieldValueEditor((filedKey, filedValue) -> filedValue = filedValue + "")
            );
            stringRedisTemplate.opsForHash().putAll(cacheKey, sMap);
            stringRedisTemplate.expire(cacheKey, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 釋放鎖
            stringRedisTemplate.delete(LOCK_SHOP_KEY + id);
        }

        //返回客戶端
        return Result.ok(shop);
    }
}

邏輯過期 - 保效能

這玩意兒在互斥鎖的基礎上再變動一下即可

邏輯過期:不保一致性,效能好,有額外記憶體消耗,會造成短暫的資料不一致

本質:資料不過期,一直在Redis中,只是程式設計師自己使用過期欄位和當前時間來判定是否過期,過期則獲取“互斥鎖”,獲取鎖成功(此時可以再判斷一下Redis中的資料是否過期,減少快取重建),則開執行緒重建快取即可

image-20230806173104369

示例:

@Data
@Accessors(chain = true)
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService EXECUTORS = Executors.newFixedThreadPool(10);

    @Override
    public Result queryShopById(Long id) {
        // 使用互斥鎖解決 快取擊穿
        // return cacheBreakDownWithMutex(id);

        String cacheKey = CACHE_SHOP_KEY + id;

        // 查 redis
        String shopJson = stringRedisTemplate.opsForValue().get(cacheKey);

        // redis 中沒有則報錯(理論上是一直存在redis中的,邏輯過期而已,所以這一步不用判斷都可以)
        if (StrUtil.isBlank(shopJson)) {
            return Result.fail("無此資料");
        }

        // redis 中有,則看是否過期
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        // 沒過期,直接返回資料
        if (expireTime.isAfter(LocalDateTime.now())) {
            return Result.ok(shop);
        }

        try {
            // 獲取互斥鎖    LOCK_SHOP_TTL = 10L
            Boolean res = stringRedisTemplate
                    .opsForValue()
                    .setIfAbsent(LOCK_SHOP_KEY + id, UUID.randomUUID().toString(true),
                            LOCK_SHOP_TTL, TimeUnit.SECONDS);
            boolean flag = BooleanUtil.isTrue(res);
            // 獲取鎖失敗則眯一會兒再嘗試
            if (!flag) {
                Thread.sleep(20);
                return queryShopById(id);
            }
            // 獲取鎖成功
            // 再看 redis 中的資料是否過期,減少快取重建
            shopJson = stringRedisTemplate.opsForValue().get(cacheKey);
            redisData = JSONUtil.toBean(shopJson, RedisData.class);
            expireTime = redisData.getExpireTime();
            shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
            // 已過期
            if (expireTime.isBefore(LocalDateTime.now())) {
                EXECUTORS.submit(() -> {
                    // 重建快取
                    this.buildCache(id, 20L);
                });
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 釋放鎖
            stringRedisTemplate.delete(LOCK_SHOP_KEY + id);
        }

        // 返回客戶端
        return Result.ok(shop);
    }

    
    /**
     * 重建快取
     */
    public void buildCache(Long id, Long expireTime) {
        String key = LOCK_SHOP_KEY + id;
        // 重建快取
        Shop shop = getById(id);
        if (null == shop) {
            // 庫中沒有則放入 空物件
            stringRedisTemplate.opsForValue().set(key, "", 10L, TimeUnit.SECONDS);
        }

        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime))
                .setData(shop);

        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }
}

簡單認識Lua指令碼

Redis提供了Lua指令碼功能,在一個指令碼中編寫多條Redis命令,確保多條命令執行時的原子性

基本語法可以參考網站:https://www.runoob.com/lua/lua-tutorial.html

Redis提供的呼叫函式,語法如下:

redis.call('命令名稱', 'key', '其它引數', ...)

如:先執行set name Rose,再執行get name,則指令碼如下:

# 先執行 set name jack
redis.call('set', 'name', 'Rose')
# 再執行 get name
local name = redis.call('get', 'name')
# 返回
return name

寫好指令碼以後,需要用Redis命令來呼叫指令碼,呼叫指令碼的常見命令如下:

1653392181413

例如,我們要執行 redis.call('set', 'name', 'jack') 這個指令碼,語法如下:

1653392218531

如果指令碼中的key、value不想寫死,可以作為引數傳遞

key型別引數會放入KEYS陣列,其它引數會放入ARGV陣列,在指令碼中可以從KEYS和ARGV陣列獲取這些引數:

1653392438917

Java+Redis呼叫Lua指令碼

RedisTemplate中,可以利用execute方法去執行lua指令碼,引數對應關係就如下圖股

1653393304844

示例:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        // 搞出指令碼物件	DefaultRedisScript是RedisTemplate的實現類
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        // 指令碼在哪個旮旯地方
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        // 返回值型別
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

public void unlock() {
    // 呼叫lua指令碼
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,	// lua指令碼
            Collections.singletonList(KEY_PREFIX + name),	// 對應key引數的數值:KEYS陣列
            ID_PREFIX + Thread.currentThread().getId());	// 對應其他引數的數值:ARGV陣列
}

Redisson

官網地址: https://redisson.org

GitHub地址: https://github.com/redisson/redisson

分散式鎖需要解決幾個問題:而下圖的問題可以透過Redisson這個現有框架解決

1653546070602

Redisson是一個在Redis的基礎上實現的Java駐記憶體資料網格(In-Memory Data Grid)。它不僅提供了一系列的分散式的Java常用物件,還提供了許多分散式服務,其中就包含了各種分散式鎖的實現

1653546736063

使用Redisson

  1. 依賴2
<!-- 基本 -->
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.6</version>
</dependency>


<!-- Spring Boot整合的依賴 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.13.6</version>
</dependency>
  1. 建立redisson客戶端

YAML配置:常用引數戳這裡

spring:
  application:
    name: springboot-redisson
  redis:
    redisson:
      config: |
        singleServerConfig:
          password: "redis服務密碼"
          address: "redis:/redis服務ip:6379"
          database: 1
        threads: 0
        nettyThreads: 0
        codec: !<org.redisson.codec.FstCodec> {}
        transportMode: "NIO"

程式碼配置

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.redisson.config.TransportMode;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        config.setTransportMode(TransportMode.NIO);
        // 新增redis地址,這裡新增了單點的地址,也可以使用 config.useClusterServers() 新增叢集地址
        config.useSingleServer()
            .setAddress("redis://redis服務ip:6379")
            .setPassword("redis密碼");
        
        // 建立RedissonClient物件
        return Redisson.create(config);
    }
}
  1. 使用redisson客戶端
@Resource
private RedissionClient redissonClient;

@Test
void testRedisson() throws Exception{
    // 獲取鎖(可重入),指定鎖的名稱
    RLock lock = redissonClient.getLock("lockName");
    /*
     * 嘗試獲取鎖
     *
     * 引數分別是:獲取鎖的最大等待時間(期間會重試),鎖自動釋放時間,時間單位
     */
    boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
    // 判斷獲取鎖成功
    if(isLock){
        try{
            System.out.println("執行業務");          
        }finally{
            //釋放鎖
            lock.unlock();
        }
        
    }
}

Redisson 可重入鎖原理

  1. 採用Hash結構
  2. key為鎖名
  3. field為執行緒標識
  4. value就是一個計數器count,同一個執行緒再來獲取鎖就讓此值 +1,同執行緒釋放一次鎖此值 -1
    • PS:Java中使用的是state,C語言中用的是count,作用差不多

原始碼在:lock.tryLock(waitTime, leaseTime, TimeUnit)中,leaseTime這個引數涉及到WatchDog機制,所以可以直接看 lock.tryLock(waitTime, TimeUnit) 這個的原始碼

1653548087334

核心點在裡面的lua指令碼中:

"if (redis.call('exists', KEYS[1]) == 0) then " +	-- KEYS[1] : 鎖名稱		判斷鎖是否存在
        -- ARGV[2] = id + ":" + threadId		鎖的小key	充當 field
        "redis.call('hset', KEYS[1], ARGV[2], 1); " +	-- 當前這把鎖不存在則新增鎖,value=count=1 是hash結構
        "redis.call('pexpire', KEYS[1], ARGV[1]); " +	-- 並給此鎖設定有效期
        "return nil; " +	-- 獲取鎖成功,返回nil,即:null
"end; " +

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +	-- 判斷 key+field 是否存在。即:判斷是否是同一執行緒來獲取鎖
    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +	-- 是自己,讓value +1
    "redis.call('pexpire', KEYS[1], ARGV[1]); " +		-- 給鎖重置有效期
    "return nil; " +	-- 成功,返回nil,即:null
"end; " +

"return redis.call('pttl', KEYS[1]);"	-- 獲取鎖失敗(含失效),返回鎖的TTL有效期

Redission 鎖重試 和 WatchDog機制

看原始碼時選擇:RedissonLock

鎖重試

這裡的 tryLock(long waitTime, long leaseTime, TimeUnit unit)選擇的是帶參的,無參的 tryLock()是,預設不會重試的

public class RedissonLock extends RedissonExpirable implements RLock {
 
    @Override
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        // 將 waitTime 最大等待時間轉成 毫秒
        long time = unit.toMillis(waitTime);
        // 獲取此時的毫秒值
        long current = System.currentTimeMillis();
        // 獲取當前執行緒ID
        long threadId = Thread.currentThread().getId();
        // 搶鎖邏輯:涉及到WatchDog,待會兒再看
        Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
        // lock acquired	表示在上一步 tryAcquire() 中搶鎖成功
        if (ttl == null) {
            return true;
        }
        
        // 有獲取當前時間
        time -= System.currentTimeMillis() - current;
        // 看執行上面的邏輯之後,是否超出了waitTime最大等待時間
        if (time <= 0) {
            // 超出waitTime最大等待時間,則獲取鎖失敗
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
        // 再精確時間,看經過上面邏輯之後,是否超出waitTime最大等待時間
        current = System.currentTimeMillis();
        // 訂閱	釋出邏輯是在 lock.unLock() 的邏輯中,裡面有一個lua指令碼,使用了 publiser 命令
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
        // 若訂閱在waitTime最大等待時間內未完成,即超出waitTime最大等待時間
        if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
            // 同時訂閱也未取消
            if (!subscribeFuture.cancel(false)) {
                subscribeFuture.onComplete((res, e) -> {
                    if (e == null) {
                        // 則取消訂閱
                        unsubscribe(subscribeFuture, threadId);
                    }
                });
            }
            // 訂閱在waitTime最大等待時間內未完成,則獲取鎖失敗
            acquireFailed(waitTime, unit, threadId);
            return false;
        }

        try {
            // 繼續精確時間,經過上面邏輯之後,是否超出waitTime最大等待時間
            time -= System.currentTimeMillis() - current;
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false;
            }
        
            // 還在waitTime最大等待時間內		這裡面就是重試的邏輯
            while (true) {
                long currentTime = System.currentTimeMillis();
                // 搶鎖
                ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
                // lock acquired	獲取鎖成功
                if (ttl == null) {
                    return true;
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {	// 經過上面邏輯之後,時間已超出waitTime最大等待時間,則獲取鎖失敗
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }

                // waiting for message
                currentTime = System.currentTimeMillis();
                // 未失效 且 失效期還在 waitTime最大等待時間 以內
                if (ttl >= 0 && ttl < time) {
                    // 同時該程式也未被中斷,則透過該訊號量繼續獲取鎖
                    subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    // 否則處於任務排程的目的,從而禁用當前執行緒,讓其處於休眠狀態
                    // 除非其他執行緒呼叫當前執行緒的 release方法 或 當前執行緒被中斷 或 waitTime已過
                    subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    // 還未獲取鎖成功,那就真的是獲取鎖失敗了
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }
            }
        } finally {
            // 取消訂閱
            unsubscribe(subscribeFuture, threadId);
        }
//        return get(tryLockAsync(waitTime, leaseTime, unit));
    }
}

上面說到“訂閱”,“釋出”的邏輯需要進入:lock.unlock();,和前面說的一樣,選擇:RedissonLock

import org.junit.jupiter.api.Test;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@SpringBootTest
class ApplicationTests {
    @Resource
    private RedissonClient redissonClient;

    /**
     * 虛擬碼
     */
    @Test
    void buildCache() throws InterruptedException {
        // 獲取 鎖名字
        RLock lock = redissonClient.getLock("");
        // 獲取鎖
        boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
        // 釋放鎖
        lock.unlock();
    }
}
public class RedissonLock extends RedissonExpirable implements RLock {
 
	protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        
        return evalWriteAsync(
            getName(), 
            LongCodec.INSTANCE, 
            RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
            	"return nil;" +
            "end; " +
            
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            
            "if (counter > 0) then " +
            	"redis.call('pexpire', KEYS[1], ARGV[2]); " +
            	"return 0; " +
            "else " +
            	"redis.call('del', KEYS[1]); " +
            	"redis.call('publish', KEYS[2], ARGV[1]); " +	// 釋出,從而在上面的 重試 中進行訂閱
            	"return 1; " +
            "end; " +
            
            "return nil;",
            Arrays.asList(
                	getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, 
            		internalLockLeaseTime, getLockName(threadId)
        	);
    }
}

WatchDog 機制

上一節中有如下的程式碼:進入 tryAcquire()

// 搶鎖邏輯:涉及到WatchDog,待會兒再看
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
public class RedissonLock extends RedissonExpirable implements RLock {

    private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
    }



    /**
     * 非同步獲取鎖
     */
    private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        // leaseTime到期時間 等於 -1 否,此值決定著是否開啟watchDog機制
        if (leaseTime != -1) {
            return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        }

        RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
            waitTime, 
            /*
             * getLockWatchdogTimeout() 就是獲取 watchDog 時間,即:
             * 		private long lockWatchdogTimeout = 30 * 1000;
             */
            commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
            TimeUnit.MILLISECONDS, 
            threadId, 
            RedisCommands.EVAL_LONG
        );

        // 上一步ttlRemainingFuture非同步執行完時
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            // 若出現異常了,則說明獲取鎖失敗,直接滾犢子了
            if (e != null) {
                return;
            }

            // lock acquired	獲取鎖成功
            if (ttlRemaining == null) {
                // 過期了,則重置到期時間,進入這個方法瞄一下
                scheduleExpirationRenewal(threadId);
            }
        });
        return ttlRemainingFuture;
    }
    
    
    
    /**
     * 重新續約到期時間
     */
	private void scheduleExpirationRenewal(long threadId) {
        ExpirationEntry entry = new ExpirationEntry();
        /*
         * private static final ConcurrentMap<String, ExpirationEntry> EXPIRATION_RENEWAL_MAP = new ConcurrentHashMap<>();
         *
         * getEntryName() 就是 this.entryName = id + ":" + name
         * 			而 this.id = commandExecutor.getConnectionManager().getId()
         *
         * putIfAbsent() key未有value值則進行關聯,相當於:
         *		 if (!map.containsKey(key))
         *			return map.put(key, value);
         *		 else
         *			return map.get(key);
         */
        ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
        if (oldEntry != null) {
            oldEntry.addThreadId(threadId);
        } else {
            entry.addThreadId(threadId);
            // 續訂到期		續約邏輯就在這裡面
            renewExpiration();
        }
    }
    
    
    
    /**
     * 續約
     */
	private void renewExpiration() {
        ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ee == null) {
            return;
        }
        
        /*
         * 這裡 newTimeout(new TimerTask(), 引數2, 引數3) 指的是:引數2,引數3去描述什麼時候去做引數1的事情
         * 這裡的引數2:internalLockLeaseTime / 3 就是前面的 lockWatchdogTimeout = (30 * 1000) / 3 = 1000ms = 10s
         * 
         * 鎖的失效時間是30s,當10s之後,此時這個timeTask 就觸發了,它就去進行續約,把當前這把鎖續約成30s,
         * 如果操作成功,那麼此時就會遞迴呼叫自己,再重新設定一個timeTask(),於是再過10s後又再設定一個timerTask,
         * 完成不停的續約
         */
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
                if (ent == null) {
                    return;
                }
                Long threadId = ent.getFirstThreadId();
                if (threadId == null) {
                    return;
                }
                
                // renewExpirationAsync() 裡面就是一個lua指令碼,指令碼中使用 pexpire 指令重置失效時間,
                // pexpire 此指令是以 毫秒 進行,expire是以 秒 進行
                RFuture<Boolean> future = renewExpirationAsync(threadId);
                future.onComplete((res, e) -> {
                    if (e != null) {
                        log.error("Can't update lock " + getName() + " expiration", e);
                        return;
                    }
                    
                    if (res) {
                        // reschedule itself	遞迴此方法,從而完成不停的續約
                        renewExpiration();
                    }
                });
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
        
        ee.setTimeout(task);
    }
}

Redis持久化

分為兩種:RDB和AOF

image-20210725151940515

Redis的持久化雖然可以保證資料安全,但也會帶來很多額外的開銷,因此持久化請遵循下列建議:

  • 用來做快取的Redis例項儘量不要開啟持久化功能
  • 建議關閉RDB持久化功能,使用AOF持久化
  • 利用指令碼定期在slave節點做RDB,實現資料備份
  • 設定合理的rewrite閾值,避免頻繁的bgrewrite
  • [不是絕對,依情況而行]配置no-appendfsync-on-rewrite = yes,禁止在rewrite / fork期間做aof,避免因AOF引起的阻塞

部署有關建議:

  • Redis例項的物理機要預留足夠記憶體,應對fork和rewrite
  • 單個Redis例項記憶體上限不要太大,如4G或8G。可以加快fork的速度、減少主從同步、資料遷移壓力
  • 不要與CPU密集型應用部署在一起。如:一臺虛擬機器中部署了很多應用
  • 不要與高硬碟負載應用一起部署。例如:資料庫、訊息佇列.........,這些應用會不斷進行磁碟IO

RDB 持久化

RDB全稱Redis Database Backup file(Redis資料備份檔案),也被叫做Redis資料快照。裡面的內容是二進位制。簡單來說就是把記憶體中的所有資料都記錄到磁碟中。當Redis例項故障重啟後,從磁碟讀取快照檔案,恢復資料。快照檔案稱為RDB檔案,預設是儲存在當前執行目錄(redis.conf的dir配置)

RDB持久化在四種情況下會執行::

  1. save命令:save命令會導致主程式執行RDB,這個過程中其它所有命令都會被阻塞。只有在資料遷移時可能用到。執行下面的命令,可以立即執行一次RDB

image-20230821234529114

  1. bgsave命令:這個命令執行後會開啟獨立程式完成RDB,主程式可以持續處理使用者請求,不受影響。下面的命令可以非同步執行RDB

image-20230821234739013

  1. Redis停機時:Redis停機時會執行一次save命令,實現RDB持久化

image-20230821235227233

  1. 觸發RDB條件:redis.conf檔案中配置的條件滿足時就會觸發RDB,如下列樣式
# 900秒內,如果至少有1個key被修改,則執行bgsave,如果是save "" 則表示禁用RDB
save 900 1  
save 300 10  
save 60 10000 

RDB的其它配置也可以在redis.conf檔案中設定:

# 是否壓縮 ,建議不開啟,壓縮也會消耗cpu,磁碟的話不值錢
rdbcompression yes

# RDB檔名稱
dbfilename dump.rdb  

# 檔案儲存的路徑目錄
dir ./ 

RDB的原理

RDB方式bgsave的基本流程?

  • fork主程式得到一個子程式,共享記憶體空間
  • 子程式讀取記憶體資料並寫入新的RDB檔案
  • 用新RDB檔案替換舊的RDB檔案

fork採用的是copy-on-write技術:

  • 當主程式執行讀操作時,訪問共享記憶體;
  • 當主程式執行寫操作時,則會複製一份資料,執行寫操作。

image-20210725151319695

所以1可以得知:RDB的缺點

  • RDB執行間隔時間長,兩次RDB之間寫入資料有丟失的風險
  • fork子程式、壓縮、寫出RDB檔案都比較耗時

AOF 持久化

AOF全稱為Append Only File(追加檔案)。Redis處理的每一個寫命令都會記錄在AOF檔案,可以看做是命令日誌檔案

image-20210725151543640

AOF預設是關閉的,需要修改redis.conf配置檔案來開啟AOF:

# 是否開啟AOF功能,預設是no
appendonly yes
# AOF檔案的名稱
appendfilename "appendonly.aof"

AOF的命令記錄的頻率也可以透過redis.conf檔案來配:

# 表示每執行一次寫命令,立即記錄到AOF檔案
appendfsync always 
# 寫命令執行完先放入AOF緩衝區,然後表示每隔1秒將緩衝區資料寫到AOF檔案,是預設方案
appendfsync everysec 
# 寫命令執行完先放入AOF緩衝區,由作業系統決定何時將緩衝區內容寫回磁碟
appendfsync no

三種策略對比:

image-20210725151654046

AOF檔案重寫

因為是記錄命令,AOF檔案會比RDB檔案大的多。而且AOF會記錄對同一個key的多次寫操作,但只有最後一次寫操作才有意義。透過執行bgrewriteaof命令,可以讓AOF檔案執行重寫功能,用最少的命令達到相同效果。

image-20210725151729118

Redis也會在觸發閾值時自動去重寫AOF檔案。閾值也可以在redis.conf中配置:

# AOF檔案比上次檔案 增長超過多少百分比則觸發重寫
auto-aof-rewrite-percentage 100
# AOF檔案體積最小多大以上才觸發重寫 
auto-aof-rewrite-min-size 64mb 

主從資料同步原理

全量同步

完整流程描述:先說完整流程,然後再說怎麼來的

  • slave節點請求增量同步
  • master節點判斷replid,發現不一致,拒絕增量同步
  • master將完整記憶體資料生成RDB,傳送RDB到slave
  • slave清空本地資料,載入master的RDB
  • master將RDB期間的命令記錄在repl_baklog,並持續將log中的命令傳送給slave
  • slave執行接收到的命令,保持與master之間的同步

主從第一次建立連線時,會執行全量同步,將master節點的所有資料都複製給slave節點,流程:

image-20210725152222497

master如何得知salve是第一次來連線??

有兩個概念,可以作為判斷依據:

  • Replication Id:簡稱replid,是資料集的標記,id一致則說明是同一資料集。每一個master都有唯一的replid,slave則會繼承master節點的replid
  • offset:偏移量,隨著記錄在repl_baklog中的資料增多而逐漸增大。slave完成同步時也會記錄當前同步的offset。如果slave的offset小於master的offset,說明slave資料落後於master,需要更新

因此slave做資料同步,必須向master宣告自己的replication id 和 offset,master才可以判斷到底需要同步哪些資料

因為slave原本也是一個master,有自己的replid和offset,當第一次變成slave與master建立連線時,傳送的replid和offset是自己的replid和offset

master判斷發現slave傳送來的replid與自己的不一致,說明這是一個全新的slave,就知道要做全量同步了

master會將自己的replid和offset都傳送給這個slave,slave儲存這些資訊。以後slave的replid就與master一致了。

因此,master判斷一個節點是否是第一次同步的依據,就是看replid是否一致

如圖:

image-20210725152700914

增量同步

全量同步需要先做RDB,然後將RDB檔案透過網路傳輸個slave,成本太高了。因此除了第一次做全量同步,其它大多數時候slave與master都是做增量同步

增量同步:就是隻更新slave與master存在差異的部分資料

image-20210725153201086

這種方式會出現失效的情況:原因就在repl_baklog中

repl_baklog原理

master怎麼知道slave與自己的資料差異在哪裡呢?

這就要說到全量同步時的repl_baklog檔案了。

這個檔案是一個固定大小的陣列,只不過陣列是環形,也就是說角標到達陣列末尾後,會再次從0開始讀寫,這樣陣列頭部的資料就會被覆蓋。

repl_baklog中會記錄Redis處理過的命令日誌及offset,包括master當前的offset,和slave已經複製到的offset:

image-20210725153359022

slave與master的offset之間的差異,就是salve需要增量複製的資料了。

隨著不斷有資料寫入,master的offset逐漸變大,slave也不斷的複製,追趕master的offset:

image-20210725153524190

直到陣列被填滿:

image-20210725153715910

此時,如果有新的資料寫入,就會覆蓋陣列中的舊資料。不過,舊的資料只要是綠色的,說明是已經被同步到slave的資料,即便被覆蓋了也沒什麼影響。因為未同步的僅僅是紅色部分。

但是,如果slave出現網路阻塞,導致master的offset遠遠超過了slave的offset:

image-20210725153937031

如果master繼續寫入新資料,其offset就會覆蓋舊的資料,直到將slave現在的offset也覆蓋:

image-20210725154155984

棕色框中的紅色部分,就是尚未同步,但是卻已經被覆蓋的資料。此時如果slave恢復,需要同步,卻發現自己的offset都沒有了,無法完成增量同步了。只能做全量同步。

image-20210725154216392

主從同步最佳化

可以從以下幾個方面來最佳化Redis主從叢集:

  • 在master中配置repl-diskless-sync yes啟用無磁碟複製,避免全量同步時的磁碟IO。
  • Redis單節點上的記憶體佔用不要太大,減少RDB導致的過多磁碟IO
  • 適當提高repl_baklog的大小,發現slave當機時儘快實現故障恢復,儘可能避免全量同步
  • 限制一個master上的slave節點數量,如果實在是太多slave,則可以採用主-從-從鏈式結構,減少master壓力

image-20210725154405899

哨兵叢集

Redis提供了哨兵(Sentinel)機制來實現主從叢集的自動故障恢復

哨兵的作用

  1. 監控:Sentinel 會不斷檢查您的master和slave是否按預期工作
  2. 自動故障恢復:如果master故障,Sentinel會將一個slave提升為master。當故障例項恢復後也以新的master為主
  3. 通知:Sentinel充當Redis客戶端的服務發現來源,當叢集發生故障轉移時,會將最新資訊推送給Redis的客戶端

叢集監控原理

Sentinel基於心跳機制監測服務狀態,每隔1秒向叢集的每個例項傳送ping命令:

  1. 主觀下線:如果某sentinel節點發現某例項未在規定時間響應,則認為該例項主觀下線
  2. 客觀下線:若超過指定數量(quorum)的sentinel都認為該例項主觀下線,則該例項客觀下線。quorum值最好超過Sentinel例項數量的一半

image-20210725154632354

叢集故障恢復原理

一旦發現master故障,sentinel需要在salve中選擇一個作為新的master,選擇依據是這樣的:

  • 首先會判斷slave節點與master節點斷開時間長短,如果超過指定值(redis.conf配置的down-after-milliseconds * 10)則會排除該slave節點
  • 然後判斷slave節點的slave-priority值,越小優先順序越高,如果是0則永不參與選舉
  • 若slave-prority一樣,則判斷slave節點的offset值,越大說明資料越新,優先順序越高
  • 若offset一樣,則最後判斷slave節點的執行id(redis開啟時都會分配一個)大小,越小優先順序越高

當選出一個新的master後,該如何實現切換?流程如下:

  • sentinel連結備選的slave節點,讓其執行 slaveof no one(翻譯:不要當奴隸) 命令,讓該節點成為master
  • sentinel給所有其它slave傳送 slaveof <newMasterIp> <newMasterPort> 命令,讓這些slave成為新master的從節點,開始從新的master上同步資料
  • 最後,sentinel將故障節點標記為slave,當故障節點恢復後會自動成為新的master的slave節點

image-20210725154816841

搭建哨兵叢集

image-20210701215227018

  1. 建立目錄
# 進入/tmp目錄
cd /tmp
# 建立目錄
mkdir s1 s2 s3
  1. 在s1目錄中建立sentinel.conf檔案,編輯如下內容
port 27001
sentinel announce-ip 192.168.150.101
sentinel monitor mymaster 192.168.150.101 7001 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
dir "/tmp/s1"
  • port 27001:是當前sentinel例項的埠
  • sentinel monitor mymaster 192.168.150.101 7001 2:指定主節點資訊
    • mymaster:主節點名稱,自定義,任意寫
    • 192.168.150.101 7001:主節點的ip和埠
    • 2:選舉master時的quorum值
  1. 將s1/sentinel.conf檔案複製到s2、s3兩個目錄中(在/tmp目錄執行下列命令):
# 方式一:逐個複製
cp s1/sentinel.conf s2
cp s1/sentinel.conf s3

# 方式二:管道組合命令,一鍵複製
echo s2 s3 | xargs -t -n 1 cp s1/sentinel.conf
  1. 修改s2、s3兩個資料夾內的配置檔案,將埠分別修改為27002、27003:
sed -i -e 's/27001/27002/g' -e 's/s1/s2/g' s2/sentinel.conf
sed -i -e 's/27001/27003/g' -e 's/s1/s3/g' s3/sentinel.conf
  1. 啟動
# 第1個
redis-sentinel s1/sentinel.conf
# 第2個
redis-sentinel s2/sentinel.conf
# 第3個
redis-sentinel s3/sentinel.conf

RedisTemplate的哨兵模式

  1. 依賴
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. YAML配置哨兵叢集
spring:
  redis:
    sentinel:
      master: mymaster	# 前面哨兵叢集搭建時的名字
      nodes:
        - 192.168.150.101:27001	# 這裡的ip:port是sentinel哨兵的,而不用關注哨兵監管下的那些節點
        - 192.168.150.101:27002
        - 192.168.150.101:27003
  1. 配置讀寫分離
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
    return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

這個bean中配置的就是讀寫策略,包括四種:

  • MASTER:從主節點讀取
  • MASTER_PREFERRED:優先從master節點讀取,master不可用才讀取replica
  • REPLICA:從slave(replica)節點讀取
  • REPLICA _PREFERRED:優先從slave(replica)節點讀取,所有的slave都不可用才讀取master
  1. 就可以在需要的地方正常使用RedisTemplate了,如前面玩的StringRedisTemplate

分片叢集

主從和哨兵可以解決高可用、高併發讀的問題。但是依然有兩個問題沒有解決:

  • 海量資料儲存問題

  • 高併發寫的問題

分片叢集可解決上述問題,分片叢集特徵:

  • 叢集中有多個master,每個master儲存不同資料

  • 每個master都可以有多個slave節點

  • master之間透過ping監測彼此健康狀態

  • 客戶端請求可以訪問叢集任意節點,最終都會被轉發到正確節點

搭建分片叢集

image-20210702164116027

  1. 建立目錄
# 進入/tmp目錄
cd /tmp
# 建立目錄
mkdir 7001 7002 7003 8001 8002 8003
  1. 在temp目錄下新建redis.conf檔案,編輯內容如下:
port 6379
# 開啟叢集功能
cluster-enabled yes
# 叢集的配置檔名稱,不需要我們建立,由redis自己維護
cluster-config-file /tmp/6379/nodes.conf
# 節點心跳失敗的超時時間
cluster-node-timeout 5000
# 持久化檔案存放目錄
dir /tmp/6379
# 繫結地址
bind 0.0.0.0
# 讓redis後臺執行
daemonize yes
# 註冊的例項ip
replica-announce-ip 192.168.146.100
# 保護模式
protected-mode no
# 資料庫數量
databases 1
# 日誌
logfile /tmp/6379/run.log
  1. 將檔案複製到每個目錄下
# 進入/tmp目錄
cd /tmp
# 執行複製
echo 7001 7002 7003 8001 8002 8003 | xargs -t -n 1 cp redis.conf
  1. 將每個目錄下的第redis.conf中的6379改為所在目錄一致
# 進入/tmp目錄
cd /tmp
# 修改配置檔案
printf '%s\n' 7001 7002 7003 8001 8002 8003 | xargs -I{} -t sed -i 's/6379/{}/g' {}/redis.conf
  1. 啟動
# 進入/tmp目錄
cd /tmp
# 一鍵啟動所有服務
printf '%s\n' 7001 7002 7003 8001 8002 8003 | xargs -I{} -t redis-server {}/redis.conf





# 檢視啟動狀態
ps -ef | grep redis

# 關閉所有程式
printf '%s\n' 7001 7002 7003 8001 8002 8003 | xargs -I{} -t redis-cli -p {} shutdown
  1. 建立叢集:上一步啟動了redis,但這幾個redis之間還未建立聯絡,以下命令要求redis版本大於等於5.0
redis-cli \
--cluster create \
--cluster-replicas 1 \
192.168.146.100:7001 \
192.168.146.100:7002 \
192.168.146.100:7003 \
192.168.146.100:8001 \
192.168.146.100:8002 \
192.168.146.100:8003


# 更多叢集相關命令
redis-cli --cluster help

  create         host1:port1 ... hostN:portN
                 --cluster-replicas <arg>
  check          <host:port> or <host> <port> - separated by either colon or space
                 --cluster-search-multiple-owners
  info           <host:port> or <host> <port> - separated by either colon or space
  fix            <host:port> or <host> <port> - separated by either colon or space
                 --cluster-search-multiple-owners
                 --cluster-fix-with-unreachable-masters
  reshard        <host:port> or <host> <port> - separated by either colon or space
                 --cluster-from <arg>
                 --cluster-to <arg>
                 --cluster-slots <arg>
                 --cluster-yes
                 --cluster-timeout <arg>
                 --cluster-pipeline <arg>
                 --cluster-replace
  rebalance      <host:port> or <host> <port> - separated by either colon or space
                 --cluster-weight <node1=w1...nodeN=wN>
                 --cluster-use-empty-masters
                 --cluster-timeout <arg>
                 --cluster-simulate
                 --cluster-pipeline <arg>
                 --cluster-threshold <arg>
                 --cluster-replace
  add-node       new_host:new_port existing_host:existing_port
                 --cluster-slave
                 --cluster-master-id <arg>
  del-node       host:port node_id
  call           host:port command arg arg .. arg
                 --cluster-only-masters
                 --cluster-only-replicas
  set-timeout    host:port milliseconds
  import         host:port
                 --cluster-from <arg>
                 --cluster-from-user <arg>
                 --cluster-from-pass <arg>
                 --cluster-from-askpass
                 --cluster-copy
                 --cluster-replace
  backup         host:port backup_directory

  • redis-cli --cluster或者./redis-trib.rb:代表叢集操作命令
  • create:代表是建立叢集
  • --replicas 1或者--cluster-replicas 1 :指定叢集中每個master的副本個數為1,此時節點總數 ÷ (replicas + 1) 得到的就是master的數量。因此節點列表中的前n個就是master,其它節點都是slave節點,隨機分配到不同master

image-20230825223416414

  1. 檢視叢集狀態
redis-cli -p 7001 cluster nodes
  1. 命令列連結叢集redis注意點
# 需要加上 -c 引數,否則進行寫操作時會報錯
redis-cli -c -p 7001

雜湊插槽

Redis會把每一個master節點對映到0~16383共16384個插槽(hash slot)上,檢視叢集資訊時就能看到

image-20230825233102693

資料key不是與節點繫結,而是與插槽繫結。redis會根據key的有效部分計算插槽值,分兩種情況:

  • key中包含"{}",且“{}”中至少包含1個字元,“{}”中的部分是有效部分
  • key中不包含“{}”,整個key都是有效部分

好處:節點掛了,但插槽還在,將插槽分配給健康的節點,那資料就恢復了

如:key是num,那麼就根據num計算;如果是{name}num,則根據name計算。計算方式是利用CRC16演算法得到一個hash值,然後對16384取餘,得到的結果就是slot值

image-20230825233644707

如上:在7001存入name,對name做hash運算,之後對16384取餘,得到的5798就是name=zixq要儲存的位置,而5798在7002節點中,所以跳入了7002節點;而 set job java 也是同樣的道理

利用上述的原理可以做到:將同一類資料固定地儲存在同一個Redis例項/節點(即:這一類資料使用相同的有效部分,如key都以{typeId}為字首)

分片叢集下的故障轉移

分片叢集下,雖然沒有哨兵,但是也可以進行故障轉移

  1. 自動故障轉移:master掛了、選一個slave為主........,和前面玩過的主從一樣

  2. 手動故障轉移:後續新增的節點(此時是slave),效能比原有的節點(master)效能好,故而將新節點弄為master

# 在新增節點一方執行下列命令,就會讓此新增節點與其master節點身份對調
cluster failover

failover命令可以指定三種模式:

  • 預設:預設的流程,如下圖1~6歩(推薦使用)

image-20210725162441407

  • force:省略了對offset的一致性校驗
  • takeover:直接執行第5歩,忽略資料一致性、忽略master狀態和其它master的意見

RedisTemplate訪問分片叢集

RedisTemplate底層同樣基於lettuce實現了分片叢集的支援、所以和哨兵叢集的RedisTemplate一樣,區別就是YAML配置

  1. 依賴
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. YAML配置分片叢集
spring:
  redis:
    cluster:
      nodes:
        - 192.168.146.100:7001	# 和哨兵叢集的區別:此叢集沒有哨兵,這裡使用的是分片叢集的每個節點的ip:port
        - 192.168.146.100:7002
        - 192.168.146.100:7003
        - 192.168.146.100:8001
        - 192.168.146.100:8002
        - 192.168.146.100:8003
  1. 配置讀寫分離
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
    return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

這個bean中配置的就是讀寫策略,包括四種:

  • MASTER:從主節點讀取
  • MASTER_PREFERRED:優先從master節點讀取,master不可用才讀取replica
  • REPLICA:從slave(replica)節點讀取
  • REPLICA _PREFERRED:優先從slave(replica)節點讀取,所有的slave都不可用才讀取master
  1. 就可以在需要的地方正常使用RedisTemplate了,如前面玩的StringRedisTemplate

叢集伴隨的問題

叢集雖然具備高可用特性,能實現自動故障恢復,但是如果使用不當,也會存在一些問題

  1. 叢集完整性問題:在Redis的預設配置中,如果發現任意一個插槽不可用,則整個叢集都會停止對外服務,即就算有一個slot不能用了,那麼叢集也會不可用,像什麼set、get......命令也用不了了,因此開發中,最重要的是可用性,因此修改 redis.conf檔案中的內容
# 叢集全覆蓋		預設值是 yes,改為no即可
cluster-require-full-coverage no
  1. 叢集頻寬問題:叢集節點之間會不斷的互相Ping來確定叢集中其它節點的狀態。每次Ping攜帶的資訊至少包括
  • 插槽資訊
  • 叢集狀態資訊

叢集中節點越多,叢集狀態資訊資料量也越大,10個節點的相關資訊可能達到1kb,此時每次叢集互通需要的頻寬會非常高,這樣會導致叢集中大量的頻寬都會被ping資訊所佔用,這是一個非常可怕的問題,所以我們需要去解決這樣的問題

解決途徑:

  • 避免大叢集,叢集節點數不要太多,最好少於1000,如果業務龐大,則建立多個叢集。
  • 避免在單個物理機中執行太多Redis例項
  • 配置合適的cluster-node-timeout值
  1. lua和事務的問題:這兩個都是為了保證原子性,這就要求執行的所有key都落在一個節點上,而叢集則會破壞這一點,叢集中無法保證lua和事務問題

  2. 資料傾斜問題:因為hash_tag(雜湊插槽中說的hash算的slot值)落在一個節點上,就導致大量資料都在一個redis節點中了

  3. 叢集和主從選擇問題:單體Redis(主從Redis+哨兵)已經能達到萬級別的QPS,並且也具備很強的高可用特性。如果主從能滿足業務需求的情況下,若非萬不得已的情況下,儘量不搭建Redis叢集

批處理

單機redis批處理:mxxx命令 與 Pipeline

Mxxx雖然可以批處理,但是卻只能操作部分資料型別(String是mset、Hash是hmset、Set是sadd key member.......),因此如果有對複雜資料型別的批處理需要,建議使用Pipeline

注意點:mxxx命令可以保證原子性,一堆命令一起執行;而Pipeline是非原子性的,這是將一堆命令一起發給redis伺服器,然後把這些命令放入伺服器的一個佇列中,最後慢慢執行而已,因此執行命令不一定是一起執行的

@Test
void testPipeline() {
    // 建立管道 	Pipeline 此物件基本上可以進行所有的redis操作,如:pipeline.set()、pipeline.hset().....
    Pipeline pipeline = jedis.pipelined();
    
    for (int i = 1; i <= 100000; i++) {
        // 放入命令到管道
        pipeline.set("test:key_" + i, "value_" + i);
        if (i % 1000 == 0) {
            // 每放入1000條命令,批次執行
            pipeline.sync();
        }
    }
}

叢集redis批處理

MSET或Pipeline這樣的批處理需要在一次請求中攜帶多條命令,而此時如果Redis是一個叢集,那批處理命令的多個key必須落在一個插槽中,否則就會導致執行失敗。這樣的要求很難實現,因為我們在批處理時,可能一次要插入很多條資料,這些資料很有可能不會都落在相同的節點上,這就會導致報錯了

image-20230828012358907

解決方式:hash_tag就是前面雜湊插槽中說的算插槽的hash值

1653126446641

在jedis中,對於叢集下的批處理並沒有解決,因此一旦使用jedis來操作redis,那麼就需要我們自己來實現叢集的批處理邏輯,一般選擇序列slot或並行slot即可

而在Spring中是解決了叢集下的批處理問題的

@Test
void testMSetInCluster() {
    Map<String, String> map = new HashMap<>(4);
    map.put("name", "Rose");
    map.put("age", "21");
    map.put("sex", "Female");
    
    stringRedisTemplate.opsForValue().multiSet(map);


    List<String> strings = stringRedisTemplate
        .opsForValue()
        .multiGet(Arrays.asList("name", "age", "sex"));
    
    strings.forEach(System.out::println);

}

原理:使用jedis操作時,要編寫叢集的批處理邏輯可以借鑑

在RedisAdvancedClusterAsyncCommandsImpl 類中

首先根據slotHash算出來一個partitioned的map,map中的key就是slot,而他的value就是對應的對應相同slot的key對應的資料

透過 RedisFuture<String> mset = super.mset(op); 進行非同步的訊息傳送

public class RedisAdvancedClusterAsyncCommandsImpl {

    @Override
    public RedisFuture<String> mset(Map<K, V> map) {

        // 算key的slot值,然後key相同的分在一組
        Map<Integer, List<K>> partitioned = SlotHash.partition(codec, map.keySet());

        if (partitioned.size() < 2) {
            return super.mset(map);
        }

        Map<Integer, RedisFuture<String>> executions = new HashMap<>();

        for (Map.Entry<Integer, List<K>> entry : partitioned.entrySet()) {

            Map<K, V> op = new HashMap<>();
            entry.getValue().forEach(k -> op.put(k, map.get(k)));

            // 非同步傳送:即mset()中的邏輯就是並行slot方式
            RedisFuture<String> mset = super.mset(op);
            executions.put(entry.getKey(), mset);
        }

        return MultiNodeExecution.firstOfAsync(executions);
    }
}

慢查詢

Redis執行時耗時超過某個閾值的命令,稱為慢查詢

慢查詢的危害:由於Redis是單執行緒的,所以當客戶端發出指令後,他們都會進入到redis底層的queue來執行,如果此時有一些慢查詢的資料,就會導致大量請求阻塞,從而引起報錯,所以我們需要解決慢查詢問題

1653129590210

慢查詢的閾值可以透過配置指定:

slowlog-log-slower-than:慢查詢閾值,單位是微秒。預設是10000,建議1000

慢查詢會被放入慢查詢日誌中,日誌的長度有上限,可以透過配置指定:

slowlog-max-len:慢查詢日誌(本質是一個佇列)的長度。預設是128,建議1000

image-20230828155342441

  1. 臨時配置
config set slowlog-log-slower-than 1000		# 臨時配置:慢查詢閾值,重啟redis則失效

config set slowlog-max-len 1000				# 慢查詢日誌長度
  1. 永久配置:在redis.conf檔案中新增相應內容即可
# 慢查詢閾值
slowlog-log-slower-than 1000
# 慢查詢日誌長度
slowlog-max-len 1000

檢視慢查詢

  1. 命令方式
slowlog len				# 查詢慢查詢日誌長度
slowlog get [n]			# 讀取n條慢查詢日誌
slowlog reset			# 清空慢查詢列表

1653130858066

  1. 客戶端工具:不同客戶端操作不一樣

image-20230828160409521

記憶體配置

當Redis記憶體不足時,可能導致Key頻繁被刪除、響應時間變長、QPS不穩定等問題。當記憶體使用率達到90%以上時就需要我們警惕,並快速定位到記憶體佔用的原因

redis中的記憶體劃分:

記憶體佔用 說明 備註
資料記憶體 是Redis最主要的部分,儲存Redis的鍵值資訊。主要問題是BigKey問題、記憶體碎片問題 記憶體碎片問題:Redis底層分配並不是這個key有多大,他就會分配多大,而是有他自己的分配策略,比如8,16,20等等,假定當前key只需要10個位元組,此時分配8肯定不夠,那麼他就會分配16個位元組,多出來的6個位元組就不能被使用,這就是我們常說的 碎片問題。這種一般重啟redis就解決了
程式記憶體 Redis主程式本身運⾏肯定需要佔⽤記憶體,如程式碼、常量池等等;這部分記憶體⼤約⼏兆,在⼤多數⽣產環境中與Redis資料佔⽤的記憶體相⽐可以忽略 這部分記憶體一般都可以忽略不計
緩衝區記憶體 一般包括客戶端緩衝區、AOF緩衝區、複製緩衝區等。客戶端緩衝區又包括輸入緩衝區和輸出緩衝區兩種。這部分記憶體佔用波動較大,不當使用BigKey,可能導致記憶體溢位 一般包括客戶端緩衝區、AOF緩衝區、複製緩衝區等。客戶端緩衝區又包括輸入緩衝區和輸出緩衝區兩種。這部分記憶體佔用波動較大,所以這片記憶體也是需要重點分析的記憶體問題

檢視記憶體情況

  1. 檢視記憶體分配的情況
# 要檢視info自己看哪些東西,直接輸入 info 回車即可看到
info memory


# 示例
127.0.0.1:7001> INFO memory
# Memory
used_memory:2353272
used_memory_human:2.24M
used_memory_rss:9281536
used_memory_rss_human:8.85M
used_memory_peak:2508864
used_memory_peak_human:2.39M
used_memory_peak_perc:93.80%
used_memory_overhead:1775724
used_memory_startup:1576432
used_memory_dataset:577548
used_memory_dataset_perc:74.35%
allocator_allocated:2432096
allocator_active:2854912
allocator_resident:5439488
total_system_memory:1907740672
total_system_memory_human:1.78G
used_memory_lua:31744
used_memory_vm_eval:31744
used_memory_lua_human:31.00K
used_memory_scripts_eval:0
number_of_cached_scripts:0
number_of_functions:0
number_of_libraries:0
used_memory_vm_functions:32768
used_memory_vm_total:64512
used_memory_vm_total_human:63.00K
used_memory_functions:184
used_memory_scripts:184
used_memory_scripts_human:184B
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
allocator_frag_ratio:1.17
allocator_frag_bytes:422816
allocator_rss_ratio:1.91
allocator_rss_bytes:2584576
rss_overhead_ratio:1.71
rss_overhead_bytes:3842048
mem_fragmentation_ratio:3.95
mem_fragmentation_bytes:6930528
mem_not_counted_for_evict:0
mem_replication_backlog:184540
mem_total_replication_buffers:184536
mem_clients_slaves:0
mem_clients_normal:3600
mem_cluster_links:10880
mem_aof_buffer:0
mem_allocator:jemalloc-5.2.1
active_defrag_running:0
lazyfree_pending_objects:0
lazyfreed_objects:0
  1. 檢視key的主要佔用情況
memory xxx

# 用法查詢
127.0.0.1:7001> help MEMORY

  MEMORY 
  summary: A container for memory diagnostics commands
  since: 4.0.0
  group: server

  MEMORY DOCTOR 
  summary: Outputs memory problems report
  since: 4.0.0
  group: server

  MEMORY HELP 
  summary: Show helpful text about the different subcommands
  since: 4.0.0
  group: server

  MEMORY MALLOC-STATS 
  summary: Show allocator internal stats
  since: 4.0.0
  group: server

  MEMORY PURGE 
  summary: Ask the allocator to release memory
  since: 4.0.0
  group: server

  MEMORY STATS 
  summary: Show memory usage details
  since: 4.0.0
  group: server

  MEMORY USAGE key [SAMPLES count]
  summary: Estimate the memory usage of a key
  since: 4.0.0
  group: server

MEMORY STATS查詢結果解讀

127.0.0.1:7001> MEMORY STATS 
 1) "peak.allocated"		# redis程式自啟動以來消耗記憶體的峰值
 2) (integer) 2508864
 3) "total.allocated"		# redis使用其分配器分配的總位元組數,即當前的總記憶體使用量
 4) (integer) 2353152
 5) "startup.allocated"		# redis啟動時消耗的初始記憶體量,即redis啟動時申請的記憶體大小
 6) (integer) 1576432
 7) "replication.backlog"	# 複製積壓快取區的大小
 8) (integer) 184540
 9) "clients.slaves"		# 主從複製中所有從節點的讀寫緩衝區大小
10) (integer) 0
11) "clients.normal"		# 除從節點外,所有其他客戶端的讀寫緩衝區大小
12) (integer) 3600
13) "cluster.links"			# 
14) (integer) 10880
15) "aof.buffer"			# AOF持久化使用的快取和AOF重寫時產生的快取
16) (integer) 0
17) "lua.caches"			# 
18) (integer) 0
19) "functions.caches"		# 
20) (integer) 184
21) "db.0"					# 業務資料庫的數量
22) 1) "overhead.hashtable.main"	# 當前資料庫的hash連結串列開銷記憶體總和,即後設資料記憶體
    2) (integer) 72
    3) "overhead.hashtable.expires"	# 用於儲存key的過期時間所消耗的記憶體
    4) (integer) 0
    5) "overhead.hashtable.slot-to-keys"	# 
    6) (integer) 16
23) "overhead.total"	# 數值 = startup.allocated + replication.backlog + clients.slaves + clients.normal + aof.buffer + db.X
24) (integer) 1775724
25) "keys.count"		# 當前redis例項的key總數
26) (integer) 1
27) "keys.bytes-per-key"	# 當前redis例項每個key的平均大小。計算公式:(total.allocated - startup.allocated) / keys.count
28) (integer) 776720
29) "dataset.bytes"		# 純業務資料佔用的記憶體大小
30) (integer) 577428
31) "dataset.percentage"	# 純業務資料佔用的第記憶體比例。計算公式:dataset.bytes * 100 / (total.allocated - startup.allocated)
32) "74.341850280761719"
33) "peak.percentage"	# 當前總記憶體與歷史峰值的比例。計算公式:total.allocated * 100 / peak.allocated
34) "93.793525695800781"
35) "allocator.allocated"	# 
36) (integer) 2459024
37) "allocator.active"		# 
38) (integer) 2891776
39) "allocator.resident"	# 
40) (integer) 5476352
41) "allocator-fragmentation.ratio"		# 
42) "1.1759852170944214"
43) "allocator-fragmentation.bytes"		# 
44) (integer) 432752
45) "allocator-rss.ratio"	# 
46) "1.8937677145004272"
47) "allocator-rss.bytes"	# 
48) (integer) 2584576
49) "rss-overhead.ratio"	# 
50) "1.6851159334182739"
51) "rss-overhead.bytes"	# 
52) (integer) 3751936
53) "fragmentation"			# 記憶體的碎片率
54) "3.9458441734313965"
55) "fragmentation.bytes"	# 記憶體碎片所佔位元組大小
56) (integer) 6889552
  1. 客戶端連結工具檢視:不同redis客戶端連結工具不一樣,操作不一樣

image-20230828175139638

解決記憶體問題

由前面鋪墊得知:記憶體緩衝區常見的有三種

  • 複製緩衝區:主從複製的repl_backlog_buf,如果太小可能導致頻繁的全量複製,影響效能。透過replbacklog-size來設定,預設1mb
  • AOF緩衝區:AOF刷盤之前的快取區域,AOF執行rewrite的緩衝區。無法設定容量上限
  • 客戶端緩衝區:分為輸入緩衝區和輸出緩衝區,輸入緩衝區最大1G且不能設定。輸出緩衝區可以設定

其他的基本上可以忽略,最關鍵的其實是客戶端緩衝區的問題:

客戶端緩衝區:指的就是我們傳送命令時,客戶端用來快取命令的一個緩衝區,也就是我們向redis輸入資料的輸入端緩衝區和redis向客戶端返回資料的響應快取區

輸入緩衝區最大1G且不能設定,所以這一塊我們根本不用擔心,如果超過了這個空間,redis會直接斷開,因為本來此時此刻就代表著redis處理不過來了,我們需要擔心的就是輸出端緩衝區

1653132410073

我們在使用redis過程中,處理大量的big value,那麼會導致我們的輸出結果過多,如果輸出快取區過大,會導致redis直接斷開,而預設配置的情況下, 其實它是沒有大小的,這就比較坑了,記憶體可能一下子被佔滿,會直接導致我們們的redis斷開,所以解決方案有兩個

  1. 設定一個大小
  2. 增加我們頻寬的大小,避免我們出現大量資料從而直接超過了redis的承受能力

原理篇

涉及的Redis原始碼採用的版本為redis-6.2.6

Redis資料結構:SDS

Redis是C語言實現的,但C語言本身的字串有缺陷,因此Redis就自己弄了一個字串SDS(Simple Dynamic String 簡單動態字串)

C語言本身的字串缺陷如下:

  1. 二級制不安全

C 語⾔的字串其實就是⼀個字元陣列,即陣列中每個元素是字串中的⼀個字元

image-20231016212331072

為什麼最後⼀個字元是“\0”?

在 C 語⾔⾥,對字串操作時,char * 指標只是指向字元陣列的起始位置,⽽字元陣列的結尾位置就⽤“\0”表示,意思是指字串的結束

所以,C 語⾔標準庫中的字串操作函式就透過判斷字元是不是 “\0” 來決定要不要停⽌操作,如果當前字元不是 “\0” ,說明字串還沒結束,可以繼續操作,如果當前字元是 “\0” 是則說明字串結束了,就要停⽌操作

因此,C 語⾔字串⽤ “\0” 字元作為結尾標記有個缺陷。假設有個字串中有個 “\0” 字元,這時在操作這個字串時就會提早結束

故C語言字串缺陷:字串⾥⾯不能含有 “\0” 字元,否則最先被程式讀⼊的 “\0” 字元將被誤認為是字串結尾,這個限制使得 C 語⾔的字串只能儲存⽂本資料,不能儲存像圖⽚、⾳頻、影片檔案這樣的⼆進位制資料;同時C 語⾔獲取字串⻓度的時間複雜度是 O(N)

  1. 字串操作函式不⾼效且不安全(可能導致發⽣緩衝區溢位)

舉個例⼦,strcat 函式是可以將兩個字串拼接在⼀起

// 將 src 字串拼接到 dest 字串後⾯
char *strcat(char *dest, const char* src);

strcat 函式和 strlen 函式類似,時間複雜度也很⾼,也都需要先透過遍歷字串才能得到⽬標字串的末尾。對於 strcat 函式來說,還要再遍歷源字串才能完成追加,對字串的操作效率不⾼

C 語⾔的字串是不會記錄⾃身的緩衝區⼤⼩的,所以 strcat 函式假定程式設計師在執⾏這個函式時,已經為dest 分配了⾜夠多的記憶體,可以容納 src 字串中的所有內容,⽽⼀旦這個假定不成⽴,就會發⽣緩衝區溢位將可能會造成程式運⾏終⽌

SDS 結構體

Redis原始碼:官網下載redis x.x.x.tar.gz,然後解壓,src目錄下即為原始碼所在

Redis中SDS是一個結構體,原始碼如下:

1653984624671

  • len,記錄了字串⻓度。這樣獲取字串⻓度的時候,只需要返回這個成員變數值就⾏,時間複雜度只需要 O(1)

  • alloc,分配給字元陣列的空間⻓度。這樣在修改字串的時候,可以透過 alloc - len 計算出剩餘的空間⼤⼩,可以⽤來判斷空間是否滿⾜修改需求,如果不滿⾜的話,就會⾃動將 SDS 的空間擴充套件⾄執⾏修改所需的⼤⼩,然後才執⾏實際的修改操作,所以使⽤ SDS 既不需要⼿動修改 SDS 的空間⼤⼩,也不會出現前⾯所說的緩衝區溢位的問題。

  • flags,⽤來表示不同型別的 SDS。⼀共設計了 5 種型別,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64

  • buf[],字元陣列,⽤來儲存實際資料。不僅可以儲存字串,也可以儲存⼆進位制資料

  1. 解決C語言原來字串效率不高問題

Redis 的 SDS 結構因為加⼊了 len 成員變數,那麼獲取字串⻓度的時候,直接返回這個成員變數的值就⾏,所以複雜度只有 O1

  1. 解決二進位制不安全問題

SDS 不需要⽤ “\0” 字元來標識字串結尾了,⽽是有個專⻔的 len 成員變數來記錄⻓度,所以可儲存包含 “\0” 的資料。但是 SDS 為了相容部分 C 語⾔標準庫的函式, SDS 字串結尾還是會加上 “\0” 字元

因此, SDS 的 API 都是以處理⼆進位制的⽅式來處理 SDS 存放在 buf[] ⾥的資料,程式不會對其中的資料做任何限制,資料寫⼊的時候時什麼樣的,它被讀取時就是什麼樣的

透過使⽤⼆進位制安全的 SDS,⽽不是 C 字串,使得 Redis 不僅可以儲存⽂本資料,也可以儲存任意格式的⼆進位制資料

  1. 解決緩衝區可能溢位問題

Redis 的 SDS 結構⾥引⼊了 alloc 和 len 成員變數,這樣 SDS API 透過 alloc - len 計算,可以算出剩餘可⽤的空間⼤⼩,這樣在對字串做修改操作的時候,就可以由程式內部判斷緩衝區⼤⼩是否⾜夠⽤

⽽且,當判斷出緩衝區⼤⼩不夠⽤時,Redis 會⾃動將擴⼤ SDS 的空間⼤⼩,擴容方式如下:

  • 若新字串小於1M,則新空間為擴充套件後字串長度的兩倍 +1

PS:+ 1的原因是“\0”字元

  • 若新字串大於1M,則新空間為擴充套件後字串長度 +1M +1。稱為記憶體預分配

PS:記憶體預分配好處,下次在操作 SDS 時,如果 SDS 空間夠的話,API 就會直接使⽤「未使⽤空間」,⽽⽆須執⾏記憶體分配,有效的減少記憶體分配次數

  1. 節省記憶體空間

SDS 結構中有個 flags 成員變數,表示的是 SDS 型別

Redos ⼀共設計了 5 種型別,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64

這 5 種型別的主要區別就在於,它們資料結構中的 len 和 alloc 成員變數的資料型別不同

如 sdshdr16 和 sdshdr32 這兩個型別,它們的定義分別如下:

struct __attribute__ ((__packed__)) sdshdr16 {
 uint16_t len;
 uint16_t alloc;
 unsigned char flags;
 char buf[];
};


struct __attribute__ ((__packed__)) sdshdr32 {
 uint32_t len;
 uint32_t alloc;
 unsigned char flags;
 char buf[];
};

可以看到:

  • sdshdr16 型別的 len 和 alloc 的資料型別都是 uint16_t,表示字元陣列⻓度和分配空間⼤⼩不能超過2 的 16 次⽅

  • sdshdr32 則都是 uint32_t,表示表示字元陣列⻓度和分配空間⼤⼩不能超過 2 的 32 次⽅

之所以 SDS 設計不同型別的結構體,是為了能靈活儲存不同⼤⼩的字串,從⽽有效節省記憶體空間。如,在儲存⼩字串時,結構頭佔⽤空間也⽐較少

除了設計不同型別的結構體,Redis 在程式設計上還使⽤了專⻔的編譯最佳化來節省記憶體空間,即在 struct 宣告瞭 __attribute__ ((packed)) ,它的作⽤是:告訴編譯器取消結構體在編譯過程中的最佳化對⻬,按照實際佔⽤位元組數進⾏對⻬

⽐如,sdshdr16 型別的 SDS,預設情況下,編譯器會按照 16 位元組對⻬的⽅式給變數分配記憶體,這意味著,即使⼀個變數的⼤⼩不到 16 個位元組,編譯器也會給它分配 16 個位元組

舉個例⼦,假設下⾯這個結構體,它有兩個成員變數,型別分別是 char 和 int,如下所示:

#include <stdio.h>

struct test1 {
	char a;
    int b;
 } test1;

int main() {
    printf("%lu\n", sizeof(test1));
    return 0; 
}

預設情況下,這個結構體⼤⼩計算出來就會是 8

image-20231016220019208

這是因為預設情況下,編譯器是使⽤「位元組對⻬」的⽅式分配記憶體,雖然 char 型別只佔⼀個位元組,但是由於成員變數⾥有 int 型別,它佔⽤了 4 個位元組,所以在成員變數為 char 型別分配記憶體時,會分配 4 個位元組,其中這多餘的 3 個位元組是為了位元組對⻬⽽分配的,相當於有 3 個位元組被浪費掉了。

如果不想編譯器使⽤位元組對⻬的⽅式進⾏分配記憶體,可以採⽤了 __attribute__ ((packed)) 屬性定義結構體,這樣⼀來,結構體實際佔⽤多少記憶體空間,編譯器就分配多少空間

Redis資料結構:intset

IntSet是Redis中set集合的一種實現方式,基於整數陣列來實現,並且如下特徵:

  • Redis會確保Intset中的元素唯一、有序
  • 具備型別升級機制,可以節省記憶體空間
  • 底層採用二分查詢方式來查詢

Redis中intset的結構體原始碼如下:

1653984923322

其中的encoding包含三種模式,表示儲存的整數大小不同:

1653984942385

為了方便查詢,Redis會將intset中所有的整數按照升序依次儲存在contents陣列中,結構如圖:

1653985149557

現在,陣列中每個數字都在int16_t的範圍內,因此採用的編碼方式是INTSET_ENC_INT16,每部分佔用的位元組大小為:

  • encoding:4位元組
  • length:4位元組
  • contents:2位元組 * 3 = 6位元組

1653985197214

假如,現在向其中新增一個數字:50000,這個數字超出了int16_t的範圍,intset會自動升級編碼方式到合適的大小
以當前案例來說流程如下:

  • 升級編碼為INTSET_ENC_INT32, 每個整數佔4位元組,並按照新的編碼方式及元素個數擴容陣列
  • 倒序依次將陣列中的元素複製到擴容後的正確位置。PS:倒序保證資料不亂
  • 將待新增的元素放入陣列末尾
  • 最後,將inset的encoding屬性改為INTSET_ENC_INT32,將length屬性改為4

1653985276621

上述邏輯的原始碼如下:

image-20231016222743208

  • 問題:intset支援降級操作嗎?

不⽀持降級操作,⼀旦對陣列進⾏了升級,就會⼀直保持升級後的狀態。如:前面已經從INTSET_ENC_INT16(2位元組整數)升級到INTSET_ENC_INT32(4位元組整數),就算刪除50000元素,intset集合的型別也還是INTSET_ENC_INT32型別,不會降級為INTSET_ENC_INT16型別

Redis資料結構:Dict

Redis是一個鍵值型(Key-Value Pair)的資料庫,我們可以根據鍵實現快速的增刪改查。而鍵與值的對映關係正是透過Dict來實現的。
Dict由三部分組成,分別是:雜湊表(DictHashTable)、雜湊節點(DictEntry)、字典(Dict)

1653985570612

雜湊表(Dictht)與雜湊節點(DictEntry)

  1. 雜湊節點(DictEntry)

image-20231016225452393

  • 結構⾥不僅包含指向鍵和值的指標,還包含了指向下⼀個雜湊表節點的指標,這個指標可以將多個雜湊值相同的鍵值對連結起來,以此來解決雜湊衝突的問題,這就是鏈式雜湊

  • dictEntry 結構⾥鍵值對中的值是⼀個「聯合體 v」定義的,因此,鍵值對中的值可以是⼀個指向實際值的指標,或者是⼀個⽆符號的 64 位整數或有符號的 64 位整數或double 類的值。這麼做的好處是可以節省記憶體空間,因為當「值」是整數或浮點數時,就可以將值的資料內嵌在 dictEntry

    結構⾥,⽆需再⽤⼀個指標指向實際的值,從⽽節省了記憶體空間

  1. 雜湊表(Dictht)

image-20231017015256894

其中:

  • 雜湊表大小size初始值為4,而且其值總等於2^n
image-20231017015602735
  • 為什麼sizemask = size -1?

因為size總等於2^n,所以size -1就為奇數,這樣”key利用hash函式得到的雜湊值h & sizemask”做與運算就正好得到的是size的低位,而這個低位值也正好和“雜湊值 % 雜湊表⼤⼩”取模一樣

image-20231017205806895

當我們向Dict新增鍵值對時,Redis首先根據key計算出hash值(h),然後利用 h & sizemask 來計算元素應該儲存到陣列中的哪個索引位置

假如儲存k1=v1,假設k1的雜湊值h =1,則1&3 =1,因此k1=v1要儲存到陣列角標1位置

image-20231017210249244

假如此時又新增了一個k2=v2,那麼就會新增到連結串列的隊首

image-20231017210335785

雜湊衝突解決方式:鏈式雜湊

image-20231016225452393

就是每個雜湊表節點都有⼀個 next 指標,⽤於指向下⼀個雜湊表節點,因此多個雜湊表節點可以⽤ next 指標構成⼀個單項鍊表,被分配到同⼀個雜湊桶上的多個節點可以⽤這個單項鍊表連線起來

不過,鏈式雜湊侷限性也很明顯,隨著連結串列⻓度的增加,在查詢這⼀位置上的資料的耗時就會增加,畢竟連結串列的查詢的時間複雜度是 O(n),需要解決就得擴容

Dict的擴容與收縮

擴容

Dict中的HashTable就是陣列結合單向連結串列的實現,當集合中元素較多時,必然導致雜湊衝突增多,連結串列過長,則查詢效率會大大降低
Dict在每次新增鍵值對時都會檢查負載因子(LoadFactor = used/size,即負載因子=雜湊表已儲存節點數量 / 雜湊表大小) ,滿足以下兩種情況時會觸發雜湊表擴容:

  1. 雜湊表的 LoadFactor >= 1;並且伺服器沒有執行 bgsave或者 bgrewiteaof 等後臺程式。也就是沒有執⾏ RDB 快照或沒有進⾏ AOF 重寫的時候
  2. 雜湊表的 LoadFactor > 5 ;此時說明雜湊衝突⾮常嚴重了,不管有沒有有在執⾏ RDB 快照或 AOF重寫都會強制執行雜湊擴容

原始碼邏輯如下:

1653985716275

收縮

Dict除了擴容以外,每次刪除元素時,也會對負載因子做檢查,當LoadFactor < 0.1 時,會做雜湊表收縮

1653985743412

字典(Dict)

image-20231016231705729

Redis 定義⼀個 dict 結構體,這個結構體⾥定義了兩個雜湊表(dictht ht[2]

在正常服務請求階段,插⼊的資料,都會寫⼊到「雜湊表 1」,此時的「雜湊表 2 」 並沒有被分配空間(這個雜湊表涉及到漸進式rehash)

1653985640422

Dict的漸進式rehash

rehash基本流程

image-20231016231705729

不管是擴容還是收縮,必定會建立新的雜湊表,導致雜湊表的size和sizemask(sizemask = size -1)變化,而key的查詢與sizemask有關(key透過hash函式計算得到雜湊值h,資料儲存的角標值 = h & sizemask)。因此必須對雜湊表中的每一個key重新計算索引,插入新的雜湊表,這個過程稱為rehash

過程如下:

  1. 計算新hash表的realeSize,值取決於當前要做的是擴容還是收縮:
  • 如果是擴容,則新size為第一個大於等於dict.ht[0].used + 1的2^n
  • 如果是收縮,則新size為第一個大於等於dict.ht[0].used的2^n (不得小於4)
  1. 按照新的realeSize申請記憶體空間,建立dictht,並賦值給dict.ht[1]
  2. 設定dict.rehashidx = 0,標示開始rehash
  3. 將dict.ht[0]中的每一個dictEntry都rehash到dict.ht[1]
  4. 將dict.ht[1]賦值給dict.ht[0],給dict.ht[1]初始化為空雜湊表,釋放原來的dict.ht[0]的記憶體

以上過程對於小資料影響小,但是對於大資料來說就有問題了,如果「雜湊表 1 」的資料量⾮常⼤,那麼在遷移⾄「雜湊表 2 」的時候,因為會涉及⼤量的資料拷⻉,此時可能會對 Redis 造成阻塞,⽆法服務其他請求,因此就需要漸進式rehash

漸進式rehash

為了避免 rehash 在資料遷移過程中,因拷⻉資料的耗時,影響 Redis 效能的情況,所以 Redis 採⽤了漸進式 rehash,也就是將資料的遷移的⼯作不再是⼀次性遷移完成,⽽是分多次遷移

Dict的rehash並不是一次性完成的。試想一下,如果Dict中包含數百萬的entry,要在一次rehash完成,極有可能導致主執行緒阻塞。所以Dict的rehash是分多次、漸進式的完成,因此稱為漸進式rehash。過程如下:

  1. 計算新hash表的realeSize,值取決於當前要做的是擴容還是收縮:
  • 如果是擴容,則新size為第一個大於等於dict.ht[0].used + 1的2^n
  • 如果是收縮,則新size為第一個大於等於dict.ht[0].used的2^n (不得小於4)
  1. 按照新的realeSize申請記憶體空間,建立dictht,並賦值給dict.ht[1]

  2. 設定dict.rehashidx = 0,標示開始rehash

  3. 將dict.ht[0]中的每一個dictEntry都rehash到dict.ht[1]

  4. 每次執行新增、刪除、查詢、修改操作時,除了執行對應操作之外,還會都檢查一下dict.rehashidx是否大於 -1;若是則按順序將dict.ht[0].table[rehashidx]的entry連結串列rehash到dict.ht[1],並且將rehashidx++,直至dict.ht[0]的所有資源都rehash到dict.ht[1]

PS:隨著處理客戶端發起的雜湊表操作請求數量越多,最終在某個時間點,會把「雜湊表 1 」的所有key-value 遷移到「雜湊表 2」

  1. 將dict.ht[1]賦值給dict.ht[0],給dict.ht[1]初始化為空雜湊表,釋放原來的dict.ht[0]的記憶體

  2. 將rehashidx賦值為-1,代表rehash結束

  3. 在rehash過程中,新增操作,則直接寫入ht[1],查詢、修改和刪除則會在dict.ht[0]和dict.ht[1]依次查詢並執行。這樣可以確保ht[0]的資料只減不增,隨著rehash最終為空

上述流程動畫圖如下:

Redis資料結構:Dict的漸進式rehash

Redis資料結構:ZipList

壓縮列表的最⼤特點,就是它被設計成⼀種記憶體緊湊型的資料結構,佔⽤⼀塊連續的記憶體空間,不僅可以利⽤ CPU 快取,⽽且會針對不同⻓度的資料,進⾏相應編碼,這種⽅法可以有效地節省記憶體開銷

但是,壓縮列表的缺陷也是有的:

  • 不能儲存過多的元素,否則查詢效率就會降低;
  • 新增或修改某個元素時,壓縮列表佔⽤的記憶體空間需要重新分配,甚⾄可能引發連鎖更新的問題

壓縮列表是 Redis 為了節約記憶體⽽開發的,它是由連續記憶體塊組成的順序型資料結構,有點類似於陣列:

1653986020491

屬性 型別 長度 用途
zlbytes uint32_t 4 位元組 記錄整個壓縮列表佔⽤對記憶體位元組數
zltail uint32_t 4 位元組 記錄壓縮列表尾節點距離壓縮列表的起始地址有多少位元組,透過這個偏移量,可以確定表尾節點的地址
zllen uint16_t 2 位元組 記錄了壓縮列表包含的節點數量。
最大值為UINT16_MAX (65534),如果超過這個值,此處會記錄為65535,但節點的真實數量需要遍歷整個壓縮列表才能計算得出。
entry 列表節點 不定 壓縮列表包含的各個節點,節點的長度由節點儲存的內容決定。
zlend uint8_t 1 位元組 特殊值 0xFF (十進位制 255 ),用於標記壓縮列表的末端。

1653985987327

在壓縮列表中,如果我們要查詢定位第⼀個元素和最後⼀個元素,可以透過表頭三個欄位的⻓度直接定位,複雜度是 O(1)。⽽查詢其他元素時,就沒有這麼⾼效了,只能逐個查詢,此時的複雜度就是 O(N)了,因此壓縮列表不適合儲存過多的元素

ZipList的Entry結構

ZipList 中的Entry並不像普通連結串列那樣記錄前後節點的指標,因為記錄兩個指標要佔用16個位元組,浪費記憶體。而是採用了下面的結構:

image-20231018123815116
  • previous_entry_length:前一節點的長度,佔1個或5個位元組。PS:這個點涉及到“連鎖更新”問題

    • 如果前一節點的長度小於254位元組,則採用1個位元組來儲存這個長度值
    • 如果前一節點的長度大於254位元組,則採用5個位元組來儲存這個長度值,第一個位元組為0xfe,後四個位元組才是真實長度資料
  • encoding:編碼屬性,記錄content的資料型別(字串還是整數)以及長度,佔用1個、2個或5個位元組

  • contents:負責儲存節點的資料,可以是字串或整數

ZipList中所有儲存長度的數值均採用小端位元組序,即低位位元組在前,高位位元組在後。例如:數值0x1234,採用小端位元組序後實際儲存值為:0x3412

PS:人的閱讀習慣是從左到右,即大端位元組序,機器讀取資料是反著的,所以採用小端位元組序,從而先處理低位,再處理高位

ZipList的Entry中的encoding編碼

當我們往壓縮列表中插⼊資料時,壓縮列表就會根據資料是字串還是整數,以及資料的⼤⼩,會使⽤不同空間⼤⼩的 previous_entry_length和 encoding 這兩個元素儲存的資訊

previous_entry_length的規則上一節中已經提到了,接下來看看encoding

encoding 屬性的空間⼤⼩跟資料是字串還是整數,以及字串的⻓度有關:

  • 如果當前節點的資料是整數,則 encoding 會使⽤ 1 位元組的空間進⾏編碼。

  • 如果當前節點的資料是字串,根據字串的⻓度⼤⼩,encoding 會使⽤ 1 位元組/2位元組/5位元組的空間進⾏編碼

  1. 當前節點的資料是整數

如果encoding是以“11”開始,則證明content是整數,且encoding固定只佔用1個位元組

編碼 編碼長度 整數型別
11000000 1 int16_t(2 bytes)
11010000 1 int32_t(4 bytes)
11100000 1 int64_t(8 bytes)
11110000 1 24位有符整數(3 bytes)
11111110 1 8位有符整數(1 bytes)
1111xxxx 1 直接在xxxx位置儲存數值,範圍從0001~1101,減1後結果為實際值

image-20231018130313093

  1. 當前節點的資料是字串

如果encoding是以“00”、“01”或者“10”開頭(即整數是11開頭,排除這種情況剩下的就是字串),則證明content是字串

編碼 編碼長度 字串大小
|00pppppp| 1 bytes <= 63 bytes
|01pppppp|qqqqqqqq| 2 bytes <= 16383 bytes
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| 5 bytes <= 4294967295 bytes

如,要儲存字串:“ab”和 “bc”

1653986172002

ZipList的連鎖更新問題

壓縮列表節點的 previous_entry_length屬性會根據前⼀個節點的⻓度進⾏不同的空間⼤⼩分配:

  • 如前⼀個節點的⻓度⼩於 254 位元組,那麼 previous_entry_length屬性需要⽤ 1 位元組的空間來儲存這個⻓度值;
  • 如果前⼀個節點的⻓度⼤於等於 254 位元組,那麼 previous_entry_length屬性需要⽤ 5 位元組的空間來儲存這個⻓度值

現在假設⼀個壓縮列表中有多個連續的、⻓度在 250~253 之間的節點,如下圖:

image-20231018131223445

因為這些節點⻓度值⼩於 254 位元組,所以 previous_entry_length屬性需要⽤ 1 位元組的空間來儲存這個⻓度值

這時,如果將⼀個⻓度⼤於等於 254 位元組的新節點加⼊到壓縮列表的表頭節點,即新節點將成為 e1 的前置節點,如下圖:

image-20231018131314154

因為 e1 節點的 previous_entry_length屬性只有 1 個位元組⼤⼩,⽆法儲存新節點的⻓度,此時就需要對壓縮列表的空間重分配,並將 e1 節點的 previous_entry_length屬性從原來的 1 位元組⼤⼩擴充套件為 5 位元組⼤⼩,那麼多⽶諾牌的效應就此開始:

image-20231018131449367

e1 原本的⻓度在 250~253 之間,因為剛才的擴充套件空間,此時 e1 的⻓度就⼤於等於 254 了,因此原本 e2儲存 e1 的 previous_entry_length屬性也必須從 1 位元組擴充套件⾄ 5 位元組⼤⼩

正如擴充套件 e1 引發了對 e2 擴充套件⼀樣,擴充套件 e2 也會引發對 e3 的擴充套件,⽽擴充套件 e3 ⼜會引發對 e4 的擴充套件................⼀直持續到結尾

因此:ZipList這種特殊情況下產生的連續多次空間擴充套件操作稱之為“連鎖更新(Cascade Update)”。新增、刪除都可能導致連鎖更新的發生。就像多⽶諾牌的效應⼀樣,第⼀張牌倒下了,推動了第⼆張牌倒下;第⼆張牌倒下,⼜推動了第三張牌倒下....

所以壓縮列表(ZipList)就有了缺陷:如果儲存的元素數量增加了,或是元素變⼤了,會導致記憶體重新分配,最糟糕的是會有「連鎖更新」的問題

因此也就可以得出結論:壓縮列表只會⽤於儲存的節點數量不多的場景,只要節點數量⾜夠⼩,即使發⽣連鎖更新,也是能接受的

當然,Redis 針對壓縮列表在設計上的不⾜,在後來的版本中,新增設計了兩種資料結構:quicklist(Redis 3.2 引⼊) 和 listpack(Redis 5.0 引⼊)。這兩種資料結構的設計⽬標,就是儘可能地保持壓縮列表節省記憶體的優勢,同時解決壓縮列表的「連鎖更新」的問題

Redis資料結構:QuickList

前面講到雖然壓縮列表是透過緊湊型的記憶體佈局節省了記憶體開銷,但是因為它的結構設計,如果儲存的元素數量增加,或者元素變⼤了,壓縮列表會有「連鎖更新」的⻛險,⼀旦發⽣,會造成效能下降

QuickList解決辦法:透過控制每個連結串列節點中的壓縮列表的⼤⼩或者元素個數,來規避連鎖更新的問題。因為壓縮列表元素越少或越⼩,連鎖更新帶來的影響就越⼩,從⽽提供了更好的訪問效能

問題1:ZipList雖然節省記憶體,但申請記憶體必須是連續空間,如果記憶體佔用較多,申請記憶體效率很低。怎麼辦?

​ 答:為了緩解這個問題,我們必須限制ZipList的長度和entry大小。

問題2:但是我們要儲存大量資料,超出了ZipList最佳的上限該怎麼辦?

​ 答:我們可以建立多個ZipList來分片儲存資料。

問題3:資料拆分後比較分散,不方便管理和查詢,這多個ZipList如何建立聯絡?

​ 答:Redis在3.2版本引入了新的資料結構QuickList,它是一個雙端連結串列,只不過連結串列中的每個節點都是一個ZipList。

1653986474927

為了避免QuickList中的每個ZipList中entry過多,Redis提供了一個配置項:list-max-ziplist-size 來限制

  • 如果值為正,則代表ZipList的允許的entry個數的最大值
  • 如果值為負,則代表ZipList的最大記憶體大小,分5種情況:
含義
-1 每個ZipList的記憶體佔用不能超過4kb
-2 每個ZipList的記憶體佔用不能超過8kb
-3 每個ZipList的記憶體佔用不能超過16kb
-4 每個ZipList的記憶體佔用不能超過32kb
-5 每個ZipList的記憶體佔用不能超過64kb

其預設值為 -2:

1653986642777

QuickList的和QuickListNode的結構原始碼:

1653986667228

方便理解,用個流程圖來描述當前的這個結構:

1653986718554

Redis資料結構:SkipList

SkipList(連結串列)在查詢元素的時候,因為需要逐⼀查詢,所以查詢效率⾮常低,時間複雜度是O(N),於是就出現了跳錶。跳錶是在連結串列基礎上改進過來的,實現了⼀種「多層」的有序連結串列,這樣的好處是能快讀定位資料

跳錶與傳統連結串列相比有幾點差異:

  • 元素按照升序排列儲存
  • 節點可能包含多個指標,指標跨度不同

1653986771309

SkipList的原始碼與圖形化示意如下:

1653986813240

跳錶是⼀個帶有層級關係的連結串列,⽽且每⼀層級可以包含多個節點,每⼀個節點透過指標連線起來,實現這⼀特性就是靠跳錶節點結構體中的zskiplistLevel 結構體型別的 level[] 陣列

level 陣列中的每⼀個元素代表跳錶的⼀層,也就是由 zskiplistLevel 結構體表示,⽐如 leve[0] 就表示第⼀層,leve[1] 就表示第⼆層。zskiplistLevel 結構體⾥定義了「指向下⼀個跳錶節點的指標」和「跨度」,跨度時⽤來記錄兩個節點之間的距離

image-20231018204546278

跳錶節點查詢過程

查詢⼀個跳錶節點的過程時,跳錶會從頭節點的最⾼層開始,逐⼀遍歷每⼀層。在遍歷某⼀層的跳錶節點時,會⽤跳錶節點中的 SDS 型別的元素和元素的權重來進⾏判斷,共有兩個判斷條件:

  • 如果當前節點的權重「⼩於」要查詢的權重時,跳錶就會訪問該層上的下⼀個節點。

  • 如果當前節點的權重「等於」要查詢的權重時,並且當前節點的 SDS 型別資料「⼩於」要查詢的資料時,跳錶就會訪問該層上的下⼀個節點

  • 如果上⾯兩個條件都不滿⾜,或者下⼀個節點為空時,跳錶就會使⽤⽬前遍歷到的節點的 level 陣列⾥的下⼀層指標,然後沿著下⼀層指標繼續查詢,這就相當於跳到了下⼀層接著查詢

如下圖有個 3 層級的跳錶:

image-20231018205007116

如果要查詢「元素:abcd,權重:4」的節點,查詢的過程是這樣的:

  • 先從頭節點的最⾼層開始,L2 指向了「元素:abc,權重:3」節點,這個節點的權重⽐要查詢節點的⼩,所以要訪問該層上的下⼀個節點;
  • 但是該層上的下⼀個節點是空節點,於是就會跳到「元素:abc,權重:3」節點的下⼀層去找,也就是 leve[1];
  • 「元素:abc,權重:3」節點的 leve[1] 的下⼀個指標指向了「元素:abcde,權重:4」的節點,然後將其和要查詢的節點⽐較。雖然「元素:abcde,權重:4」的節點的權重和要查詢的權重相同,但是當前節點的 SDS 型別資料「⼤於」要查詢的資料,所以會繼續跳到「元素:abc,權重:3」節點的下⼀層去找,也就是 leve[0];
  • 「元素:abc,權重:3」節點的 leve[0] 的下⼀個指標指向了「元素:abcd,權重:4」的節點,該節點正是要查詢的節點,查詢結束

跳錶節點側層數設定

跳錶的相鄰兩層的節點數量最理想的⽐例是 2:1,查詢複雜度可以降低到 O(logN)

下圖的跳錶就是,相鄰兩層的節點數量的⽐例是 2 : 1

image-20231018205310965

  1. 怎樣才能維持相鄰兩層的節點數量的⽐例為 2 : 1 ?

如果採⽤新增節點或者刪除節點時,來調整跳錶節點以維持⽐例的⽅法的話,會帶來額外的開銷

Redis 則採⽤⼀種巧妙的⽅法是,跳錶在建立節點的時候,隨機⽣成每個節點的層數,並沒有嚴格維持相鄰兩層的節點數量⽐例為 2 : 1 的情況

具體的做法是:跳錶在建立節點時候,會⽣成範圍為[0-1]的⼀個隨機數,如果這個隨機數⼩於 0.25(相當於機率 25%),那麼層數就增加 1 層,然後繼續⽣成下⼀個隨機數,直到隨機數的結果⼤於 0.25 結束,最終確定該節點的層數

這樣的做法,相當於每增加⼀層的機率不超過 25%,層數越⾼,機率越低,層⾼最⼤限制是 64層

Redis資料結構:RedisObject

Redis中的任意資料型別的鍵和值都會被封裝為一個RedisObject,也叫做Redis物件

從Redis的使用者的角度來看,⼀個Redis節點包含多個database(非cluster模式下預設是16個,cluster模式下只能是1個),而一個database維護了從key space到object space的對映關係。這個對映關係的key是string型別,⽽value可以是多種資料型別,比如:string, list, hash、set、sorted set等。即key的型別固定是string,而value可能的型別是多個

⽽從Redis內部實現的⾓度來看,database內的這個對映關係是用⼀個dict來維護的。dict的key固定用⼀種資料結構來表達就夠了,即SDS。而value則比較複雜,為了在同⼀個dict內能夠儲存不同型別的value,這就需要⼀個通⽤的資料結構,這個通用的資料結構就是robj,全名是redisObject

1653986956618

RedisObject中encoding編碼方式

Redis中會根據儲存的資料型別不同,選擇不同的編碼方式,共包含11種不同型別:

編號 編碼方式 說明
0 OBJ_ENCODING_RAW raw編碼動態字串
1 OBJ_ENCODING_INT long型別的整數的字串
2 OBJ_ENCODING_HT hash表(字典dict)
3 OBJ_ENCODING_ZIPMAP 已廢棄
4 OBJ_ENCODING_LINKEDLIST 雙端連結串列
5 OBJ_ENCODING_ZIPLIST 壓縮列表
6 OBJ_ENCODING_INTSET 整數集合
7 OBJ_ENCODING_SKIPLIST 跳錶
8 OBJ_ENCODING_EMBSTR embstr的動態字串
9 OBJ_ENCODING_QUICKLIST 快速列表
10 OBJ_ENCODING_STREAM Stream流

五種資料結構對應的編碼方式

Redis中會根據儲存的資料型別不同,選擇不同的編碼方式。每種資料型別使用的編碼方式如下:

資料型別 編碼方式
OBJ_STRING raw、embstr、int
OBJ_LIST LinkedList和ZipList(3.2以前)、QuickList(3.2以後)
OBJ_SET HT、intset
OBJ_ZSET ZipList、HT、SkipList
OBJ_HASH HT、ZipList

Redis物件:String

String是Redis中最常見的資料儲存型別,下圖為SDS原始碼:

1653987159575

開發中,能用embstr編碼就用,若不能則用int編碼,raw編碼最後考慮

  1. 基本編碼方式是raw,基於簡單動態字串(SDS)實現,儲存上限為512mb。驗證方式:使用命令object encoding key,下面說的另外情況也可透過這種方式驗證

image-20231018215119348

  1. 如果儲存的SDS長度小於44位元組,則會採用embstr編碼,此時object head與SDS是一段連續空間。申請記憶體時只需要呼叫一次記憶體分配函式,效率更高

image-20231018214804080

  1. 如果⼀個String型別的value值是數字,那麼Redis內部會把它轉成long型別來儲存,從⽽減少記憶體的使用

  2. 如果儲存的字串是整數值,並且大小在LONG_MAX範圍內,則會採用INT編碼:直接將資料儲存在RedisObject的ptr指標位置(剛好8位元組),不再需要SDS了

image-20231018215051896

驗證圖:

image-20231018220624001

當然,確切地說,String在Redis中是⽤⼀個robj來表示的

用來表示String的robj可能編碼成3種內部表⽰:OBJ_ENCODING_RAW,OBJ_ENCODING_EMBSTR,OBJ_ENCODING_INT。其中前兩種編碼使⽤的

是sds來儲存,最後⼀種OBJ_ENCODING_INT編碼直接把string存成了long型

在“對string進行incr, decr等操作時”,如果它內部是OBJ_ENCODING_INT編碼,那麼可以直接行加減操作;如果它內部是OBJ_ENCODING_RAW或

OBJ_ENCODING_EMBSTR編碼,那麼Redis會先試圖把sds儲存的字串轉成long型,如果能轉成功,再進行加減操作

“對⼀個內部表示成long型的string執行append, setbit, getrange這些命令”,針對的仍然是string的值(即⼗進製表示的字串),而不是針對內部表⽰的long型進⾏操作。比如字串”32”,如果按照字元陣列來解釋,它包含兩個字元,它們的ASCII碼分別是0x33和0x32。當我們執行命令setbit key 7 0的時候,相當於把字元0x33變成了0x32,這樣字串的值就變成了”22”。⽽如果將字串”32”按照內部的64位long型來解釋,那麼它是0x0000000000000020,在這個基礎上執⾏setbit位操作,結果就完全不對了。因此,在這些命令的實現中,會把long型先轉成字串再進行相應的操作

Redis物件:List

Redis的List型別可以從首、尾操作列表中的元素,滿足這種條件的有以下方式:

  • LinkedList :普通連結串列,可以從雙端訪問,記憶體佔用較高,記憶體碎片較多
  • ZipList :壓縮列表,可以從雙端訪問,記憶體佔用低,儲存上限低
  • QuickList:LinkedList + ZipList,可以從雙端訪問,記憶體佔用較低,包含多個ZipList,儲存上限高

在3.2版本之前,Redis採用LinkedList和ZipList來實現List,當元素數量小於512並且元素大小小於64位元組時採用ZipList編碼,超過則採用LinkedList編碼。

在3.2版本之後,Redis統一採用QuickList來實現List:

image-20231026122343136

1653987313461

Redis物件:Set

Set是Redis中的單列集合,滿足“無序不重複、查詢效率高”的特點

什麼樣的資料結構可以滿足?

HashTable,也就是Redis中的Dict,不過Dict是雙列集合(可以存鍵、值對)

Set是Redis中的集合,不一定確保元素有序,可以滿足元素唯一、查詢效率要求極高。

為了查詢效率和唯一性,set採用HT編碼(Dict)。Dict中的key用來儲存元素,value統一為null。當儲存的所有資料都是整數,並且元素數量不超過 set-max-intset-entries 時,Set會採用IntSet編碼,以節省記憶體

1653987388177

image-20231026172009595

1653987454403

Redis物件:SortedSet

SortedSet也就是ZSet,其中每一個元素都需要指定一個score值和member值:

  • 可以根據score值排序後
  • member必須唯一
  • 可以根據member查詢分數

1653992091967

因此,zset底層資料結構必須滿足鍵值儲存、鍵必須唯一、可排序這幾個需求。哪種編碼結構可以滿足?

  • SkipList:可以排序,並且可以同時儲存score和ele值(member)
  • HT(Dict):可以鍵值儲存,並且可以根據key找value

1653992121692

1653992172526

當元素數量不多時,HT和SkipList的優勢不明顯,而且更耗記憶體。因此zset還會採用ZipList結構來節省記憶體,不過需要同時滿足兩個條件:

  • 元素數量小於 zset_max_ziplist_entries,預設值128
  • 每個元素都小於 zset_max_ziplist_value字 節,預設值64

ziplist本身沒有排序功能,而且沒有鍵值對的概念,因此需要有zset透過編碼實現:

  • ZipList是連續記憶體,因此score和element是緊挨在一起的兩個entry, element在前,score在後
  • score越小越接近隊首,score越大越接近隊尾,按照score值升序排列

1653992238097

image-20231026172217766

1653992299740

Redis物件:Hash

Hash結構與Redis中的Zset非常類似:

  • 都是鍵值儲存
  • 都需求根據鍵獲取值
  • 鍵必須唯一

區別如下:

  • zset的鍵是member,值是score;hash的鍵和值都是任意值
  • zset要根據score排序;hash則無需排序

底層實現方式:壓縮列表ziplist 或者 字典dict
當Hash中資料項比較少的情況下,Hash底層才⽤壓縮列表ziplist進⾏儲存資料,隨著資料的增加,底層的ziplist就可能會轉成dict,具體配置如下:

hash-max-ziplist-entries 512

hash-max-ziplist-value 64

當滿足上面兩個條件其中之⼀的時候,Redis就使⽤dict字典來實現hash。
Redis的hash之所以這樣設計,是因為當ziplist變得很⼤的時候,它有如下幾個缺點:

  • 每次插⼊或修改引發的realloc操作會有更⼤的機率造成記憶體複製,從而降低效能。
  • ⼀旦發生記憶體複製,記憶體複製的成本也相應增加,因為要複製更⼤的⼀塊資料。
  • 當ziplist資料項過多的時候,在它上⾯查詢指定的資料項就會效能變得很低,因為ziplist上的查詢需要進行遍歷。

總之,ziplist本來就設計為各個資料項挨在⼀起組成連續的記憶體空間,這種結構並不擅長做修改操作。⼀旦資料發⽣改動,就會引發記憶體realloc,可能導致記憶體複製。

hash結構如下:

1653992339937

zset集合如下:

1653992360355

因此,Hash底層採用的編碼與Zset也基本一致,只需要把排序有關的SkipList去掉即可:

Hash結構預設採用ZipList編碼,用以節省記憶體。 ZipList中相鄰的兩個entry 分別儲存field和value

當資料量較大時,Hash結構會轉為HT編碼,也就是Dict,觸發條件有兩個:

  • ZipList中的元素數量超過了hash-max-ziplist-entries(預設512)
  • ZipList中的任意entry大小超過了hash-max-ziplist-value(預設64位元組)

Redis物件:hash原始碼

1653992413406

過期Key處理

Redis之所以效能強,最主要的原因就是基於記憶體儲存。然而單節點的Redis其記憶體大小不宜過大,會影響持久化或主從同步效能。
我們可以透過修改配置檔案來設定Redis的最大記憶體:

1653983341150

當記憶體使用達到上限時,就無法儲存更多資料了。為了解決這個問題,Redis提供了一些策略實現記憶體回收

記憶體過期策略

透過expire命令給Redis的key設定TTL(存活時間):key的TTL到期以後,再次訪問name返回的是nil,說明這個key已經不存在了,對應的記憶體也得到釋放。從而起到記憶體回收的目的

Redis本身是一個典型的key-value記憶體儲存資料庫,因此所有的key、value都儲存在前面玩過的Dict結構中。不過在其database結構體中,有兩個Dict:一個用來記錄key-value;另一個用來記錄key-TTL

1653983423128

1653983606531
  1. 問題:Redis是如何知道一個key是否過期?

利用兩個Dict分別記錄key-value對及key-ttl對

  1. 問題:是不是TTL到期就立即刪除了?

方式一:惰性刪除:顧明思議並不是在TTL到期後就立刻刪除,而是在訪問一個key的時候,檢查該key的存活時間,如果已經過期才執行刪除

1653983652865

方式二:週期刪除:顧明思議是透過一個定時任務,週期性的抽樣部分過期的key,然後執行刪除

執行週期有兩種:

  • Redis服務初始化函式initServer()中設定定時任務,按照server.hz的頻率來執行過期key清理,模式為SLOW
  • Redis的每個事件迴圈前會呼叫beforeSleep()函式,執行過期key清理,模式為FAST

SLOW模式規則:即:低頻率高時長

  • 執行頻率受server.hz影響,預設為10,即每秒執行10次,每個執行週期100ms。
  • 執行清理耗時不超過一次執行週期的25%.預設slow模式耗時不超過25ms
  • 逐個遍歷db,逐個遍歷db中的bucket,抽取20個key判斷是否過期
  • 如果沒達到時間上限(25ms)並且過期key比例大於10%,再進行一次抽樣,否則結束

FAST模式規則(過期key比例小於10%不執行 ):即:高頻率低時長

  • 執行頻率受beforeSleep()呼叫頻率影響,但兩次FAST模式間隔不低於2ms
  • 執行清理耗時不超過1ms
  • 逐個遍歷db,逐個遍歷db中的bucket,抽取20個key判斷是否過期
    如果沒達到時間上限(1ms)並且過期key比例大於10%,再進行一次抽樣,否則結束

記憶體淘汰策略

記憶體淘汰:就是當Redis記憶體使用達到設定的上限時,主動挑選部分key刪除以釋放更多記憶體的流程

Redis會在處理客戶端命令的方法processCommand()中嘗試做記憶體淘汰:

1653983978671

Redis支援8種不同策略來選擇要刪除的key:

  1. noeviction: 不淘汰任何key,但是記憶體滿時不允許寫入新資料,預設就是這種策略。
  2. volatile-ttl: 對設定了TTL的key,比較key的剩餘TTL值,TTL越小越先被淘汰
  3. allkeys-random:對全體key ,隨機進行淘汰。也就是直接從db->dict中隨機挑選
  4. volatile-random:對設定了TTL的key ,隨機進行淘汰。也就是從db->expires中隨機挑選。
  5. allkeys-lru: 對全體key,基於LRU演算法進行淘汰
  6. volatile-lru: 對設定了TTL的key,基於LRU演算法進行淘汰
  7. allkeys-lfu: 對全體key,基於LFU演算法進行淘汰
  8. volatile-lfu: 對設定了TTL的key,基於LFI演算法進行淘汰

比較容易混淆的有兩個:

  • LRU(Least Recently Used),最少最近使用。用當前時間減去最後一次訪問時間,這個值越大則淘汰優先順序越高。
  • LFU(Least Frequently Used),最少頻率使用。會統計每個key的訪問頻率,值越小淘汰優先順序越高。

edis的資料都會被封裝為RedisObject結構:

1653984029506

LFU的訪問次數之所以叫做邏輯訪問次數,是因為並不是每次key被訪問都計數,而是透過運算:

  • 生成0~1之間的隨機數R
  • 計算 (舊次數 * lfu_log_factor + 1),記錄為P
  • 如果 R < P ,則計數器 + 1,且最大不超過255
  • 訪問次數會隨時間衰減,距離上一次訪問時間每隔 lfu_decay_time 分鐘,計數器 -1

結合原始碼整套邏輯如下:

image-20231030222610465

相關文章