最近使用redis,對key做到期時間的時候,碰到了個問題,這裡原因就不說了,我對一個key設定了到期時間為100天,結果測試過程中並沒有什麼問題,但是線上卻頻頻報錯。
組件使用的是spring-data-redis&Jedis。
jedis.exceptions.JedisConnectionException: Unknown reply: 3
org.springframework.data.redis.RedisConnectionFailureException: Unknown reply: 3; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: Unknown reply: 3
at org.springframework.data.redis.connection.jedis.JedisExceptionConverter.convert(JedisExceptionConverter.java:47)
at org.springframework.data.redis.connection.jedis.JedisExceptionConverter.convert(JedisExceptionConverter.java:36)
at org.springframework.data.redis.PassThroughExceptionTranslationStrategy.translate(PassThroughExceptionTranslationStrategy.java:37)
at org.springframework.data.redis.FallbackExceptionTranslationStrategy.translate(FallbackExceptionTranslationStrategy.java:37)
at org.springframework.data.redis.connection.jedis.JedisConnection.convertJedisAccessException(JedisConnection.java:181)
at org.springframework.data.redis.connection.jedis.JedisConnection.expire(JedisConnection.java:773)
at org.springframework.data.redis.core.RedisTemplate$7.doInRedis(RedisTemplate.java:648)
at org.springframework.data.redis.core.RedisTemplate$7.doInRedis(RedisTemplate.java:641)
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:190)
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:152)
at org.springframework.data.redis.core.RedisTemplate.expire(RedisTemplate.java:641)
……
Caused by: redis.clients.jedis.exceptions.JedisConnectionException: Unknown reply: 2
at redis.clients.jedis.Protocol.process(Protocol.java:128)
at redis.clients.jedis.Protocol.read(Protocol.java:187)
at redis.clients.jedis.Connection.getIntegerReply(Connection.java:201)
at redis.clients.jedis.BinaryJedis.expire(BinaryJedis.java:330)
at org.springframework.data.redis.connection.jedis.JedisConnection.expire(JedisConnection.java:771)
… 25 more
看這個錯誤,有點莫名其妙了,搜了一下,發現好多都是說單例線程不安全的問題。讓用線程池,但是我這裡使用的是spring的封裝,也確實是使用線程池了的。
實在搞不定,於是決定跟下代碼,看看會是什麼原因。
首先根據錯誤提示,瞭解到是在執行expire的時候出的問題,如下:
at org.springframework.data.redis.core.RedisTemplate.expire(RedisTemplate.java:641)
也就是除了自己代碼的上一行錯誤資訊,這條錯誤資訊可以讓我定位到錯誤是因為執行對某個key進行設定到期時間導致的。
接下來看具體的代碼:
public Boolean expire(K key, final long timeout, final TimeUnit unit) {
final byte[] rawKey = rawKey(key);
final long rawTimeout = TimeoutUtils.toMillis(timeout, unit);
return execute(new RedisCallback<Boolean>() {
public Boolean doInRedis(RedisConnection connection) {
try {
return connection.pExpire(rawKey, rawTimeout);
} catch (Exception e) {
// Driver may not support pExpire or we may be running on Redis 2.4
return connection.expire(rawKey, TimeoutUtils.toSeconds(timeout, unit));
}
}
}, true);
}
這裡其實就已經引起我的一點注意了,這個注釋實在是,,
首先看他是先利用pexpire命令來執行,而不是我們想要的expire命令,如果被捕獲異常的或,就用expire命令執行。這裡注釋寫的如果使用的驅動不支援pExpire命令,或者是2.4版本的redis的話,就會執行expire。首先我確認了自己使用的驅動,是支援這個命令的。
接著上官網,根據官方命令介紹,pexpire是redis2.6才開始支援的。一會再看版本,先看pExpire裡面的代碼。
(其實根據下面一行堆棧錯誤資訊可以知道,其實具體執行的是catch裡面的那行)
at org.springframework.data.redis.core.RedisTemplate$7.doInRedis(RedisTemplate.java:648)
我們先看下pExpire執行了什麼可能導致拋異常。
public Boolean pExpire(byte[] key, long millis) {
/*
* @see DATAREDIS-286 to avoid overflow in Jedis
*
* TODO Remove this workaround when we upgrade to a Jedis version that contains a
* fix for: https://github.com/xetorthio/jedis/pull/575
*/
if (millis > Integer.MAX_VALUE) {
return pExpireAt(key, time() + millis);
}
try {
if (isPipelined()) {
pipeline(new JedisResult(pipeline.pexpire(key, (int) millis), JedisConverters.longToBoolean()));
return null;
}
if (isQueueing()) {
transaction(new JedisResult(transaction.pexpire(key, (int) millis), JedisConverters.longToBoolean()));
return null;
}
return JedisConverters.toBoolean(jedis.pexpire(key, (int) millis));
} catch (Exception ex) {
throw convertJedisAccessException(ex);
}
}
這個TODO才真是讓我煩,咋還這樣啊。這裡因為要使用pExpire命令,所以把單位轉換成了毫秒,100天是8640000000,Integer.MAX_VALUE是2的31次方減1,是2147483647,很明顯,大了很多。所以改執行pExpireAt命令了,這個命令是根據Unix時間去設定具體到期時間的。所以它這裡調用了redis的server命令,time去擷取當前系統時間,然後加上需要的到期時間8640000000就是最終的到期時間了。
這裡的time是個坑,如果你使用的是twemproxy,這是一個在redis cluster出現之前的一個分布式解決方案。
官方程式碼程式庫中的wiki有個支援的命令列表:
https://raw.githubusercontent.com/twitter/twemproxy/master/notes/redis.md
從這裡可以搜到,它不支援TIME命令。(相關命令可以通過redis官網查看)
但是很明顯,堆棧中並沒有對TIME命令報錯的資訊,所以就要正常執行pExpireAt方法了,這裡免直接執行jedis.pexpireAt,如果版本低於2.6,那麼不支援這個命令。
public Boolean pExpireAt(byte[] key, long unixTimeInMillis) {
try {
if (isPipelined()) {
pipeline(new JedisResult(pipeline.pexpireAt(key, unixTimeInMillis), JedisConverters.longToBoolean()));
return null;
}
if (isQueueing()) {
transaction(new JedisResult(transaction.pexpireAt(key, unixTimeInMillis), JedisConverters.longToBoolean()));
return null;
}
return JedisConverters.toBoolean(jedis.pexpireAt(key, unixTimeInMillis));
} catch (Exception ex) {
throw convertJedisAccessException(ex);
}
}
拋出異常,被最外層接住,使用expire命令去處理。
回來看expire方法。
public Boolean expire(byte[] key, long seconds) {
/*
* @see DATAREDIS-286 to avoid overflow in Jedis
*
* TODO Remove this workaround when we upgrade to a Jedis version that contains a
* fix for: https://github.com/xetorthio/jedis/pull/575
*/
if (seconds > Integer.MAX_VALUE) {
return pExpireAt(key, time() + TimeUnit.SECONDS.toMillis(seconds));
}
try {
if (isPipelined()) {
pipeline(new JedisResult(pipeline.expire(key, (int) seconds), JedisConverters.longToBoolean()));
return null;
}
if (isQueueing()) {
transaction(new JedisResult(transaction.expire(key, (int) seconds), JedisConverters.longToBoolean()));
return null;
}
return JedisConverters.toBoolean(jedis.expire(key, (int) seconds));
} catch (Exception ex) {
throw convertJedisAccessException(ex);
}
}
暈,怎麼又來了。哦,沒事,這次是秒,沒那麼大了。直接執行下面的jedis.expire方法。通過堆棧資訊也可以知道確實執行這一行了。
at org.springframework.data.redis.connection.jedis.JedisConnection.expire(JedisConnection.java:771)
這次怎麼感覺沒什麼問題了。根據最下面的堆棧資訊網上看。
public Long expire(final byte[] key, final int seconds) {
checkIsInMulti();
client.expire(key, seconds);
return client.getIntegerReply();
}
繼續看堆棧資訊。
at redis.clients.jedis.BinaryJedis.expire(BinaryJedis.java:330)
然後對比代碼是執行了client.getIntegerReply();出的問題。
public Long getIntegerReply() {
flush();
pipelinedCommands–;
return (Long) Protocol.read(inputStream);
}
看這裡,就是Protocol.read(inputStream);有問題了,跟進去,裡面有個process(is);。
private static Object process(final RedisInputStream is) {
try {
byte b = is.readByte();
if (b == MINUS_BYTE) {
processError(is);
} else if (b == ASTERISK_BYTE) {
return processMultiBulkReply(is);
} else if (b == COLON_BYTE) {
return processInteger(is);
} else if (b == DOLLAR_BYTE) {
return processBulkReply(is);
} else if (b == PLUS_BYTE) {
return processStatusCodeReply(is);
} else {
throw new JedisConnectionException(“Unknown reply: ” + (char) b);
}
} catch (IOException e) {
throw new JedisConnectionException(e);
}
return null;
}
找到根源了。原來這裡的b返回的不是這裡聲明的幾個常量啊。根據錯誤資訊列印出的2,對比下ASCII可以知道,2是本文開始。好吧,其實這裡就是傳輸返回協議的內容了。如果開頭協議不對就不行了。
什麼叫不對呢?來來來,看這裡,官方介紹的協議。
http://redis.io/topics/protocol
它是用的是RESP。
RESP protocol description
The RESP protocol was introduced in Redis 1.2, but it became the standard way for talking with the Redis server in Redis 2.0. This is the protocol you should implement in your Redis client.
RESP is actually a serialization protocol that supports the following data types: Simple Strings, Errors, Integers, Bulk Strings and Arrays.
The way RESP is used in Redis as a request-response protocol is the following:
Clients send commands to a Redis server as a RESP Array of Bulk Strings.
The server replies with one of the RESP types according to the command implementation.
In RESP, the type of some data depends on the first byte:
For Simple Strings the first byte of the reply is “+”
For Errors the first byte of the reply is “-”
For Integers the first byte of the reply is “:”
For Bulk Strings the first byte of the reply is “$”
For Arrays the first byte of the reply is “*”
Additionally RESP is able to represent a Null value using a special variation of Bulk Strings or Array as specified later.
In RESP different parts of the protocol are always terminated with “\r\n” (CRLF).
不同的情況會有不同的協議開頭。這裡就不具體介紹了大家有興趣自己往下看。
其實與redis服務端互動是在進行協議資訊的互動,廢話了,不過如果無法理解服務端相應的內容的話,那就報錯了。
後來把程式改了,把緩衝時間縮小為20天,換算得到毫秒數為1728000000小於2147483647了,這樣就可以執行pExpire而不是pExpireAt了。線上停止報錯,正常執行了。計算一下2147483647最大支援的時間是24.8天。所以大家看著設定吧。
最終只是改了時間就解決了,但是實際上為什麼在expire處出現unknow reply不太好確認。
而導致執行catch裡的expire,這個原因可以確認是執行了pExpireAt導致的,把時間改小了之後會執行pExpire,則不再報錯。兩者的唯一區別就是時間的區別,也就是那個if的判斷,懷疑pExpireAt與pExpire那裡(畢竟這個問題是2014年3月多才提交出來的,懷疑線上jar沒有這段if判斷導致報錯)。
這裡總結的兩點,一個是我們通過查問題發現,TIME命令不能在使用了twemproxy代理中使用,這裡通過spring的代碼可以發現如果設定時間過長是一定會遭遇這個的。
另一個是我們學習到了redis的通訊協定。