最近使用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
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 “rn” (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的通信协议。