Redis解讀(4):Redis中HyperLongLog、布隆過濾器、限流、Geo、及Scan等進階應用

DOONDO發表於2021-07-28

Redis中的HyperLogLog

一般我們評估一個網站的訪問量,有幾個主要的引數:

  • pv,Page View,網頁的瀏覽量
  • uv,User View,訪問的使用者

一般來說,pv 或者 uv 的統計,可以自己來做,也可以藉助一些第三方的工具,比如 cnzz,友盟 等。

如果自己實現,pv 比較簡單,可以直接通過 Redis 計數器就能實現。但是 uv 就不一樣,uv 涉及到另外一個問題,去重。

我們首先需要在前端給每一個使用者生成一個唯一 id,無論是登入使用者還是未登入使用者,都要有一個唯一 id,這個 id 伴隨著請求一起到達後端,在後端我們通過 set 集合中的 sadd 命令來儲存這個 id,最後通過 scard 統計集合大小,進而得出 uv 資料。

HyperLogLog 問題場景:

例如:CSDN、部落格園這種網站首頁,或者商城的爆款頁面、活動頁面。高峰時期都是千萬級別的 UV,需要的儲存空間就非常驚人,通過Redis中的 Set型別資料結構儲存,並不是最佳的解決方案。而且,像 UV 統計這種,一般也不需要特別精確,對於網站服務商來說,800w 的 uv 和 803w 的 uv 資料,其實差別不大。所以,這個場景下,Redis中的 HyperLogLog 就能很好的去解決這個問題。

HyperLogLog 提供了一套不怎麼精確但是夠用的去重方案,會有誤差,官方給出的誤差資料是 0.81%,這個精確度,統計 UV 夠用了。

HyperLogLog 主要提供了兩個命令:pfadd 和 pfcount。

pfadd 用來新增記錄,類似於 sadd ,新增過程中,重複的記錄會自動去重。

pfcount 則用來統計資料。

127.0.0.1:6379> pfadd uv u1 u2 u3
(integer) 1
127.0.0.1:6379> PFCOUNT uv
(integer) 3
127.0.0.1:6379> pfadd uv u1 u2 u3 u3 u2 u1 u4 
(integer) 1
127.0.0.1:6379> PFCOUNT uv
(integer) 4

目前我們在伺服器上統計UV 資料量少的時候看不出來誤差。在 Java 中,我們多新增幾個元素:

package org.taoguoguo.hyper;

import org.taoguoguo.redis.Redis;

/**
 * @author taoguoguo
 * @description HyperLogLog
 * @website https://www.cnblogs.com/doondo
 * @create 2021-04-18 14:10
 */
public class HyperLogLog {
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis -> {
            for (int i = 0; i < 1000; i++) {
                //pfadd uv u0 u1
                jedis.pfadd("uv","u"+i,"u"+(i+1));
            }
            long uv = jedis.pfcount("uv");
            System.out.println("uv統計值為:" + uv);
        });
    }
}

控制檯列印值:

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
uv統計值為:994

Process finished with exit code 0

理論值是 1001,實際列印出來 994,有誤差,但是在可以接受的範圍內。

除了 pfadd 和 pfcount 之外,還有一個命令 pfmerge ,合併多個統計結果,在合併的過程中,會自動 去重多個集合中重複的元素。

布隆過濾器

1.問題場景

我們用 HyperLogLog 來估計一個數,有偏差但是也夠用。HyperLogLog 主要提供兩個方法:

  • pfadd
  • pfcount

但是 HyperLogLog 沒有判斷是否包含的方法,例如 pfexists 、pfcontains 等。沒有這樣的方法存在,但是我們有這樣的業務需求。

例如刷今日頭條,推送的內容有相似的,但是沒有重複的,如何將未推送過的內容進行去重推送?50億個電話號碼,清單中的200個已經拉入工信部黑名單,判斷是否在這50億電話號碼中。此類場景如何進行過濾統計?

解決方案很多,例如將使用者的瀏覽歷史記錄下來,然後每次推送時去比較該條訊息是否已經給使用者推送了。但是這種方式效率極低,不推薦。即便使用快取,快取資料量會特別多,效率也非常低,並不適合此類場景。

使用布隆過濾器,就能很好的解決這個問題。

2.Bloom Filter 介紹

Bloom Filter 專門用來解決我們上面所說的去重問題的,使用 Bloom Filter 不會像使用快取那麼浪費空間。當然,他也存在一個小小問題,就是不太精確。

Bloom Filter 相當於是一個不太精確的 set 集合,我們可以利用它裡邊的 contains 方法去判斷某一個物件是否存在,但是需要注意,這個判斷不是特別精確。一般來說,通過 contains 判斷某個值不存在,那就一定不存在,但是判斷某個值存在的話,則他可能不存在。

以今日頭條為例,假設我們將使用者的瀏覽記錄用 B 表示,A 表示使用者沒有瀏覽的新聞,現在要給使用者推送訊息,先去 B 裡邊判斷這條訊息是否已經推送過,如果判斷結果說沒推送過(B 裡邊沒有這條記錄),那就一定沒有推送過。如果判斷結果說有推送(B 裡邊也有可能沒有這條訊息),這個時候該條訊息就不會推送給使用者,導致使用者錯過該條訊息,當然這是概率極低的。

3.Bloom Filter 原理

每一個布隆過濾器,在 Redis 中都對應了一個大型的位陣列以及幾個不同的 hash 函式(無偏hash)。

所謂的 add 操作是這樣的:首先根據幾個不同的 hash 函式給元素進行 hash 運算一個整數索引值,拿到這個索引值之後,對位數
組的長度進行取模運算,得到一個位置,每一個 hash 函式都會得到一個位置,將位陣列中對應的位置設定位 1 ,這樣就完成了新增操作。

當判斷元素是否粗存在時,依然先對元素進行 hash 運算,將運算的結果和位陣列取模,然後去對應的
位置檢視是否有相應的資料,如果有,表示元素可能存在(因為這個有資料的地方也可能是其他元素存
進來的),如果沒有表示元素一定不存在。

Bloom Filter 中,誤判的概率和位陣列的大小有很大關係,位陣列越大,誤判概率越小,當然佔用的存
儲空間越大;位陣列越小,誤判概率越大,當然佔用的儲存空間就小。

4.Bloom Filter 安裝

布隆過濾器外掛官方網站:https://oss.redislabs.com/redisbloom/Quick_Start/

這邊主要介紹三種安裝方式

  • Docker,指定容器對映埠和別名啟動

    docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest
    
  • Git 克隆編譯安裝啟動

    cd redis-6.2.1
    git clone https://github.com/RedisBloom/RedisBloom.git
    cd RedisBloom/
    make
    cd ..
    redis-server redis.conf --loadmodule ./RedisBloom/redisbloom.so
    
  • 上傳tar包,解壓安裝啟動

    cd redis-6.2.1
    tar -zxvf RedisBloom-2.2.5.tar.gz
    mv ./RedisBloom-2.2.5 /home/redis-6.2.1/RedisBloom
    redis-server redis.conf --loadmodule ./RedisBloom/redisbloom.so
    

安裝完成後,執行 bf.add 命令,測試安裝是否成功。

每次啟動時都輸入 redis-server redis.conf --loadmodule ./RedisBloom/redisbloom.so 比較
麻煩,我們可以將要載入的模組在 redis.conf 中提前配置好。

################################## MODULES #####################################
# Load modules at startup. If the server is not able to load modules
# it will abort. It is possible to use multiple loadmodule directives.
#
# loadmodule /path/to/my_module.so
# loadmodule /path/to/other_module.so
loadmodule /root/redis-5.0.7/RedisBloom/redisbloom.so

最下面這一句,配置完成後,以後只需要 redis-server redis.conf 來啟動 Redis 即可

5.基本用法

主要是兩類命令,新增和判斷是否存在。

  • bf.add 新增
  • bf.madd 批量新增
  • bf.exists 判斷是否存在
  • bf.mexists 批量判斷
#命令使用示例
127.0.0.1:6379> BF.ADD k1 v1
(integer) 1
127.0.0.1:6379> BF.EXISTS k1 v1
(integer) 1
127.0.0.1:6379> BF.EXISTS k1 v2
(integer) 0
127.0.0.1:6379> BF.MADD k1 v1 v2 v3 v4 
1) (integer) 0
2) (integer) 1
3) (integer) 1
4) (integer) 1
127.0.0.1:6379> BF.MEXISTS k1 v1 v2 v3 v4 v5
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 1
5) (integer) 0
127.0.0.1:6379> 

使用 Jedis 操作布隆過濾器,首先新增依賴:

<dependency>
  <groupId>com.redislabs</groupId>
  <artifactId>jrebloom</artifactId>
  <version>1.2.0</version>
</dependency>

進行測試:

package org.taoguoguo.bloom;

import io.rebloom.client.Client;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import redis.clients.jedis.JedisPool;

/**
 * @author taoguoguo
 * @description BloomFilter
 * @website https://www.cnblogs.com/doondo
 * @create 2021-04-21 21:04
 */
public class BloomFilter {

    public static void main(String[] args) {
        GenericObjectPoolConfig config = new GenericObjectPoolConfig();
        config.setMaxIdle(300);
        config.setMaxTotal(1000);
        config.setMaxWaitMillis(30000);
        config.setTestOnBorrow(true);
        JedisPool pool = new JedisPool(config, "192.168.124.5", 6379, 30000, "123456");
        Client client = new Client(pool);
        //存入資料
        for (int i = 0; i < 100000; i++) {
            client.add("name", "taoguoguo-"+ i );
        }
        //判斷是否存在
        boolean exists = client.exists("name", "taoguoguo-9");
        System.out.println(exists);
    }
}

控制檯列印:

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
true

布隆過濾器 存在是有可能會誤判斷的,我們將判斷數量加大,嘗試執行多次,是會存在誤判的。所以布隆過濾器判斷不存在時一定準確,判斷存在時不一定準確。

6.引數配置

預設情況下,我們使用的布隆過濾器它的錯誤率是 0.01 ,預設的元素大小是 100。但是這兩個引數也
是可以配置的。
我們可以呼叫 bf.reserve 方法進行配置。

BF.RESERVE k1 0.0001 1000000

第一個引數是 key,第二個引數是錯誤率,錯誤率越低,佔用的空間越大,第三個引數預計儲存的數
量,當實際數量超出預計數量時,錯誤率會上升。

7.應用場景

  • 之前說的新聞推送過濾、電話號碼過濾是一個應用場景

  • 快取穿透問題,又叫快取擊穿問題。

    假設我有 1億 條使用者資料,現在查詢使用者要去資料庫中查,效率低而且資料庫壓力大,所以我們會把請
    求首先在 Redis 中處理(活躍使用者存在 Redis 中),Redis 中沒有的使用者,再去資料庫中查詢。

    現在可能會存在一種惡意請求,這個請求攜帶上了很多不存在的使用者,這個時候 Redis 無法攔截下來請
    求,所以請求會直接跑到資料庫裡去。這個時候,這些惡意請求會擊穿我們的快取,甚至資料庫,進而
    引起“雪崩效應”。

    為了解決這個問題,我們就可以使用布隆過濾器。將 1億條使用者資料存在 Redis 中不現實,但是可以存
    在布隆過濾器中,請求來了,首先去判斷資料是否存在,如果存在,再去資料庫中查詢,否則就不去數
    據庫中查詢。

Redis限流

1.預備知識

Pipeline(管道)本質上是由客戶端提供的一種操作。Pipeline 通過調整指令列表的讀寫順序,可以大幅度的節省 IO 時間,提高效率。

怎麼理解這個呢?假設A使用者在客戶端做寫入快取操作和讀取快取操作,B使用者也在客戶端做寫入快取操作和讀取快取操作。如果沒有使用Piepline技術,那麼A、B使用者寫操作和讀操作一共需要使用4個網路來回。使用Pipeline進行指令調整,可以把寫操作放入同一個網路來回進行寫,讀取操作放入同一個網路來回進行讀取,大幅度節省網路IO,提高效率。

2.簡單限流

簡單限流思路其實很簡單,就是在一個限流週期的時間視窗允許執行的操作次數,這邊自己編寫的一個程式碼如下,能達到基本的限流需求。

package org.taoguoguo.limit;

import org.taoguoguo.redis.Redis;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;

/**
 * @author taoguoguo
 * @description RateLimiter Redis簡單限流
 * @website https://www.cnblogs.com/doondo
 * @create 2021-04-21 21:49
 */
public class RateLimiter {
    private Jedis jedis;

    public RateLimiter(Jedis jedis) {
        this.jedis = jedis;
    }

    /**
     * 限流方法
     * @param user 操作使用者,相當於限流的物件
     * @param action 具體的操作
     * @param period 時間戳 限流的週期
     * @param maxCount 限流次數
     * @return
     */
    public boolean isAllowed(String user, String action, int period, int maxCount){
        //1.首先生成一個key,資料用zset儲存
        String key = user + "-" + action;
        //2.獲取當前時間戳
        long nowTime = System.currentTimeMillis();
        //3.建立管道
        Pipeline pipelined = jedis.pipelined();
        //開啟任務執行
        pipelined.multi();
        //4.將當前操作先儲存下來
        pipelined.zadd(key, nowTime, String.valueOf(nowTime));
        //5.移除時間窗之外的資料 假設我要統計30S內的操作次數 先移除當前時間之前的統計時間週期內的操作次數
        pipelined.zremrangeByScore(key, 0, nowTime- period * 1000);
        //6.統計週期內剩下的key
        Response<Long> response = pipelined.zcard(key);
        //7.將當前key設定一個過期時間,過期時間就是時間窗
        pipelined.expire(key, period+1);
        //執行任務
        pipelined.exec();
        //關閉管道
        pipelined.close();
        return response.get() <= maxCount;
    }

    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(j -> {
            RateLimiter rateLimiter = new RateLimiter(j);
            for (int i = 0; i < 20; i++) {
                boolean allowed = rateLimiter.isAllowed("taoguoguo", "addOrder", 5, 3);
                System.out.println(allowed);
            }
        });
    }
}

3.Redis-Cell 限流

Redis4.0 開始提供了一個 Redis-Cell 模組,這個模組使用漏斗演算法,提供了一個非常好用的限流指令。

漏斗演算法就像名字一樣,是一個漏斗,請求從漏斗的大口進,然後從小口出進入到系統中,這樣,無論
是多大的訪問量,最終進入到系統中的請求,都是固定的。

使用漏斗演算法,需要我們首先安裝 Redis-Cell 模組:https://github.com/brandur/redis-cell

安裝步驟

  1. 安裝redis-cell外掛

    wget https://github.com/brandur/redis-cell/releases/download/v0.2.4/redis-cell-
    v0.2.4-x86_64-unknown-linux-gnu.tar.gz
    tar -zxvf redis-cell-v0.2.4-x86_64-unknown-linux-gnu.tar.gz
    mkdir redis-cell
    mv libredis_cell.d ./redis-cell
    mv libredis_cell.so ./redis-cell
    
  2. 安裝 GLIBC依賴,不裝GLIBC可能會出現配置完成 reids-cell ,reids服務缺少依賴啟動不成功。

    // 下載 glibc 壓縮包
    wget http://ftp.gnu.org/gnu/glibc/glibc-2.18.tar.gz 
    // 解壓 glibc 壓縮包
    tar -zxvf glibc-2.18.tar.gz
    // 進入解壓後的目錄
    cd glibc-2.18
    // 建立編譯目錄
    mkdir build
    // 進入到建立好的目錄
    cd build/
    // 編譯、安裝
    ../configure --prefix=/usr --disable-profile --enable-add-ons --with-headers=/usr/include --with-binutils=/usr/bin
    
    //這步會比較慢
    make -j 8
    
    make install
    

    接下來修改 redis.conf 檔案,載入額外的模組

    loadmodule /home/redis-6.2.1/redis-cell/libredis_cell.so
    

    然後,啟動 Redis:

    redis-server redis.conf
    

    redis 啟動成功後,如果存在 CL.THROTTLE 命令,說明 redis-cell 已經安裝成功了。

CL.THROTTLE 命令使用

該命令一共有五個引數:

  • 第一個引數是 key
  • 第二個引數是漏斗的容量
  • 時間窗內可以操作的次數
  • 時間窗
  • 每次漏出數量

執行完成後,返回值也有五個:

  • 第一個 0 表示允許,1表示拒絕
  • 第二個引數是漏斗的容量
  • 第三個引數是漏斗的剩餘空間
  • 如果拒絕了,多長時間後,可以再試
  • 多長時間後,漏斗會完全空出來

命令示例

127.0.0.1:6379> CL.THROTTLE taoguoguo-publish 10 10 60 1
1) (integer) 0
2) (integer) 11
3) (integer) 10
4) (integer) -1
5) (integer) 6

4.客戶端Lettuce擴充

定義一個Redis命令擴充介面,可通過命令描述在客戶端自由擴充Redis的命令

package org.taoguoguo;

import io.lettuce.core.dynamic.Commands;
import io.lettuce.core.dynamic.annotation.Command;

import java.util.List;

/**
 * @author taoguoguo
 * @description RedisCommandInterface   Redis漏斗限流擴充介面
 * @website https://www.cnblogs.com/doondo
 * @create 2021-04-22 15:29
 */
public interface RedisCommandInterface extends Commands {

    @Command("CL.THROTTLE ?0 ?1 ?2 ?3 ?4")
    List<Object> throttle(String key, long init, long count, Long period, Long quota);

}

命令呼叫

package org.taoguoguo;

import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.dynamic.RedisCommandFactory;

import java.util.List;

/**
 * @author taoguoguo
 * @description ThrottleTest
 * @website https://www.cnblogs.com/doondo
 * @create 2021-04-22 15:32
 */
public class ThrottleTest {
    public static void main(String[] args) {
        RedisClient redisClient = RedisClient.create("redis://123456@172.20.10.2");
        StatefulRedisConnection<String, String> connect = redisClient.connect();
        RedisCommandFactory factory = new RedisCommandFactory(connect);
        RedisCommandInterface commands = factory.getCommands(RedisCommandInterface.class);
        List<Object> list = commands.throttle("taoguoguo", 10L, 10L, 60L, 1L);
        System.out.println(list);
    }
}

Redis Geo

Redis3.2 開始提供了 GEO 模組。該模組也使用了 GeoHash 演算法

1.Geo Hash演算法

核心思想:GeoHash 是一種地址編碼方法,使用這種方式,能夠將二維的空間經緯度資料編碼成一個一維字串。

地球上經緯度的劃分:以經過倫敦格林尼治天文臺舊址的經線為 0 度經線,向東就是東經,向西就是西經。如果我們將西經定
義負,經度的範圍就是 [-180,180]。緯度北緯 90 度到南緯 90 度,如果我們將南緯定義負,則緯度的範圍就是 [-90,90]。

接下來,以本初子午線和赤道為界,我們可以將地球上的點分配到一個二維座標中:

GeoHash 演算法就是基於這樣的思想,劃分的次數越多,區域越多,每個區域中的面積就更小了,精確度就會提高。

GeoHash 具體演算法:

​ 以北京天安門廣場為例(39.9053908600,116.3980007200)

  • 緯度的範圍在 (-90,90) 之間,中間值為 0,對於 39.9053908600 值落在 (0,90),因此得到的值為 1
  • (0,90) 的中間值為 45,39.9053908600 落在 (0,45) 之間,因此得到一個 0
  • (0,45) 的中間值為 22.5,39.9053908600 落在 (22.5,45)之間,因此得到一個 1
  • ......

這樣,我們得到的緯度二進位制是 101,按照同樣的步驟,我們可以算出來經度的二進位制是 110

接下來將經緯度合併(經度佔偶數位,緯度佔奇數位):111001

按照 Base32 (0-9,b-z,去掉 a i l 0)對合並後的二進位制資料進行編碼,編碼的時候,先將二進位制轉換為
十進位制,然後進行編碼。

將編碼得到的字串,可以拿去 geohash.org 網站上解析

GeoHash 有哪些特點:

  • 用一個字串表示經緯度
  • GeoHash 表示的是一個區域,而不是一個點
  • 編碼格式有規律,例如一個地址編碼之後的格式是 123,另一個地址編碼之後的格式是 123456,從字串上就可以看出來,123456 處於 123 之中。

2.Redis 中的使用

經緯度查詢網站:http://www.gpsspg.com/maps.htm

新增地址:

GEOADD city 116.3980007200 39.9053908600 beijing
GEOADD city 114.0592002900 22.5536230800 shenzhen

檢視兩個地址之間的距離:

127.0.0.1:6379> GEODIST city beijing shenzhen km
"1942.5435"

獲取元素的位置:

127.0.0.1:6379> GEOPOS city beijing
1) 1) "116.39800339937210083"
 2) "39.90539144357683909"

獲取元素 hash 值:

127.0.0.1:6379> GEOHASH city beijing
1) "wx4g08w3y00"

通過 hash 值可以檢視定位。http://geohash.org/wx4g08w3y00

檢視附近的人:

127.0.0.1:6379> GEORADIUSBYMEMBER city beijing 200 km count 3 asc
1) "beijing"

以北京為中心,方圓 200km 以內的城市找出來 3 個,按照遠近順序排列,這個命令不會排除 北京。
當然,也可以根據經緯度來查詢(將 member 換成對應的經緯度):

127.0.0.1:6379> GEORADIUS city 116.3980007200 39.9053908600 2000 km withdist
withhash withcoord count 4 desc

Redis Scan

1.簡單介紹

scan 實際上是 keys 的一個升級版。

可以用 keys 來查詢 key,在查詢的過程中,可以使用萬用字元。keys 雖然用著還算方便,但是沒有分頁功能。同時因為 Redis 是單執行緒,所以 key 的執行會比較消耗時間,特別是當資料量大的時候,影響整個程式的執行。

為了解決 keys 存在的問題,從 Redis2.8 中開始,引入了 scan。

scan 具備 keys 的功能,但是不會阻塞執行緒,而且可以控制每次返回的結果數。

2.基本用法

首先準備 10000 條測試資料:

package org.taoguoguo.scan;

import org.taoguoguo.redis.Redis;

/**
 * @author taoguoguo
 * @description ScanTest
 * @website https://www.cnblogs.com/doondo
 * @create 2021-04-25 14:27
 */
public class ScanTest {
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis -> {
            for (int i = 0; i < 10000; i++) {
                jedis.set("k" + i, "v" + i);
            }
        });
    }
}

scan 命令一共提供了三個引數,第一個 cursor,第二個引數是 key,第三個引數是 limit。

cursor 實際上是指一維陣列的位置索引,limit 則是遍歷的一維陣列個數(所以每次返回的資料大小可能不確定)。

scan 0 match k8* count 1000

3.遍歷原理及漸進式 rehash機制

SCAN的遍歷順序

假設目前有三條資料:

127.0.0.1:6379> keys *
1) "key1"
2) "db_number"
3) "myKey"
127.0.0.1:6379> scan 0 match * count 1
1) "2"
2) 1) "key1"
127.0.0.1:6379> scan 2 match * count 1
1) "1"
2) 1) "myKey"
127.0.0.1:6379> scan 1 match * count 1
1) "3"
2) 1) "db_number"
127.0.0.1:6379> scan 3 match * count 1
1) "0"
2) (empty list or set)
127.0.0.1:6379>

在遍歷的過程中,大家發現遊標的順序是 0 2 1 3,從十進位制來看好像沒有規律,但是從轉為二進位制,則是有規律的:

00->10->01->11

這種規律就是高位進1,傳統的二進位制加法,是從右往左加,這裡是從左往右加。

實際上,在 Redis 中,它的具體計算流程給是這樣:

  1. 將要計算的數字反轉
  2. 給反轉後的數字加 1
  3. 再反轉

那麼為什麼不是按照 0、1、2、3、4...這樣的順序遍歷呢?因為主要考慮到兩個問題:

  1. 字典擴容
  2. 字典縮容

根據Scan遍歷原理假如我們將要訪問 110 時,發生了擴容,此時 scan 就會從 0110 開始遍歷,之前已經被遍歷過的元素就不會被重複遍歷了

假如我們將要訪問 110 時,發生縮容,此時 scan 就會從 10 開始遍歷,這個時候,也會遍歷到 010,但是 010 之前的不會再被遍歷了。所以,在發生縮容的時候,可能返回重複的元素。

Redis一共支援5種資料結構,hash是其中的一種,在hash擴容的時候採用的是漸進式rehash的方式。要想深入理解漸進式rehash,首先要了解以下Redis中hash的資料結構。 雜湊表節點 typedef struct...

雜湊表節點

typedef struct dictEntry {
    void *key; // 鍵
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; // 值
    struct dictEntry *next; // 下一個節點
} dictEntry;

雜湊表

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table; // 雜湊表陣列
    unsigned long size; // 雜湊表大小
    unsigned long sizemask; // 掩碼,計算索引值,size-1
    unsigned long used; // 雜湊表已有節點的數量
} dictht;

字典

typedef struct dict {
    dictType *type; // 型別特定函式
    void *privdata; // 私有資料
    dictht ht[2]; // 雜湊表
    // rehash索引
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

特定函式

typedef struct dictType {
    // 計算雜湊值的函式
    uint64_t (*hashFunction)(const void *key);
    // 複製鍵的函式
    void *(*keyDup)(void *privdata, const void *key);
    // 複製值的函式
    void *(*valDup)(void *privdata, const void *obj);
    // 對比鍵的函式
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    // 銷燬鍵的函式
    void (*keyDestructor)(void *privdata, void *key);
    // 銷燬值的函式
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

字典中包含一個資料結構dicththt陣列,一般情況下字典只是用ht[0]用來儲存資料,ht[1]在rehash時使用。

雜湊演算法原理

當向字典中新增一個元素時(假設此時 rehashidx = -1,也就是沒有進行rehash),首先通過dict->type->hashFunction計算該元素的hash值,然後通過hash & dict->ht[x].sizemask計算雜湊地址index。如果該元素對應的下標沒有資料,則直接新增,否則採用鏈地址法新增到hash對應index元素的連結串列尾部。

rehash原理

隨著操作的不斷執行,雜湊表中的元素會逐漸增加或者減少,為了讓雜湊表的負載因子維持在一個合理的範圍內,程式需要對雜湊表的大小進行相應的擴容和收縮。步驟如下:

  1. ht[1]雜湊表分配空間。如果是擴容操作,ht[1]的大小為第一個大於等於ht[0].used*22n次方冪,如果是收縮操作,ht[1]的大小為第一個大於等於ht[0].used2n次方冪
  2. 將儲存在ht[0]中的所有鍵值對rehash到ht[1]:rehash指的是重新計算鍵的雜湊值和索引值,然後將鍵值對放到ht[1]對應位置上
  3. ht[0]包含的所有鍵值對都遷移到ht[1]之後,釋放ht[0],將ht[1]設定為ht[0],並在ht[1]新建立一個空白雜湊表,為下一次rehash做準備

漸進式rehash原理

在擴容和收縮的時候,如果雜湊字典中有很多元素,一次性將這些鍵全部rehash到ht[1]的話,可能會導致伺服器在一段時間內停止服務。所以,採用漸進式rehash的方式,詳細步驟如下:

  1. ht[1]分配空間,讓字典同時持有ht[0]ht[1]兩個雜湊表
  2. rehashindex的值設定為0,表示rehash工作正式開始
  3. 在rehash期間,每次對字典執行增刪改查操作是,程式除了執行指定的操作以外,還會順帶將ht[0]雜湊表在rehashindex索引上的所有鍵值對rehash到ht[1],當rehash工作完成以後,rehashindex的值+1
  4. 隨著字典操作的不斷執行,最終會在某一時間段上ht[0]的所有鍵值對都會被rehash到ht[1],這時將rehashindex的值設定為-1,表示rehash操作結束

漸進式rehash採用的是一種分而治之的方式,將rehash的操作分攤在每一個的訪問中,避免集中式rehash而帶來的龐大計算量。

需要注意的是在漸進式rehash的過程,如果有增刪改查操作時,如果index大於rehashindex,訪問ht[0],否則訪問ht[1]

4.Scan其他指令

scan 是一系列的指令,除了遍歷所有的 key 之外,也可以遍歷某一個型別的 key,對應的命令有:

  • zscan-->zset
  • hscan-->hash
  • sscan-->set

相關文章