首頁 > 軟體

Redis的9種資料型別用法解讀

2023-09-07 10:01:48

在具體描述這幾種資料型別之前,我們先通過一張圖瞭解下 Redis 內部記憶體管理中是如何描述這些不同資料型別的:


首先Redis內部使用一個redisObject物件來表示所有的key和value,redisObject最主要的資訊如上圖所示:type代表一個value物件具體是何種資料型別,encoding是不同資料型別在redis內部的儲存方式,

比如:type=string代表value儲存的是一個普通字串,那麼對應的encoding可以是raw或者是int,如果是int則代表實際redis內部是按數值型類儲存和表示這個字串的,當然前提是這個字串本身可以用數值表示,比如:"123" "456"這樣的字串。

這需要特殊說明一下vm欄位,只有開啟了Redis的虛擬記憶體功能,此欄位才會真正的分配記憶體,該功能預設是關閉狀態的,該功能會在後面具體描述。

通過上圖我們可以發現Redis使用redisObject來表示所有的key/value資料是比較浪費記憶體的,當然這些記憶體管理成本的付出主要也是為了給Redis不同資料型別提供一個統一的管理介面,實際作者也提供了多種方法幫助我們儘量節省記憶體使用,我們隨後會具體討論。

redis支援豐富的資料型別

不同的場景使用合適的資料型別可以有效的優化記憶體資料的存放空間:

  • string:最基本的資料型別,二進位制安全的字串,最大512M。
  • list:按照新增順序保持順序的字串列表。
  • set:無序的字串集合,不存在重複的元素。
  • sorted set:已排序的字串集合。
  • hash:key-value對的一種集合。
  • bitmap:更細化的一種操作,以bit為單位。
  • hyperloglog:基於概率的資料結構。 # 2.8.9新增
  • Geo:地理位置資訊儲存起來, 並對這些資訊進行操作   # 3.2新增
  • 流(Stream)# 5.0新增

String 字串

常用命令:

setnx,set,get,decr,incr,mget 等。

應用場景

字串是最常用的資料型別,他能夠儲存任何型別的字串,當然也包括二進位制、JSON化的物件、甚至是Base64編碼之後的圖片。

在Redis中一個字串最大的容量為512MB,可以說是無所不能了。redis的key和string型別value限制均為512MB。

雖然Key的大小上限為512M,但是一般建議key的大小不要超過1KB,這樣既可以節約儲存空間,又有利於Redis進行檢索

  • 快取,熱點資料
  • 分散式session
  • 分散式鎖
  • INCR計數器
  • 文章的閱讀量,微博點贊數,允許一定的延遲,先寫入 Redis 再定時同步到資料庫
  • 全域性ID
  • INT 型別,INCRBY,利用原子性
  • INCR 限流
  • 以存取者的 IP 和其他資訊作為 key,存取一次增加一次計數,超過次數則返回 false。
  • setbit 位元運算

內部編碼

  • int:8 個位元組的長整型(long,2^63-1)
  • embstr:小於等於44個位元組的字串,embstr格式的SDS(Simple Dynamic String)
  • raw:SDS大於 44 個位元組的字串

接下來就是ebmstr和raw兩種內部編碼的長度界限,請看下面的原始碼

#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
    if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
        return createEmbeddedStringObject(ptr,len);
    else
        return createRawStringObject(ptr,len);
}

通過下圖可以直觀感受一下字串型別和雜湊型別的區別:


redis 為什麼要自己寫一個SDS的資料型別,主要是為了解決C語言 char[] 的四個問題

  • 字元陣列必須先給目標變數分配足夠的空間,否則可能會溢位
  • 查詢字元陣列長度 時間複雜度O(n)
  • 長度變化,需要重新分配記憶體
  • 通過從字串開始到結尾碰到的第一個來標記字串的結束,因此不能儲存圖片、音訊、視訊、壓縮檔案等二進位制(bytes)儲存的內容,二進位制不安全

redis SDS

  • 不用擔心記憶體溢位問題,如果需要會對 SDS 進行擴容
  • 因為定義了 len 屬性,查詢陣列長度時間複雜度O(1) 固定長度
  • 空間預分配,惰性空間釋放
  • 根據長度 len來判斷是結束,而不是

為什麼要有embstr編碼呢?他比raw的優勢在哪裡?

embstr編碼將建立字串物件所需的空間分配的次數從raw編碼的兩次降低為一次。

因為emstr編碼字串的素有物件保持在一塊連續的記憶體裡面,所以那個編碼的字串物件比起raw編碼的字串物件能更好的利用快取。

並且釋放embstr編碼的字串物件只需要呼叫一次記憶體釋放函數,而釋放raw編碼物件的字串物件需要呼叫兩次記憶體釋放函數,如圖所示,左邊emstr編碼,右邊是raw編碼:

Hash 雜湊表

常用命令:

hget,hsetnx,hset,hvals,hgetall,hmset,hmget 等。

redis> HSET phone myphone nokia-1110
(integer) 1
 
redis> HEXISTS phone myphone
(integer) 1
 
redis> HSET people jack "Jack Sparrow"
(integer) 1
 
redis> HSET people gump "Forrest Gump"
(integer) 1
 
redis> HGETALL people
1) "jack"          # 域
2) "Jack Sparrow"  # 值
3) "gump"
4) "Forrest Gump"

應用場景

我們簡單舉個範例來描述下 Hash 的應用場景,比如我們要儲存一個使用者資訊物件資料,包含以下資訊:使用者 ID 為查詢的 key,儲存的 value 使用者物件包含姓名,年齡,生日等資訊,如果用普通的 key/value 結構來儲存,主要有以下2種儲存方式:



第一種方式將使用者 ID 作為查詢 key,把其他資訊封裝成一個物件以序列化的方式儲存,這種方式的缺點是,增加了序列化/反序列化的開銷,並且在需要修改其中一項資訊時,需要把整個物件取回,並且修改操作需要對並行進行保護,引入CAS等複雜問題。


第二種方法是這個使用者資訊物件有多少成員就存成多少個 key-value 對兒,用使用者 ID +對應屬性的名稱作為唯一標識來取得對應屬性的值,雖然省去了序列化開銷和並行問題,但是使用者 ID 為重複儲存,如果存在大量這樣的資料,記憶體浪費還是非常可觀的。

那麼 Redis 提供的 Hash 很好的解決了這個問題,Redis 的 Hash 實際是內部儲存的 Value 為一個 HashMap,並提供了直接存取這個 Map 成員的介面,如下圖:


也就是說,Key 仍然是使用者 ID,value 是一個 Map,這個 Map 的 key 是成員的屬性名,value 是屬性值,這樣對資料的修改和存取都可以直接通過其內部 Map 的 Key(Redis 裡稱內部 Map 的 key 為 field),也就是通過 key(使用者 ID) + field(屬性標籤)就可以操作對應屬性資料了,既不需要重複儲存資料,也不會帶來序列化和並行修改控制的問題。很好的解決了問題。

這裡同時需要注意,Redis 提供了介面(hgetall)可以直接取到全部的屬性資料,但是如果內部 Map 的成員很多,那麼涉及到遍歷整個內部 Map 的操作,由於 Redis 單執行緒模型的緣故,這個遍歷操作可能會比較耗時,而另其它使用者端的請求完全不響應,這點需要格外注意。

 購物車

內部編碼     

  • ziplist(壓縮列表):當雜湊型別中元素個數小於 hash-max-ziplist-entries 設定(預設 512 個),同時所有值都小於 hash-max-ziplist-value 設定(預設 64 位元組)時,Redis 會使用 ziplist 作為雜湊的內部實現。
  • hashtable(雜湊表):當上述條件不滿足時,Redis 則會採用 hashtable 作為雜湊的內部實現。

下面我們通過以下命令來演示一下 ziplist 和 hashtable 這兩種內部編碼。


當 field 個數比較少並且 value 也不是很大時候 Redis 雜湊型別的內部編碼為 ziplist:



當 value 中的位元組數大於 64 位元組時(可以通過 hash-max-ziplist-value 設定),內部編碼會由 ziplist 變成 hashtable。



當 field 個數超過 512(可以通過 hash-max-ziplist-entries 引數設定),內部編碼也會由 ziplist 變成 hashtable

List 列表

常用命令:

lpush,rpush,lpop,rpop,lrange等。

127.0.0.1:6379> lpush list one   # 將一個值或者多個值,插入到列表的頭部(左)(integer) 1
127.0.0.1:6379> lpush list two
(integer) 2
127.0.0.1:6379> lpush list three 
(integer) 3
127.0.0.1:6379> lrange list 0 -1   # 檢視全部元素
1) "three"
2) "two"
3) "one"
127.0.0.1:6379> lrange list 0 1    # 通過區間獲取值
1) "three"
2) "two"
127.0.0.1:6379> rpush list right   # 將一個值或者多個值,插入到列表的尾部(右)(integer) 4
127.0.0.1:6379> lrange list 0 -1
1) "three"
2) "two"
3) "one"
4) "right"
127.0.0.1:6379>

列表(list)用來儲存多個有序的字串,每個字串稱為元素;一個列表可以儲存2^32-1個元素。Redis中的列表支援兩端插入和彈出,並可以獲得指定位置(或範圍)的元素,可以充當陣列、佇列、棧等

應用場景

比如 twitter 的關注列表,粉絲列表等都可以用 Redis 的 list 結構來實現,可以利用lrange命令,做基於Redis的分頁功能,效能極佳,使用者體驗好。

訊息佇列

列表型別可以使用 rpush 實現先進先出的功能,同時又可以使用 lpop 輕鬆的彈出(查詢並刪除)第一個元素,所以列表型別可以用來實現訊息佇列

發紅包的場景

在發紅包的場景中,假設發一個10元,10個紅包,需要保證搶紅包的人不會多搶到,也不會少搶到

下面我們通過下圖來看一下 Redis 中列表型別的插入和彈出操作:



下面我們看一下 Redis 中列表型別的獲取與刪除操作:



Redis 列表型別的特點如下:

  • 列表中所有的元素都是有序的,所以它們是可以通過索引獲取的lindex 命令。並且在 Redis 中列表型別的索引是從 0 開始的。
  • 列表中的元素是可以重複的,也就是說在 Redis 列表型別中,可以儲存同名元素

內部編碼

  • ziplist(壓縮列表):當列表中元素個數小於 512(預設)個,並且列表中每個元素的值都小於 64(預設)個位元組時,Redis 會選擇用 ziplist 來作為列表的內部實現以減少記憶體的使用。當然上述預設值也可以通過相關引數修改:list-max-ziplist-entried(元素個數)、list-max-ziplist-value(元素值)。
  • linkedlist(連結串列):當列表型別無法滿足 ziplist 條件時,Redis 會選擇用 linkedlist 作為列表的內部實現。

Set 集合

常用命令:

sadd,spop,smembers,sunion,scard,sscan,sismember等。

# 初始化
sadd poker T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 TJ TQ TK X1 X2 X3 X4 X5 X6 X7 X8 X9 X10 XJ XQ XK M1 M2 M3 M4 M5 M6 M7 M8 M9 M10 MJ MQ MK F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 FJ FQ FK XW DW
 
# 數量
scard poker
 
# 複用撲克牌
sunionstore pokernew poker
 
# 成員
smembers poker
 
# 是否包含
sismember poker T1
 
# 隨機讀取
spop poker
 
# 設定
sadd user1tag tagID1 tagID2 tagID3
sadd user2tag tagID2 tagID3
sadd user3tag tagID2 tagID4 tagID5
 
# 獲取共同擁有的tag(交集)
sinter user1tag user2tag user3tag
 
# 獲取擁有的所有tag(並集)
sunion user1tag user2tag user3tag
 
# 獲取兩個之間的區別(差集)
sdiff user2tag user3tag

應用場景

Redis set 對外提供的功能與 list 類似是一個列表的功能,特殊之處在於 set 是可以自動排重的,當你需要儲存一個列表資料,又不希望出現重複資料時,set 是一個很好的選擇,並且 set 提供了判斷某個成員是否在一個 set 集合內的重要介面,這個也是 list 所不能提供的。

1知乎點贊數

2京東的商品篩選

127.0.0.1:6379> sadd brand:apple iPhone11
(integer) 1
127.0.0.1:6379> sadd brand:ios iPhone11
(integer) 1
127.0.0.1:6379> sadd screensize:6.0-6.24 iPhone11
(integer) 1
127.0.0.1:6379> sadd memorysize:256GB iPhone11
(integer) 1
127.0.0.1:6379> sinter brand:apple brand:ios screensize:6.0-6.24 memorysize:256GB
1) "iPhone11"

篩選商品,蘋果,IOS,螢幕6.0-6.24,記憶體大小256G

sinter brand:apple brand:ios screensize:6.0-6.24 memorysize:256GB

3.儲存社交關係

使用者(編號user001)關注

sadd focus:user001 user003

sadd focus:user002 user003 user004

相互關注

sadd focus:user001 user002

sadd focus:user002 user001

#判斷使用者2是否關注了使用者1
127.0.0.1:6379> SISMEMBER focus:user002 user001
(integer) 1

我關注得到人也關注了他(共同關注) 

#獲取關注的交集
127.0.0.1:6379> sinter focus:user001 focus:user002
1) "user003"

可能認識的人

#將所有的人存放到allusers集合
127.0.0.1:6379> SUNIONSTORE alluser:user001 focus:user001 focus:user002
(integer) 4
127.0.0.1:6379> SDIFF alluser:user001 focus:user001
1) "user004"
2) "user001"
#剔除掉自己
127.0.0.1:6379> SREM alluser:user001 user001
(integer) 1
127.0.0.1:6379> SDIFF alluser:user001 focus:user001
1) "user004"

實現方式:

set 的內部實現是一個 value 永遠為 null 的 HashMap,實際就是通過計算 hash 的方式來快速排重的,這也是 set 能提供判斷一個成員是否在集合內的原因。

Redis 中的集合型別,也就是 set。在 Redis 中 set 也是可以儲存多個字串的,經常有人會分不清 list 與 set,下面我們重點介紹一下它們之間的不同:

  • set 中的元素是不可以重複的,而 list 是可以儲存重複元素的。
  • set 中的元素是無序的,而 list 中的元素是有序的。
  • set 中的元素不能通過索引下標獲取元素,而 list 中的元素則可以通過索引下標獲取元素。
  • 除此之外 set 還支援更高階的功能,例如多個 set 取交集、並集、差集等

為什麼 Redis 要提供 sinterstore、sunionstore、sdiffstore 命令來將集合的交集、並集、差集的結果儲存起來呢?這是因為 Redis 在進行上述比較時,會比較耗費時間,所以為了提高效能可以將交集、並集、差集的結果提前儲存起來,這樣在需要使用時,可以直接通過 smembers 命令獲取。

內部編碼    

  • intset(整數集合):當集合中的元素都是整數,並且集合中的元素個數小於set-max-intset-entries 引數時,預設512,Redis 會選用 intset 作為底層內部實現。   
  • hashtable(雜湊表):當上述條件不滿足時,Redis 會採用 hashtable 作為底層實現。

Sorted set 有序集合

常用命令:

zadd,zrange,zrem,zcard,zscore,zcount,zlexcount等

redis> ZRANGE salary 0 -1 WITHSCORES    # 測試資料
1) "tom"
2) "2000"
3) "peter"
4) "3500"
5) "jack"
6) "5000"
 
redis> ZSCORE salary peter              # 注意返回值是字串
"3500"
 
redis> ZCOUNT salary 2000 5000          # 計算薪水在 2000-5000 之間的人數
(integer) 3
 
# rank:key 100: 分數 u1: ID
# 初始化 時間複雜度: O(log(N))
zadd rank 100 u1
zadd rank 200 u2
zadd rank 300 u3
zadd rank 400 u4
zadd rank 500 u5
 
# 數量
zcard rank
 
# 內容(正序、倒序)時間複雜度: O(log(N)+M)
zrange rank 0 -1
zrange rank 0 -1 withscores
 
zrevrange rank 0 -1 withscores
zrevrange rank 0 -2 withscores
zrevrange rank 0 -1
 
# 獲取使用者分數(不存在返回空) 時間複雜度: O(1)
zscore rank u1
 
# 修改分數
zadd rank 800 u3
 
# 修改分數(累加:新增或減少,返回修改後的分數)
zincrby rank 100 u3
zincrby rank -100 u3
 
# 查詢分數範圍內容(正序、倒序)
zrangebyscore rank (200 800 withscores
zrevrangebyscore rank (200 800 withscores
 
# 移除元素
zrem rank u3

下面先看一下列表、集合、有序集合三種資料型別之間的區別:

內部編碼 

  • ziplist(壓縮列表):當有序集合的元素個數小於 128 個(預設設定),同時每個元素的值都小於 64 位元組(預設設定),Redis 會採用 ziplist 作為有序集合的內部實現。
  • skiplist(跳躍表):當上述條件不滿足時,Redis 會採用 skiplist 作為內部編碼。

備註:上述中的預設值,也可以通過以下引數設定:zset-max-ziplist-entries 和 zset-max-ziplist-value。

應用場景

Redis sorted set 的使用場景與 set 類似,區別是 set 不是自動有序的,而 sorted set 可以通過使用者額外提供一個優先順序(score)的引數來為成員排序,並且是插入有序的,即自動排序。

當你需要一個有序的並且不重複的集合列表,那麼可以選擇 sorted set 資料結構,比如 twitter 的 public timeline 可以以發表時間作為 score 來儲存,這樣獲取時就是自動按時間排好序的。點選數做出排行榜。

1.商品的評價標籤,可以記錄商品的標籤,統計標籤次數,增加標籤次數,按標籤的分值進行排序

#新增商品(編號i5001)的標籤tag和對應標籤的評價次數
127.0.0.1:6379> zadd goods_tag:i5001 442 tag1 265 tag2 264 tag3
(integer) 3
#不帶分數
127.0.0.1:6379> zrange goods_tag:i5001 0 -1
1) "tag3"
2) "tag2"
3) "tag1"
#帶分數
127.0.0.1:6379> zrange goods_tag:i5001 0 -1 withscores
1) "tag3"
2) "264"
3) "tag2"
4) "265"
5) "tag1"
6) "442"

2.百度搜尋熱點

#維護2020年1月21號的熱點新聞
127.0.0.1:6379> zadd hotspot:20200121 520 pot1 263 pot2 244 pot3
(integer) 3
127.0.0.1:6379> zrange hotspot:20200121 0 -1 withscores
1) "pot3"
2) "244"
3) "pot2"
4) "263"
5) "pot1"
6) "520"
#增加點選次數
127.0.0.1:6379> ZINCRBY hotspot 1 pot1
"521"

3.反spam系統

作為一個電商網站被各種spam攻擊是少不免(垃圾評論、釋出垃圾商品、廣告、刷自家商品排名等)針對這些spam制定一系列anti-spam規則,其中有些規則可以利用redis做實時分析
譬如:1分鐘評論不得超過2次、5分鐘評論少於5次等

#獲取5秒內操作記錄
$res = $redis->zRangeByScore('user:1000:comment', time() - 5, time());
#判斷5秒內不能評論
if (!$res) {
    $redis->zAdd('user:1000:comment', time(), '評論內容');
} else {
    echo '5秒之內不能評論';
}
 
#5秒內評論不得超過2次
if($redis->zRangeByScore('user:1000:comment',time()-5 ,time())==1)
echo '5秒之內不能評論2次';
 
#5秒內評論不得少於2次
if(count($redis->zRangeByScore('user:1000:comment',time()-5 ,time()))<2)
echo '5秒之內不能評論2次';

BitMap 點陣圖

在應用場景中,有一些資料只有兩個屬性,比如是否是學生,是否是黨員等等,對於這些資料,最節約記憶體的方式就是用bit去記錄,以是否是學生為例,1代表是學生,0代表不是學生。那麼1000110就代表7個人中3個是學生,這就是BitMaps的儲存需求。

Bitmaps是一個可以對位進行操作的字串,我們可以把Bitmaps想象成是一串二進位制數位,每個位置只儲存0和1。下標是Bitmaps的偏移量。

BitMap 就是通過一個 bit 位來表示某個元素對應的值或者狀態, 其中的 key 就是對應元素本身,實際上底層也是通過對字串的操作來實現。Redis從2.2.0版本開始新增了setbit,getbit,bitcount等幾個bitmap相關命令。

雖然是新命令,但是並沒有新增新的資料型別,因為setbit等命令只不過是在set上的擴充套件。

使用場景一:使用者簽到

很多網站都提供了簽到功能(這裡不考慮資料落地事宜),並且需要展示最近一個月的簽到情況

<?php
$redis = new Redis();
$redis->connect('127.0.0.1');
 
//使用者uid
$uid = 1;
 
//記錄有uid的key
$cacheKey = sprintf("sign_%d", $uid);
 
//開始有簽到功能的日期
$startDate = '2017-01-01';
 
//今天的日期
$todayDate = '2017-01-21';
 
//計算offset
$startTime = strtotime($startDate);
$todayTime = strtotime($todayDate);
$offset = floor(($todayTime - $startTime) / 86400);
 
echo "今天是第{$offset}天" . PHP_EOL;
 
//簽到
//一年一個使用者會佔用多少空間呢?大約365/8=45.625個位元組,好小,有木有被驚呆?
$redis->setBit($cacheKey, $offset, 1);
 
//查詢簽到情況
$bitStatus = $redis->getBit($cacheKey, $offset);
echo 1 == $bitStatus ? '今天已經簽到啦' : '還沒有簽到呢';
echo PHP_EOL;
 
//計算總簽到次數
echo $redis->bitCount($cacheKey) . PHP_EOL;
 
/**
 * 計算某段時間內的簽到次數
 * 很不幸啊,bitCount雖然提供了start和end引數,但是這個說的是字串的位置,而不是對應"位"的位置
 * 幸運的是我們可以通過get命令將value取出來,自己解析。並且這個value不會太大,上面計算過一年一個使用者只需要45個位元組
 * 給我們的網站定一個小目標,執行30年,那麼一共需要1.31KB(就問你屌不屌?)
 */
//這是個錯誤的計算方式
echo $redis->bitCount($cacheKey, 0, 20) . PHP_EOL;

使用場景二:統計活躍使用者

使用時間作為cacheKey,然後使用者ID為offset,如果當日活躍過就設定為1

那麼我該如果計算某幾天/月/年的活躍使用者呢(暫且約定,統計時間內只有有一天線上就稱為活躍),有請下一個redis的命令

命令 BITOP operation destkey key [key ...]

說明:對一個或多個儲存二進位制位的字串 key 進行位元操作,並將結果儲存到 destkey 上。

說明:BITOP 命令支援 AND 、 OR 、 NOT 、 XOR 這四種操作中的任意一種引數

bitop and destKey key1 key2....  //交
bitop or destKey key1 key2....   //並
bitop not destKey key1 key2....  //非
bitop xor destKey key1 key2....  //互斥或
<?php
//日期對應的活躍使用者
$data = array(
    '2017-01-10' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10),

    '2017-01-11' => array(1, 2, 3, 4, 5, 6, 7, 8),

    '2017-01-12' => array(1, 2, 3, 4, 5, 6),

    '2017-01-13' => array(1, 2, 3, 4),
    '2017-01-14' => array(1, 2)

);
//批次設定活躍狀態

foreach ($data as $date => $uids) {
    $cacheKey = sprintf("stat_%s", $date);
    foreach ($uids as $uid) {
        $redis->setBit($cacheKey, $uid, 1);
    }
}
$redis->bitOp('AND', 'stat', 'stat_2017-01-10', 'stat_2017-01-11', 'stat_2017-01-12') . PHP_EOL;
//總活躍使用者:6

echo "總活躍使用者:" . $redis->bitCount('stat') . PHP_EOL;
 
$redis->bitOp('AND', 'stat1', 'stat_2017-01-10', 'stat_2017-01-11', 'stat_2017-01-14') . PHP_EOL;


//總活躍使用者:2

echo "總活躍使用者:" . $redis->bitCount('stat1') . PHP_EOL;
 
$redis->bitOp('AND', 'stat2', 'stat_2017-01-10', 'stat_2017-01-11') . PHP_EOL; //總活躍使用者:8

echo "總活躍使用者:" . $redis->bitCount('stat2') . PHP_EOL;

使用場景三:使用者線上狀態

前段時間開發一個專案,對方給我提供了一個查詢當前使用者是否線上的介面。不瞭解對方是怎麼做的,自己考慮了一下,使用bitmap是一個節約空間效率又高的一種方法,只需要一個key,然後使用者ID為offset,如果線上就設定為1,不線上就設定為0,和上面的場景一樣,5000W使用者只需要6MB的空間。

<?php
//批次設定線上狀態
$uids = range(1, 500000);
foreach ($uids as $uid) {
    $redis->setBit('online', $uid, $uid % 2);
}
//一個一個獲取狀態
$uids = range(1, 500000);
$startTime = microtime(true);
foreach ($uids as $uid) {
    echo $redis->getBit('online', $uid) . PHP_EOL;
}
$endTime = microtime(true);
 
//在我的電腦上,獲取50W個使用者的狀態需要25秒

echo "total:" . ($endTime - $startTime) . "s";

內部編碼

這個就是Redis實現的BloomFilter,BloomFilter非常簡單,如下圖所示,假設已經有3個元素a、b和c,分別通過3個hash演演算法h1()、h2()和h2()計算然後對一個bit進行賦值,接下來假設需要判斷d是否已經存在,那麼也需要使用3個hash演演算法h1()、h2()和h2()對d進行計算,然後得到3個bit的值,恰好這3個bit的值為1,這就能夠說明:d可能存在集合中。再判斷e,由於h1(e)算出來的bit之前的值是0,那麼說明:e一定不存在集合中:

需要說明的是,bitmap並不是一種真實的資料結構,它本質上是String資料結構,只不過操作的粒度變成了位,即bit。因為String型別最大長度為512MB,所以bitmap最多可以儲存2^32個bit。 

HyperLogLog 基數統計

HyperLogLog演演算法時一種非常巧妙的近似統計大量去重元素數量的演演算法,它內部維護了16384個桶來記錄各自桶的元素數量,當一個元素過來,它會雜湊到其中一個桶。

當元素到來時,通過 hash 演演算法將這個元素分派到其中的一個小集合儲存,同樣的元素總是會雜湊到同樣的小集合。這樣總的計數就是所有小集合大小的總和。

使用這種方式精確計數除了可以增加元素外,還可以減少元素

一個HyperLogLog實際佔用的空間大約是 13684 * 6bit / 8 = 12k 位元組。但是在計數比較小的時候,大多數桶的計數值都是零。如果 12k 位元組裡面太多的位元組都是零,那麼這個空間是可以適當節約一下的。Redis 在計數值比較小的情況下采用了稀疏儲存,稀疏儲存的空間佔用遠遠小於 12k 位元組。相對於稀疏儲存的就是密集儲存,密集儲存會恆定佔用 12k 位元組。

內部編碼

HyperLogLog 整體的內部結構就是 HLL 物件頭 加上 16384 個桶的計數值點陣圖。它在 Redis 的內部結構表現就是一個字串點陣圖。你可以把 HyperLogLog 物件當成普通的字串來進行處理。

應用場景

Redis 的基數統計,這個結構可以非常省記憶體的去統計各種計數,比如註冊 IP 數、每日存取 IP 數、頁面實時UV)、線上使用者數等。但是它也有侷限性,就是隻能統計數量,而沒辦法去知道具體的內容是什麼。當然用集合也可以解決這個問題。但是一個大型的網站,每天 IP 比如有 100 萬,粗算一個 IP 消耗 15 位元組,那麼 100 萬個 IP 就是 15M。而 HyperLogLog 在 Redis 中每個鍵佔用的內容都是 12K,理論儲存近似接近 2^64 個值,不管儲存的內容是什麼,它一個基於基數估算的演演算法,只能比較準確的估算出基數,可以使用少量固定的記憶體去儲存並識別集合中的唯一元素。而且這個估算的基數並不一定準確,是一個帶有 0.81% 標準錯誤的近似值。

HyperLogLog 主要的應用場景就是進行基數統計。這個問題的應用場景其實是十分廣泛的。例如:對於 Google 主頁面而言,同一個賬戶可能會存取 Google 主頁面多次。於是,在諸多的存取流水中,如何計算出 Google 主頁面每天被多少個不同的賬戶存取過就是一個重要的問題。那麼對於 Google 這種存取量巨大的網頁而言,其實統計出有十億 的存取量或者十億零十萬的存取量其實是沒有太多的區別的,因此,在這種業務場景下,為了節省成本,其實可以只計算出一個大概的值,而沒有必要計算出精準的值

這個資料結構的命令有三個:PFADD、PFCOUNT、PFMERGE

redis> PFADD databases "Redis" "MongoDB" "MySQL"
(integer) 1
 
redis> PFADD databases "Redis"  # Redis 已經存在,不必對估計數量進行更新
(integer) 0
 
redis> PFCOUNT databases
(integer) 3

Geo 地理位置

Redis 的 GEO 特性在 Redis 3.2 版本中推出, 這個功能可以將使用者給定的地理位置資訊儲存起來, 並對這些資訊進行操作。

GEO的資料結構總共有六個命令:geoadd、geopos、geodist、georadius、georadiusbymember、gethash,GEO使用的是國際通用座標系WGS-84。

  • 1.GEOADD:新增地理位置
  • 2.GEOPOS:查詢地理位置(經緯度),返回陣列
  • 3.GEODIST:計算兩位位置間的距離
  • 4.GEORADIUS:以給定的經緯度為中心, 返回鍵包含的位置元素當中, 與中心的距離不超過給定最大距離的所有位置元素。
  • 5.GEORADIUSBYMEMBER:以給定的地理位置為中心, 返回鍵包含的位置元素當中, 與中心的距離不超過給定最大距離的所有位置元素。
127.0.0.1:6379> geoadd kcityGeo 116.405285 39.904989 "beijing"
(integer) 1
127.0.0.1:6379> geoadd kcityGeo 121.472644 31.231706 "shanghai"
(integer) 1 
127.0.0.1:6379> geodist kcityGeo beijing shanghai km
"1067.5980"
127.0.0.1:6379> geopos kcityGeo beijing
1) 1) "116.40528291463851929"
   2) "39.9049884229125027"
127.0.0.1:6379> geohash kcityGeo beijing
1) "wx4g0b7xrt0"
127.0.0.1:6379> georadiusbymember kcityGeo beijing 1200 km withdist withcoord asc count 5
1) 1) "beijing"
   2) "0.0000"
   3) 1) "116.40528291463851929"
      2) "39.9049884229125027"
2) 1) "shanghai"
   2) "1067.5980"
   3) 1) "121.47264629602432251"
      2) "31.23170490709807012"

內部編碼

但是,需要說明的是,Geo本身不是一種資料結構,它本質上還是藉助於Sorted Set(ZSET),並且使用GeoHash技術進行填充。Redis中將經緯度使用52位的整數進行編碼,放進zset中,score就是GeoHash的52位整數值。在使用Redis進行Geo查詢時,其內部對應的操作其實就是zset(skiplist)的操作。通過zset的score進行排序就可以得到座標附近的其它元素,通過將score還原成座標值就可以得到元素的原始座標。

總之,Redis中處理這些地理位置座標點的思想是:二維平面座標點 --> 一維整數編碼值 --> zset(score為編碼值) --> zrangebyrank(獲取score相近的元素)、zrangebyscore --> 通過score(整數編碼值)反解座標點 --> 附近點的地理位置座標。

應用場景

比如現在比較火的直播業務,我們需要檢索附近的主播,那麼GEO就可以很好的實現這個功能。

  • 一是主播開播的時候寫入主播Id的經緯度
  • 二是主播關播的時候刪除主播Id元素,這樣就維護了一個具有位置資訊的線上主播集合提供給線上檢索

Streams 流

這是Redis5.0引入的全新資料結構,用一句話概括Streams就是Redis實現的記憶體版kafka。支援多播的可持久化的訊息佇列,用於實現釋出訂閱功能,借鑑了 kafka 的設計。

Redis Stream的結構有一個訊息連結串列,將所有加入的訊息都串起來,每個訊息都有一個唯一的ID和對應的內容。訊息是持久化的,Redis重啟後,內容還在。

每個Stream都有唯一的名稱,它就是Redis的key,在我們首次使用xadd指令追加訊息時自動建立。

每個Stream都可以掛多個消費組,每個消費組會有個遊標last_delivered_id在Stream陣列之上往前移動,表示當前消費組已經消費到哪條訊息了。每個消費組都有一個Stream內唯一的名稱,消費組不會自動建立,它需要單獨的指令xgroup create進行建立,需要指定從Stream的某個訊息ID開始消費,這個ID用來初始化last_delivered_id變數。

每個消費組(Consumer Group)的狀態都是獨立的,相互不受影響。也就是說同一份Stream內部的訊息會被每個消費組都消費到。

同一個消費組(Consumer Group)可以掛接多個消費者(Consumer),這些消費者之間是競爭關係,任意一個消費者讀取了訊息都會使遊標last_delivered_id往前移動。每個消費者者有一個組內唯一名稱。

消費者(Consumer)內部會有個狀態變數pending_ids,它記錄了當前已經被使用者端讀取的訊息,但是還沒有ack。如果使用者端沒有ack,這個變數裡面的訊息ID會越來越多,一旦某個訊息被ack,它就開始減少。這個pending_ids變數在Redis官方被稱之為PEL,也就是Pending Entries List,這是一個很核心的資料結構,它用來確保使用者端至少消費了訊息一次,而不會在網路傳輸的中途丟失了沒處理。

  • 訊息ID:訊息ID的形式是timestampInMillis-sequence,例如1527846880572-5,它表示當前的訊息在毫米時間戳1527846880572時產生,並且是該毫秒內產生的第5條訊息。訊息ID可以由伺服器自動生成,也可以由使用者端自己指定,但是形式必須是整數-整數,而且必須是後面加入的訊息的ID要大於前面的訊息ID。
  • 訊息內容:訊息內容就是鍵值對,形如hash結構的鍵值對,這沒什麼特別之處。
127.0.0.1:6379> XADD mystream * field1 value1 field2 value2 field3 value3
"1588491680862-0"
127.0.0.1:6379> XADD mystream * username lisi age 18
"1588491854070-0"
127.0.0.1:6379> xlen mystream
(integer) 2
127.0.0.1:6379> XADD mystream * username lisi age 18
"1588491861215-0"
127.0.0.1:6379> xrange mystream - + 
1) 1) "1588491680862-0"
   2) 1) "field1"
      2) "value1"
      3) "field2"
      4) "value2"
      5) "field3"
      6) "value3"
2) 1) "1588491854070-0"
   2) 1) "username"
      2) "lisi"
      3) "age"
      4) "18"
3) 1) "1588491861215-0"
   2) 1) "username"
      2) "lisi"
      3) "age"
      4) "18"
127.0.0.1:6379> xdel mystream 1588491854070-0 
(integer) 1
127.0.0.1:6379> xrange mystream - + 
1) 1) "1588491680862-0"
   2) 1) "field1"
      2) "value1"
      3) "field2"
      4) "value2"
      5) "field3"
      6) "value3"
2) 1) "1588491861215-0"
   2) 1) "username"
      2) "lisi"
      3) "age"
      4) "18"
127.0.0.1:6379> xlen mystream
(integer) 2

內部編碼

streams底層的資料結構是radix tree:Radix Tree(基數樹) 事實上就幾乎相同是傳統的二元樹。僅僅是在尋找方式上,以一個unsigned int型別數為例,利用這個數的每個位元位作為樹節點的推斷。

能夠這樣說,比方一個數10001010101010110101010,那麼依照Radix 樹的插入就是在根節點,假設遇到0,就指向左節點,假設遇到1就指向右節點,在插入過程中構造樹節點,在刪除過程中刪除樹節點。如下是一個儲存了7個單詞的Radix Tree:

應用場景總結

實際上,所謂的應用場景,其實就是合理的利用Redis本身的資料結構的特性來完成相關業務功能


常用記憶體優化手段與引數

通過我們上面的一些實現上的分析可以看出 redis 實際上的記憶體管理成本非常高,即佔用了過多的記憶體,作者對這點也非常清楚,所以提供了一系列的引數和手段來控制和節省記憶體,我們分別來討論下。

首先最重要的一點是不要開啟 Redis 的 VM 選項,即虛擬記憶體功能,這個本來是作為 Redis 儲存超出實體記憶體資料的一種資料在記憶體與磁碟換入換出的一個持久化策略,但是其記憶體管理成本也非常的高,並且我們後續會分析此種持久化策略並不成熟,所以要關閉 VM 功能,請檢查你的 redis.conf 檔案中 vm-enabled 為 no。

 其次最好設定下 redis.conf 中的 maxmemory 選項,該選項是告訴 Redis 當使用了多少實體記憶體後就開始拒絕後續的寫入請求,該引數能很好的保護好你的 Redis 不會因為使用了過多的實體記憶體而導致 swap,最終嚴重影響效能甚至崩潰。

 另外 Redis 為不同資料型別分別提供了一組引數來控制記憶體使用,我們在前面詳細分析過 Redis Hash 是 value 內部為一個 HashMap,如果該 Map 的成員數比較少,則會採用類似一維線性的緊湊格式來儲存該 Map,即省去了大量指標的記憶體開銷,這個引數控制對應在 redis.conf 組態檔中下面2項:

hash-max-ziplist-entries 64
hash-max-zipmap-value    512

含義是當 value 這個 Map 內部不超過多少個成員時會採用線性緊湊格式儲存,預設是64,即 value 內部有64個以下的成員就是使用線性緊湊儲存,超過該值自動轉成真正的 HashMap。

hash-max-zipmap-value 含義是當 value 這個 Map 內部的每個成員值長度不超過多少位元組就會採用線性緊湊儲存來節省空間。

以上2個條件任意一個條件超過設定值都會轉換成真正的 HashMap,也就不會再節省記憶體了,那麼這個值是不是設定的越大越好呢,答案當然是否定的,HashMap 的優勢就是查詢和操作的時間複雜度都是 O(1) 的,而放棄 Hash 採用一維儲存則是 O(n) 的時間複雜度,如果成員數量很少,則影響不大,否則會嚴重影響效能,所以要權衡好這個值的設定,總體上還是最根本的時間成本和空間成本上的權衡。

同樣類似的引數還有

list-max-ziplist-entries 512  

說明:list 資料型別多少節點以下會採用去指標的緊湊儲存格式。

list-max-ziplist-value   64 

說明:list 資料型別節點值大小小於多少位元組會採用緊湊儲存格式。

set-max-intset-entries 512

說明:set 資料型別內部資料如果全部是數值型,且包含多少節點以下會採用緊湊格式儲存。

最後想說的是 Redis 內部實現沒有對記憶體分配方面做過多的優化,在一定程度上會存在記憶體碎片,不過大多數情況下這個不會成為 Redis 的效能瓶 頸,不過如果在 Redis 內部儲存的大部分資料是數值型的話,Redis 內部採用了一個 shared integer 的方式來省去分配記憶體的開銷,即在系統啟動時先分配一個從 1~n 那麼多個數值物件放在一個池子中,如果儲存的資料恰好是這個數值範圍內的資料,則直接從池子裡取出該物件,並且通過參照計數的方式來共用,這樣在系統儲存了大量數值下,也能一定程度上節省記憶體並且提高效能,這個引數值 n 的設定需要修改原始碼中的一行宏定義 REDIS_SHARED_INTEGERS,該值 預設是 10000,可以根據自己的需要進行修改,修改後重新編譯就可以了。

總結

以上為個人經驗,希望能給大家一個參考,也希望大家多多支援it145.com。


IT145.com E-mail:sddin#qq.com