《Mysql Connector/J 源码分析(综述)》提到普通的Connection是最基本的连接。本文试图揭开它的内幕,看看它是如何与Mysql数据库管理软件(下文简称Mysql)交互的。
本次分析的版本为5.1.46。若通过maven下载,可添加以下依赖:
mysql
mysql-connector-java
5.1.46
撇开所有框架,我们最原始的获取Mysql连接的方式如下:
Connection conn = null;
URL =“jdbc:mysql://ip:port/dbname”;
try{
// 注册 JDBC 驱动
Class.forName("com.mysql.jdbc.Driver");
// 打开链接
conn = DriverManager.getConnection(DB_URL,USER,PASS);
....
这几行代码表面上看,只是加载了"com.mysql.jdbc.Driver"这个类,然后把url和用户名与密码通过DriverManager#getConnection方法就能够得到连接了。貌似连Class#forName的方法调用都可以省略。但如果我们真把这句删掉,程序就跑不下去了。
以前有个牛人说过“简单是复杂的最终状态”,简单的两行代码,背后藏着不简单处理,目的就是让我们用得轻松,我们做软件设计时不正是抱着这宗旨吗?
我们看看com.mysql.jdbc.Driver的代码:
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}
public Driver() throws SQLException {
// Required for Class.forName().newInstance()
}
}
从结构上看,它继承了NonRegisteringDriver,实现了java.sql.Driver接口。代码简洁,只有看上去不复杂的静态块和什么事情都没做的构造函数。
这里有一个常识性在里面,就是加载一个类的时候,静态块就会被执行。所以当程序执行Class.forName("com.mysql.jdbc.Driver")命令的时候,com.mysql.jdbc.Driver的静态块会被执行,但它并不是第一段被执行的静态块,原因是Driver继承了NonRegisteringDriver。所以程序会先执行NonRegisteringDriver的静态块,然后再执行Driver的静态块代码。NonRegisteringDriver的静态块所做的事情并不是本文要探讨的范围,看官可自行阅读。
在Driver的静态块里,只是构建一个Driver,然后就注册到 DriverManager。然而,DriverManger自己也有静态块,当程序执行java.sql.DriverManager.registerDriver(new Driver());命令的时候,DriverManger的静态块会先被执行,然后再执行registerDriver命令。它的静态块代码如下:
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
AccessController.doPrivileged(new PrivilegedAction() {
public Void run() {
ServiceLoader loadedDrivers = ServiceLoader.load(Driver.class);
Iterator driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
它的静态块主要是调用了loadInitialDrivers静态方法。该方法主要做的事情就是加载所有的驱动类到JDK里。这些驱动类来源于两部分,一部分是jdk的环境变量jdbc.drivers,另一部分使用了java spi机制,加载实现了“java.sql.Driver”接口的驱动类。当前jar包/META-INF/services/java.sql.Driver文件内容是:
com.mysql.jdbc.Driver com.mysql.fabric.jdbc.FabricMySQLDriver
所以,这些类都将会被加载。根据程序的执行顺序,com.mysql.jdbc.Driver的静态块正在执行中,后续将补充执行FabricMySQLDriver的静态块。
现在,我们简单的归纳下Class.forName("com.mysql.jdbc.Driver")命令,引发的一连串操作:
所以,设计者就是利用静态块在类被加载进JDK时自动执行的特性,暗地里做了一些事情,包括将驱动注册到DriverManager。注册驱动的时候,驱动会被保存在DriverManager的registeredDrivers静态属性里。
当执行conn = DriverManager.getConnection(DB_URL,USER,PASS);命令的时候,我们跟踪调用链,会来到DriverManager#getConnection( String , java.util.Properties , Class> )方法:
private static Connection getConnection(
String url, java.util.Properties info, Class> caller) throws SQLException {
....
for(DriverInfo aDriver : registeredDrivers) {
// If the caller does not have permission to load the driver then
// skip it.
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}
....
}
该方法轮循已注册到DriverManager的驱动,如果哪个驱动能根据url等信息创建连接,就返回此连接,不再继续轮循其他驱动。不过,一个驱动无法根据url等信息成功地创建连接的原因会很多,所以这种轮循的机制存在进一步优化的空间。在当前,控制着注册到DriverManager的驱动数量也是一种折衷的方法。
从上面的代码片断看出,接下来的创建连接的工作交给驱动去做。因为只有com.mysql.jdbc.Driver才能解析到我们的url,所以由它的实例对象建立连接。但它并没有重载connect方法,所以这操作由父类NonRegisteringDriver#connect方法进行。
public java.sql.Connection connect(String url, Properties info) throws SQLException {
if (url == null) {
throw SQLError.createSQLException(Messages.getString("NonRegisteringDriver.1"), SQLError.SQL_STATE_UNABLE_TO_CONNECT_TO_DATASOURCE, null);
}
if (StringUtils.startsWithIgnoreCase(url, LOADBALANCE_URL_PREFIX)) {
return connectLoadBalanced(url, info);
} else if (StringUtils.startsWithIgnoreCase(url, REPLICATION_URL_PREFIX)) {
return connectReplicationConnection(url, info);
}
....
NonRegisteringDriver的两个常量LOADBALANCE_URL_PREFIX 和 REPLICATION_URL_PREFIX 定义了loadbalance和 replication的场景下url的前缀,因为普通Connection用“jdbc:mysql://”作为前缀,所以程序继续往下走。
Properties props = null;
if ((props = parseURL(url, info)) == null) {
return null;
}
if (!"1".equals(props.getProperty(NUM_HOSTS_PROPERTY_KEY))) {
return connectFailover(url, info);
}
try {
Connection newConn = com.mysql.jdbc.ConnectionImpl.getInstance(host(props), port(props), props, database(props), url);
return newConn;
} catch (SQLException sqlEx) {
....
接下来对我们的url继续分析,parseURL函数可以分析出我们的url有多少个ip:port的组合,如果这种组合的数量超过1,那么判定为failover的连接场景,程序将进入connecFailover方法。我们现在用的是普通Connection对应的url,它只有一组ip:port,所以程序继续往下走,由ConnectionImpl的静太方法getInstance生成连接。
protected static Connection getInstance(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url)
throws SQLException {
if (!Util.isJdbc4()) {
return new ConnectionImpl(hostToConnectTo, portToConnectTo, info, databaseToConnectTo, url);
}
return (Connection) Util.handleNewInstance(JDBC_4_CONNECTION_CTOR,
new Object[] { hostToConnectTo, Integer.valueOf(portToConnectTo), info, databaseToConnectTo, url }, null);
}
在上面的代码里,Util#isJdbc4的判断依据是在Util的静态块里完成的赋值,JDBC_4_CONNECTION_CTOR是在ConnectionImpl的静态块里完成的赋值。我当前的5.1.46版本里,Util#isJdbc4方法的判断是真值,所以程序执行Util#handleNewInstance方法,使用JDBC_4_CONNECTION_CTOR对应的构造函数来构造Connection。
static {
....
if (Util.isJdbc4()) {
try {
JDBC_4_CONNECTION_CTOR = Class.forName("com.mysql.jdbc.JDBC4Connection")
.getConstructor(new Class[] { String.class, Integer.TYPE, Properties.class, String.class, String.class });
} catch (SecurityException e) {
throw new RuntimeException(e);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
} else {
JDBC_4_CONNECTION_CTOR = null;
}
}
从上面的代码块可知,连接使用的具体类是com.mysql.jdbc.JDBC4Connection。它的UML类图如下:
从类图可知,JDBC4Connection实现了java.sql.Connection接口,所以JDBC4Connection的对象能够转换成此接口,以java的jdbc规范使用。
至此,我们已经知道普通Connection的具体类型是JDBC4Connection,并且看到它在ConnectionImpl#getInstance方法里被构造出实例。到目前为止,我们从最初调用DriverManager时传进去的url和用户名与密码都没被真正使用,而构造JDBC4Connection的时候把这些参数(虽然已经换成了其他形式)传进构造函数,那么在构造函数里,是不是仅仅将它们作为连接的属性呢?答案是否定的,并且在构造函数里进行了比较复杂的处理。
JDBC4Connection的构造函数仅仅调用了父类ConnectionImpl的构造函数。在ConnectionImpl的构造函数里,它把url和用户名与密码都保存为属性。经过一翻的属性处理,然后调用createNewIO(false)命令。这条命令在不同的连接场景都会看到。延着该命令的调用链来到ConnectionImpl#coreConnect方法:
private void coreConnect(Properties mergedProps) throws SQLException, IOException {
....
this.io = new MysqlIO(newHost, newPort, mergedProps, getSocketFactoryClassName(), getProxy(), getSocketTimeout(),
this.largeRowSizeThreshold.getValueAsInt());
this.io.doHandshake(this.user, this.password, this.database);
....
}
从代码上看,构造了MysqlIO对象后,将它作为ConnectionImpl的io属性保存着。我们先看看两个关键的参数:
接下来我们看看MysqlIO构造函数的关键代码:
public MysqlIO(String host, int port, Properties props, String socketFactoryClassName, MySQLConnection conn, int socketTimeout,
int useBufferRowSizeThreshold) throws IOException, SQLException {
....
this.mysqlConnection = this.socketFactory.connect(this.host, this.port, props);
if (socketTimeout != 0) {
try {
this.mysqlConnection.setSoTimeout(socketTimeout);
} catch (Exception ex) {
/* Ignore if the platform does not support it */
}
}
this.mysqlConnection = this.socketFactory.beforeHandshake();
if (this.connection.getUseReadAheadInput()) {
this.mysqlInput = new ReadAheadInputStream(this.mysqlConnection.getInputStream(), 16384, this.connection.getTraceProtocol(),
this.connection.getLog());
} else if (this.connection.useUnbufferedInput()) {
this.mysqlInput = this.mysqlConnection.getInputStream();
} else {
this.mysqlInput = new BufferedInputStream(this.mysqlConnection.getInputStream(), 16384);
}
this.mysqlOutput = new BufferedOutputStream(this.mysqlConnection.getOutputStream(), 16384);
....
}
StandardSocketFactory#connect方法生成Socket,并且让该Socket与Mysql进行连接。从上面的代码片断看,生成的Socket对象被ConnectionImpl的mysqlConnection属性保存。而该Socket的输入流和输出流由mysqlInput和mysqlOutput属性保存。
StandardSocketFactory#connect方法代码片断如下:
public Socket connect(String hostname, int portNumber, Properties props) throws SocketException, IOException {
....
if (this.host != null) {
InetAddress[] possibleAddresses = InetAddress.getAllByName(this.host);
if (possibleAddresses.length == 0) {
throw new SocketException("No addresses for host");
}
// save last exception to propagate to caller if connection fails
SocketException lastException = null;
// Need to loop through all possible addresses. Name lookup may return multiple addresses including IPv4 and IPv6 addresses. Some versions of
// MySQL don't listen on the IPv6 address so we try all addresses.
for (int i = 0; i < possibleAddresses.length; i++) {
try {
this.rawSocket = createSocket(props);
configureSocket(this.rawSocket, props);
InetSocketAddress sockAddr = new InetSocketAddress(possibleAddresses[i], this.port);
// bind to the local port if not using the ephemeral port
if (localSockAddr != null) {
this.rawSocket.bind(localSockAddr);
}
this.rawSocket.connect(sockAddr, getRealTimeout(connectTimeout));
break;
} catch (SocketException ex) {
lastException = ex;
resetLoginTimeCountdown();
this.rawSocket = null;
}
}
....
return this.rawSocket;
}
}
throw new SocketException("Unable to create socket");
}
protected Socket createSocket(Properties props) {
return new Socket();
}
至此,在构造ConnectionImpl对象过程中,实际上已经与Mysql所在的服务器进行了Socket的连通。也就是说此时客户端与DB服务器进行了3次握手,实现了tcp连接。然而,从客户端上送的用户名与密码到目前为止还没使用上。接下来,还要进行与Mysql“握手”的过程,彼时将用到这部分信息。
我们回到ConnectionImpl#coreConnect方法:
private void coreConnect(Properties mergedProps) throws SQLException, IOException {
....
this.io = new MysqlIO(newHost, newPort, mergedProps, getSocketFactoryClassName(), getProxy(), getSocketTimeout(),
this.largeRowSizeThreshold.getValueAsInt());
this.io.doHandshake(this.user, this.password, this.database);
....
}
刚刚执行构造MysqlIO对象的命令,并实现了与Mysql所在的服务器Socket层面的握手,接下来看看与Mysql的握手,即代码片断的this.io.doHandshake方法。该方法主要是以读取socket的输入流以及写进输出流的过程,以下归纳出每次交互传递的信息,看官感兴趣的话可以自行调试代码观察。
第一回,客户端接收数据库管理软件信息,并赋值到以下属性:
然后转入MysqlIO#proceedHandshakeWithPluggableAuthentication方法。从方法名可看出,是通过验证插件进行握手。
在该方法体内,有一个100次循环的do/while循环体。实现上也不会执行100次,只要完成某方面的操作就会退出该循环。
第二回,客户端发送数据库管理软件信息:
我的数据库版本是8.0.18,高于5.7.0,所以在MysqlIO#doHandshake方法体内已经将ConnectionImpl#useSSL属性被设置为真值:
if (versionMeetsMinimum(5, 7, 0) && !this.connection.getUseSSL() && !this.connection.isUseSSLExplicit()) {
this.connection.setUseSSL(true);
this.connection.setVerifyServerCertificate(false);
this.connection.getLog().logWarn(Messages.getString("MysqlIO.SSLWarning"));
}
在MysqlIO#proceedHandshakeWithPluggableAuthentication方法的循环体内,因为ConnectionImpl#useSSL属性值已true,所以会进入 negotiateSSLConnection方法,而此方法向Mysql传送以下信息:
第三回,与数据库管理软件所在服务器进行SSL协议握手
MysqlIO#negotiateSSLConnection方法体内在发送信息后,调用ExportControlled#transformSocketToSSLSocket方法,将MysqlIO#mysqlConnection属性转为支持SSL协议的SSLSocket,MysqlIO的mysqlInput、mysqlOutput和socketFactory属性也相应的被替换。此外,升级后的SSLSocket也与Mysql所在的服务器进行了握手。
第四回,客户端发送数据库管理软件信息
经过以上的握手过程,Mysql已经知道客户端使用的用户是谁,并且数据传输通道使用了SSL协议,确保数据传输的安全。接下来,正在构造中的Connection需要读取Mysql的信息:
private void connectOneTryOnly(boolean isForReconnect, Properties mergedProps) throws SQLException {
Exception connectionNotEstablishedBecause = null;
try {
coreConnect(mergedProps);
this.connectionId = this.io.getThreadId();
this.isClosed = false;
....
// Server properties might be different from previous connection, so initialize again...
initializePropsFromServer();
....
return;
} catch (Exception EEE) {
....
}
}
在initializePropsFromServer方法中,会调用loadServerVariables方法,从数据库读取信息,并保存到ContionImpl#serverVariables集合属性里,随后使用这部分数据为ContionImpl的属性赋值。这部分代码比较简单,请看官自行阅读。
前面章节已经尽最大努力分析了构造连接的过程,接下来我们以最简的例子继续分析,连接在平时的查询过程中是如何工作的。
public static void main(String[] args){
Connection conn = null;
Statement stmt = null;
try{
// 注册 JDBC 驱动
Class.forName(JDBC_DRIVER);
conn = DriverManager.getConnection(DB_URL,USER,PASS);
stmt = conn.createStatement();
String sql = "SELECT * FROM readingrecord";
ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
// 通过字段检索
int id = rs.getInt("id");
String title = rs.getString("Title");
String readDate = rs.getString("ReadDate");
System.out.print("ID: " + id);
System.out.print(", Title: " + title);
System.out.print(", ReadDate: " + readDate);
System.out.print("\n");
}
rs.close();
}catch(Exception ex){
ex.printStackTrace();
}
}finally{
try{
if(stmt!=null) stmt.close();
}catch(SQLException se2){
se2.printStackTrace();
}
try{
if(conn!=null) conn.close();
}catch(SQLException se){
se.printStackTrace();
}
}
}
我们继续分析上面例子的conn.createStatement()命令。JDBC4Connection本身没有重载createStatement方法,而它的父类ConnectionImpl有定义该方法。
public java.sql.Statement createStatement() throws SQLException {
return createStatement(DEFAULT_RESULT_SET_TYPE, DEFAULT_RESULT_SET_CONCURRENCY);
}
public java.sql.Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {
checkClosed();
StatementImpl stmt = new StatementImpl(getMultiHostSafeProxy(), this.database);
stmt.setResultSetType(resultSetType);
stmt.setResultSetConcurrency(resultSetConcurrency);
return stmt;
}
public MySQLConnection getMultiHostSafeProxy() {
return this.getProxy();
}
private MySQLConnection getProxy() {
return (this.proxy != null) ? this.proxy : (MySQLConnection) this;
}
我们的例子调用无参数的createStatement方法,该方法随后调用带两个参数的createStatement方法。我们先看看这两个常量的定义:
private static final int DEFAULT_RESULT_SET_TYPE = ResultSet.TYPE_FORWARD_ONLY;
private static final int DEFAULT_RESULT_SET_CONCURRENCY = ResultSet.CONCUR_READ_ONLY;
从定义上看,它俩指向ResultSet的两个枚举值,从命名上不难看出,ResultSet单向前进,并且是同步只读的操作方式。
因为我的连接只是普通的Connection,所以getMultiHostSafeProxy方法返回的是当前JDBC4Connection对象,但是以父类ConnectionImpl的形式返回。
Statement的构造函数参数之一是ConnectionImpl对象,在其构造函数内复制了大量的连接对象的属性,看官可自行阅读。
public java.sql.ResultSet executeQuery(String sql) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
MySQLConnection locallyScopedConn = this.connection;
....
this.results = locallyScopedConn.execSQL(this, sql, this.maxRows, null, this.resultSetType, this.resultSetConcurrency,
createStreamingResultSet(), this.currentCatalog, cachedFields);
....
return this.results;
}
}
在StatementImpl#executeQuery方法体内,我们看到查询工作委托给连接,即当前的JDBC4Connection对象。
我们沿着ConnectionImpl#exeSQL的调用链,将进入以下方法:
public ResultSetInternalMethods execSQL(StatementImpl callingStatement, String sql, int maxRows, Buffer packet, int resultSetType, int resultSetConcurrency,
boolean streamResults, String catalog, Field[] cachedMetadata, boolean isBatch) throws SQLException {
synchronized (getConnectionMutex()) {
....
return this.io.sqlQueryDirect(callingStatement, sql, encoding, null, maxRows, resultSetType, resultSetConcurrency, streamResults, catalog,
cachedMetadata);
....
}
}
我们看到,查询工作委托给MysqlIO处理。MysqlIO对象的io属性就存储着Socket(或者SSLSocket)。
final ResultSetInternalMethods sqlQueryDirect(StatementImpl callingStatement, String query, String characterEncoding, Buffer queryPacket, int maxRows,
int resultSetType, int resultSetConcurrency, boolean streamResults, String catalog, Field[] cachedMetadata) throws Exception {
....
// Send query command and sql query string
Buffer resultPacket = sendCommand(MysqlDefs.QUERY, null, queryPacket, false, null, 0);
....
ResultSetInternalMethods rs = readAllResults(callingStatement, maxRows, resultSetType, resultSetConcurrency, streamResults, catalog, resultPacket,
false, -1L, cachedMetadata);
....
return rs;
....
}
在MysqlIO#sqlQueryDirect方法里,会先准备好发给数据库管理软件的数据包,该包的内容包括了SQL语句,调用sendCommand方法发送信息。然后该方法返回的信息保存在resultPacket里。
根据我们对Socket的使用常识,这个resultPacket的内容应该是从Socket的输入流读取的,它是Mysql的响应内容。然后把resultPacket连同前文提到的Result的两个枚举和其他参数生成ResultSetInternalMethods类型的结果。ResultSetInternalMethods接口其实就是继承了ResultSet接口。
我们先继续探讨MysqlIO#sendCommand方法。
final Buffer sendCommand(int command, String extraData, Buffer queryPacket, boolean skipCheck, String extraDataCharEncoding, int timeoutMillis)
throws SQLException {
....
send(this.sendPacket, this.sendPacket.getPosition());
....
Buffer returnPacket = null;
if (!skipCheck) {
if ((command == MysqlDefs.COM_EXECUTE) || (command == MysqlDefs.COM_RESET_STMT)) {
this.readPacketSequence = 0;
this.packetSequenceReset = true;
}
returnPacket = checkErrorPacket(command);
}
return returnPacket;
....
}
private final void send(Buffer packet, int packetLen) throws SQLException {
....
this.mysqlOutput.write(packetToSend.getByteBuffer(), 0, packetLen);
this.mysqlOutput.flush();
....
}
sendCommand方法调用send方法实现最终的发送信息的功能。send方法体内的mysqlOutput是SSLSocket的输出流。我们猜想returnPacket是从SSLSocket的输入流读取信息,我们继续追踪,看看是否符合预期。
跟踪readAllResults方法的调用链,会进入reuseAndReadPacket方法,该方法与readFull方法一起读取出SSLSocket输入流的信息:
private final Buffer reuseAndReadPacket(Buffer reuse, int existingPacketLength) throws SQLException {
try {
reuse.setWasMultiPacket(false);
int packetLength = 0;
if (existingPacketLength == -1) {
int lengthRead = readFully(this.mysqlInput, this.packetHeaderBuf, 0, 4);
....
// Read the data from the server
int numBytesRead = readFully(this.mysqlInput, reuse.getByteBuffer(), 0, packetLength);
return reuse;
} catch (IOException ioEx) {
....
}
}
private final int readFully(InputStream in, byte[] b, int off, int len) throws IOException {
if (len < 0) {
throw new IndexOutOfBoundsException();
}
int n = 0;
while (n < len) {
int count = in.read(b, off + n, len - n);
if (count < 0) {
throw new EOFException(Messages.getString("MysqlIO.EOF", new Object[] { Integer.valueOf(len), Integer.valueOf(n) }));
}
n += count;
}
return n;
}
无论是Socket还是SSLSocket,来往于客户端和Mysql间的数据包,都有一个基本的规范,包的业务数据的长度在包的前3个字节表示。
现在我们已经了解到查询sql的发送以及得到返回的数据。而返回数据经过读取后存放在字节数组里(Buffer)。那么它又时如何封装成我们平时使用的ResultSet呢,这部分内容超出本文的范围,请看官自行分析。
本节主要探讨异常发生后,会有什么处理,以及对于客户端的影响。
经过前的铺垫,连接的父类ConnectionImpl最终会使用MysqlIO对象以Socket(SSLSocket)发送信息。ConnectionImpl与MysqlIO的包路径都位于com.mysql.jdbc下,所以观察MysqlIO的protect方法签名,基本都会抛异常。而ConnectionImpl调用MysqlIO的方法签名也是会抛异常,有的方法体内接收MysqlIO往上抛的异常后,会分析当前连接是否多机连接(即failover、loadbalance、replication的场景),对于普通连接基本上都会往上继续抛异常,这样客户端就会感知到异常,并做相应的处理。我们以需要使用事务的例子来观察一些命令在异常发生的情况下会怎么样。
public static void main(String[] args){
String DB_URL = "jdbc:mysql://localhost:3306/xxxx?serverTimezone=UTC";
String USER = "root";
String PASS = "123456";
Connection conn = null;
Statement stmt = null;
try{
// 注册 JDBC 驱动
Class.forName("com.mysql.jdbc.Driver");
// 打开链接
System.out.println("连接数据库...");
conn = DriverManager.getConnection(DB_URL,USER,PASS);
conn.setAutoCommit(false);
// 执行查询
stmt = conn.createStatement();
String sql = "SELECT * FROM worksheet";
ResultSet rs = stmt.executeQuery(sql);
conn.commit();
// 完成后关闭
rs.close();
stmt.close();
conn.close();
}catch(SQLException se){
// 处理 JDBC 错误
se.printStackTrace();
try {
conn.rollback();
} catch (SQLException e) {
e.printStackTrace();
}
}finally{
//关闭连接
....
}
System.out.println("Goodbye!");
}
我们假设连接能够正常获取,接下来观察假如conn.setAutoCommit(false)命令出现异常会怎么处理:
public void setAutoCommit(final boolean autoCommitFlag) throws SQLException {
synchronized (getConnectionMutex()) {
....
try {
....
if (needsSetOnServer) {
execSQL(null, autoCommitFlag ? "SET autocommit=1" : "SET autocommit=0", -1, null, DEFAULT_RESULT_SET_TYPE,
DEFAULT_RESULT_SET_CONCURRENCY, false, this.database, null, false);
}
....
} finally {
if (this.getAutoReconnectForPools()) {
setHighAvailability(false);
}
}
return;
}
}
从代码结构看,setAutoCommit不会捕获异常,而是任由其继续往上抛。同时我们也知道控制是否自动提交事务的方式是通过改变autocommit环境变量来实现。我们进入execSQL方法继续观察:
public ResultSetInternalMethods execSQL(StatementImpl callingStatement, String sql, int maxRows, Buffer packet, int resultSetType, int resultSetConcurrency,
boolean streamResults, String catalog, Field[] cachedMetadata, boolean isBatch) throws SQLException {
synchronized (getConnectionMutex()) {
....
try {
....
return this.io.sqlQueryDirect(callingStatement, null, null, packet, maxRows, resultSetType, resultSetConcurrency, streamResults, catalog,
cachedMetadata);
} catch (java.sql.SQLException sqlE) {
....
throw sqlE;
} catch (Exception ex) {
....
SQLException sqlEx = SQLError.createSQLException(Messages.getString("Connection.UnexpectedException"), SQLError.SQL_STATE_GENERAL_ERROR,
getExceptionInterceptor());
sqlEx.initCause(ex);
throw sqlEx;
} finally {
....
}
}
}
从代码结构看,本方法会捕获MysqlIO方法抛出的异常,并且区分SQLException和普通的Exception来分别处理,这里不禁让人联想到MysqlIO#sqlQueryDirect抛出的异常不止SQLException一种类型,不过最终这些异常都会被往上抛。当获取到非SQLException异常时,会调用SQLError#createSQLException方法生成一个SQLException,并将捕获的异常作为这个新的SQLException的初始异常,然后再往上抛。因此,不难猜测出,在ConnectionImpl#execSQL方法体里就是想把所有的异常都确保以SQLException的表现形式往外抛。所以我们回过头来看ConnectionImpl#execSQL、ConnectionImpl#setAutoCommit方法,它们的方法声明都仅仅是抛SQLException,就是因为ConnectionImpl#execSQL方法内部已经统一了底层往上抛的异常的表现形式。
我们沿着调用链,一直来到最底层的方法MysqlIO#send,这里是使用Socket(SSLSocket)的输出流发送信息:
private final void send(Buffer packet, int packetLen) throws SQLException {
try {
....
this.packetSequence++;
Buffer packetToSend = packet;
packetToSend.setPosition(0);
packetToSend.writeLongInt(packetLen - HEADER_LENGTH);
packetToSend.writeByte(this.packetSequence);
this.mysqlOutput.write(packetToSend.getByteBuffer(), 0, packetLen);
this.mysqlOutput.flush();
....
} catch (IOException ioEx) {
throw SQLError.createCommunicationsException(this.connection, this.lastPacketSentTimeMs, this.lastPacketReceivedTimeMs, ioEx,
getExceptionInterceptor());
}
}
从代码上看,在客户端发送信息到Mysql的方向上,会出现的异常其实仅仅是IOException,即遇到网络异常的情况下就会出现异常。处理方式为先创建CommunicationsException,然后把原来的IOException作为它的原始异常,最后往上抛。
CommunicationException的继承树如下:
CommunicationException
|----SQLRecoverableException
|--------SQLException
Mysql#send方法的声明是抛出SQLException,所以,即使遇到通讯异常,程序捕获后仍以SQLException的表现形式向外抛。
通过例子里的“conn.setAutoCommit(false)”命令,我们了解到了从客户端发送信息到服务端时异常的处理方式,就是统一封装成SQLException再往上抛这么一种设计思想。其实真正会遇到的麻烦就是网络异常的情况,那么我们进一步思考下在已经取得连接的情况下,出现通讯异常会有什么原因呢?无非就是断网、网络不稳定为主。
那么,当Mysql成功接收到请求信息,执行后发现有问题(比如需要查询的表不存在,或者需要更新的数据规定必须大于0但现在需要更新一个负值)。那么会如何让客户端感知到这问题呢?
在我们的例子里,我们通过查询一个不存在的表来窥探connector/j是如何处理的。我们将目光放在MysqlIO#sendCommand方法。该方法一方面调用最底层的MysqlIO#send方法发送请求,另一方面从Socket(SSLSocket)的输入流获取返回信息:
final Buffer sendCommand(int command, String extraData, Buffer queryPacket, boolean skipCheck, String extraDataCharEncoding, int timeoutMillis)
throws SQLException {
this.commandCount++;
....
send(this.sendPacket, this.sendPacket.getPosition());
....
Buffer returnPacket = null;
if (!skipCheck) {
if ((command == MysqlDefs.COM_EXECUTE) || (command == MysqlDefs.COM_RESET_STMT)) {
this.readPacketSequence = 0;
this.packetSequenceReset = true;
}
returnPacket = checkErrorPacket(command);
}
return returnPacket;
} catch (IOException ioEx) {
....
} finally {
....
}
}
看官可顺着checkErrorPacket方法来到private void checkErrorPacket(Buffer resultPacket) throws SQLException方法。它里面就是根据约定,先从输入流里读出代表状态码的字节,如果该字节是0xff,代表Mysql返回的是错误信息,然后再读取代表异常号的字节和代表错误信息的字节,最后通过SQLError#createSQLException方法生成具体的SQLException子类,但这些子类并不包括通讯异常的CommunicationException。也就是说从Mysql方向过来的数据,有可能造成通讯异常以外的异常,即数据异常。
所以对于客户端来说,捕获到的SQLException背后的原因并不单纯,有可能是通讯异常也有可能是数据异常。无论哪种情况,都可以理解为该事务无法正常进行下去了,但我们可以根据SQLException的具体类型判断异常属于通讯异常还是数据异常。如果是数据异常,可发送回滚请求通知Mysql回滚,同时放弃本次事务并且以恰当的方式通知用户出了问题;如果是通讯异常,客户端除了需要进行业务异常情况下要做的事情外,还需要有一个重新建立连接的机制,否则后续应用程序无法操作Mysql数据。
对于Mysql服务端来说,如果在接收到客户端请求并执行完毕准备返回执行结果给客户端时出现了通讯异常,这时候就要靠Mysql自己的容错机制回滚事务了。我们可以大胆猜想Mysql是如何感知到网络中断,我们可以自行编写程序以socket来实现客户端与服务端的交互,当我们强行中止客户端,服务端那边是会抛异常的,所以Mysql也许是利用这种特征来感知事务要回滚。另外,Mysql也有可能是发现某个事务超时了就进行回滚。
ConnectionImpl#execSQL(StatementImpl, String , int , Buffer , int , int , boolean , String , Field[] , boolean ) 是所有向Mysql服务端发送请求的必经之路。它对于底层方法抛上来的异常进行一些处理后,才将该异常继续往上抛。这里面包括了相当于自杀的行为。
public ResultSetInternalMethods execSQL(StatementImpl callingStatement, String sql, int maxRows, Buffer packet, int resultSetType, int resultSetConcurrency,
boolean streamResults, String catalog, Field[] cachedMetadata, boolean isBatch) throws SQLException {
synchronized (getConnectionMutex()) {
....
try {
....
return this.io.sqlQueryDirect(callingStatement, sql, encoding, null, maxRows, resultSetType, resultSetConcurrency, streamResults, catalog,
cachedMetadata);
....
} catch (java.sql.SQLException sqlE) {
....
if (getHighAvailability()) {
if (SQLError.SQL_STATE_COMMUNICATION_LINK_FAILURE.equals(sqlE.getSQLState())) {
// IO may be dirty or damaged beyond repair, force close it.
this.io.forceClose();
}
this.needsPing = true;
} else if (SQLError.SQL_STATE_COMMUNICATION_LINK_FAILURE.equals(sqlE.getSQLState())) {
cleanup(sqlE);
}
throw sqlE;
} catch (Exception ex) {
....
} finally {
....
}
}
}
当底层方法往上抛CommunicationException异常时,会被 catch (java.sql.SQLException sqlE) 处捕获到。因为当前连接是普通的Connection,所以会进行SQLError.SQL_STATE_COMMUNICATION_LINK_FAILURE.equals(sqlE.getSQLState())的比较。CommunicationException#getSQLState()返回的值就是固定为SQLError.SQL_STATE_COMMUNICATION_LINK_FAILURE,所以程序进入cleanup方法。
private void cleanup(Throwable whyCleanedUp) {
try {
if (this.io != null) {
if (isClosed()) {
this.io.forceClose();
} else {
realClose(false, false, false, whyCleanedUp);
}
}
} catch (SQLException sqlEx) {
// ignore, we're going away.
}
this.isClosed = true;
}
public boolean isClosed() {
return this.isClosed;
}
因为出现异常后首次进入本方法,所以ConnectionImpl#isClosed属性值仍为false,而且此时ConnectionImpl#io属性值仍指向MysqlIO的实例化对象,所以程序进入realClose方法。
public void realClose(boolean calledExplicitly, boolean issueRollback, boolean skipLocalTeardown, Throwable reason) throws SQLException {
SQLException sqlEx = null;
....
if (this.io != null) {
try {
this.io.quit();
} catch (Exception e) {
}
}
} else {
this.io.forceClose();
}
....
} finally {
....
if (this.io != null) {
this.io.releaseResources();
this.io = null;
}
....
this.isClosed = true;
}
....
}
代码片段内调用MysqlIO的quit、forceClose和releaseResources方法,都是在释放资源。this.io=null 把ConnectionImpl的io属性清掉了,也就是ConnectionImpl去掉了与Mysql通讯的能力。同时ConnectionImpl#isClosed属性设置为true,这操作很关键,因为包括Statement在内,在执行SQL命令前都会先调用ConnectionImpl#checkClosed()方法检查isClosed属性值,如果该值为ture,那么SQL命令将不会发送到Mysql了。
public void checkClosed() throws SQLException {
if (this.isClosed) {
throwConnectionClosedException();
}
}
public void throwConnectionClosedException() throws SQLException {
SQLException ex = SQLError.createSQLException("No operations allowed after connection closed.", SQLError.SQL_STATE_CONNECTION_NOT_OPEN,
getExceptionInterceptor());
if (this.forceClosedReason != null) {
ex.initCause(this.forceClosedReason);
}
throw ex;
}
通过分析连接的构造与使用,我们知道构造的时候主要经历了多次与服务端的握手,这些握手从OSI7层网络结构来看,一开始是使用普通的tcp协议(通过Socket实现),紧接着升级为ssl协议(通过SSLSocket实现),其实是高版本Mysql要求的;从业务的角度看,握手要实现的目标包括验证用户身份和获取数据库会话的环境变量。从此可看出,构造普通Connection的过程是复杂而且耗时,所以这就不难理解我们实际开发时通常会使用连接池技术,目的就是要减少构造连接的时间消耗。
通过阅读MysqlIO#send方法,我们了解到连接与Mysql通讯时,就是通过Socket(高版本数据库要求SSLSocket)进行数据的交互,这一点与OSI7层网络结构实现两台设备进行网络通讯的规范是一致的。
当客户端需要执行一条SQL命令时,有可能会遇到通讯异常或者数据异常。产生通讯异常的原因可能是,客户端发送命令到Mysql过程中遇到断网或者网络异常或者服务端停止了服务;产生数据异常的原因可能是,服务端发现SQL命令无法正常执行。无论是哪种原因产生异常, ConnectionImpl内部会统一封装成SQLException异常并抛给客户端。对于客户端来说,无论是网络异常还是业务异常,都代表着当前事务无法正常进行下去了,当前的事务需要放弃。如果是数据异常,可以通知Mysql回滚当前事务,如果是通讯异常,客户端需要重建连接。
当遇到通讯异常就要重建连接,这工作交给客户端去处理的话会比较麻烦,因此failover、loadbalance、replication的连接场景诞生了。但普通Connection依然是其它连接场景的实现基础,后续文章将继续介绍其他连接场景,敬请关注。