初探 Redis 客戶端 Lettuce:真香!

vivo網際網路技術發表於2021-07-12

一、Lettuce 是啥?

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

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

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

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

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

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

(截圖來源:https://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 幫助文件給出的對比表格:

(截圖來源:https://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 快速開始: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 連線池

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

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

作者:vivo網際網路資料智慧團隊-Li Haoxuan

相關文章