设置SO_TIMEOUT,为什么还有大于超时阈值的请求?扒一下HotSpot源码看看

目前由于Redis对时延的要求都非常高, 大多数服务都将超时时间设置成1s;防止由于网络抖动等不可预知的原因导致请求迟迟无法返回而最终导致服务雪崩的情况;

但即便如此,在监控面板上也会发现请求超时高达2s的情况?所以有些同学就有疑问了,我设置了请求超时时间为1s,请求时长最长不应该是1s吗?为什么有些请求还有超过1s呢?

可能很多人印象中,SO_TIMEOUT应该是这样的

SO_TIMEOUT = RTT = 三段时间组成:即 1+2+3;即请求发送到Server的网络耗时 + Server自身处理的耗时 + 响应返回Client的网络耗时;

但实际上如此吗?我们探讨一下

目前Redis-SDK有两种客户端;

  1. 一种是Jedis(利用BIO进行和Redis Server通信)
  2. 一个是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
  1. 检查是否开启事务、pipeline
  2. 交给Connection进行处理;在这里Jedis对象更像是一个代理门面;
vbnet 复制代码
public String get(final String key) {
  checkIsInMultiOrPipeline();
  return connection.executeCommand(commandObjects.get(key));
}
执行命令 redis.clients.jedis.Connection#executeCommand
  1. 将命令发送出去,(sendCommand)
  2. 阻塞并等待返回结果
  3. 将结果封装,返回给客户端
  4. 重点在: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

  1. 当前传入了so_timeout,会调用Net_ReadWithTimeout方法,来获取数据;如果没有传入so_timeout,会一直阻塞这里等待结果返回;
  2. 异常场景,如果nread<=0, 那就会抛出诸多我们曾经在日志里面看到的异常
  3. 正常场景,会将读到的数据转换成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
  1. 将超时毫秒单位换算成纳秒
  2. 先检测是否有就绪读事件;如果有,则返回正数;如果result等于0,说明在这一段时间内没有可读数据,所以socket timeout; 如果result==-1,然后又有三种错误类型判断;
  3. 如果有就绪读事件,则触发NET_NonBlockingRead函数,非阻塞读;如果result=-1并且errno是EAGAIN、EWOULDBLOCK,说明可以重试,更新时间 ; 如果result>0,说明有可读数据,直接返回实际读取的字节数;
  4. 可以看出当前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. 注释模拟1代码,放开模拟2代码;用于模拟丢包重传等网络异常情况导致命令一直没有发送到server侧;此时Client不会报 Read timed out
  2. 放开模拟2代码,注释模拟1代码;用于模拟命令已经发到Server,但Server处理该结果需要消耗3s,此时Client端会报 Read timed out

结论

  1. SO_TIMEOUT 是客户端用于控制Socket读取数据的超时时间(也就是下图中的阶段5 );如果从执行SocketInputStream.socketRead开始,在一段时间内没有读到数据,就会抛出SocketTimeoutException
  2. SO_TIMEOUT是JVM层设置的超时检测机制; SO_RCVTIMEOUT0 没有关系;SO_RCVTIMEOUT是OS层设置的接收时的超时时间,功能类似,但不等同;

对于Jedis客户端来说,为什么还有大于超时阈值的请求呢?

  1. 公司封装的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来进行设置的;

  1. 在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类似的写入、超时检测的功能呢?

这块的内容后续再起一篇文章详细描述这些机制的源码实现,

  1. IdleStateHandler: 读写空闲检测机制,检测channelRead、write;用于判断当前handler 是否处于长时间空闲,如果处于长时间空闲,就会发送fireUserEventTriggered通知;
  2. ReadTimeoutHandler: IdleStateHandler子类,默认行为检测出长时间没有读到数据,就会将handler关闭;当然我们可以修改其默认的超时机制;
  3. WriteTimeoutHandler: 检测写入内容是否超时;(从第一次write到flush的时间),用于检测是否写入过长时间;
  4. HashWheelTimer : 当前lettuce使用的就是这种机制;
  5. TCP_USER_TIMEOUT:作为内核Socket的一个配置参数,在Epoll网络模型下,Netty的源码里面进行通过JNI的方式通过嵌入C的代码,优化了Epoll的调用,并将这个参数成功带入;具体可以参看Netty源码:transport-native-epoll包中的netty_epoll_linuxsocket.c

参考网址

  1. 从linux源码看socket(tcp)的timeout
  2. DeepSeek等AI模型
相关推荐
趁你还年轻_28 分钟前
Redis大量key集中过期怎么办
数据库·redis·缓存
要阿尔卑斯吗6 小时前
对一个变化的 Set 使用 SSCAN,元素被扫描的情况:
redis
泽韦德7 小时前
【Redis】笔记|第9节|Redis Stack扩展功能
数据库·redis·笔记
清风~徐~来9 小时前
【Redis】类型补充
数据库·redis·缓存
代码探秘者9 小时前
【Redis从入门到精通实战文章汇总】
数据库·redis·缓存
weixin_748877009 小时前
【Redis实战:缓存与消息队列的应用】
数据库·redis·缓存
R_AirMan17 小时前
结合源码分析Redis的内存回收和内存淘汰机制,LRU和LFU是如何进行计算的?
redis·lfu·lru·内存回收·内存淘汰
趁你还年轻_21 小时前
Redis-旁路缓存策略详解
数据库·redis·缓存
数据艺术家.21 小时前
Java八股文——Redis篇
java·redis·缓存·面试·nosql数据库·nosql·八股文
ghie909021 小时前
Spring Boot使用Redis实现分布式锁
spring boot·redis·分布式