事务报错,为何数据还是插入成功了❓

前言

🧓我是[提前退休的java猿],一名7年java开发经验的开发组长,分享工作中的各种问题!

❗最近出现提交事务之后,程序反馈提交报错了,但是数据库中的数据确实是事务提交成功了的状态, 在生产环境造成了很多问题,比如前面几期的问题:

这个问题所有的后端程序员都值得一看,因为平时很少遇到这种情况!

底层的报错如下:

js 复制代码
Caused by: java.net.SocketTimeoutException: Read timed out
        at java.net.SocketInputStream.socketRead0(Native Method)
        at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
        at java.net.SocketInputStream.read(SocketInputStream.java:171)
        at java.net.SocketInputStream.read(SocketInputStream.java:141)
        at com.kingbase8.core.VisibleBufferedInputStream.readMore(VisibleBufferedInputStream.java:210)
        at com.kingbase8.core.VisibleBufferedInputStream.ensureBytes(VisibleBufferedInputStream.java:165)
        at com.kingbase8.core.VisibleBufferedInputStream.read(VisibleBufferedInputStream.java:117)
        at com.kingbase8.core.KBStream.receiveChar(KBStream.java:584)
        at com.kingbase8.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2658)
        at com.kingbase8.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:405)
        ... 208 common frames omitted

socket 基础知识

了解这个问题前,需要回顾一下socket的几个关键时间参数:

  • 连接超时时间(Connect Timeout) :指客户端在尝试连接到服务器时,等待连接建立的最长时间。如果在指定的时间内未能成功建立连接,将抛出SocketTimeoutException异常。在 Java 中,可以通过Socket类的connect(SocketAddress endpoint, int timeout)方法来设置连接超时时间,例如socket.connect(new InetSocketAddress("www.example.com", 80), 5000);表示设置连接超时时间为 5 秒。
  • 读取超时时间(Read Timeout) :也称为SO_TIMEOUT,用于设置从输入流读取数据时的超时时间。它指定了在读取数据时等待的最长时间,如果在指定时间内没有数据可读取,将抛出SocketTimeoutException异常。在 Java 中,可以通过Socket类的setSoTimeout(int timeout)方法来设置读取超时时间 (今天讨论的就是这个参数
  • 发送超时时间(Write Timeout) :在一些情况下,还可以设置发送数据的超时时间,即从本地 socket 发送数据到对方 socket 时,等待对方确认接收的最长时间。在 Python 中,可以通过socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDTIMEO, timeout)来设置发送超时时间,其中timeout为超时时间值(单位为秒)。不过在 Java 中,通常没有一个像设置读取超时那样直接对应的标准方法来单独设置发送超时,一般情况下,如果网络连接正常,数据会被不断尝试发送,直到达到操作系统层面的相关限制。

🏍了解了上面socket的基础知识之后,我就很清楚了。本次的问题就是解决 重输入流读取数据超时的问题。也就是说发送提交事务消息之后,读取反馈结果的时候超时了

解决问题

排查问题过程

目前数据库是人大金仓,驱动就是kingbase 8.6.0 连接池是druid 1.2.19,数据库采用主从架构。通过代码发现主从架构和单节在设置 socketTimeout 是否逻辑是不一样的,带着以下两个问题我们去分析源码(逻辑基于 主从架构)。

🗺先说解决方案:spring.datasource.druid.socket-timeout = 60000 (当然这个值可以根据实际情况调整),这个配置实际上并不是直接修改 socketSO_TIMEOUT ,分析了源码之后发现上面这个配置只是修改的重试次数

1. 既然出现 SocketTimeoutException ,首先就要搞清楚现在的超时时间是多少❓

2.参数配置到url后面为何失效了呢?

从上面的报错可以定位到kingbase的最终报错的代码是:com.kingbase8.core.VisibleBufferedInputStream.readMore(VisibleBufferedInputStream.java:210)

定位VisibleBufferedInputStream类,看到socketTimeout属性如下:

java 复制代码
public class VisibleBufferedInputStream extends InputStream {
    private static final int MINIMUM_READ = 1024;
    private static final int STRING_SCAN_SPAN = 1024;
    private final InputStream wrappedInputStream;
    private byte[] _buffer;
    private int _index;
    private int endIndex;
    private String _host;
    private boolean useDispatch;
    private int _version;
    //--------------------------------- 这就是我们要确认的配置------------
    private int socketTimeout;

找到设置socketTimeout的地方:

✔可以看到默认时间设置的是0(那么问题来了如果真的是无线等待为何会出现超时呢)

在 JDBC 驱动和网络编程中,socketTimeout=0 是一个标准约定,表示不设置超时(无限等待)。此时:

  • 读取操作会一直阻塞,直到有数据返回、连接被关闭或发生其他异常。
  • 若数据库端处理缓慢、网络中断或连接被意外保持,客户端可能会长时间无响应

看到这儿还有完,我们看看VisibleBufferedInputStream.readMore方法的实现:

异常之后的处理有个变量非常重要就是 socketTimeout 这个参数和实际的名称 含义

java 复制代码
private boolean readMore(int wanted) throws IOException {
    .................................
    TraceLogger.logLineInfo(Level.ALL, "lineInfo");
    int readT = true;
    int i = 0;
    int j = 0;

    int readT;
    while(true) {
        try {
        //----------------------------------------读取响应数据-----------------------------
            readT = this.wrappedInputStream.read(this._buffer, this.endIndex, canFit);
            break;
        } catch (SocketTimeoutException var10) {
            TraceLogger.logLineInfo(Level.ALL, "lineInfo");
            if (this.useDispatch) {
                if ((this.pCMV2.master_online_ip.equals(this._host) || this.pCMV2.slave_online_ip.contains(this._host + ",")) && (Integer)this.pCMV2._connVersion.get(this._host) == this._version) {
                    label78: {
                        // -------------------重试 socketTimeout 次数,------------
                        if (this.socketTimeout != 0) {
                            ++i;
                            if (i >= this.socketTimeout) {
                                break label78;
                            }
                        }

                        ++j;
                        if (j % 5 == 0) {
                            KBLOGGER.log(Level.INFO, "Online _host {0} has been waiting for {1} times, socketTimeout is {2}", new Object[]{this._host, j, this.socketTimeout});
                        }
                        continue;
                    }  
                }
            ............................
            }
            throw var10;
        }
    }
    if (readT < 0) {
        return false;
    } else {
        this.endIndex += readT;
        return true;
    }
}

debug如下,发现socketTimeout 的值有两种情况:

  • 当this.useDispatch = false 的时候socketTimeout = 0 (false的时候是由集群管理线程ClusterMonitorThread发起)
  • 当this.useDispatch = true 的时候 socketTimeout = 10
✔this.socketTimeout 只是发生异常的时候的重试次数

那么我们在debug一下创建链接的时候,是不是也是这个默认的10呢: com.kingbase8.core.v3.ConnectionFactoryImpl.tryConnect()创建链接的关键源码如下:

java 复制代码
private KBStream tryConnect(String user, String database, Properties infoProps, SocketFactory socketFactory, HostSpec _hostSpec, SslMode sslMode, int _version, Object cCMV2) throws SQLException, IOException {
    TraceLogger.logLineInfo(Level.ALL, "lineInfo");
    // 创建socket 时候的链接超时时间
    int connectTimeoutT = KBProperty.CONNECT_TIMEOUT.getInt(infoProps) * 1000;
    //----------------创建socket封装成KBStream 对象,创建的时候 设置建立链接的超时时间:默认 10*1000 毫秒
    KBStream newStreamT = new KBStream(socketFactory, _hostSpec, connectTimeoutT, KBProperty.USEDISPATCH.getBoolean(infoProps) && infoProps.getProperty("isMonitor") == null, _version, cCMV2);
    TraceLogger.logLineInfo(Level.ALL, "lineInfo");
    // -------------------------默认0
    int socketTimeoutT = KBProperty.SOCKET_TIMEOUT.getInt(infoProps);
    if (KBProperty.USEDISPATCH.getBoolean(infoProps) && infoProps.getProperty("isMonitor") == null) {
        TraceLogger.logLineInfo(Level.ALL, "lineInfo");
        if (socketTimeoutT < 0) {
            TraceLogger.logLineInfo(Level.ALL, "lineInfo");
            socketTimeoutT = 0;
        }
        //-------------------直接调用底层 `Socket` 对象的 `setSoTimeout` 方法,将 **Socket 的读取超时时间设置为 1000 毫秒(1 秒)** 。
        newStreamT.getSocket().setSoTimeout(1000);
        //---------------------- `VisibleBufferedInputStream.readMore`------------使用会用到,实际上是读取失败的重试次数
        newStreamT.setSocketTimeout(socketTimeoutT);
        KBLOGGER.log(Level.INFO, "Dispatch : socketTimeout is " + socketTimeoutT * 1000, new Object[0]);
    } else {

❗创建链接socket链接之后,会从连接池的配置读取,二次设置,下面方法的参数,是读取的连接池参数(没有配置的话就使用druid线程池默认的配置10_000)

java 复制代码
// 入参从连接池获取(就算没有设置,也会取连接池的默认)
public void setNetworkTimeout(int millisecs) throws IOException {
    TraceLogger.logLineInfo(Level.ALL, "lineInfo");
    //--------- 目前我们是主从结构,useDispatch = true------------
    if (this.isUseDispatch()) {
        TraceLogger.logLineInfo(Level.ALL, "lineInfo");
        //-------------写死socket 读时间为 1000 毫秒-------------------------------------
        this.connectionSocket.setSoTimeout(1000);
        //-------------设置连接池配置的 socketTimeout,用来重试读取数据次数 ------------------------------
        this.setSocketTimeout(millisecs % 1000 == 0 ? millisecs / 1000 : millisecs / 1000 + 1);
        KBLOGGER.log(Level.INFO, "Dispatch : socketTimeout is " + millisecs, new Object[0]);
    } else {
        TraceLogger.logLineInfo(Level.ALL, "lineInfo");
        this.connectionSocket.setSoTimeout(millisecs);
        KBLOGGER.log(Level.INFO, "Single or Monitor : socketTimeout is " + millisecs, new Object[0]);
    }
}
✔真正的scoket的读取时间在集群模式下面被写死成了1000ms
✔ 原来我们配置的socket-timeout 最后会被除以1000 设置到 this.scoketTimeout 上面
✔ this.socketTimeout 最后会被线程池的参数覆盖掉,所以设置到url中socketTimeout会失效
📕结论

VisibleBufferedInputStream 类中的 socketTimeout 为读取响应数据异常时候的重试次数,并非底层socket 读取响应超时时间 默认重试10次(主从)

真正的socket 的读取timeout 通过上面的代码发现是 固定的1000ms 就是1s,集群模式下面读取超时后会触发重试机制,实际上我们配置的是重试次数

❌注意的坑:

  • int connectTimeoutT = KBProperty.CONNECT_TIMEOUT.getInt(infoProps) * 1000;建立链接的时间被*1000,并且这个参数只从url中读取😂 并且单位是秒
  • 坑人的地方第二点,集群模式下面 socket 真正的读时间 被写死成了1000ms,不能从url中配置。只能从连接池配置

验证

通过上面的debug 我们发现,kinbase在主从架构下面,没办法设置socketTimeout,虽然连接池和url都能配置这个参数,但是对于人大金仓来说 这个只能当成 重试次数来使用:

按照逻辑 超过重试次数次出有打印日志 抛出异常:

java 复制代码
private boolean readMore(int wanted) throws IOException {
    .....................
    while(true) {
        try {
            TraceLogger.logLineInfo(Level.ALL, "lineInfo");
            readT = this.wrappedInputStream.read(this._buffer, this.endIndex, canFit);
            break;
        } catch (SocketTimeoutException var10) {
                 集群模式重试
                ....................
            }
            // 按照逻辑 超过重试此处打印日志 抛出异常
            KBLOGGER.log(Level.INFO, "socketTimeout Exception: useDispatch is " + this.useDispatch, new Object[0]);
            throw var10;
        }
    }
   ...................
}

开启 KBLOGGER.logsocketTimeout 设置成 3000 重试三次(3000/1000),观察果然又出现报错,并且有日志打印:

js 复制代码
[2025-07-31 09:00:33] [43] [com.kingbase8.core.VisibleBufferedInputStream-->readMore]   
socketTimeout Exception: useDispatch is true, and _host = [node1], master_online_ip = [node1], 
slave_online_ip = [node2,], currentVersion = [1], lastVersion = [1], socketTimeout = [3]  

开始日志的方式需要引入特有的依赖,以及在数据库连接的url后面或者代码里面设置驱动的日志级别

总结

之前的这两个问题,从今天来说也算是给了一个相对合理的解决方案把,就是增加了读取数据的重试次数,因为socket read time out 写死1s的(kingbase主从架构)。

而且这两个参数如何配置各有各的想法:

  • socket-timeout: 3000

    • 如果配置到数据库的url后(请用驼峰命名),在集群模式下面,这个参数后面会被 连接池的配置给覆盖(就算连接池的没有手动配置也会被默认值覆盖 10_000 )。所以不会生效
    • 如果配置到连接池,在集群模式下面,就被被当作 重试次数来使用
  • connect-timeout: 3000

    • 配置到数据库url后面,那么这个值的单位就是s,因为驱动在创建连接的时候还会*1000 😂
    • 配置到连接池的时候,根本不会生效😂 (源码DruidAbstractDataSource.createPhysicalConnection,没有对kingbase驱动做处理)

所以说程序也不能完全信任事务,事务在数据库是可靠的。只是程序和数据之间的信息传输不可靠了,导致程序做出错误的处理方案,从而引发bug

相关推荐
剪刀石头布啊4 分钟前
数据口径
前端·后端·程序员
剪刀石头布啊8 分钟前
http状态码大全
前端·后端·程序员
jiangxia_102431 分钟前
面试系列:什么是JAVA并发编程中的JUC并发工具类
java·后端
用户15129054522033 分钟前
踩坑与成长:WordPress、MyBatis-Plus 及前端依赖问题解决记录
前端·后端
A_氼乚34 分钟前
JVM运行时数据区相关知识,这篇文档会勘正你的许多理解!(本周会补上更详细的图式)
后端
斜月38 分钟前
Springboot 项目加解密的那些事儿
spring boot·后端
草莓爱芒果41 分钟前
Spring Boot中使用Bouncy Castle实现SM2国密算法(与前端JS加密交互)
java·spring boot·算法
慕y2741 小时前
Java学习第九十三部分——RestTemplate
java·开发语言·学习
旋风菠萝1 小时前
设计模式---单例
android·java·开发语言
AI视觉网奇1 小时前
音频获取长度
java·前端·python