目前由于Redis对时延的要求都非常高, 大多数服务都将超时时间设置成1s;防止由于网络抖动等不可预知的原因导致请求迟迟无法返回而最终导致服务雪崩的情况;
但即便如此,在监控面板上也会发现请求超时高达2s的情况?所以有些同学就有疑问了,我设置了请求超时时间为1s,请求时长最长不应该是1s吗?为什么有些请求还有超过1s呢?
可能很多人印象中,SO_TIMEOUT应该是这样的
SO_TIMEOUT = RTT = 三段时间组成:即 1+2+3;即请求发送到Server的网络耗时 + Server自身处理的耗时 + 响应返回Client的网络耗时;
但实际上如此吗?我们探讨一下
目前Redis-SDK有两种客户端;
- 一种是Jedis(利用BIO进行和Redis Server通信)
- 一个是Lettuce(背靠Netty,使用Netty的异步能力和Redis Server通信)
Jedis
首先先了解下内核中Socket的关键配置
Socket关键参数
首先我们先了解下 TCP的关键参数,从中看看是否有蛛丝马迹;
在Java的Socket定义中
Socket常见参数 | 含义 |
---|---|
SO_TIMEOUT | 读取数据所需要的耗时 这是JVM的Socket封装的参数 |
SO_LINGER | 调用close后套接字的行为;on:0/1代表是否开启,linger:写缓存区数据滞留时间; on=0,代表一旦close,socket缓存区的数据不再进行管理,而是交给内核发送出去,不再关心发送成功或者失败;on=1,linger=0,表示立刻将SND_BUF中的数据丢失,同时发送RST报文;on=1,ling>0,表示将SND_BUF中的数据在linger时间范围内完整的发送出去(不报错)后,进行正常的四次挥手 |
Tcp_No_Delay | 收到Tcp包后,是否立刻发送出去;实时性比较高,但是比较消耗性能 |
SO_RCVBUF | 接收缓存区的大小 |
SO_SNDBUF | 发送缓存区的大小 |
SO_KEEPALIVE | 是否保持长连接 |
SO_REUSEADDR | 是否重用在close_wait状态的地址 |
注意:这两个定义是没有在Java的Socket中体现的;
Socket常见参数 | 含义 |
---|---|
SO_RCV_TIMEOUT | 在此段时间内,未接收到数据;或者未接收完,就会报错 |
SO_SND_TIMEOUT | 在此段时间内,没有写数据;或者未写完,就会报错 |
那在Jedis中,我们设置的SO_TIMEOUT参数作用在哪里了?
源码溯源
简单样例
ini
Jedis jedis = new Jedis("localhost", 6379,2,2);
jedis.get("key");
源码分析
功能入口 redis.clients.jedis.Jedis#get
- 检查是否开启事务、pipeline
- 交给Connection进行处理;在这里Jedis对象更像是一个代理门面;
vbnet
public String get(final String key) {
checkIsInMultiOrPipeline();
return connection.executeCommand(commandObjects.get(key));
}
执行命令 redis.clients.jedis.Connection#executeCommand
- 将命令发送出去,(sendCommand)
- 阻塞并等待返回结果
- 将结果封装,返回给客户端
- 重点在:getOne()
scss
public <T> T executeCommand(final CommandObject<T> commandObject) {
final CommandArguments args = commandObject.getArguments();
// 根据RESP格式将命令封装完成后,写入到SocketOutputStream,
sendCommand(args);
if (!args.isBlocking()) {
// getOne
return commandObject.getBuilder().build(getOne());
} else {
try {
setTimeoutInfinite();
return commandObject.getBuilder().build(getOne());
} finally {
rollbackTimeout();
}
}
}
获取执行结果 redis.clients.jedis.Connection#getOne
csharp
public Object getOne() {
// flush data
flush();
return readProtocolWithCheckingBroken();
}
protected Object readProtocolWithCheckingBroken() {
if (broken) {
throw new JedisConnectionException("Attempting to read from a broken connection");
}
try {
// 开始通过将inputStream中的数据读出来
return Protocol.read(inputStream);
}
...... 省略调用过程,直接调到read
private void ensureFill() throws JedisConnectionException {
if (count >= limit) {
try {
// 如果还没有到当前最大容纳值,则可以从inputstream读取数据,读到buf中
limit = in.read(buf);
count = 0;
if (limit == -1) {
throw new JedisConnectionException("Unexpected end of stream.");
}
} catch (IOException e) {
throw new JedisConnectionException(e);
}
}
}
java.net.SocketInputStream#read
利用JNI调用JVM中的实现
java
public int read(byte b[], int off, int length) throws IOException {
// 此处将so_timeout作为一个参数传入
return read(b, off, length, impl.getTimeout());
}
..... 省略若干次调用流转
// 利用JNI调用JVM的socketRead实现
private native int socketRead0(FileDescriptor fd,
byte b[], int off, int len,
int timeout)
throws IOException;
HotSpot中实现 Java_java_net_SocketInputStream_socketRead0
看下HotSpot里面的源码 对应版本OpenJDK_11.0.27
- 当前传入了so_timeout,会调用Net_ReadWithTimeout方法,来获取数据;如果没有传入so_timeout,会一直阻塞这里等待结果返回;
- 异常场景,如果nread<=0, 那就会抛出诸多我们曾经在日志里面看到的异常
- 正常场景,会将读到的数据转换成java的数据结构返回给java侧;
src\java.base\unix\native\libnet\SocketInputStream.c
c++
JNIEXPORT jint JNICALL
Java_java_net_SocketInputStream_socketRead0(JNIEnv *env, jobject this,
jobject fdObj, jbyteArray data,
jint off, jint len, jint timeout)
{
......省略
// 如果设置了so_timeout,则会走此流程;
if (timeout) {
// 下文会讲此方法; 正常情况nread>0,代表已经读到数据,
nread = NET_ReadWithTimeout(env, fd, bufP, len, timeout);
if ((*env)->ExceptionCheck(env)) {
if (bufP != BUF) {
free(bufP);
}
return nread;
}
} else {
nread = NET_Read(fd, bufP, len);
}
if (nread <= 0) {
if (nread < 0) {
// 这里是不是可以看到我们经常在日志里面看到Connection Reset、Socket Closed
switch (errno) {
case ECONNRESET:
case EPIPE:
JNU_ThrowByName(env, "sun/net/ConnectionResetException",
"Connection reset");
break;
case EBADF:
JNU_ThrowByName(env, "java/net/SocketException",
"Socket closed");
break;
case EINTR:
JNU_ThrowByName(env, "java/io/InterruptedIOException",
"Operation interrupted");
break;
default:
JNU_ThrowByNameWithMessageAndLastError
(env, "java/net/SocketException", "Read failed");
}
}
} else {
// 将数据转换到java的byte[]数组;
(*env)->SetByteArrayRegion(env, data, off, nread, (jbyte *)bufP);
}
if (bufP != BUF) {
free(bufP);
}
return nread;
}
超时轮询:SocketInputStream.c
- 将超时毫秒单位换算成纳秒
- 先检测是否有就绪读事件;如果有,则返回正数;如果result等于0,说明在这一段时间内没有可读数据,所以socket timeout; 如果result==-1,然后又有三种错误类型判断;
- 如果有就绪读事件,则触发NET_NonBlockingRead函数,非阻塞读;如果result=-1并且errno是EAGAIN、EWOULDBLOCK,说明可以重试,更新时间 ; 如果result>0,说明有可读数据,直接返回实际读取的字节数;
- 可以看出当前SO_TIMEOUT并不是作用在内核的Socket配置项中,而是在JVM层自己包装的一层超时检测;;作用是检测读数据是否超时
src\java.base\unix\native\libnet\SocketInputStream.c
c++
static int NET_ReadWithTimeout(JNIEnv *env, int fd, char *bufP, int len, long timeout) {
int result = 0;
jlong prevNanoTime = JVM_NanoTime(env, 0);
jlong nanoTimeout = (jlong) timeout * NET_NSEC_PER_MSEC;
while (nanoTimeout >= NET_NSEC_PER_MSEC) {
// 通过此方法判断是否有可读事件
result = NET_Timeout(env, fd, nanoTimeout / NET_NSEC_PER_MSEC, prevNanoTime);
if (result <= 0) {
if (result == 0) {
JNU_ThrowByName(env, "java/net/SocketTimeoutException", "Read timed out");
} else if (result == -1) {
if (errno == EBADF) {
JNU_ThrowByName(env, "java/net/SocketException", "Socket closed");
} else if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "NET_Timeout native heap allocation failed");
} else {
JNU_ThrowByNameWithMessageAndLastError
(env, "java/net/SocketException", "select/poll failed");
}
}
return -1;
}
// 通过此方法将数据读到bufP中
result = NET_NonBlockingRead(fd, bufP, len);
if (result == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))) {
jlong newtNanoTime = JVM_NanoTime(env, 0);
nanoTimeout -= newtNanoTime - prevNanoTime;
if (nanoTimeout >= NET_NSEC_PER_MSEC) {
prevNanoTime = newtNanoTime;
}
} else {
break;
}
}
return result;
}
Socket.write 有超时设置吗?
答案:没有
佐证
我们可以写一个Server-Client进行Jedis-Redis Server的简单的收发包流程;代码如下;
利用ServerSocket开启服务端
java
private static void startServer() throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(18888));
System.out.println("Server started on port 18888, currentTime = " + System.currentTimeMillis());
Socket accept = serverSocket.accept();
System.out.println("Client connected, currentTime = " + System.currentTimeMillis());
InputStream inputStream = accept.getInputStream();
byte[] bytes = new byte[1024];
int read = inputStream.read(bytes);
String message = new String(bytes, 0, read);
System.out.println("Received message from client: " + message + ", currentTime = " + System.currentTimeMillis());
// 1、模拟服务端处理时长3s,
// TimeUnit.MILLISECONDS.sleep(3000);// 模拟1
System.out.println("simulate Network Latency ;sleep 5 after send message, currentTime = " + System.currentTimeMillis());
OutputStream outputStream = accept.getOutputStream();
outputStream.write("Hello, Client!".getBytes());
System.out.println("Sent message to client, currentTime = " + System.currentTimeMillis());
TimeUnit.SECONDS.sleep(5);
}
利用Socket模拟Jedis客户端
java
private static void startClient() throws IOException, InterruptedException {
Socket socket = new Socket();
socket.setSoTimeout(3000);
socket.connect(new InetSocketAddress("localhost", 18888), 3000);
System.out.println("Connected to server, currentTime = " + System.currentTimeMillis());
OutputStream outputStream = socket.getOutputStream();
byte[] bytes = "Hello, Server!".getBytes();
outputStream.write(bytes);
System.out.println("Sent message to server, currentTime = " + System.currentTimeMillis());
// 2、用sleep来模拟命令发送耗时长 或者 超时重传的情况(简单模拟)
// TimeUnit.SECONDS.sleep(5); // 模拟2
InputStream inputStream = socket.getInputStream();
System.out.println("get InputStream, currentTime = " + System.currentTimeMillis());
byte[] readBuffer = new byte[1024];
int read = inputStream.read(readBuffer);
String message = new String(readBuffer, 0, read);
System.out.println("Received message from server: " + message + ", currentTime = " + System.currentTimeMillis());
}
验证结果
- 注释
模拟1代码
,放开模拟2代码
;用于模拟丢包重传等网络异常情况导致命令一直没有发送到server侧;此时Client不会报 Read timed out - 放开
模拟2代码
,注释模拟1代码
;用于模拟命令已经发到Server,但Server处理该结果需要消耗3s,此时Client端会报 Read timed out
结论
- SO_TIMEOUT 是客户端用于控制Socket读取数据的超时时间(也就是下图中的阶段5 );如果从执行
SocketInputStream.socketRead
开始,在一段时间内没有读到数据,就会抛出SocketTimeoutException - SO_TIMEOUT是JVM层设置的超时检测机制; SO_RCVTIMEOUT0 没有关系;SO_RCVTIMEOUT是OS层设置的接收时的超时时间,功能类似,但不等同;
对于Jedis客户端来说,为什么还有大于超时阈值的请求呢?
- 公司封装的Redis-SDK中记录的是RTT,监控上的打点也是RTT时间,而目前设置的超时时间是SO_TIMEOUT;也就是说这两个严格来说不是等价的;因为RTT是包括1+2+3+4+5这五个阶段处理;而SO_TIMEOUT统计的只有第五阶段的耗时;所以两者相差4个阶段的GAP;
Lettuce
SO_TIMEOUT是Java的Socket编程中特有的配置参数,在JVM层面进行做的超时检测,在Lettuce、Netty源码中并没有相关的设置;那Lettuce是怎么设置超时呢?
Lettuce是怎么设置超时时间呢?
答案是通过HashWheelTimer来进行设置的;
- 在Lettuce的调用链路上,增加一个HashWheelTimer的Task任务,交给HashWheelTimer进行检测;如果到期,并且Command还没有执行完成,就会往Command中设置LettuceTimeoutException,同时唤醒Command,回调通知;
Lettuce对应源码
io.lettuce.core.protocol.CommandExpiryWriter#potentiallyExpire
java
private void potentiallyExpire(RedisCommand<?, ?, ?> command, ScheduledExecutorService executors) {
long timeout = applyConnectionTimeout ? this.timeout : source.getTimeout(command);
if (timeout <= 0) {
return;
}
if (CommandExpiryWriterUtils.isEnableTimerCheck()) {
// 创建一个Task,将其放入到HashWheelTimer,如果到期,并且command也没有执行完,就会往Command中设置一个异常;
Timeout commandTimeout = timer.newTimeout(it -> executors.submit(() -> {
if (!command.isDone()) {
command.completeExceptionally(ExceptionFactory.createTimeoutException(Duration.ofNanos(timeUnit.toNanos(timeout))));
}
}), timeout, timeUnit);
if (command instanceof CompleteableCommand) {
((CompleteableCommand) command).onComplete((o, o2) -> {
commandTimeout.cancel();
});
}
}
.......
}
Netty扩展
那在Netty这个异步网络框架模型中,哪些功能具有SO_RCVTIMEOUT、SO_SNDTIMEOUT类似的写入、超时检测的功能呢?
这块的内容后续再起一篇文章详细描述这些机制的源码实现,
IdleStateHandler
: 读写空闲检测机制,检测channelRead、write
;用于判断当前handler 是否处于长时间空闲,如果处于长时间空闲,就会发送fireUserEventTriggered通知;ReadTimeoutHandler
: IdleStateHandler子类,默认行为检测出长时间没有读到数据,就会将handler关闭;当然我们可以修改其默认的超时机制;WriteTimeoutHandler
: 检测写入内容是否超时;(从第一次write到flush的时间),用于检测是否写入过长时间;HashWheelTimer
: 当前lettuce使用的就是这种机制;TCP_USER_TIMEOUT
:作为内核Socket的一个配置参数,在Epoll网络模型下,Netty的源码里面进行通过JNI的方式通过嵌入C的代码,优化了Epoll的调用,并将这个参数成功带入;具体可以参看Netty源码:transport-native-epoll
包中的netty_epoll_linuxsocket.c
参考网址
- 从linux源码看socket(tcp)的timeout
- DeepSeek等AI模型