初探 Redis 客戶端 Lettuce:真香!

作者:vivo 互聯網數據智能團隊 - Li Haoxuan

一、Lettuce 是啥?

一次技術討論會上,大家說起 Redis 的 Java 客戶端哪家強,我第一時間毫不猶豫地喊出 "Jedis, YES!"

“Jedis 可是官方客戶端,用起來直接省事,公司中間件都用它。除了 Jedis 外難道還有第二個能打的?” 我直接扔出王炸。

剛學 Spring 的小張聽了不服:“SpringDataRedis 都用 RedisTemplate!Jedis?不存在的。”

“坐下吧秀兒,SpringDataRedis 就是基於 Jedis 封裝的。” 旁邊李哥呷了一口剛開的快樂水,嘴角微微上揚,露出一絲不屑。

“現在很多都是用 Lettuce 了,你們不會不知道吧?” 老王推了推眼鏡淡淡地說道,隨即緩緩打開鏡片後那雙心靈的窗戶,用關懷的眼神俯視着我們幾隻菜雞。

Lettuce?生菜?滿頭霧水的我趕緊打開了 Redis 官網的客戶端列表。發現 Java 語言有三個官方推薦的實現:JedisLettuceRedission

(截圖來源:https://redis.io/clients#java)

Lettuce 是什麼客戶端?沒聽過。但發現它的官方介紹最長:

Advanced Redis client for thread-safe sync, async, and reactive usage. Supports Cluster, Sentinel, Pipelining, and codecs.

趕緊查着字典翻譯了下:

老王擺擺手示意我收好字典,不緊不慢介紹起來。

1.1 高級客戶端

“師爺,你給翻譯翻譯,什麼(嗶——)叫做(嗶——)高級客戶端?”

“高級客戶端嘛,高級嘛,就是 Advanced 啊!new 一下就能用,什麼實現細節都不用管,拿起業務邏輯直接突突。”

1.2 線程安全

這是和 Jedis 主要不同之一。

Jedis 的連接實例是線程不安全的,於是需要維護一個連接池,每個線程需要時從連接池取出連接實例,完成操作後或者遇到異常歸還實例。當連接數隨着業務不斷上升時,對物理連接的消耗也會成爲性能和穩定性的潛在風險點。

Lettuce 使用 Netty 作爲通信層組件,其連接實例是線程安全的,並且在條件具備時可訪問操作系統原生調用 epoll, kqueue 等獲得性能提升。

我們知道 Redis 服務端實例雖然可以同時連接多個客戶端收發命令,但每個實例執行命令時都是單線程的。

這意味着如果應用可以通過多線程 + 單連接方式操作 Redis,將能夠精簡 Redis 服務端的總連接數,而多應用共享同一個 Redis 服務端時也能夠獲得更好的穩定性和性能。對於應用來說也減少了維護多個連接實例的資源消耗。

1.3 支持同步、異步和反應式 API

Lettuce 從一開始就按照非阻塞式 IO 進行設計,是一個純異步客戶端,對異步和反應式 API 的支持都很全面。

即使是同步命令,底層的通信過程仍然是異步模型,只是通過阻塞調用線程來模擬出同步效果而已。

1.4 支持集羣、哨兵、管道和編解碼

“這些特性都是標配,Lettuce 可是高級客戶端!高級,懂嗎?” 老王說到這裏興奮地用手指點着桌面,但似乎不想多做介紹,我默默地記下打算好好學習一番。

(在項目使用過程中,pipeling 機制用起來和 Jedis 相比稍微抽象已點,下文會給出在使用過程中遇到的小坑和解決辦法。)

1.5 在 Spring 中的使用情況

除了 Redis 官方介紹,我們也可以發現 Spring Data Redis 在升級到 2.0 時,將 Lettuce 升級到了 5.0。其實 Lettuce 早就在 SpringDataRedis 1.6 時就被官方集成了;而 SpringSessionDataRedis 則直接將 Lettuce 作爲默認 Redis 客戶端,足見其成熟和穩定。

Jedis 廣爲人知甚至是事實上的標準 Java 客戶端(de-facto standard driver),和它推出時間早(1.0.0 版本 2010 年 9 月,Lettuce 1.0.0 是 2011 年 3 月)、API 直接易用、對 Redis 新特性支持最快等特點都密不可分。

但 Lettuce 作爲後進,其優勢和易用性也獲得了 Spring 等社區的青睞。下面會分享我們在項目中集成 Lettuce 時的經驗總結,供大家參考。

二、Jedis 和 Lettuce 有啥主要區別?

說了這麼多,Lettuce 和老牌客戶端 Jedis 主要都有哪些區別呢?我們可以看下 Spring Data Redis 幫助文檔給出的對比表格:

圖片

(截圖來源:https://docs.spring.io

注:其中 X 標記的是支持.

經過比較我們可以發現:

這麼看來 Spring 中越來越多地使用 Lettuce 也就不奇怪了。

三、Lettuce 初體驗

光說不練假把式,給大家分享我們嘗試 Lettuce 時的收穫,尤其是批量命令部分花了比較多的時間踩坑,下文詳解。

3.1 快速開始

如果最簡單的例子都令人費解,那這個庫肯定流行不起來。Lettuce 的快速開始真的夠快:

a. 引入 maven 依賴(其他依賴類似,具體可見文末參考資料)

<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>5.3.6.RELEASE</version>
</dependency>

b. 填上 Redis 地址,連接、執行、關閉。Perfect!

import io.lettuce.core.*;
// Syntax: redis://[password@]host[:port][/databaseNumber]
// Syntax: redis://[username:password@]host[:port][/databaseNumber]
RedisClient redisClient = RedisClient.create("redis://password@localhost:6379/0");
StatefulRedisConnection<String, String> connection = redisClient.connect();
RedisCommands<String, String> syncCommands = connection.sync();
syncCommands.set("key", "Hello, Redis!");
connection.close();
redisClient.shutdown();

3.2 支持集羣模式嗎?支持!

Redis Cluster 是官方提供的 Redis Sharding 方案,大家應該非常熟悉不再多介紹,官方文檔可參考 Redis Cluster 101

Lettuce 連接 Redis 集羣對上述客戶端代碼一行換一下即可:

// Syntax: redis://[password@]host[:port]
// Syntax: redis://[username:password@]host[:port]
RedisClusterClient redisClient = RedisClusterClient.create("redis://password@localhost:7379");

3.3 支持高可靠嗎?支持!

Redis Sentinel 是官方提供的高可靠方案,通過 Sentinel 可以在實例故障時自動切換到從節點繼續提供服務,官方文檔可參考 Redis Sentinel Documentation

仍然是替換客戶端的創建方式就可以了:

// Syntax: redis-sentinel://[password@]host[:port][,host2[:port2]][/databaseNumber]#sentinelMasterId
RedisClient redisClient = RedisClient.create("redis-sentinel://localhost:26379,localhost:26380/0#mymaster");

3.4 支持集羣下的 pipeline 嗎?支持!

Jedis 雖然有 pipeline 命令,但不能支持 Redis Cluster。一般都需要自行歸併各個 key 所在的 slot 和實例後再批量執行 pipeline。

官網對集羣下的 pipeline 支持 PR 截至本文寫作時(2021 年 2 月)四年過去了仍然未合入,可見 Cluster pipelining

Lettuce 雖然號稱支持 pipeling,但並沒有直接看到 pipeline 這種 API,這是怎麼回事?

3.4.1 實現 pipeline

使用 AsyncCommands 和 flushCommands 實現 pipeline,經過閱讀官方文檔可以知道,Lettuce 的同步、異步命令其實都共享同一個連接實例,底層使用 pipeline 的形式在發送 / 接收命令。

區別在於:

由此我們可以通過異步命令 + 手動批量推送的方式來實現 pipeline,來看官方示例

StatefulRedisConnection<String, String> connection = client.connect();
RedisAsyncCommands<String, String> commands = connection.async();
// disable auto-flushing
commands.setAutoFlushCommands(false);
// perform a series of independent calls
List<RedisFuture<?>> futures = Lists.newArrayList();
for (int i = 0; i < iterations; i++) {
futures.add(commands.set("key-" + i, "value-" + i));
futures.add(commands.expire("key-" + i, 3600));
}
// write all commands to the transport layer
commands.flushCommands();
// synchronization example: Wait until all futures complete
boolean result = LettuceFutures.awaitAll(5, TimeUnit.SECONDS,
futures.toArray(new RedisFuture[futures.size()]));
// later
connection.close();

3.4.2 這麼做有沒有問題?

乍一看很完美,但其實有暗坑:

setAutoFlushCommands(false) **設置後,會發現 sync() 方法調用的同步命令都不返回了!**這是爲什麼呢?我們再看看官方文檔:

Lettuce is a non-blocking and asynchronous client. It provides a synchronous API to achieve a blocking behavior on a per-Thread basis to create await (synchronize) a command response..... As soon as the first request returns, the first Thread’s program flow continues, while the second request is processed by Redis and comes back at a certain point in time

sync 和 async 在底層實現上都是一樣的,只是 sync 通過阻塞調用線程的方式模擬了同步操作。並且 setAutoFlushCommands 通過源碼可以發現就是作用在 connection 對象上,於是該操作對 sync 和 async 命令對象都生效。

所以,只要某個線程中設置了 auto flush commands 爲 false,就會影響到所有使用該連接實例的其他線程。

/**
* An asynchronous and thread-safe API for a Redis connection.
*
* @param <K> Key type.
* @param <V> Value type.
* @author Will Glozer
* @author Mark Paluch
*/
public abstract class AbstractRedisAsyncCommands<K, V> implements RedisHashAsyncCommands<K, V>, RedisKeyAsyncCommands<K, V>,
RedisStringAsyncCommands<K, V>, RedisListAsyncCommands<K, V>, RedisSetAsyncCommands<K, V>,
RedisSortedSetAsyncCommands<K, V>, RedisScriptingAsyncCommands<K, V>, RedisServerAsyncCommands<K, V>,
RedisHLLAsyncCommands<K, V>, BaseRedisAsyncCommands<K, V>, RedisTransactionalAsyncCommands<K, V>,
RedisGeoAsyncCommands<K, V>, RedisClusterAsyncCommands<K, V> {
    @Override
    public void setAutoFlushCommands(boolean autoFlush) {
        connection.setAutoFlushCommands(autoFlush);
    }
}

對應的,如果多個線程調用 async() 獲取異步命令集,並在自身業務邏輯完成後調用 flushCommands(),那將會強行 flush 其他線程還在追加的異步命令,原本邏輯上屬於整批的命令將被打散成多份發送。

雖然對於結果的正確性不影響,但如果因爲線程相互影響打散彼此的命令進行發送,則對性能的提升就會很不穩定。

自然我們會想到:每個批命令創建一個 connection,然後…… 這不和 Jedis 一樣也是靠連接池麼?

回想起老王鏡片後那穿透靈魂的目光,我打算硬着頭皮再挖掘一下。果然,再次認真閱讀文檔後我發現了另外一個好東西:Batch Execution

3.4.3 Batch Execution

既然 flushCommands 會對 connection 產生全局影響,那把 flush 限制在線程級別不就行了?我從文檔中找到了示例官方示例。

回想起前文 Lettuce 是高級客戶端,看了文檔後發現確實高級,只需要定義接口就行了(讓人想起 MyBatis 的 Mapper 接口),下面是項目中使用的例子:

/**
 * 定義會用到的批量命令
 */
@BatchSize(100)
public interface RedisBatchQuery extends Commands, BatchExecutor {
    RedisFuture<byte[]> get(byte[] key);
    RedisFuture<Set<byte[]>> smembers(byte[] key);
    RedisFuture<List<byte[]>> lrange(byte[] key, long start, long end);
    RedisFuture<Map<byte[], byte[]>> hgetall(byte[] key);
}

調用時這樣操作:

// 創建客戶端
RedisClusterClient client = RedisClusterClient.create(DefaultClientResources.create(), "redis://" + address);
// service 中持有 factory 實例,只創建一次。第二個參數表示 key 和 value 使用 byte[] 編解碼
RedisCommandFactory factory = new RedisCommandFactory(connect, Arrays.asList(ByteArrayCodec.INSTANCE, ByteArrayCodec.INSTANCE));
// 使用的地方,創建一個查詢實例代理類調用命令,最後刷入命令
List<RedisFuture<?>> futures = new ArrayList<>();
RedisBatchQuery batchQuery = factory.getCommands(RedisBatchQuery.class);
for (RedisMetaGroup redisMetaGroup : redisMetaGroups) {
    // 業務邏輯,循環調用多個 key 並將結果保存到 futures 結果中
    appendCommand(redisMetaGroup, futures, batchQuery);
}
// 異步命令調用完成後執行 flush 批量執行,此時命令纔會發送給 Redis 服務端
batchQuery.flush();

就是這麼簡單。

此時批量的控制將在線程粒度上進行,並在調用 flush 或達到 @BatchSize 配置的緩存命令數量時執行批量操作。而對於 connection 實例,不用再設置 auto flush commands,保持默認的 true 即可,對其他線程不造成影響。

ps:優秀、嚴謹的你肯定會想到:如果單命令執行耗時長或者誰放了個諸如 BLPOP 的命令的話,肯定會造成影響的,這個話題官方文檔也有涉及,可以考慮使用連接池來處理。

3.5 還能再給力一點嗎?

Lettuce 支持的當然不僅僅是上面所說的簡單功能,還有這些也值得一試:

3.5.1 讀寫分離

我們知道 Redis 實例是支持主從部署的,從實例異步地從主實例同步數據,並藉助 Redis Sentinel 在主實例故障時進行主從切換。

當應用對數據一致性不敏感、又需要較大吞吐量時,可以考慮主從讀寫分離方式。Lettuce 可以設置 StatefulRedisClusterConnection 的 readFrom 配置來進行調整:

圖片

3.5.2 配置自動更新集羣拓撲

當使用 Redis Cluster 時,服務端發生了擴容怎麼辦?

Lettuce 早就考慮好了——通過 RedisClusterClient#setOptions 方法傳入 ClusterClientOptions 對象即可配置相關參數(全部配置見文末參考鏈接)。

ClusterClientOptions 中的 

topologyRefreshOptions 常見配置如下:

圖片

3.5.3 連接池

雖然 Lettuce 基於線程安全的單連接實例已經具有非常好的性能,但也不排除有些大型業務需要通過線程池來提升吞吐量。另外對於事務性操作是有必要獨佔連接的。

Lettuce 基於 Apache Common-pool2 組件提供了連接池的能力(以下是官方提供的 RedisCluster 對應的客戶端線程池使用示例):

RedisClusterClient clusterClient = RedisClusterClient.create(RedisURI.create(host, port));
GenericObjectPool<StatefulRedisClusterConnection<String, String>> pool = ConnectionPoolSupport
               .createGenericObjectPool(() -> clusterClient.connect(), new GenericObjectPoolConfig());
// execute work
try (StatefulRedisClusterConnection<String, String> connection = pool.borrowObject()) {
    connection.sync().set("key", "value");
    connection.sync().blpop(10, "list");
}
// terminating
pool.close();
clusterClient.shutdown();

這裏需要說明的是:createGenericObjectPool 創建連接池默認設置 wrapConnections 參數爲 true。此時借出的對象 close 方法將通過動態代理的方式重載爲歸還連接;若設置爲 false 則 close 方法會關閉連接。

Lettuce 也支持異步的連接池(從連接池獲取連接爲異步操作),詳情可參考文末鏈接。還有很多特性不能一一列舉,都可以在官方文檔上找到說明和示例,十分值得一讀。

四、使用總結

Lettuce 相較於 Jedis,使用上更加方便快捷,抽象度高。並且通過線程安全的連接降低了系統中的連接數量,提升了系統的穩定性。

對於高級玩家,Lettuce 也提供了很多配置、接口,方便對性能進行優化和實現深度業務定製的場景。

另外不得不說的一點,Lettuce 的官方文檔寫的非常全面細緻,十分難得。社區比較活躍,Commiter 會積極回答各類 issue,這使得很多疑問都可以自助解決。

相比之下,Jedis 的文檔、維護更新速度就比較慢了。JedisCluster pipeline 的 PR 至今(2021 年 2 月)四年過去還未合入。

參考資料

其中兩個 GitHub 的 issue 含金量很高,強烈推薦一讀!

1.Lettuce 快速開始:https://lettuce.io

2.Redis Java Clients

3.Lettuce 官網:https://lettuce.io

4.SpringDataRedis 參考文檔

5.Question about pipelining

6.Why is Lettuce the default Redis client used in Spring Session Redis

7.Cluster-specific options:https://lettuce.io

8.Lettuce 連接池

  1. 客戶端配置:https://lettuce.io/core/release

10.SSL 配置:https://lettuce.io

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vpRVUTOU7uz6Sc26bzZjGA