字符串使用与内部实现原理
Redis 發展到現在已經有 9 種數據類型了,其中最基礎、最常用的數據類型有 5 種,它們分別是:字符串類型、列表類型、哈希表類型、集合類型、有序集合類型,而在這 5 種數據類型中最常用的是字符串類型,所以本文我們先從字符串的使用開始說起。
字符串類型的全稱是 Simple Dynamic Strings 簡稱 SDS,中文意思是:簡單動態字符串。它是以鍵值對 key-value 的形式進行存儲的,根據 key 來存儲和獲取 value 值,它的使用相對來說比較簡單,但在實際項目中應用非常廣泛。
1 字符串類型能做什么?
字符串類型的使用場景有很多,但從功能的角度來區分,大致可分為以下兩種:
- 字符串存儲和操作;
- 整數類型和浮點類型的存儲和計算。
字符串最常用的業務場景有以下幾個。
1)頁面數據緩存
我們知道,一個系統最寶貴的資源就是數據庫資源,隨著公司業務的發展壯大,數據庫的存儲量也會越來越大,并且要處理的請求也越來越多,當數據量和并發量到達一定級別之后,數據庫就變成了拖慢系統運行的“罪魁禍首”,為了避免這種情況的發生,我們可以把查詢結果放入緩存(Redis)中,讓下次同樣的查詢直接去緩存系統取結果,而非查詢數據庫,這樣既減少了數據庫的壓力,同時也提高了程序的運行速度。
介于以上這個思路,我們可以把文章詳情頁的數據放入緩存系統。具體的做法是先將文章詳情頁序列化為字符串存入緩存,再從緩存中讀取到字符串,反序列化成對象,然后再賦值到頁面進行顯示 (當然也可以用哈希類型進行存儲,這會在下一篇文章中講到),這樣我們就實現了文章詳情頁的緩存功能,架構流程對比圖如下所示。
原始系統運行流程圖:
引入緩存系統后的流程圖:
2)數字計算與統計
Redis 可以用來存儲整數和浮點類型的數據,并且可以通過命令直接累加并存儲整數信息,這樣就省去了每次先要取數據、轉換數據、拼加數據、再存入數據的麻煩,只需要使用一個命令就可以完成此流程,具體實現過程本文下半部分會講。這樣我們就可以使用此功能來實現訪問量的統計,當有人訪問時訪問量 +1 就可以了。
3)共享 Session 信息
通常我們在開發后臺管理系統時,會使用 Session 來保存用戶的會話(登錄)狀態,這些 Session 信息會被保存在服務器端,但這只適用于單系統應用,如果是分布式系統此模式將不再適用。
例如用戶一的 Session 信息被存儲在服務器一,但第二次訪問時用戶一被分配到服務器二,這個時候服務器并沒有用戶一的 Session 信息,就會出現需要重復登錄的問題。分布式系統每次會把請求隨機分配到不同的服務器,因此我們需要借助緩存系統對這些 Session 信息進行統一的存儲和管理,這樣無論請求發送到那臺服務器,服務器都會去統一的緩存系統獲取相關的 Session 信息,這樣就解決了分布式系統下 Session 存儲的問題。
分布式系統單獨存儲 Session 流程圖:
分布式系統使用同一的緩存系統存儲 Session 流程圖:
2 字符串如何使用?
通常我們會使用兩種方式來操作 Redis:第一種是使用命令行來操作,例如 redis-cli;另一種是使用代碼的方式來操作,下面我們分別來看。
1)命令行操作方式
字符串的操作命令有很多,但大體可分為以下五類:
- 單個鍵值對操作
- 多個鍵值對操作
- 數字統計
- 鍵值對過期屬性相關操作
- 字符串操作進階
我們本文使用 redis-cli 來實現對 Redis 的操作,在使用命令之前,先輸入 redis-cli 來鏈接到 Redis 服務器。
① 單個鍵值對操作
a.添加鍵值對
語法:set key value [expiration EX seconds|PX milliseconds] [NX|XX] 示例:
127.0.0.1:6379> set k1 val1 OKb.獲取鍵值對
語法:get key 示例:
127.0.0.1:6379> get k1 "val1"c.給元素追加值
語法:append key value 示例:
127.0.0.1:6379> get k1 "v1" 127.0.0.1:6379> append k1 append (integer) 5 127.0.0.1:6379> get k1 "v1append"d.查詢字符串的長度
語法:strlen key 示例:
127.0.0.1:6379> strlen k1 (integer) 5② 多個鍵值對操作
a.創建一個或多個鍵值對
語法:mset key value [key value …] 示例:
127.0.0.1:6379> mset k2 v2 k3 v3 OK小貼士:mset 是一個原子性(atomic)操作,所有給定 key 都會在同一時間內被設置,不會出現某些 key 被更新,而另一些 key 沒被更新的情況。
b.查詢一個或多個元素
語法:mget key [key …] 示例:
127.0.0.1:6379> mget k2 k3 1) "v2" 2) "v3"③ 數字統計
在 Redis 中可以直接操作整型和浮點型,例如可以直接使用命令來加、減值。
a.給整數類型的值加 1
語法:incr key 示例:
127.0.0.1:6379> get k1 "3" 127.0.0.1:6379> incr k1 (integer) 4 127.0.0.1:6379> get k1 "4"b.給整數類型的值減 1
語法:decr key 示例:
127.0.0.1:6379> get k1 "4" 127.0.0.1:6379> decr k1 (integer) 3 127.0.0.1:6379> get k1 "3"c.根據 key 減去指定的值
語法:decrby key decrement 示例:
127.0.0.1:6379> get k1 "3" 127.0.0.1:6379> decrby k1 2 (integer) 1 127.0.0.1:6379> get k1 "1"如果 key 不存在,則會先初始化此 key 為 0 ,然后再執行減法操作:
127.0.0.1:6379> get k2 (nil) 127.0.0.1:6379> decrby k2 3 (integer) -3 127.0.0.1:6379> get k2 "-3"d.根據 key 加指定的整數值
語法:incrby key increment 示例:
127.0.0.1:6379> get k1 "1" 127.0.0.1:6379> incrby k1 2 (integer) 3 127.0.0.1:6379> get k1 "3"如果 key 不存在,則會先初始化此 key 為 0 ,然后再執行加整數值的操作:
127.0.0.1:6379> get k3 (nil) 127.0.0.1:6379> incrby k3 5 (integer) 5 127.0.0.1:6379> get k3 "5"e.根據 key 加上指定的浮點數
語法:incrbyfloat key increment 示例:
127.0.0.1:6379> get k3 "5" 127.0.0.1:6379> incrbyfloat k3 4.9 "9.9" 127.0.0.1:6379> get k3 "9.9"如果 key 不存在,則會先初始化此 key 為 0 ,然后再執行加浮點數的操作:
127.0.0.1:6379> get k4 (nil) 127.0.0.1:6379> incrbyfloat k4 4.4 "4.4" 127.0.0.1:6379> get k4 "4.4"④ 鍵值對過期操作
a.添加鍵值對并設置過期時間
語法:set key value [expiration EX seconds|PX milliseconds] [NX|XX] 示例:
127.0.0.1:6379> set k1 val1 ex 1000 OK設置鍵值對 k1=val1,過期時間為 1000 秒。 查詢鍵的過期時間可以使用 ttl key,如下代碼所示:
127.0.0.1:6379> ttl k1 (integer) 997b.賦值字符串,并設置過期時間(單位/秒)
語法:setex key seconds value 示例:
127.0.0.1:6379> setex k1 1000 v1 OK 127.0.0.1:6379> ttl k1 (integer) 999 127.0.0.1:6379> get k1 "v1"如果 key 已經存在,setex 命令將會覆寫原來的舊值。
c.賦值字符串,并設置過期時間(單位/毫秒)
與 setex 用法類似,只不過 psetex 設置的單位是毫秒。 語法:psetex key milliseconds value 示例:
127.0.0.1:6379> psetex k1 100000 v11 OK 127.0.0.1:6379> ttl k1 (integer) 97 127.0.0.1:6379> get k1 "v11"⑤ 字符串操作進階
a.根據指定的范圍截取字符串
語法:getrange key start end 示例:
127.0.0.1:6379> get hello "hello world" 127.0.0.1:6379> getrange hello 0 4 "hello" 127.0.0.1:6379> getrange hello 0 -1 "hello world" 127.0.0.1:6379> getrange hello 0 -2 "hello worl"負數表示從字符串最后開始計數, -1 表示最后一個字符, -2 表示倒數第二個,以此類推。
b.設置字符串新值并返回舊值
語法:getset key value 示例:
127.0.0.1:6379> get db "redis" 127.0.0.1:6379> getset db mysql "redis" 127.0.0.1:6379> get db "mysql"使用 getset 命令時,如果 key 不為字符串會報錯,如下效果所示:
127.0.0.1:6379> type myset set 127.0.0.1:6379> getset myset v1 (error) WRONGTYPE Operation against a key holding the wrong kind of value根據 type 命令可以查詢出 key 所對應的數據類型為非字符串,在使用 getset 命令就會報錯。
c.賦值(創建)鍵值對,當 key 不存在時
如果 key 已經存在,則執行命令無效,不會修改原來的值,否則會創建新的鍵值對。 語法:setnx key value 示例:
127.0.0.1:6379> setnx k9 v9 (integer) 1 127.0.0.1:6379> get k9 "v9" 127.0.0.1:6379> setnx k9 v99 (integer) 0 127.0.0.1:6379> get k9 "v9"d.設置一個或多個鍵值,當所有鍵值都不存在時
語法:msetnx key value [key value …] 示例:
127.0.0.1:6379> msetnx k5 v5 k6 v6 (integer) 1 127.0.0.1:6379> mget k5 k6 1) "v5" 2) "v6"注意:msetnx 是一個原子操作,當一個操作失敗時,其他操作也會失敗。例如,如果有一個已經存在的值,那么全部鍵值都會設置失敗,效果如下:
127.0.0.1:6379> get k1 "val1" 127.0.0.1:6379> get k8 (nil) 127.0.0.1:6379> msetnx k1 v1 k8 v8 (integer) 0 127.0.0.1:6379> get k1 "val1" 127.0.0.1:6379> get k8 (nil)e.截取字符串并賦值
語法:setrange key offset value 示例:
127.0.0.1:6379> get hello "hello java" 127.0.0.1:6379> setrange hello 6 redis (integer) 11 127.0.0.1:6379> get hello "hello redis"如果待截取的鍵不存在,會當作空白字符串處理,效果如下:
127.0.0.1:6379> setrange mystr 3 mystring (integer) 11 127.0.0.1:6379> get mystring (nil)以上這些命令基本涵蓋了所有的字符串操作,有些不常用,但很好用,例如 setnx key value 命令,當 key 已經存在,則執行命令無效,并不會覆蓋原有的值,如果沒有此 key 則會新創建一個鍵值對。類似其他命令還有很多,需要讀者在實戰中慢慢發掘。
2)代碼操作方式
本文我們使用 Java 語言來實現對 Redis 的操作,首先我們在項目中添加對 Jedis 框架的引用,如果是 Maven 項目,我們會在 pom.xml 文件中添加如下信息:
<!-- https://mvnrepository.com/artifact/io.lettuce/lettuce-core --> <dependency><groupId>io.lettuce</groupId><artifactId>lettuce-core</artifactId><version>${version}</version> </dependency> <!-- https://mvnrepository.com/artifact/redis.clients/jedis --> <dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>${version}</version> </dependency>Jedis 是 Redis 官方推薦的 Java 客戶端開發包,用于實現快速簡單的操作 Redis。添加完 Jedis 之后,我們來寫具體的操作代碼,操作函數與命令方式的調用比較相似,如下代碼所示:
import redis.clients.jedis.Jedis; import java.util.List;public class StringExample {public static void main(String[] args) {Jedis jedis = new Jedis("127.0.0.1", 6379);// jedis.auth("xxx"); // 輸入密碼,沒有密碼,可以不設置// 添加一個元素jedis.set("mystr", "redis");// 獲取元素String myStr = jedis.get("mystr");System.out.println(myStr); // 輸出:redis// 添加多個元素(key,value,key2,value2)jedis.mset("db", "redis", "lang", "java");// 獲取多個元素List<String> mlist = jedis.mget("db", "lang");System.out.println(mlist); // 輸出:[redis, java]// 給元素追加字符串jedis.append("db", ",mysql");// 打印追加的字符串System.out.println(jedis.get("db")); // 輸出:redis,mysql// 當 key 不存在時,賦值鍵值Long setnx = jedis.setnx("db", "db2");// 因為 db 元素已經存在,所以會返回 0 條修改System.out.println(setnx); // 輸出:0// 字符串截取String range = jedis.getrange("db", 0, 2);System.out.println(range); // 輸出:red// 添加鍵值并設置過期時間(單位:毫秒)String setex = jedis.setex("db", 1000, "redis");System.out.println(setex); // 輸出:ok// 查詢鍵值的過期時間Long ttl = jedis.ttl("db");System.out.println(ttl); // 輸出:1000} }3 代碼實戰
本文的上半部分我們講到了字符串的很多種使用場景,本小節就以字符串存儲用戶對象信息為例,我們先將用戶對象信息序列化為字符串存儲在 Redis,再從 Redis 中取出字符串并反序列化為對象信息為例,使用 Java 語言來實現。
首先添加 JSON 轉換類,用于對象和字符串之間的序列化和反序列化,我們這里采用 Google 的 Gson 來實現,首先在 pom.xml 文件中添加如下引用:
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson --> <dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.8.6</version> </dependency>添加完 Gson 引用之后,我們來寫具體的業務代碼,先見用戶信息序列化之后存儲在 Redis 中:
Jedis jedis = new Jedis("xxx.xxx.xxx.xxx", 6379); jedis.auth("xxx"); Gson gson = new Gson(); // 構建用戶數據 User user = new User(); user.setId(1); user.setName("Redis"); user.setAge(10); String jsonUser = gson.toJson(user); // 打印用戶信息(json) System.out.println(jsonUser); // 輸出:{"id":1,"name":"Redis","age":10} // 把字符串存入 Redis jedis.set("user", jsonUser);當使用用戶信息時,我們從 Redis 反序列化出來,代碼如下:
String getUserData = jedis.get("user"); User userData = gson.fromJson(getUserData, User.class); // 打印對象屬性信息 System.out.println(userData.getId() + ":" + userData.getName()); // 輸出結果:1:Redis以上兩個步驟就完成了用戶信息存放至 Redis 中的過程,也是常用的經典使用場景之一。
4 字符串的內部實現
1)源碼分析
Redis 3.2 之前 SDS 源碼如下:
struct sds{int len; // 已占用的字節數int free; // 剩余可以字節數char buf[]; // 存儲字符串的數據空間 }可以看出 Redis 3.2 之前 SDS 內部是一個帶有長度信息的字節數組,存儲結構如下圖所示:
為了更加有效的利用內存,Redis 3.2 優化了 SDS 的存儲結構,源碼如下:
typedef char *sds;struct __attribute__ ((__packed__)) sdshdr5 { // 對應的字符串長度小于 1<<5unsigned char flags;char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { // 對應的字符串長度小于 1<<8uint8_t len; /* 已使用長度,1 字節存儲 */uint8_t alloc; /* 總長度 */unsigned char flags; char buf[]; // 真正存儲字符串的數據空間 }; struct __attribute__ ((__packed__)) sdshdr16 { // 對應的字符串長度小于 1<<16uint16_t len; /* 已使用長度,2 字節存儲 */uint16_t alloc; unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { // 對應的字符串長度小于 1<<32uint32_t len; /* 已使用長度,4 字節存儲 */uint32_t alloc; unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { // 對應的字符串長度小于 1<<64uint64_t len; /* 已使用長度,8 字節存儲 */uint64_t alloc; unsigned char flags; char buf[]; };這樣就可以針對不同長度的字符串申請相應的存儲類型,從而有效的節約了內存使用。
2)數據類型
我們可以使用 object encoding key 命令來查看對象(鍵值對)存儲的數據類型,當我們使用此命令來查詢 SDS 對象時,發現 SDS 對象竟然包含了三種不同的數據類型:int、embstr 和 raw。
① int 類型
127.0.0.1:6379> set key 666 OK 127.0.0.1:6379> object encoding key "int"② embstr 類型
127.0.0.1:6379> set key abc OK 127.0.0.1:6379> object encoding key "embstr"③ raw 類型
127.0.0.1:6379> set key abcdefghigklmnopqrstyvwxyzabcdefghigklmnopqrs OK 127.0.0.1:6379> object encoding key "raw"int 類型很好理解,整數類型對應的就是 int 類型,而字符串則對應是 embstr 類型,當字符串長度大于 44 字節時,會變為 raw 類型存儲。
3)為什么是 44 字節?
在 Redis 中,如果 SDS 的存儲值大于 64 字節時,Redis 的內存分配器會認為此對象為大字符串,并使用 raw 類型來存儲,當數據小于 64 字節時(字符串類型),會使用 embstr 類型存儲。既然內存分配器的判斷標準是 64 字節,那為什么 embstr 類型和 raw 類型的存儲判斷值是 44 字節?
這是因為 Redis 在存儲對象時,會創建此對象的關聯信息,redisObject 對象頭和 SDS 自身屬性信息,這些信息都會占用一定的存儲空間,因此長度判斷標準就從 64 字節變成了 44 字節。
在 Redis 中,所有的對象都會包含 redisObject 對象頭。我們先來看 redisObject 對象的源碼:
typedef struct redisObject {unsigned type:4; // 4 bitunsigned encoding:4; // 4 bitunsigned lru:LRU_BITS; // 3 個字節int refcount; // 4 個字節void *ptr; // 8 個字節 } robj;它的參數說明如下:
- type:對象的數據類型,例如:string、list、hash 等,占用 4 bits 也就是半個字符的大小;
- encoding:對象數據編碼,占用 4 bits;
- lru:記錄對象的 LRU(Least Recently Used 的縮寫,即最近最少使用)信息,內存回收時會用到此屬性,占用 24 bits(3 字節);
- refcount:引用計數器,占用 32 bits(4 字節);
- *ptr:對象指針用于指向具體的內容,占用 64 bits(8 字節)。
redisObject 總共占用 0.5 bytes + 0.5 bytes + 3 bytes + 4 bytes + 8 bytes = 16 bytes(字節)。
了解了 redisObject 之后,我們再來看 SDS 自身的數據結構,從 SDS 的源碼可以看出,SDS 的存儲類型一共有 5 種:SDSTYPE5、SDSTYPE8、SDSTYPE16、SDSTYPE32、SDSTYPE64,在這些類型中最小的存儲類型為 SDSTYPE5,但 SDSTYPE5 類型會默認轉成 SDSTYPE8,以下源碼可以證明,如下圖所示:
那我們直接來看 SDSTYPE8 的源碼:
struct __attribute__ ((__packed__)) sdshdr8 {uint8_t len; // 1 byteuint8_t alloc; // 1 byteunsigned char flags; // 1 bytechar buf[]; };可以看出除了內容數組(buf)之外,其他三個屬性分別占用了 1 個字節,最終分隔字符等于 64 字節,減去 redisObject 的 16 個字節,再減去 SDS 自身的 3 個字節,再減去結束符 \0 結束符占用 1 個字節,最終的結果是 44 字節(64-16-3-1=44),內存占用如下圖所示:
5 小結
本文介紹了字符串的定義及其使用,它的使用主要分為:單鍵值對操作、多鍵值對操作、數字統計、鍵值對過期操作、字符串操作進階等。同時也介紹了字符串使用的三個場景,字符串類型可用作為:頁面數據緩存,可以緩存一些文章詳情信息等;數字計算與統計,例如計算頁面的訪問次數;也可以用作 Session 共享,用來記錄管理員的登錄信息等。同時我們深入的介紹了字符串的五種數據存儲結構,以及字符串的三種內部數據類型,如下圖所示:
同時我們也知道了 embstr 類型向 raw 類型轉化,是因為每個 Redis 對象都包含了一個 redisObject 對象頭和 SDS 自身屬性占用了一定的空間,最終導致數據類型的判斷長度是 44 字節。
總結
以上是生活随笔為你收集整理的字符串使用与内部实现原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 消息队列终极解决方案——Stream(上
- 下一篇: 检索COM类工厂中CLSID为{0002