1. 程式人生 > 實用技巧 >redis的五種結構雜湊表型別

redis的五種結構雜湊表型別

前言

Redis是基於c語言編寫的開源非關係型記憶體資料庫,可以用作資料庫、快取、訊息中介軟體,這麼優秀的東西一定要一點一點的吃透它。

關於Redis的文章之前也寫過三篇,閱讀量和讀者的反映都還可以,其中第一篇是Redis的快取三大問題[看完這篇Redis快取三大問題,保你能和麵試官互扯。]。

第二篇是Redis的記憶體管理和淘汰策略[別再問我Redis記憶體滿了該怎麼辦了]和持久化[面試造飛機系列:面對Redis持久化連環Call,你還頂得住嗎?]。

這是關於Redis的第三篇文章,主要講解Redis的五種資料結構詳解,包括這五種的資料結構的底層原理實現。

理論肯定是要用於實踐的,因此最重要的還是實戰部分,也就是這裡還會講解五種資料結構的應用場景。

話不多說,我們直接進入主題,很多人都知道Redis的五種資料結構包括以下五種:

  1. String:字串型別
  2. List:列表型別
  3. Set:無序集合型別
  4. ZSet:有序集合型別
  5. Hash:雜湊表型別

但是作為一名優秀的程式設計師可能不能只停留在只會用這五種型別進行crud工作,還是得深入瞭解這五種資料結構的底層原理。

Redis核心物件

在Redis中有一個「核心的物件」叫做redisObject,是用來表示所有的key和value的,用redisObject結構體來表示String、Hash、List、Set、ZSet五種資料型別。

redisObject的原始碼在redis.h中,使用c語言寫的,感興趣的可以自行檢視,關於redisObject我這裡畫了一張圖,表示redisObject的結構如下所示:

在redisObject中「type表示屬於哪種資料型別,encoding表示該資料的儲存方式」,也就是底層的實現的該資料型別的資料結構。因此這篇文章具體介紹的也是encoding對應的部分。

那麼encoding中的儲存型別又分別表示什麼意思呢?具體資料型別所表示的含義,如下圖所示:

可能看完這圖,還是覺得一臉懵。不慌,會進行五種資料結構的詳細介紹,這張圖只是讓你找到每種中資料結構對應的儲存型別有哪些,大概腦子裡有個印象。

舉一個簡單的例子,你在Redis中設定一個字串key 234,然後檢視這個字串的儲存型別就會看到為int型別,非整數型的使用的是embstr儲存型別,具體操作如下圖所示:

String型別

String是Redis最基本的資料型別,上面的簡介中也說到Redis是用c語言開發的。但是Redis中的字串和c語言中的字串型別卻是有明顯的區別。

String型別的資料結構儲存方式有三種int、raw、embstr。那麼這三種儲存方式有什麼區別呢?

int

Redis中規定假如儲存的是「整數型值」,比如set num 123這樣的型別,就會使用 int的儲存方式進行儲存,在redisObject的「ptr屬性」中就會儲存該值。

SDS

假如儲存的「字串是一個字串值並且長度大於32個位元組」就會使用SDS(simple dynamic string)方式進行儲存,並且encoding設定為raw;若是「字串長度小於等於32個位元組」就會將encoding改為embstr來儲存字串。

SDS稱為「簡單動態字串」,對於SDS中的定義在Redis的原始碼中有的三個屬性int len、int free、char buf[]

len儲存了字串的長度,free表示buf陣列中未使用的位元組數量,buf陣列則是儲存字串的每一個字元元素。

因此當你在Redsi中儲存一個字串Hello時,根據Redis的原始碼的描述可以畫出SDS的形式的redisObject結構圖如下圖所示:

SDS與c語言字串對比

Redis使用SDS作為儲存字串的型別肯定是有自己的優勢,SDS與c語言的字串相比,SDS對c語言的字串做了自己的設計和優化,具體優勢有以下幾點:

(1)c語言中的字串並不會記錄自己的長度,因此「每次獲取字串的長度都會遍歷得到,時間的複雜度是O(n)」,而Redis中獲取字串只要讀取len的值就可,時間複雜度變為O(1)。

(2)「c語言」中兩個字串拼接,若是沒有分配足夠長度的記憶體空間就「會出現緩衝區溢位的情況」;而「SDS」會先根據len屬性判斷空間是否滿足要求,若是空間不夠,就會進行相應的空間擴充套件,所以「不會出現緩衝區溢位的情況」

(3)SDS還提供「空間預分配」「惰性空間釋放」兩種策略。在為字串分配空間時,分配的空間比實際要多,這樣就能「減少連續的執行字串增長帶來記憶體重新分配的次數」

當字串被縮短的時候,SDS也不會立即回收不適用的空間,而是通過free屬性將不使用的空間記錄下來,等後面使用的時候再釋放。

具體的空間預分配原則是:「當修改字串後的長度len小於1MB,就會預分配和len一樣長度的空間,即len=free;若是len大於1MB,free分配的空間大小就為1MB」

(4)SDS是二進位制安全的,除了可以儲存字串以外還可以儲存二進位制檔案(如圖片、音訊,視訊等檔案的二進位制資料);而c語言中的字串是以空字串作為結束符,一些圖片中含有結束符,因此不是二進位制安全的。

為了方便易懂,做了一個c語言的字串和SDS進行對比的表格,如下所示:

c語言字串SDS
獲取長度的時間複雜度為O(n) 獲取長度的時間複雜度為O(1)
不是二進位制安全的 是二進位制安全的
只能儲存字串 還可以儲存二進位制資料
n次增長字串必然會帶來n次的記憶體分配 n次增長字串記憶體分配的次數<=n

String型別應用

說到這裡我相信很多人可以說已經精通Redis的String型別了,但是純理論的精通,理論還是得應用實踐,上面說到String可以用來儲存圖片,現在就以圖片儲存作為案例實現。

(1)首先要把上傳得圖片進行編碼,這裡寫了一個工具類把圖片處理成了Base64得編碼形式,具體得實現程式碼如下:

/**
     * 將圖片內容處理成Base64編碼格式
     * @param file
     * @return
     */
    public static String encodeImg(MultipartFile file) {
        byte[] imgBytes = null;
        try {
            imgBytes = file.getBytes();
        } catch (IOException e) {
            e.printStackTrace();
        }
        BASE64Encoder encoder = new BASE64Encoder();
        return imgBytes==null?null:encoder.encode(imgBytes );
    }

(2)第二步就是把處理後的圖片字串格式儲存進Redis中,實現的程式碼如下所示:

  /**
     * Redis儲存圖片
     * @param file
     * @return
     */
    public void uploadImageServiceImpl(MultipartFile image) {
        String imgId = UUID.randomUUID().toString();
        String imgStr= ImageUtils.encodeImg(image);
        redisUtils.set(imgId , imgStr);
        // 後續操作可以把imgId存進資料庫對應的欄位,如果需要從redis中取出,只要獲取到這個欄位後從redis中取出即可。
    }

這樣就是實現了圖片得二進位制儲存,當然String型別得資料結構得應用也還有常規計數:「統計微博數、統計粉絲數」等。

Hash型別

Hash物件的實現方式有兩種分別是ziplist、hashtable,其中hashtable的儲存方式key是String型別的,value也是以key value的形式進行儲存。

字典型別的底層就是hashtable實現的,明白了字典的底層實現原理也就是明白了hashtable的實現原理,hashtable的實現原理可以與HashMap的是底層原理相類比。

字典

兩者在新增時都會通過key計算出陣列下標,不同的是計演算法方式不同,HashMap中是以hash函式的方式,而hashtable中計算出hash值後,還要通過sizemask 屬性和雜湊值再次得到陣列下標。

我們知道hash表最大的問題就是hash衝突,為了解決hash衝突,假如hashtable中不同的key通過計算得到同一個index,就會形成單向連結串列(「鏈地址法」),如下圖所示:

rehash

在字典的底層實現中,value物件以每一個dictEntry的物件進行儲存,當hash表中的存放的鍵值對不斷的增加或者減少時,需要對hash表進行一個擴充套件或者收縮。

這裡就會和HashMap一樣也會就進行rehash操作,進行重新雜湊排布。從上圖中可以看到有ht[0]ht[1]兩個物件,先來看看物件中的屬性是幹嘛用的。

在hash表結構定義中有四個屬性分別是dictEntry **table、unsigned long size、unsigned long sizemask、unsigned long used,分別表示的含義就是「雜湊表陣列、hash表大小、用於計算索引值,總是等於size-1、hash表中已有的節點數」

ht[0]是用來最開始儲存資料的,當要進行擴充套件或者收縮時,ht[0]的大小就決定了ht[1]的大小,ht[0]中的所有的鍵值對就會重新雜湊到ht[1]中。

擴充套件操作:ht[1]擴充套件的大小是比當前 ht[0].used 值的二倍大的第一個 2 的整數冪;收縮操作:ht[0].used 的第一個大於等於的 2 的整數冪。

當ht[0]上的所有的鍵值對都rehash到ht[1]中,會重新計算所有的陣列下標值,當資料遷移完後ht[0]就會被釋放,然後將ht[1]改為ht[0],並新建立ht[1],為下一次的擴充套件和收縮做準備。

漸進式rehash

假如在rehash的過程中資料量非常大,Redis不是一次性把全部資料rehash成功,這樣會導致Redis對外服務停止,Redis內部為了處理這種情況採用「漸進式的rehash」

Redis將所有的rehash的操作分成多步進行,直到都rehash完成,具體的實現與物件中的rehashindex屬性相關,「若是rehashindex 表示為-1表示沒有rehash操作」

當rehash操作開始時會將該值改成0,在漸進式rehash的過程「更新、刪除、查詢會在ht[0]和ht[1]中都進行」,比如更新一個值先更新ht[0],然後再更新ht[1]。

而新增操作直接就新增到ht[1]表中,ht[0]不會新增任何的資料,這樣保證「ht[0]只減不增,直到最後的某一個時刻變成空表」,這樣rehash操作完成。

上面就是字典的底層hashtable的實現原理,說完了hashtable的實現原理,我們再來看看Hash資料結構的兩一種儲存方式「ziplist(壓縮列表)」

ziplist

壓縮列表(ziplist)是一組連續記憶體塊組成的順序的資料結構,壓縮列表能夠節省空間,壓縮列表中使用多個節點來儲存資料。

壓縮列表是列表鍵和雜湊鍵底層實現的原理之一,「壓縮列表並不是以某種壓縮演算法進行壓縮儲存資料,而是它表示一組連續的記憶體空間的使用,節省空間」,壓縮列表的記憶體結構圖如下:

壓縮列表中每一個節點表示的含義如下所示:

  1. zlbytes:4個位元組的大小,記錄壓縮列表佔用記憶體的位元組數。
  2. zltail:4個位元組大小,記錄表尾節點距離起始地址的偏移量,用於快速定位到尾節點的地址。
  3. zllen:2個位元組的大小,記錄壓縮列表中的節點數。
  4. entry:表示列表中的每一個節點。
  5. zlend:表示壓縮列表的特殊結束符號'0xFF'

再壓縮列表中每一個entry節點又有三部分組成,包括previous_entry_ength、encoding、content

  1. previous_entry_ength表示前一個節點entry的長度,可用於計算前一個節點的其實地址,因為他們的地址是連續的。
  2. encoding:這裡儲存的是content的內容型別和長度。
  3. content:content儲存的是每一個節點的內容。
  4. 說到這裡相信大家已經都hash這種資料結構已經非常瞭解,若是第一次接觸Redis五種基本資料結構的底層實現的話,建議多看幾遍,下面來說一說hash的應用場景。

    應用場景

    雜湊表相對於String型別儲存資訊更加直觀,儲存更加方便,經常會用來做使用者資料的管理,儲存使用者的資訊。

    hash也可以用作高併發場景下使用Redis生成唯一的id。下面我們就以這兩種場景用作案例編碼實現。

    儲存使用者資料

    第一個場景比如我們要儲存使用者資訊,一般使用使用者的ID作為key值,保持唯一性,使用者的其他資訊(地址、年齡、生日、電話號碼等)作為value值儲存。

    若是傳統的實現就是將使用者的資訊封裝成為一個物件,通過序列化儲存資料,當需要獲取使用者資訊的時候,就會通過反序列化得到使用者資訊。

  5. 但是這樣必然會造成序列化和反序列化的效能的開銷,並且若是隻修改其中的一個屬性值,就需要把整個物件序列化出來,操作的動作太大,造成不必要的效能開銷。

    若是使用Redis的hash來儲存使用者資料,就會將原來的value值又看成了一個k v形式的儲存容器,這樣就不會帶來序列化的效能開銷的問題。

    分散式生成唯一ID

  6. 第二個場景就是生成分散式的唯一ID,這個場景下就是把redis封裝成了一個工具類進行實現,實現的程式碼如下:

        // offset表示的是id的遞增梯度值
        public Long getId(String key,String hashKey,Long offset) throws BusinessException{
            try {
                if (null == offset) {
                    offset=1L;
                }
                // 生成唯一id
                return redisUtil.increment(key, hashKey, offset);
            } catch (Exception e) {
                //若是出現異常就是用uuid來生成唯一的id值
                int randNo=UUID.randomUUID().toString().hashCode();
                if (randNo < 0) {
                    randNo=-randNo;
                }
                return Long.valueOf(String.format("%16d", randNo));
            }
        

    List型別

    Redis中的列表在3.2之前的版本是使用ziplistlinkedlist進行實現的。在3.2之後的版本就是引入了quicklist

    ziplist壓縮列表上面已經講過了,我們來看看linkedlist和quicklist的結構是怎麼樣的。

    linkedlist是一個雙向連結串列,他和普通的連結串列一樣都是由指向前後節點的指標。插入、修改、更新的時間複雜度尾O(1),但是查詢的時間複雜度確實O(n)。

    linkedlist和quicklist的底層實現是採用連結串列進行實現,在c語言中並沒有內建的連結串列這種資料結構,Redis實現了自己的連結串列結構。

    Redis中連結串列的特性:

    1. 每一個節點都有指向前一個節點和後一個節點的指標。
    2. 頭節點和尾節點的prev和next指標指向為null,所以連結串列是無環的。
    3. 連結串列有自己長度的資訊,獲取長度的時間複雜度為O(1)。

    Redis中List的實現比較簡單,下面我們就來看看它的應用場景。

    應用場景

    Redis中的列表可以實現「阻塞佇列」,結合lpush和brpop命令就可以實現。生產者使用lupsh從列表的左側插入元素,消費者使用brpop命令從佇列的右側獲取元素進行消費。

    (1)首先配置redis的配置,為了方便我就直接放在application.yml配置檔案中,實際中可以把redis的配置檔案放在一個redis.properties檔案單獨放置,具體配置如下:

    spring
        redis:
            host: 127.0.0.1
            port: 6379
            password: user
            timeout: 0
            database: 2
            pool:
                max-active: 100
                max-idle: 10
                min-idle: 0
                max-wait: 100000

    (2)第二步建立redis的配置類,叫做RedisConfig,並標註上@Configuration註解,表明他是一個配置類。

    @Configuration
    public class RedisConfiguration {
    
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.password}")
    private String password;
    @Value("${spring.redis.pool.max-active}")
    private int maxActive;
    @Value("${spring.redis.pool.max-idle}")
    private int maxIdle;
    @Value("${spring.redis.pool.min-idle}")
    private int minIdle;
    @Value("${spring.redis.pool.max-wait}")
    private int maxWait;
    @Value("${spring.redis.database}")
    private int database;
    @Value("${spring.redis.timeout}")
    private int timeout;
    
    @Bean
    public JedisPoolConfig getRedisConfiguration(){
        JedisPoolConfig jedisPoolConfig= new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(maxActive);
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMinIdle(minIdle);
        jedisPoolConfig.setMaxWaitMillis(maxWait);
        return jedisPoolConfig;
    }
    
    @Bean
    public JedisConnectionFactory getConnectionFactory() {
        JedisConnectionFactory factory = new JedisConnectionFactory();
        factory.setHostName(host);
        factory.setPort(port);
        factory.setPassword(password);
        factory.setDatabase(database);
        JedisPoolConfig jedisPoolConfig= getRedisConfiguration();
        factory.setPoolConfig(jedisPoolConfig);
        return factory;
    }
    
    @Bean
    public RedisTemplate<?, ?> getRedisTemplate() {
        JedisConnectionFactory factory = getConnectionFactory();
        RedisTemplate<?, ?> redisTemplate = new StringRedisTemplate(factory);
        return redisTemplate;
    }
    }

    (3)第三步就是建立Redis的工具類RedisUtil,自從學了面向物件後,就喜歡把一些通用的東西拆成工具類,好像一個一個零件,需要的時候,就把它組裝起來。

    @Component
    public class RedisUtil {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    /**
    * 存訊息到訊息佇列中
    * @param key 鍵
    * @param value 值
    * @return
    */
    public boolean lPushMessage(String key, Object value) {
        try {
                redisTemplate.opsForList().leftPush(key, value);
                return true;
        } catch (Exception e) {
                e.printStackTrace();
                return false;
        }
    }
    
    /**
    * 從訊息佇列中彈出訊息
    * @param key 鍵
    * @return
    */
    public Object rPopMessage(String key) {
        try {
                return redisTemplate.opsForList().rightPop(key);
        } catch (Exception e) {
                e.printStackTrace();
                return null;
        }
    }
    
    /**
    * 檢視訊息
    * @param key 鍵
    * @param start 開始
    * @param end 結束 0 到 -1代表所有值
    * @return
    */
    public List<Object> getMessage(String key, long start, long end) {
        try {
                return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
                e.printStackTrace();
                return null;
        }
    }

    這樣就完成了Redis訊息佇列工具類的建立,在後面的程式碼中就可以直接使用。

    Set集合

    Redis中列表和集合都可以用來儲存字串,但是「Set是不可重複的集合,而List列表可以儲存相同的字串」,Set集合是無序的這個和後面講的ZSet有序集合相對。

    Set的底層實現是「ht和intset」,ht(雜湊表)前面已經詳細瞭解過,下面我們來看看inset型別的儲存結構。

    inset也叫做整數集合,用於儲存整數值的資料結構型別,它可以儲存int16_tint32_t或者int64_t的整數值。

    在整數集合中,有三個屬性值encoding、length、contents[],分別表示編碼方式、整數集合的長度、以及元素內容,length就是記錄contents裡面的大小。

    在整數集合新增元素的時候,若是超出了原集合的長度大小,就會對集合進行升級,具體的升級過程如下:

    1. 首先擴充套件底層陣列的大小,並且陣列的型別為新元素的型別。
    2. 然後將原來的陣列中的元素轉為新元素的型別,並放到擴充套件後陣列對應的位置。
    3. 整數集合升級後就不會再降級,編碼會一直保持升級後的狀態。

    應用場景

    Set集合的應用場景可以用來「去重、抽獎、共同好友、二度好友」等業務型別。接下來模擬一個新增好友的案例實現:

  7. @RequestMapping(value = "/addFriend", method = RequestMethod.POST)
    public Long addFriend(User user, String friend) {
        String currentKey = null;
        // 判斷是否是當前使用者的好友
        if (AppContext.getCurrentUser().getId().equals(user.getId)) {
            currentKey = user.getId.toString();
        }
        //若是返回0則表示不是該使用者好友
        return currentKey==null?0l:setOperations.add(currentKey, friend);
    }

    假如兩個使用者A和B都是用上上面的這個介面添加了很多的自己的好友,那麼有一個需求就是要實現獲取A和B的共同好友,那麼可以進行如下操作:

    public Set intersectFriend(User userA, User userB) {
        return setOperations.intersect(userA.getId.toString(), userB.getId.toString());
    }

    舉一反三,還可以實現A使用者自己的好友,或者B使用者自己的好友等,都可以進行實現。

    ZSet集合

    ZSet是有序集合,從上面的圖中可以看到ZSet的底層實現是ziplistskiplist實現的,ziplist上面已經詳細講過,這裡來講解skiplist的結構實現。

    skiplist也叫做「跳躍表」,跳躍表是一種有序的資料結構,它通過每一個節點維持多個指向其它節點的指標,從而達到快速訪問的目的。

    skiplist有如下幾個特點:

    1. 有很多層組成,由上到下節點數逐漸密集,最上層的節點最稀疏,跨度也最大。
    2. 每一層都是一個有序連結串列,至少包含兩個節點,頭節點和尾節點。
    3. 每一層的每一個每一個節點都含有指向同一層下一個節點和下一層同一個位置節點的指標。
    4. 如果一個節點在某一層出現,那麼該以下的所有連結串列同一個位置都會出現該節點。

    具體實現的結構圖如下所示

  8. 在跳躍表的結構中有head和tail表示指向頭節點和尾節點的指標,能快速的實現定位。level表示層數,len表示跳躍表的長度,BW表示後退指標,在從尾向前遍歷的時候使用。
  9. BW下面還有兩個值分別表示分值(score)和成員物件(各個節點儲存的成員物件)。

    跳躍表的實現中,除了最底層的一層儲存的是原始連結串列的完整資料,上層的節點數會越來越少,並且跨度會越來越大。

    跳躍表的上面層就相當於索引層,都是為了找到最後的資料而服務的,資料量越大,條表所體現的查詢的效率就越高,和平衡樹的查詢效率相差無幾。

    應用場景

    因為ZSet是有序的集合,因此ZSet在實現排序型別的業務是比較常見的,比如在首頁推薦10個最熱門的帖子,也就是閱讀量由高到低,排行榜的實現等業務。

    下面就選用獲取排行榜前前10名的選手作為案例實現,實現的程式碼如下所示:

  10. @Autowired
    private RedisTemplate redisTemplate;
        /**
         * 獲取前10排名
         * @return
         */
        public static List<levelVO > getZset(String key, long baseNum, LevelService levelService){
            ZSetOperations<Serializable, Object> operations = redisTemplate.opsForZSet();
            // 根據score分數值獲取前10名的資料
            Set<ZSetOperations.TypedTuple<Object>> set = operations.reverseRangeWithScores(key,0,9);
            List<LevelVO> list= new ArrayList<LevelVO>();
            int i=1;
            for (ZSetOperations.TypedTuple<Object> o:set){
                int uid = (int) o.getValue();
                LevelCache levelCache = levelService.getLevelCache(uid);
                LevelVO levelVO = levelCache.getLevelVO();
                long score = (o.getScore().longValue() - baseNum + levelVO .getCtime())/CommonUtil.multiplier;
                levelVO .setScore(score);
                levelVO .setRank(i);
                list.add( levelVO );
                i++;
            }
            return list;
        }

    以上的程式碼實現大致邏輯就是根據score分數值獲取前10名的資料,然後封裝成lawyerVO物件的列表進行返回。

    到這裡我們已經精通Redis的五種基本資料型別了,又可以去和麵試官扯皮了,扯不過就跑路吧,或者這篇文章多看幾遍,相信對你總是有好處的。

轉載自:https://mp.weixin.qq.com/s/wIc0om-hINTMYjE3jJzQww