在這篇文章中,我們將徹底了解 redis 的使用場景、Redis 的五種數據結構,以及如何在
Spring Boot 中使用 Redis,文章的最后還會列舉面試過程中經常被問到的關于 Redis
的問題以及其解決方案。
Redis 簡介
Redis 是一個開源(BSD
許可)、內存存儲的數據結構服務器,可用作數據庫,高速緩存和消息隊列代理。它支持字符串、哈希表、列表、集合、有序集合等數據類型。內置復制、Lua
腳本、LRU 收回、事務以及不同級別磁盤持久化功能,同時通過 Redis Sentinel
提供高可用,通過 Redis Cluster
提供自動分區。在實際的開發過程中,多多少少都會涉及到緩存,而 Redis
通常來說是我們分布式緩存的最佳選擇。Redis 也是我們熟知的
NoSQL(非關系性數據庫)之一,雖然其不能完全的替代關系性數據庫,但它可作為其良好的補充。
Redis 使用場景
微服務以及分布式被廣泛使用后,Redis
的使用場景就越來越多了,這里我羅列了主要的幾種場景。
- 分布式緩存:在分布式的系統架構中,將緩存存儲在內存中顯然不當,因為緩存需要與其他機器共享,這時
Redis 便挺身而出了,緩存也是 Redis 使用最多的場景。 - 分布式鎖:在高并發的情況下,我們需要一個鎖來防止并發帶來的臟數據,JAVA
自帶的鎖機制顯然對進程間的并發并不好使,此時可以利用 Redis單線程的特性來實現我們的分布式。 - Session 存儲/共享:Redis 可以將 Session
持久化到存儲中,這樣可以避免由于機器宕機而丟失用戶會話信息。 - 發布/訂閱:Redis 還有一個發布/訂閱的功能,您可以設定對某一個 key
值進行消息發布及消息訂閱,當一個 key值上進行了消息發布后,所有訂閱它的客戶端都會收到相應的消息。這一功能最明顯的用法就是用作實時消息系統。 - 任務隊列:Redis 的 lpush+brpop
命令組合即可實現阻塞隊列,生產者客戶端使用 lrpush從列表左側插入元素,多個消費者客戶端使用 brpop命令阻塞式的"搶"列表尾部的元素,多個客戶端保證了消費的負載均衡和高可用性。 - 限速,接口訪問頻率限制:比如發送短信驗證碼的接口,通常為了防止別人惡意頻刷,會限制用戶每分鐘獲取驗證碼的頻率,例如一分鐘不能超過
5 次。
當然 Redis
的使用場景并不僅僅只有這么多,還有很多未列出的場景,如計數、排行榜等,可見 Redis
的強大。不過 Redis
說到底還是一個數據庫(非關系型),那么我們還是有必要了解一下它支持存儲的數據結構。
Redis 數據類型
前面也提到過,Redis
支持字符串、哈希表、列表、集合、有序集合五種數據類型的存儲。了解這五種數據結構非常重要,可以說如果吃透了這五種數據結構,你就掌握了
Redis 應用知識的三分之一,下面我們就來逐一解析。
字符串(string)
string 這種數據結構應該是我們最為常用的。在 Redis 中 string
表示的是一個可變的字節數組,我們初始化字符串的內容、可以拿到字符串的長度,可以獲取
string 的子串,可以覆蓋 string 的子串內容,可以追加子串。
圖 1. Redis 的 string 類型數據結構
如上圖所示,在 Redis
中我們初始化一個字符串時,會采用預分配冗余空間的方式來減少內存的頻繁分配,如圖 1
所示,實際分配的空間 capacity 一般要高于實際字符串長度 len。如果您看過 Java 的
ArrayList 的源碼相信會對此種模式很熟悉。
列表(list)
在 Redis 中列表 list
采用的存儲結構是雙向鏈表,由此可見其隨機定位性能較差,比較適合首位插入刪除。像
Java 中的數組一樣,Redis 中的列表支持通過下標訪問,不同的是 Redis
還為列表提供了一種負下標,-1 表示倒數一個元素,-2
表示倒數第二個數,依此類推。綜合列表首尾增刪性能優異的特點,通常我們使用
rpush/rpop/lpush/lpop 四條指令將列表作為隊列來使用。
圖 2. List 類型數據結構
如上圖所示,在列表元素較少的情況下會使用一塊連續的內存存儲,這個結構是
ziplist,也即是壓縮列表。它將所有的元素緊挨著一起存儲,分配的是一塊連續的內存。當數據量比較多的時候才會改成
quicklist。因為普通的鏈表需要的附加指針空間太大,會比較浪費空間。比如這個列表里存的只是
int 類型的數據,結構上還需要兩個額外的指針 prev 和 next。所以 Redis 將鏈表和
ziplist 結合起來組成了 quicklist。也就是將多個 ziplist
使用雙向指針串起來使用。這樣既滿足了快速的插入刪除性能,又不會出現太大的空間冗余。
哈希表(hash)
hash 與 Java 中的 HashMap
差不多,實現上采用二維結構,第一維是數組,第二維是鏈表。hash 的 key 與 value
都存儲在鏈表中,而數組中存儲的則是各個鏈表的表頭。在檢索時,首先計算 key 的
hashcode,然后通過 hashcode 定位到鏈表的表頭,再遍歷鏈表得到 value
值??赡苣容^好奇為啥要用鏈表來存儲 key 和 value,直接用 key 和 value
一對一存儲不就可以了嗎?其實是因為有些時候我們無法保證 hashcode
值的唯一,若兩個不同的 key 產生了相同的
hashcode,我們需要一個鏈表在存儲兩對鍵值對,這就是所謂的 hash 碰撞。
集合(set)
熟悉 Java 的同學應該知道 HashSet 的內部實現使用的是 HashMap,只不過所有的 value
都指向同一個對象。Redis 的 Set 結構也是一樣,它的內部也使用 Hash 結構,所有的
value 都指向同一個內部值。
有序集合(sorted set)
有時也被稱作 ZSet,是 Redis
中一個比較特別的數據結構,在有序集合中我們會給每個元素賦予一個權重,其內部元素會按照權重進行排序,我們可以通過命令查詢某個范圍權重內的元素,這個特性在我們做一個排行榜的功能時可以說非常實用了。其底層的實現使用了兩個數據結構,
hash 和跳躍列表,hash 的作用就是關聯元素 value 和權重 score,保障元素 value
的唯一性,可以通過元素 value 找到相應的 score 值。跳躍列表的目的在于給元素 value
排序,根據 score 的范圍獲取元素列表。
在 Spring Boot 項目中使用 Redis
準備工作
開始在 Spring Boot 項目中使用 Redis 之前,我們還需要一些準備工作。
- 一臺安裝了 Redis 的機器或者虛擬機。
- 一個創建好的 Spring Boot 項目。
添加 Redis 依賴
Spring Boot 官方已經為我們提供好了集成 Redis 的 Starter,我們只需要簡單地在
pom.xml 文件中添加如下代碼即可。Spring Boot 的 Starter
給我們在項目依賴管理上提供了諸多便利。
清單 1. 添加 Redis 依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
添加完依賴之后,我們還需要配置 Redis
的地址等信息才能使用,在 Application.properties 中添加如下配置即可。
清單 2. Spring Boot 中配置 Redis
spring.redis.host=192.168.142.132
spring.redis.port=6379
# Redis 數據庫索引(默認為 0)
spring.redis.database=0
# Redis 服務器連接端口
# Redis 服務器連接密碼(默認為空)
spring.redis.password=
#連接池最大連接數(使用負值表示沒有限制)
spring.redis.jedis.pool.max-active=8
# 連接池最大阻塞等待時間(使用負值表示沒有限制)
spring.redis.jedis.pool.max-wait=-1
# 連接池中的最大空閑連接
spring.redis.jedis.pool.max-idle=8
# 連接池中的最小空閑連接
spring.redis.jedis.pool.min-idle=0
# 連接超時時間(毫秒)
spring.redis.timeout=0
Spring Boot 的 spring-boot-starter-data-redis 為 Redis
的相關操作提供了一個高度封裝的 RedisTemplate 類,而且對每種類型的數據結構都進行了歸類,將同一類型操作封裝為
operation 接口。RedisTemplate 對五種數據結構分別定義了操作,如下所示:
- 操作字符串:redisTemplate.opsForValue()
- 操作 Hash:redisTemplate.opsForHash()
- 操作 List:redisTemplate.opsForList()
- 操作 Set:redisTemplate.opsForSet()
- 操作 ZSet:redisTemplate.opsForZSet()
但是對于 string 類型的數據,Spring Boot還專門提供了 StringRedisTemplate 類,而且官方也建議使用該類來操作 String類型的數據。那么它和 RedisTemplate 又有啥區別呢?
- RedisTemplate 是一個泛型類,而 StringRedisTemplate 不是,后者只能對鍵和值都為 String 類型的數據進行操作,而前者則可以操作任何類型。
- 兩者的數據是不共通的,StringRedisTemplate 只能管理 StringRedisTemplate 里面的數據,RedisTemplate 只能管理RedisTemplate 中
的數據。
RedisTemplate 的配置
一個 Spring Boot
項目中,我們只需要維護一個 RedisTemplate 對象和一個 StringRedisTemplate 對象就可以了。所以我們需要通過一個 Configuration 類來初始化這兩個對象并且交由的 BeanFactory 管理。我們在 cn.itweknow.sbredis.config包下面新建了一個 RedisConfig 類,其內容如下所示:
清單 3. RedisTemplate 和 StringRedisTemplate 的配置
@Configuration
public class RedisConfig {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(jackson2JsonRedisSerializer);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashKeySerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
@ConditionalOnMissingBean(StringRedisTemplate.class)
public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
操作字符串
StringRedisTempalte 在上面已經初始化好了,我們只需要在需要用到的地方通過 @AutoWired 注解注入就行。
- 設置值,對于設置值,我們可以使用 opsForValue().void set(K var1, V var2);
@Test
public void testSet() {
stringRedisTemplate.opsForValue().set("test-string-value", "Hello Redis");
}
- 獲取值,與 set 方法相對于 StringRedisTemplate 還提供了.opsForValue().get(Object
var1) 方法來獲取指定 key 對應的 value 值。
@Test
public void testGet() {
String value = stringRedisTemplate.opsForValue().get("test-string-value");
System.out.println(value);
}
- 設置值的時候設置過期時間。在設置緩存的時候,我們通常都會給他設置一個過期時間,讓其能夠達到定時刷新的效果。StringRedisTemplate 提供了 void
set(K var1, V var2, long var3, TimeUnitvar5) 方法來達到設置過期時間的目的,其中 var3 這個參數就是過期時間的數值,而 TimeUnit 是個枚舉類型,我們用它來設置過期時間的單位,是小時或是秒等等。
@Test
public void testSetTimeOut() {
stringRedisTemplate.opsForValue().set("test-string-key-time-out", "Hello Redis", 3, TimeUnit.HOURS);
}
- 刪除數據,我們同樣可以通過 StringRedisTmeplate 來刪除數據, Boolean delete(K
key)方法提供了這個功能。
@Test
public void testDeleted() {
stringRedisTemplate.delete("test-string-value");
}
操作數組
在 Redis 數據類型小節中,我們提到過我們經常使用 Redis
的 lpush/rpush/lpop/rpop 四條指令來實現一個隊列。那么這四條指令在 RedisTemplate 中也有相應的實現。
- leftPush(K key, V value),往 List 左側插入一個元素,如 從左邊往數組中 push
元素:
@Test
public void testLeftPush() {
redisTemplate.opsForList().leftPush("TestList", "TestLeftPush");
}
- rightPush(K key, V value),往 List 右側插入一個元素, 如從右邊往數組中 push
元素:
@Test
public void testRightPush() {
redisTemplate.opsForList().rightPush("TestList", "TestRightPush");
}
- 執行完上面兩個 Test 之后,我們可以使用 Redis 客戶端工具 RedisDesktopManager
來查看 TestList 中的內容,如下圖 (Push 之后 TestList 中的內容)所示:
此時我們再一次執行 leftPush 方法,TestList 的內容就會變成下圖(第二次執行leftPush 之后的內容)所示:
可以看到 leftPush 實際上是往數組的頭部新增一個元素,那么 rightPush就是往數組尾部插入一個元素。
- leftPop(K key),從 List 左側取出第一個元素,并移除,
如從數組頭部獲取并移除值:
@Test
public void testLeftPop() {
Object leftFirstElement = redisTemplate.opsForList().leftPop("TestList");
System.out.println(leftFirstElement);
}
執行上面的代碼之后,您會看到控制臺會打印出 TestLeftPush,然后再去RedisDesktopManager 中查看 TestList 的內容,如下圖(同數組頂端移除一個元素后)所示。您會發現數組中的第一個元素已經被移除了。
- rightPop(K key),從 List 右側取出第一個元素,并移除,如從數組尾部獲取并移除值:
@Test
public void testRightPop() {
Object rightFirstElement = redisTemplate.opsForList().rightPop("TestList");
System.out.println(rightFirstElement);
}
操作 Hash
Redis 中的 Hash 數據結構實際上與 Java 中的 HashMap 是非常類似的,提供的 API
也很類似。下面我們就一起來看下 RedisTemplate 為 Hash 提供了哪些 API。
- Hash 中新增元素。
@Test
public void testPut() {
redisTemplate.opsForHash().put("TestHash", "FirstElement", "Hello,Redis hash.");
Assert.assertTrue(redisTemplate.opsForHash().hasKey("TestHash", "FirstElement"));
}
- 判斷指定 key 對應的 Hash 中是否存在指定的 map
鍵,使用用法可以見上方代碼所示。 - 獲取指定 key 對應的 Hash 中指定鍵的值。
@Test
public void testGet() {
Object element = redisTemplate.opsForHash().get("TestHash", "FirstElement");
Assert.assertEquals("Hello,Redis hash.", element);
}
- 刪除指定 key 對應 Hash 中指定鍵的鍵值對。
@Test
public void testDel() {
redisTemplate.opsForHash().delete("TestHash", "FirstElement");
Assert.assertFalse(redisTemplate.opsForHash().hasKey("TestHash", "FirstElement"));
}
操作集合
集合很類似于 Java 中的 Set,RedisTemplate 也為其提供了豐富的 API。
- 向集合中添加元素。
@Test
public void testAdd() {
redisTemplate.opsForSet().add("TestSet", "e1", "e2", "e3");
long size = redisTemplate.opsForSet().size("TestSet");
Assert.assertEquals(3L, size);
}
- 獲取集合中的元素。
@Test
public void testGet() {
Set<String> testSet = redisTemplate.opsForSet().members("TestSet");
System.out.println(testSet);
}
執行上面的代碼后,控制臺輸出的是 [e1, e3,e2],當然您可能會看到其他結果,因為 Set是無序的,并不是按照我們添加的順序來排序的。
- 獲取集合的長度,在像集合中添加元素的示例代碼中展示了如何獲取集合長度。
- 移除集合中的元素。’
@Test
public void testRemove() {
redisTemplate.opsForSet().remove("TestSet", "e1", "e2");
Set testSet = redisTemplate.opsForSet().members("TestSet");
Assert.assertEquals("e3", testSet.toArray()[0]);
}
操作有序集合
與 Set 不一樣的地方是,ZSet 對于集合中的每個元素都維護了一個權重值,那么
RedisTemplate 提供了不少與這個權重值相關的 API。
API描述add(K key, V value, double score)添加元素到變量中同時指定元素的分值。range(K key, long start, long end)獲取變量指定區間的元素。rangeByLex(K key, RedisZSetCommands.Range range)用于獲取滿足非 score 的排序取值。這個排序只有在有相同分數的情況下才能使用,如果有不同的分數則返回值不確定。angeByLex(K key, RedisZSetCommands.Range range, RedisZSetCommands.Limit limit)用于獲取滿足非 score 的設置下標開始的長度排序取值。add(K key, Set<ZSetOperations.TypedTuple<V>> tuples)通過 TypedTuple 方式新增數據。rangeByScore(K key, double min, double max)根據設置的 score 獲取區間值。rangeByScore(K key, double min, double max,long offset, long count)根據設置的 score 獲取區間值從給定下標和給定長度獲取最終值。rangeWithScores(K key, long start, long end)獲取 RedisZSetCommands.Tuples 的區間值。
實現分布式鎖
上面基本列出了 RedisTemplate 和 StringRedisTemplate 兩個類所提供的對 Redis
操作的相關 API,但是有些時候這些 API
并不能完成我們所有的需求,這個時候我們其實還可以在 Spring Boot 項目中直接與
Redis
交互來完成操作。比如,我們在實現分布式鎖的時候其實就是使用了 RedisTemplate 的 execute 方法來執行
lua 腳本來獲取和釋放鎖的。
清單 4. 獲取鎖
Boolean lockStat = stringRedisTemplate.execute((RedisCallback<Boolean>)connection ->
connection.set(key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")),
Expiration.from(timeout, timeUnit), RedisStringCommands.SetOption.SET_IF_ABSENT));
清單 5. 釋放鎖
String script = “if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else
return 0 end”; boolean unLockStat =
stringRedisTemplate.execute((RedisCallback<Boolean>)connection -> connection.eval(script.getBytes(), ReturnType.BOOLEAN, 1, key.getBytes(Charset.forName(“UTF-8”)), value.getBytes(Charset.forName(“UTF-8”))));
關于 Redis 的幾個經典問題
最近幾年 Redis一直都是面試的熱點話題,在面試的過程中相信大家都會被問到緩存與數據庫一致性問題、緩存擊穿、緩存雪崩以及緩存并發等問題。那么在文章的最后部分我們就一起來了解一下這幾個問題。
緩存與數據庫一致性問題
對于既有數據庫操作又有緩存操作的接口,一般分為兩種執行順序。
- 先操作數據庫,再操作緩存。這種情況下如果數據庫操作成功,緩存操作失敗就會導致緩存和數據庫不一致。
- 第二種情況就是先操作緩存再操作數據庫,這種情況下如果緩存操作成功,數據庫操作失敗也會導致數據庫和緩存不一致。
大部分情況下,我們的緩存理論上都是需要可以從數據庫恢復出來的,所以基本上采取第一種順序都是不會有問題的。針對那些必須保證數據庫和緩存一致的情況,通常是不建議使用緩存的。
緩存擊穿問題
緩存擊穿表示惡意用戶頻繁的模擬請求緩存中不存在的數據,以致這些請求短時間內直接落在了數據上,導致數據庫性能急劇下降,最終影響服務整體的性能。這個在實際項目很容易遇到,如搶購活動、秒殺活動的接口API被大量的惡意用戶刷,導致短時間內數據庫宕機。對于緩存擊穿的問題,有以下幾種解決方案,這里只做簡要說明。
- 使用互斥鎖排隊。當從緩存中獲取數據失敗時,給當前接口加上鎖,從數據庫中加載完數據并寫入后再釋放鎖。若其它線程獲取鎖失敗,則等待一段時間后重試。
- 使用布隆過濾器。將所有可能存在的數據緩存放到布隆過濾器中,當黑客訪問不存在的緩存時迅速返回避免緩存及DB 掛掉。
緩存雪崩問題
在短時間內有大量緩存失效,如果這期間有大量的請求發生同樣也有可能導致數據庫發生宕機。在
Redis 機群的數據分布算法上如果使用的是傳統的 hash 取模算法,在增加或者移除 Redis
節點的時候就會出現大量的緩存臨時失效的情形。
- 像解決緩存穿透一樣加鎖排隊。
- 建立備份緩存,緩存 A 和緩存 B,A 設置超時時間,B 不設值超時時間,先從 A
讀緩存,A 沒有讀 B,并且更新 A 緩存和 B 緩存。 - 計算數據緩存節點的時候采用一致性 hash
算法,這樣在節點數量發生改變時不會存在大量的緩存數據需要遷移的情況發生。
緩存并發問題
這里的并發指的是多個 Redis 的客戶端同時 set值引起的并發問題。比較有效的解決方案就是把 set
操作放在隊列中使其串行化,必須得一個一個執行。