Redis中的bitmap和zset兩種數據結構在業務場景中很是有用,巧妙的使用它們每每能將複雜問題完美解決。咱們先來簡單介紹一下這兩種數據結構。javascript
信息在計算機上存儲的基本單位是位。位只能存儲0或者1,咱們平時所說的字符串、數字等全部的數據信息在計算機中都是經過多個0和1組合一塊兒表示。8個位爲1個字節(1B),1024個字節爲1KB,1024KB是1M;也就是說1M就有8388608個位,試想咱們用每一個位上的0和1來表示1條信息,那麼僅僅1M空間就能存儲很是豐富的內容。php
編程語言都提供了一套標準的「位」運算操做符來操做計算機底層的位,位運算的執行效率遠遠高於加、減、乘、除、取模等運算指令,下面是C語言提供的六種運算符:java
位運算符 | 說明 |
---|---|
& | 按位與 |
| | 按位或 |
^ | 按位異或 |
~ | 取反 |
<< | 左移 |
>> | 右移 |
Redis經過bitmap也提供了一系列的「位」操做指令,咱們來簡單看幾個經常使用的命令:laravel
語法:SETBIT key offset value,對使用大的 offset 的 SETBIT 操做來講,內存分配可能形成 Redis 服務器被阻塞。一個好的實踐是能夠分配一個足夠用的連續位空間避免使用SETBIT過程當中頻繁的內存分配redis
GETBIT key offsetsql
語法:BITCOUNT key start數據庫
Redis使用ANSI C語言編寫,bitmap中的大部分操做指令也都是基於C對位的基本操做,C語言對二進制位的操做有不少有用的技巧,好比說SETBIT中將指定位設置位0和1,C語言是這樣操做:編程
n | (1 << (m-1)); //從低位到高位,將n的第m位置設置爲1
n & ~(1 << (m-1)); //從低位到高位,將n的第m位設置爲0
複製代碼
咱們之因此要強調從低位到高位,是由於不一樣計算機的CPU有不一樣的字節序列,也就是大端和小端。bash
1. Little endian:將低序字節存儲在起始地址服務器
2. Big endian:將高序字節存儲在起始地址
判斷機器使用的是小端模式仍是大端模式有不少方法,下面筆者提供一種(聯合體union的存放順序是全部成員都從低地址開始存放,利用該特性就能夠輕鬆地得到了CPU對內存採用Little-endian仍是Big-endian模式讀寫。):
//return 1 : little-endian
// 0 : big-endian
int checkCPUendian()
{
union {
unsigned int a;
unsigned char b;
} c;
c.a = 1;
return (c.b == 1);
}
複製代碼
判斷機器使用大端仍是小端很重要,它決定了程序使用GET命令從Redis中獲取的bitmap數據後需不須要進行位的從新排序。
咱們再來看看zset,zset稱爲有序集合。它容許給集合中的每一個元素設置一個score做爲權重,這個score值很是重要,筆者研究過Laravel使用redis實現延時任務的源碼Lumen框架「異步隊列任務」源碼剖析,將score值設置爲任務要執行的時間戳,一個守護進程經過時間滑動窗口的方式獲取任務,而後執行。
zset經常使用的命令有不少,下邊列舉一些最經常使用的簡單指令:
bitmap使用位上的0和1表示信息,由於位是計算機存儲計算的基本單位,使用位相比較於其餘數據結構能夠極大的節省存儲空間,同時能夠提供很是快的計算效率。由於0和1只能表示非是即否的信息,使用它僅能在一些數據量較大,又只關心是與否的場景下使用使用,即便這樣,它的威力也是巨大的。咱們下邊列舉幾個例子,再來講一下如何將位信息持久化存儲到數據庫。
咱們這個場景是這樣的,假設有10萬個客戶端設備,服務端要對這10萬的客戶端設備進行心跳檢測,方案是:每一個客戶端每隔1s向服務端發送ping信息,服務端在收到ping信息以後,記錄下來ping的信息,表示客戶端有心跳。
一天有86400秒,設備數量又是10萬,若是咱們將設備每秒的心跳信息都存儲到Mysql的一條記錄中,僅一天的數據量Mysql就承受不了了。考慮到心跳信息就是非是即否的屬性,又由於時間戳是連續的,因此咱們可使用bitmap中的86400個位來記錄一臺設備的心跳信息,假設設備的編號位001,咱們能夠在redis中這樣初始化:
127.0.0.1:6379> setbit 001 86399 0
複製代碼
爲何是86399呢?由於key的位下標是從0開始的,初始化一個最大的位是爲了不每次記錄客戶端心跳時頻繁的內存分配。
以後的工做就特別簡單了,客戶端上報數據以後,服務端就以客戶端設備編號位key,上報時間戳相對於當天凌晨的偏移量位offset,設置心跳便可(下邊是Go語言的一個範例):
currentTime := time.Now()
startTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 0, 0, 0, 0, currentTime.Location())
offsetSecond := currentTime.Unix() - startTime.Unix()
redisConn := cache.NewPool().Get()
defer redisConn.Close()
result, _ := redisConn.Do("SETBIT", "001", offsetSecond, 1)
fmt.Println(result)
複製代碼
不少APP和網站都稱擁有上百萬的活躍用戶,服務端經常須要統計用戶的在線狀態,或者查找一些近期活躍用戶(好比近一個月連續在線的用戶)。由於數據量巨大,怎樣使用更快的速度、更小的空間查詢出來咱們想要的結果呢?答案就是bitmap。前邊咱們說過1M的空間就有8388608個位,咱們能夠將用戶的id映射爲bitmp的偏移量,也就是說使用不到1M的空間,就能夠存儲百萬用戶的在線信息了。這時使用BITCOUNT就能夠很是方便獲得當前在線用戶數量了。
咱們在生產/消費模式中,數據去重避免重複消費一直都是一個很是頭疼的話題。數據量較少的狀況下能夠存儲全量數據,能夠經過創建索引或者hash在每次檢索數據時檢查一下數據是否已經被消費;當數據量較大時,很顯然這種方法就很是不適用了,數據查找和存儲的代價是巨大的;咱們能夠想辦法將每一條消費數據都映射爲一個惟一的一個int型id上,能夠是毫秒時間戳 * 設備id,也能夠是其餘的組合,只要保證惟一便可。假設咱們天天從消費隊列中取出的數據是1千萬,咱們可使用不到10M的存儲空間記錄消費信息了(相應的位設置爲1),另外咱們在判斷信息是否被消費時,位運算的代碼執行效率遠遠優於數據庫索引和hash。
咱們知道bitmap底層實現默認是操做的一個個位,也就是一種010101......這樣的字符串表示,但它又不是字符串(存儲字符串所使用的空間比二進制位大的多)。一個好的實踐方式是將bitmap存儲拆分紅程序中的一個個int值表示,而後存儲到數據庫,編程語言中例如PHP,最大的int值也只能存儲63個二進制位而已(PHP7的int值使用zend_long結構體,8個字節表示int值,最高位是符號位),Go使用Uint64最多也只能每次表示64個二進制位而已。咱們先來看看Go從redis中取出的bitmap是什麼樣子的吧,咱們設置bitmap中的key是testBit,bit的第1位爲1:
127.0.0.1:6379> setbit testBit 0 1
(integer) 0
複製代碼
Go語言取出testBit代碼
redisConn := cache.NewPool().Get()
defer redisConn.Close()
cfg, err := redisConn.Do("get", "testBit")
if err != nil {
fmt.Println(err)
}
fmt.Println(cfg)
複製代碼
結果爲:
[128]
複製代碼
Go語言從redis中取出的bitmap後會賦值給本身的內部(uint8的slice)變量,每個slice中的值爲一個字節表示(連續8個的位),但是我明明設置的是testBit的第1個位呀,爲何取出來的值是128而不是1呢?由於筆者的計算機是小端的,因此在取出來值以後,咱們還須要對每一個uint8表示的值進行位的對稱轉換(第1位和第8位交換,第7位和第2位交換.....)
程序實現上也很是的簡單:
/**
* 反轉二進制位
*/
func swapBit(uint8Slice []uint8) {
var j uint8
for k, i := range uint8Slice {
j ^= (j & (1 << 0)) ^ ((i >> 7 & 1) << 0)
j ^= (j & (1 << 1)) ^ ((i >> 6 & 1) << 1)
j ^= (j & (1 << 2)) ^ ((i >> 5 & 1) << 2)
j ^= (j & (1 << 3)) ^ ((i >> 4 & 1) << 3)
j ^= (j & (1 << 4)) ^ ((i >> 3 & 1) << 4)
j ^= (j & (1 << 5)) ^ ((i >> 2 & 1) << 5)
j ^= (j & (1 << 6)) ^ ((i >> 1 & 1) << 6)
j ^= (j & (1 << 7)) ^ ((i >> 0 & 1) << 7)
uint8Slice[k] = j
}
}
複製代碼
咱們再設置testBit的第1位爲1,使用swapBit函數處理取出來的值。
127.0.0.1:6379> setbit testBit 1 1
(integer) 0
複製代碼
理論上咱們獲得的應該是3,事實也是如此:
redisConn := cache.NewPool().Get()
defer redisConn.Close()
cfg, err := redisConn.Do("get", "testBit")
if err != nil {
fmt.Println(err)
}
//強制類型轉化(interface{} -> uint8)
int8Slice := cfg.([]uint8)
swapBit(int8Slice)
fmt.Println(int8Slice)
複製代碼
咱們前邊提到,不管哪一種編程語言的int值最多也不過能存儲64個連續的二進制位,咱們不妨換一種思路,將連續的60位存儲位一個int64變量中(在時間表示中,1分鐘等於60秒,1小時等於60分鐘,這樣一個int值能夠表達更具體的含義),具體的轉換過程比較複雜,也就是每7.5個byte轉換成一個int64值(可是隻使用60個位),下面是代碼示例,感興趣的讀者能夠研究一下:
//將uint8轉化爲uint64,只使用60位存儲:1分鐘 => 60秒
func fillInt64(int8Slice []uint8, int64Slice []uint64) {
int64Len := len(int64Slice)
for i := 0; i < int64Len; i++ {
odd := i % 2
if odd == 0 {
offset := uint64(float64(i) * 7.5)
int64Slice[i] = uint64(int8Slice[offset]) + uint64(int8Slice[offset + 1]) << 8 + uint64(int8Slice[offset + 2]) << 16 + uint64(int8Slice[offset + 3]) << 24 + uint64(int8Slice[offset + 4]) << 32 + uint64(int8Slice[offset + 5]) << 40 + uint64(int8Slice[offset + 6]) << 48 + uint64(int8Slice[offset + 7] & 31) << 56
} else {
offset := uint64(math.Floor(float64(i) * 7.5))
int64Slice[i] = uint64(int8Slice[offset] & 230) >> 4 + uint64(int8Slice[offset + 1]) << 4 + uint64(int8Slice[offset + 2]) << 12 + uint64(int8Slice[offset + 3]) << 20 + uint64(int8Slice[offset + 4]) << 28 + uint64(int8Slice[offset + 5]) << 36 + uint64(int8Slice[offset + 6]) << 44 + uint64(int8Slice[offset + 7]) << 52
}
}
}
複製代碼
這樣咱們就能夠將bitmap中的值轉換成int64值類型的slice了,可使用','分割存儲到數據庫中,Mysql查詢統計的時候只須要根據','分割加載到內部數據int類型中,根據偏移量計算便可。
前邊咱們說過,將zset中的score設置爲時間戳能夠造成一個時間滑動窗口,利用這個特性能夠在業務邏輯中實現很是豐富的功能,咱們來簡單看一下:
說到定時器,開發者每每會想到javascript中的timer,其實服務端也有定時器,用於控制程序在指定的時間執行任務。php中經過pcntl庫來實現定時器,咱們來看php官網給出的例子:
<?php
pcntl_signal(SIGALRM, function () {
echo 'Received an alarm signal !' . PHP_EOL;
}, false);
pcntl_alarm(5);
while (true) {
pcntl_signal_dispatch();
sleep(1);
}
複製代碼
例子中,設置了一個5s後執行的任務,經過分發信號的機制實現,在生產環境中不多這樣來實現定時任務,laravel程序中能夠經過以下命令來設置一個任務延時執行:
$job = (new ExampleJob())->delay(Carbon::now()->addMinute(1));
dispatch($job);
複製代碼
其原理就是將任務打包成payload,組成一個消息體添加到redis中,將其score設置爲任務要執行的時間戳,經過一個守護進程隔必定時間(例如3s掃描一下zset)取出要執行的任務執行。
Go語言對timer的支持比較友好,感興趣的讀者能夠研究一下,go1.12版本使用的是4叉堆,到1.13版本使用了64叉堆,能夠在上萬高併發下保持毫秒級的偏差,在生產環境中有普遍的應用。
兵法雲:「兵馬未到,糧草先行」,在不少微服務中,服務端每每將大量要下發到服務端的資源和任務先整理好,使用cron腳本或者守護進程,在指定的時間將任務下發下去。這個時候zset結構又派上用場了,能夠將任務要執行的時間戳存儲爲score,這樣就造成了一個計劃任務規劃單,原理和前邊講的延時任務差很少。
前邊咱們講了如何使用bitmap來存儲客戶端的心跳信息,如今咱們再來看一下使用zset如何實現客戶端狀態的實時監控。咱們只關心客戶端最後的狀態,咱們設置設備的編號爲key,設備上報心跳的最後時間戳爲score,客戶端每次上報心跳信息的時候,咱們都更新設備的score便可。
經過一個守護進程,使用redis的ZRANGEBYSCORE命令取出前3秒內設備的心跳信息,便可判斷哪些設備在這段時間沒有了心跳(離線)。下面演示了獲取1575880725->1575880728有心跳的設備:
func main() {
redisConn := cache.NewPool().Get()
defer redisConn.Close()
result, err := redis.Strings(redisConn.Do("ZRANGEBYSCORE", "client_heart_bit", 1575880725, 1575880728, "WITHSCORES"))
if err != nil {
fmt.Println(err)
}
fmt.Println(result)
}
複製代碼
本文並未涉及太多的代碼邏輯和架構設計,而是從業務的角度,講解了如何在合適的場景使用redis的bitmap和zset來解決問題。同時,筆者延伸了不少拓展內容,也只是拋磚引玉罷了,讀者感興趣能夠深刻探索研究,簡單總結是以下兩點: