初探 Redis 客戶端 Lettuce:真香!

一、Lettuce 是啥?

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

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

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

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

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

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

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

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

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

趕緊查著字典翻譯了下:

  • 高級客戶端

  • 執行緒安全

  • 支援同步、非同步和反應式 API

  • 支援集群、哨兵、管道和編解碼

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

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 幫助文檔給出的對比表格:

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

註:其中 X 標記的是支援.

經過比較我們可以發現:

  • Jedis 支援的 Lettuce 都支援;

  • Jedis 不支援的 Lettuce 也支援!

這麼看來 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 的形式在發送/接收命令。

區別在於:

  • connection.sync() 方法獲取的同步命令對象,每一個操作都會立刻將命令通過 TCP 連接發送出去;

  • connection.async() 獲取的非同步命令對象,執行操作後得到的是 RedisFuture<?>,在滿足一定條件的情況下才批量發送。

由此我們可以通過非同步命令+手動批量推送的方式來實現 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 快速開始://lettuce.io

2.Redis Java Clients

3.Lettuce 官網://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://lettuce.io

8.Lettuce 連接池

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

10.SSL配置://lettuce.io

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