Redis連線超時排查實錄

CofJus發表於2024-04-03

記一次Redis超時

關鍵字:#spring-data-redis、#RedisTemplate、#Pipeline、#Lettuce

spring-data-redis:2.6.3

1 現象

時間軸(已脫敏)

day01 線上發現介面耗時不正常變高

day02 其他介面mget操作偶現超時,陸續發現其他Redis命令也偶爾出現超時(持續半個月)

day03 排查Redis無慢查詢,連線數正常,確認為批次寫快取導致

day04 嘗試去除問題快取,Redis超時消失,服務多個介面耗時下降50%~60%

day05 改進配置,重新上線,快取正常,介面耗時波動不大

2 錯誤

2.1 spring-data-redis虛假的pipeline

需求:高頻批次刷快取,每個string key單獨設定隨機過期時間,單次批次操作上限為500。

spring-data-redis的multiSet不支援同時設定過期時間,但是spring-data-redis支援pipeline。

問題程式碼鑑賞

    /**
     * 批次快取
     * @param time base過期時間
     * @param random 隨機過期時間範圍 1表示不增加隨機範圍
     */
    private void msetWithRandomExpire(Map<String, String> kv, long time, int random) {
        RedisSerializer<String> stringSerializer = template.getStringSerializer();
        Random rand = new Random();
        template.executePipelined((RedisCallback<String>) connection -> {
            connection.openPipeline();
            kv.forEach((k, v) -> {
                long expireTime = time + rand.nextInt(random);
                connection.setEx(Objects.requireNonNull(stringSerializer.serialize(k)),
                        expireTime, Objects.requireNonNull(stringSerializer.serialize(v)));
            });
            connection.closePipeline();
            return null;
        });
    }

測試發現redis連線超時。

spring-data-redis採用的預設Redis客戶端是Lettuce,Lettuce所有請求預設使用同一個共享連線的例項,只有當執行事務/pipeline命令時會新建一個私有連線。

執行單個Redis命令時,每收到一條命令,Lettuce就傳送給Redis伺服器,而pipeline需要將批次的命令快取在記憶體,然後一次性傳送給Redis伺服器。

但是,檢視LettuceConnection原始碼發現,Lettuce預設的pipeline刷入方式是FlushEachCommand,也就是每條命令都會產生一次傳送行為。

使用pipeline的本意是避免多次傳送帶來的網路開銷,所以spring-data-redis的pipeline是個偽批次操作,本質上和一條一條傳送沒有區別。

// org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory#pipeliningFlushPolicy
public class LettuceConnection extends AbstractRedisConnection {
    // ...
    private PipeliningFlushPolicy pipeliningFlushPolicy = PipeliningFlushPolicy.flushEachCommand();
    // ...
}

2.2 Lettuce手動刷入的併發問題

spring-data-redis對Lettuce的封裝存在缺陷,考慮使用原生的Lettuce客戶端實現pipeline。

Lettuce的連線有一個AutoFlushCommands,預設是true,即收到一個命令就發到服務端一個。如果配置為 false,則將所有命令快取起來,手動呼叫flushCommands的時候,將快取的命令一起發到服務端,這樣其實就是實現了 Pipeline。

在Lettuce官網找到了用非同步方式實現的pipeline程式碼,參考官網樣例後寫出的問題程式碼如下:

    public void msetWithRandomExpire(Map<String, String> kv, long time, int random) {
        RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();
        LettuceConnection connection = null;
        RedisClusterAsyncCommands<byte[], byte[]> commands = null;
        try {
            Random rand = new Random();
            connection = (LettuceConnection) RedisConnectionUtils.getConnection(connectionFactory);
            commands = connection.getNativeConnection();
            commands.setAutoFlushCommands(false);
            List<RedisFuture<?>> futures = new ArrayList<>();
            for (Map.Entry<String, String> entry : kv.entrySet()) {
                String k = entry.getKey();
                String v = entry.getValue();
                long expireTime = time + rand.nextInt(random);
                futures.add(commands.setex(k.getBytes(), expireTime, v.getBytes()));
            }
			// 批次flush命令
            commands.flushCommands();

            LettuceFutures.awaitAll(5, TimeUnit.SECONDS, futures.toArray(new RedisFuture[futures.size()]));
        } finally {
            // 恢復自動刷入
            if (commands != null) {
                commands.setAutoFlushCommands(true);
            }
            if (connection != null) {
                RedisConnectionUtils.releaseConnection(connection, connectionFactory);
            }
        }
    }

官方聲稱這樣寫,在50-1000個批次操作的區間內,吞吐量可以提高五倍,簡直完美滿足我的需求。

上線測試後確實非常快,500個SETEX命令可以在10ms內完成,沒有再發生過Redis連線超時的現象。

問題在於,AutoFlushCommands這個配置對於共享連線是全域性的,會影響到其他正在使用共享連線的執行緒。

所以,Lettuce官方的建議是把這個操作放在一個私有連線裡進行,這樣就不會影響到共享連線中的命令。

The AutoFlushCommands state is set per connection and therefore affects all threads using the shared connection. If you want to omit this effect, use dedicated connections. The AutoFlushCommands state cannot be set on pooled connections by the Lettuce connection pooling.

spring-data-redis裡執行pipeline命令,會先申請一個私有連線,雖然它的刷入命令的策略有問題,但這個可以參考下。

翻了下Lettuce的API,發現透過getNativeConnection方法可以獲取到私有連線。

connection = (LettuceConnection) RedisConnectionUtils.getConnection(connectionFactory);
commands = connection.getNativeConnection();

@Override
public RedisClusterAsyncCommands<byte[], byte[]> getNativeConnection() {
		LettuceSubscription subscription = this.subscription;
		// getAsyncConnection()會返回一個私有連線
		return (subscription != null ? subscription.getNativeConnection().async() : getAsyncConnection());
}

研究到這裡以為大功告成,由於用了比較取巧的寫法,上線也觀察了兩天,並沒有出現問題,直到第三天排行榜Redis莫名開始出現超時。

報錯如下

io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s)

Github issue翻到一個老哥遇到了同樣的問題,Lettuce作者的回答是

Switching setAutoFlushCommands to false is only recommended for single-threaded connection use that wants to optimize command buffering for batch imports.

Lettuce works in general in a non-blocking, multiplexing mode regardless of the API that you're using. You can use synchronous, asynchronous, any reactive APIs with the same connection.

That being said, if you don't touch setAutoFlushCommands, you should be good.

只推薦在單執行緒的應用中使用setAutoFlushCommands來手動刷命令。Lettuce通常以非阻塞、多路複用模式工作,與使用什麼API無關,不管是同步/非同步/響應式API。如果你不碰這東西,就沒事了。

作者跟官網說的有點矛盾,官網強調了只要在私有連線裡進行pipeline操作就不會影響到共享連線,所以懷疑到底有沒有正確獲取到私有連線。

回到Lettuce的API,getNativeConnection這個方法再點進去一層

	RedisClusterAsyncCommands<byte[], byte[]> getAsyncConnection() {

		if (isQueueing() || isPipelined()) {
			return getAsyncDedicatedConnection();
		}
        // 當共享連線不為空 返回一個共享連線
		if (asyncSharedConn != null) {

			if (asyncSharedConn instanceof StatefulRedisConnection) {
				return ((StatefulRedisConnection<byte[], byte[]>) asyncSharedConn).async();
			}
			if (asyncSharedConn instanceof StatefulRedisClusterConnection) {
				return ((StatefulRedisClusterConnection<byte[], byte[]>) asyncSharedConn).async();
			}
		}
		return getAsyncDedicatedConnection();
	}

原來getNativeConnection這個方法獲取私有連線是有條件的,只有當共享連線被關閉時才會返回私有連線。

而關閉共享連線需要呼叫setShareNativeConnection(false)這個方法,這個配置同樣是全域性的,關閉後,所有的命令都會走私有連線,這時需要用連線池來管理Lettuce連線。

到這裡Redis超時的原因就找到了。

Lettuce官方在文件最後的QA裡貼了一個出現RedisCommandTimeoutException的可能原因,最後一條是:【為什麼要貼在最後…】

If you manually control the flushing behavior of commands (setAutoFlushCommands(true/false)), you should have a good reason to do so. In multi-threaded environments, race conditions may easily happen, and commands are not flushed. Updating a missing or misplaced flushCommands() call might solve the problem.

意思是,修改在AutoFlushCommands這個配置的時候需要注意,多執行緒環境中,競態會頻繁出現,命令將會阻塞,修改在不當的場景下使用手動刷入flushCommands也許會解決問題。

【以下為個人理解】

雖然在finally中恢復了自動刷入,但是在併發場景下,會有一些在AutoFlushCommands=false時執行的命令,這些命令將會被阻塞在本地記憶體,無法傳送到Redis伺服器。所以這個問題本質是網路的阻塞,透過info clients查詢Redis連線數正常,配置超時沒有用,慢日誌也查不到任何記錄,幹掉快取的批次操作後,Redis終於正常了。

3 修復

在上面的前提下修復這個問題,需要三步

3.1 配置

3.1.1 Lettuce連線池

所有命令走單獨的私有連線,需要用連線池管理。

具體引數根據業務調整

spring.redis.lettuce.pool.max-active=50  
# Minimum number of idle connections in the connection pool.
spring.redis.lettuce.pool.min-idle=5  
# Maximum number of idle connections in the connection pool.
spring.redis.lettuce.pool.max-idle=50  
# Maximum time for waiting for connections in the connection pool. A negative value indicates no limit.
spring.redis.lettuce.pool.max-wait=5000  
# Interval for scheduling an eviction thread.
spring.redis.pool.time-between-eviction-runs-millis=2000  

3.1.2 關閉共享連線

搭配spring-data-redis使用,關閉共享連線

@Bean
public LettuceConnectionFactory lettuceConnectionFactory() {
    LettuceConnectionFactory factory = new LettuceConnectionFactory();
    factory.setShareNativeConnection(true);
    // read config
    return factory;
}

3.2 寫法調整

3.2.1 用Lettuce API重寫程式碼

    public void msetWithRandomExpire(Map<String, String> kv, long baseTime, int random) {
        RedisClient client = RedisClient.create();
        try (StatefulRedisConnection<String, String> connection = client.connect()) {
            Random rand = new Random();
            RedisAsyncCommands<String, String> commands = connection.async();
            // 關閉命令自動flush
            commands.setAutoFlushCommands(false);
            List<RedisFuture<?>> futures = new ArrayList<>();
            for (Map.Entry<String, String> entry : kv.entrySet()) {
                long expireTime = baseTime + rand.nextInt(random);
                futures.add(commands.setex(entry.getKey(), expireTime, entry.getValue()));
            }
            // 手動批次flush
            commands.flushCommands();
            LettuceFutures.awaitAll(5, TimeUnit.SECONDS, futures.toArray(new RedisFuture[0]));
        }
    }

3.2.2 spring-data-redis封裝的flush策略

除了用Lettuce原生API實現之外,spring-data-redis也已經給pipeline封裝好了三種flush策略。

PipeliningFlushPolicy也就是Lettuce的pipeline重新整理策略,包括預設的每個命令都刷入,一共有三種,基本上滿足大部分業務場景。

  /** 
    * org.springframework.data.redis.connection.lettuce.LettuceConnection.PipeliningFlushPolicy
    * FlushEachCommand: 每個命令flush一次 預設策略
    * FlushOnClose: 每次連線關閉時flush一次
    * BufferedFlushing: 設定buffer大小 每達到buffer個命令刷一次 連線關閉時也刷一次
    */
	public interface PipeliningFlushPolicy {

		static PipeliningFlushPolicy flushEachCommand() {
			return FlushEachCommand.INSTANCE;
		}
    
		static PipeliningFlushPolicy flushOnClose() {
			return FlushOnClose.INSTANCE;
		}
    
		static PipeliningFlushPolicy buffered(int bufferSize) {

			Assert.isTrue(bufferSize > 0, "Buffer size must be greater than 0");
			return () -> new BufferedFlushing(bufferSize);
		}

		PipeliningFlushState newPipeline();
	}

設定pipeliningFlushPolicy=FlushOnClose之後,上面在2.1節提到的虛假的pipeline就成為真正的pipeline了。

@Bean
public LettuceConnectionFactory lettuceConnectionFactory() {
    LettuceConnectionFactory factory = new LettuceConnectionFactory();
    factory.setShareNativeConnection(true);
    // 設定pipeline的flush策略
    factory.setPipeliningFlushPolicy(LettuceConnection.PipeliningFlushPolicy.flushOnClose());
    // read config
    return factory;
}

4 思考

去除問題快取後,服務所有帶Redis快取的介面平均耗時下降了一半,問題介面耗時穩定在5ms左右。

監控耗時對比非常誇張,這裡不放圖了,

修復問題後,介面耗時整體穩定,效能無明顯提升。

關於效能

Redis是單執行緒的,Lettuce也是單執行緒多路複用的。

實際上Lettuce在單執行緒狀態下有著最佳的效能表現,採用執行緒池管理後,給系統引入了不必要的複雜度,Lettuce官方也吐槽大量的issue和bug來自多執行緒環境。

只有當事務/Pipeline等阻塞性操作較多時,主動放棄單執行緒的優勢才是值得的。

否則,在併發沒有那麼高,甚至db都能hold住的場景,沒有必要折騰Redis。

// TODO 效能測試

關於壞的技術

什麼是壞的技術?(尤其是在引入新的技術的時候)

  • 研究不透徹的API:陌生的API,從入口到最底部的鏈路,隨手呼叫一下到底走的是哪條,需要搞清楚

  • 脫離業務場景的:非必要引入的技術只會增加系統複雜度,帶來負面影響。開發一時的自我滿足是有害的

5 參考

[1] Lettuce文件 https://lettuce.io/

[2] Lettuce Github issue https://github.com/lettuce-io/lettuce-core/issues/1604

[3] lettuce 在spring-data-redis包裝後關於pipeline的坑,你知道嗎?

[4] 初探 Redis 客戶端 Lettuce:真香!

[5] Lettuce連線池

相關文章