近期生产环境出现了一类典型的分布式事务异常:事务提交操作触发程序报错反馈,但数据库层面实际已完成事务提交并持久化数据。该问题已经引发多起业务故障,例如:
- 代码没有问题,为何还会重复报名
- 半年都还没解决的定时任务问题
这种情况平时真不多见,但所有后端程序员都该了解一下 —— 毕竟谁也说不准哪天就遇上了,提前搞懂能少走很多弯路!
底层的报错如下:
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异常。可以通过Socket类的connect(SocketAddress endpoint, int timeout)方法来设置连接超时时间。
- 读取超时时间(Read Timeout) :也称为SO_TIMEOUT,用于设置从输入流读取数据时的超时时间。它指定了在读取数据时等待的最长时间,如果在指定时间内没有数据可读取,将抛出SocketTimeoutException异常。可以通过setSoTimeout(int timeout)方法来设置读取超时时间。 (今天讨论的就是这个参数)
- 发送超时时间(Write Timeout) :在一些情况下,还可以设置发送数据的超时时间,即从本地 socket 发送数据到对方 socket 时,等待对方确认接收的最长时间。通常不需要单独设置,一般情况下,如果网络连接正常,数据会被不断尝试发送,直到达到操作系统层面的相关限制。
解决问题
排查问题过程
目前数据库是人大金仓,驱动就是kingbase 8.6.0 连接池是druid 1.2.19,数据库采用主从架构。通过代码发现主从架构和单节在设置 socketTimeout 是否逻辑是不一样的,带着以下两个问题我们去分析源码(逻辑基于 主从架构)。
1. 既然出现 SocketTimeoutException ,首先就要搞清楚现在的超时时间是多少
2.参数配置到url后面为何失效了呢?
从上面的报错可以定位到kingbase的最终报错的代码是
:com.kingbase8.core.VisibleBufferedInputStream.readMore(
VisibleBufferedInputStream.java:210)
定位
VisibleBufferedInputStream类,看到socketTimeout属性如下:
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 这个参数和实际的名称 含义
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()创建链接的关键源码如下:
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)
// 入参从连接池获取(就算没有设置,也会取连接池的默认)
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都能配置这个参数,但是对于人大金仓来说 这个只能当成 重试次数来使用:
按照逻辑 超过重试次数次出有打印日志 抛出异常:
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.log 将socketTimeout 设置成 3000 重试三次(3000/1000),观察果然又出现报错,并且有日志打印:
[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驱动做处理)
这个案例也给所有后端开发者提了个醒:事务的可靠性不仅依赖数据库本身,还受限于网络通信、驱动逻辑等中间环节。在设计业务逻辑时,除了信任事务的 ACID 特性,更要考虑 “通信不可靠” 的可能性 —— 比如通过幂等设计、状态校验等机制,降低因链路异常导致的业务风险。程序与数据库之间的 “对话”,有时比我们想象中更需要 “容错思维”。
