初探 Redis 客戶端 Lettuce:真香!
作者:vivo 互聯網數據智能團隊 - Li Haoxuan
一、Lettuce 是啥?
一次技術討論會上,大家說起 Redis 的 Java 客戶端哪家強,我第一時間毫不猶豫地喊出 "Jedis, YES!"
“Jedis 可是官方客戶端,用起來直接省事,公司中間件都用它。除了 Jedis 外難道還有第二個能打的?” 我直接扔出王炸。
剛學 Spring 的小張聽了不服:“SpringDataRedis 都用 RedisTemplate!Jedis?不存在的。”
“坐下吧秀兒,SpringDataRedis 就是基於 Jedis 封裝的。” 旁邊李哥呷了一口剛開的快樂水,嘴角微微上揚,露出一絲不屑。
“現在很多都是用 Lettuce 了,你們不會不知道吧?” 老王推了推眼鏡淡淡地說道,隨即緩緩打開鏡片後那雙心靈的窗戶,用關懷的眼神俯視着我們幾隻菜雞。
Lettuce?生菜?滿頭霧水的我趕緊打開了 Redis 官網的客戶端列表。發現 Java 語言有三個官方推薦的實現:Jedis、Lettuce 和 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
3.Lettuce 官網:https://lettuce.io
4.SpringDataRedis 參考文檔
6.Why is Lettuce the default Redis client used in Spring Session Redis
7.Cluster-specific options:https://lettuce.io
10.SSL 配置:https://lettuce.io
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vpRVUTOU7uz6Sc26bzZjGA