共计 4612 个字符,预计需要花费 12 分钟才能阅读完成。
现象描述
测试中发现,服务 A 在得到了服务 B 的注册用户成功 response 以后,开始调用查询用户信息接口,却发现无法查询出任何结果。检查 binlog 发现,在查询请求之前,数据库确实已经完成了 commit 操作,并且可以在 sqlyog 等客户端工具中查询出正确的结果。
下面是这个流程的时序图:
问题出现在 Server A 向数据库发起查询的时候,返回的结果总是空。
问题分析
这个问题显然是一个事务隔离的问题,最开始的思路是,服务 A 所在的机器,其事务开启时间应该是在服务 B 的机器 commit 操作之前开启的,但是通过 DEBUG 日志分析 connection 的获取和提交时间,发现两个服务器之间不存在这样的关系,服务 B 永远是在服务 A 返回了正确的 response 之后才会调用数据库接口,进行 getConnection 操作,进而进行查询操作。
显然这并不能支持刚才的设想,但是结论一定是正确的,就是因为事务隔离级别导致了 Server A 读到的永远是快照,发生了可重复读。
后来调整了一下思路,发现 MySQL 还有一个特性就是 AutoCommit,即默认情况下,MySQL 是开启事务的,下面表格能说明问题,表 1:
但是,如果 AutoCommit 不是默认开启呢?结果就会变成下面的表格,表 2:
在关闭 AutoCommit 的条件下,SessionA 在 T1 和 T2 两个时间点执行的 SQL 语句其实在一个事务里,因此每次读到的其实只是一个快照。
那么在连接池条件下,情况如何?
设置一个极端条件,连接池只给一个连接,编写两个类,一个负责插入数据,一个负责循环读取数据,但是读取数据的类在执行读取方法之前,会执行一个空方法,这个方法只会做一件事情,就是获取连接,将其 AutoCommit 设置为 FALSE,关闭连接。
两段代码如下:
写入线程:
public static void main(String[] args ) throws Exception
{DBconfigEntity entity = new DBconfigEntity();
entity.setDbName("test");
entity.setDbPasswd("123456");
entity.setDbUser("root");
entity.setIp("127.0.0.1");
entity.setPort(3306);
MysqlClient.init(entity);
MysqlClient instance = MysqlClient.getInstance();
Connection conn = instance.getConnection();
conn.setAutoCommit(false);
String sql = "insert into test1(uname) values (?)";
PreparedStatement statement = conn.prepareStatement(sql);
statement.setString(1, "PPP");
statement.executeUpdate();
conn.commit();
statement.close();
conn.close();
// 永远休眠,但是永远持有连接池
Thread.sleep(Long.MAX_VALUE);
}
读取类:
public class GetClient {private void query() throws SQLException
{System.out.println("start");
MysqlClient instance = MysqlClient.getInstance();
Connection conn = instance.getConnection();
String sql = "select uname from test1";
PreparedStatement statement = conn.prepareStatement(sql);
ResultSet rs = statement.executeQuery();
while (rs.next()) {System.out.println(rs.getString("uname"));
}
statement.close();
rs.close();
conn.close();}
private void nothing() throws SQLException
{MysqlClient instance = MysqlClient.getInstance();
Connection conn = instance.getConnection();
conn.setAutoCommit(false);
conn.close();}
public static void main(String[] args) throws SQLException, InterruptedException, ClassNotFoundException {DBconfigEntity entity = new DBconfigEntity();
entity.setDbName("test");
entity.setDbPasswd("123456");
entity.setDbUser("root");
entity.setIp("127.0.0.1");
entity.setPort(3306);
MysqlClient.init(entity);
GetClient client = new GetClient();
client.nothing();
while (true) {client.query();
Thread.sleep(5000);
}
}
}
表初始没有任何数据,首先运行读取类,此时读取类只会不停的打印“start”,此时启动写入类,观察发现,console 并不会打印数据库 test1 表查询的结果,但是在数据库工具中查看,test1 表确实已经有了数据。
这是因为在连接池条件下,如果这个连接之前被借出过,并且曾经被设置成了 AutoCommit 为 FALSE,那么这个连接在其生存时间内,永远会默认开启事务,这是 MySQL 自身决定的,因为连接池只是持有连接,代码中的 close 操作只是将该连接还给连接池,但是并没有真的将连接销毁,因此连接的属性仍然保持上次设置的样子。当另一个方法开始,重新执行 getConnection 获取链接时,是有可能获取到之前被设置为 AutoCommit 为 FALSE 的连接的,这个时候就相当于上面的表 2 中 Session A 在 T3 时间点的情况,无论如何查询,都会查不出任何数据来。
如下图:
无论如何 commit,都无法改变这个连接的 autocommit 属性。
因为测试时采用的是一个连接这种极端条件,因此该现象非常容易复现,且是 100% 的复现,但是在测试条件下,并非 100% 复现,而是在重启之后会好一段时间,一段时间以后就会重新出现这个情况。
如果将读取类的代码稍加修改:
public class GetClient {private void query() throws SQLException
{System.out.println("start");
MysqlClient instance = MysqlClient.getInstance();
Connection conn = instance.getConnection();
conn.setAutoCommit(true);
String sql = "select uname from test1";
PreparedStatement statement = conn.prepareStatement(sql);
ResultSet rs = statement.executeQuery();
while (rs.next()) {System.out.println(rs.getString("uname"));
}
statement.close();
rs.close();
conn.close();}
private void nothing() throws SQLException
{MysqlClient instance = MysqlClient.getInstance();
Connection conn = instance.getConnection();
conn.setAutoCommit(false);
conn.close();}
public static void main(String[] args) throws SQLException, InterruptedException, ClassNotFoundException {DBconfigEntity entity = new DBconfigEntity();
entity.setDbName("test");
entity.setDbPasswd("123456");
entity.setDbUser("root");
entity.setIp("127.0.0.1");
entity.setPort(3306);
MysqlClient.init(entity);
GetClient client = new GetClient();
client.nothing();
while (true) {client.query();
Thread.sleep(5000);
}
}
}
注意我在 query 方法中加入这一句:conn.setAutoCommit(true);
此时这个问题不再出现。
源码分析
jdbc 驱动源码分析
Connection 是 Java 提供的一个标准接口:java.sql.Connection,其具体实现是:com.mysql.jdbc.ConnectionImpl。
分析 jdbc 驱动代码可知,jdbc 默认的 AutoCommit 状态是 TRUE:
这实际上和 MySQL 的默认值是一样的。
tomcat-jdbc 源码分析
tomcat-jdbc 的 close 方法由拦截器实现,具体的逻辑代码:
if (compare(CLOSE_VAL,method)) {if (connection==null) return null; //noop for already closed.
PooledConnection poolc = this.connection;
this.connection = null;
pool.returnConnection(poolc);
return null;
}
实际上此处只是将连接还给了连接池,没有对连接进行任何处理。
tomcat-jdbc 维护了两个 Queue:busy 和 idle,用于存放空闲和已借出连接,连接还给连接池的过程简单的说就是将该连接从 busy 队列中移除,并放在 idle 队列中的过程。
boneCP 源码分析
根据实际使用的经验看,boneCP 连接池在使用的过程中并没有出现这个问题,分析 boneCP 的 Connection 具体实现,发现在 close 方法的具体实现中,有这样的一段代码逻辑:
if (!getAutoCommit()) {setAutoCommit(true);
}
这段逻辑会判断该连接的 AutoCommit 属性是否为 FALSE,如果是,就自动将其置为 TRUE。
因此,在这个连接被交还回连接池时,AutoCommit 属性总是 TRUE。
结论
任何查询接口都应该在获取连接以后进行 AutoCommit 的设置,将其设置为 true。