URL
date
AI summary
slug
status
tags
summary
type

背景

最近有个需求需要通过公网从外部的MySQL拉取数据。在得到数据源信息之后,我们先通过命令行验证可以正常连接。但是配置到Datax上去跑任务拉数据的时候,却报错了:
2024-07-22 17:34:29.831 [job-0] WARN DBUtil - test connection of [jdbc:mysql://xxx:3306/nhtg?characterEncoding=utf8] failed, for Code:[MYSQLErrCode-02], Description:[数据库服务的IP地址或者Port错误,请检查填写的IP地址和Port或者联系DBA确认IP地址和Port是否正确。如果是同步中心用户请联系DBA确认idb上录入的IP和PORT信息和数据库的当前实际信息是一致的]. - 具体错误信息为:com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure The last packet successfully received from the server was 233 milliseconds ago. The last packet sent successfully to the server was 225 milliseconds ago.. 2024-07-22 17:34:29.834 [job-0] ERROR RetryUtil - Exception when calling callable, 异常Msg:DataX无法连接对应的数据库,可能原因是:1) 配置的ip/port/database/jdbc错误,无法连接。2) 配置的username/password错误,鉴权失败。请和DBA确认该数据库的连接信息是否正确。 java.lang.Exception: DataX无法连接对应的数据库,可能原因是:1) 配置的ip/port/database/jdbc错误,无法连接。2) 配置的username/password错误,鉴权失败。请和DBA确认该数据库的连接信息是否正确。 at com.alibaba.datax.plugin.rdbms.util.DBUtil$2.call(DBUtil.java:71) ~[plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.rdbms.util.DBUtil$2.call(DBUtil.java:51) ~[plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.common.util.RetryUtil$Retry.call(RetryUtil.java:164) ~[datax-common-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.common.util.RetryUtil$Retry.doRetry(RetryUtil.java:111) ~[datax-common-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.common.util.RetryUtil.executeWithRetry(RetryUtil.java:30) [datax-common-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.rdbms.util.DBUtil.chooseJdbcUrl(DBUtil.java:51) [plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.rdbms.reader.util.OriginalConfPretreatmentUtil.dealJdbcAndTable(OriginalConfPretreatmentUtil.java:92) [plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.rdbms.reader.util.OriginalConfPretreatmentUtil.simplifyConf(OriginalConfPretreatmentUtil.java:59) [plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.rdbms.reader.util.OriginalConfPretreatmentUtil.doPretreatment(OriginalConfPretreatmentUtil.java:33) [plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.rdbms.reader.CommonRdbmsReader$Job.init(CommonRdbmsReader.java:55) [plugin-rdbms-util-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.plugin.reader.mysqlreader.MysqlReader$Job.init(MysqlReader.java:37) [mysqlreader-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.core.job.JobContainer.initJobReader(JobContainer.java:673) [datax-core-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.core.job.JobContainer.init(JobContainer.java:303) [datax-core-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.core.job.JobContainer.start(JobContainer.java:113) [datax-core-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.core.Engine.start(Engine.java:92) [datax-core-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.core.Engine.entry(Engine.java:171) [datax-core-0.0.1-SNAPSHOT.jar:na] at com.alibaba.datax.core.Engine.main(Engine.java:204) [datax-core-0.0.1-SNAPSHOT.jar:na] 2024-07-22 17:34:30.835 [job-0] ERROR RetryUtil - Exception when calling callable, 即将尝试执行第1次重试.本次重试计划等待[1000]ms,实际等待[1001]ms, 异常Msg:[DataX无法连接对应的数据库,可能原因是:1) 配置的ip/port/database/jdbc错误,无法连接。2) 配置的username/password错误,鉴权失败。请和DBA确认该数据库的连接信息是否正确。] 2024-07-22 17:34:30.857 [job-0] WARN DBUtil - test connection of [jdbc:mysql://xxx:3306/nhtg?characterEncoding=utf8] failed, for Code:[MYSQLErrCode-02], Description:[数据库服务的IP地址或者Port错误,请检查填写的IP地址和Port或者联系DBA确认IP地址和Port是否正确。如果是同步中心用户请联系DBA确认idb上录入的IP和PORT信息和数据库的当前实际信息是一致的]. - 具体错误信息为:com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure The last packet successfully received from the server was 1 milliseconds ago. The last packet sent successfully to the server was 1 milliseconds ago.. 2024-07-22 17:34:32.861 [job-0] ERROR RetryUtil - Exception when calling callable, 即将尝试执行第2次重试.本次重试计划等待[2000]ms,实际等待[2000]ms, 异常Msg:[DataX无法连接对应的数据库,可能原因是:1) 配置的ip/port/database/jdbc错误,无法连接。2) 配置的username/password错误,鉴权失败。请和DBA确认该数据库的连接信息是否正确。] 2024-07-22 17:34:32.881 [job-0] WARN DBUtil - test connection of [jdbc:mysql://xxx:3306/nhtg?characterEncoding=utf8] failed, for Code:[MYSQLErrCode-02], Description:[数据库服务的IP地址或者Port错误,请检查填写的IP地址和Port或者联系DBA确认IP地址和Port是否正确。如果是同步中心用户请联系DBA确认idb上录入的IP和PORT信息和数据库的当前实际信息是一致的]. - 具体错误信息为:com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure
Datax打印出的异常信息是com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure,并没有把原始错误打印出来,并且由于对公访问所以对方对我们的阿里云机房开了白名单,我们本地环境也连不上这个远程的MySQL实例,所以这块儿对问题的排查也增加了一定的困难。

分析

当时的怀疑是MySQL驱动的版本和MySQL Server的版本不适配:驱动用的是5.1.48的版本,算5.1.x里比较新的了。通过MySQL命令行连上后,查看了Server的版本是5.7.31。而前不久,我们刚刚接过一个5.7.30版本的,都可以正常访问,所以感觉不像是适配的问题。

抓包

于是我打算通过抓包来看看,总共抓了三次包:
  1. 之前可以成功连接的5.7.30版本MySQL的抓包(命令行)
    1. notion image
  1. 本次成功连接MySQL实例的抓包(命令行)
    1. notion image
  1. 通过datax未成功连接本次MySQL实例的抓包
    1. notion image
再附上后面两次抓包信息里,Server Greeting的响应结果:
notion image
对比上面三次的抓包信息,我们找到了如下差异
  1. 之前成功连接的5.7.30版本MySQL,没有SSL/TLS握手的流程。而另外两次Server Greeting的响应来看,服务端都想要切换到SSL(Switch to SSL after handshake = 1)。
  1. 没有SSL/TLS握手流程,客户端的身份认证请求会直接把账号密码带过去,而有SSL/TLS握手流程的,Login Request(也叫Handshake Response)包里不会带认证信息,而是会等待SSL/TLS通道建立成功之后再传递认证信息
  1. 第二次连接,确实切换到SSL/TLS握手,并且也成功建立了连接,整个链路为Server Greeting、Login Request、Client Hello、Server Hello...
  1. 而异常的请求链路为:Server Greeting、Login Request、之后就发了一个FIN包去中断TCP链接
难道是Server Greeting里MySQL Server给的响应有差别导致了后续走了不同的链路?但是从上面的两次响应来看几乎一模一样。
不过通过这样的对比,我感觉应该是在切换到SSL/TLS加密连接的过程中出了问题。我们尝试在客户端声明禁用加密连接:
jdbc:mysql://xxx:3306/nhtg?characterEncoding=utf8&useSSL=false
禁掉SSL/TLS之后,果然就正常了。不过还有几个疑问没有得到解答,我们来看看:
  1. 为什么第一次连接并没有默认切换到SSL/TLS?
  1. 为什么开启SSL/TLS就不能正常工作了?

为什么第一次连接并没有默认切换到SSL

是否启用加密连接应该是由客户端和服务端共同协商决定的,服务端首先要配置SSL/TLS相关的证书之类的东西,这样才满足使用SSL/TLS的基本条件,然后会在Server GreetingHandshake)包里下发标志位,并且客户端也要启用SSL/TLS(8.0以上的客户端应该是默认启用)。我登录上去之后看了一下MySQL的配置,发现服务端确实配置了SSL:
MySQL [(none)]> show variables like '%ssl%'; +---------------+-----------------+ | Variable_name | Value | +---------------+-----------------+ | have_openssl | YES | | have_ssl | YES | | ssl_ca | ca.pem | | ssl_capath | | | ssl_cert | server-cert.pem | | ssl_cipher | | | ssl_crl | | | ssl_crlpath | | | ssl_key | server-key.pem | +---------------+-----------------+ 9 rows in set, 1 warning (0.01 sec)
而对于mysql-connector-java-5.1.48来说,当MySQL Server的版本大于5.7.0时,且Server配置了SSL/TLS时,就会默认启用加密连接:
// com.mysql.jdbc.MysqlIO#doHandshake void doHandshake(String user, String password, String database) throws SQLException { // 省略部分代码 // Changing SSL defaults for 5.7+ server: useSSL=true, requireSSL=false, verifyServerCertificate=false 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")); } // check SSL availability if (((this.serverCapabilities & CLIENT_SSL) == 0) && this.connection.getUseSSL()) { if (this.connection.getRequireSSL()) { this.connection.close(); forceClose(); throw SQLError.createSQLException(Messages.getString("MysqlIO.15"), SQLError.SQL_STATE_UNABLE_TO_CONNECT_TO_DATASOURCE, getExceptionInterceptor()); } this.connection.setUseSSL(false); } }

为什么开启SSL/TLS就不能正常工作了

那么为什么开启SSL/TLS就不能正常工作了呢?还得先找到报错信息,Datax把异常吃掉了,我们自己用connector(这里用了8.0.16版本,没太大区别)创建连接的时候报了如下异常:
Caused by: javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate) at sun.security.ssl.HandshakeContext.<init>(HandshakeContext.java:171) ~[?:1.8.0_292] at sun.security.ssl.ClientHandshakeContext.<init>(ClientHandshakeContext.java:98) ~[?:1.8.0_292] at sun.security.ssl.TransportContext.kickstart(TransportContext.java:220) ~[?:1.8.0_292] at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:428) ~[?:1.8.0_292] at com.mysql.cj.protocol.ExportControlled.performTlsHandshake(ExportControlled.java:316) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.StandardSocketFactory.performTlsHandshake(StandardSocketFactory.java:188) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.a.NativeSocketConnection.performTlsHandshake(NativeSocketConnection.java:99) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.a.NativeProtocol.negotiateSSLConnection(NativeProtocol.java:352) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.a.NativeAuthenticationProvider.negotiateSSLConnection(NativeAuthenticationProvider.java:777) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.a.NativeAuthenticationProvider.proceedHandshakeWithPluggableAuthentication(NativeAuthenticationProvider.java:486) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.a.NativeAuthenticationProvider.connect(NativeAuthenticationProvider.java:202) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.protocol.a.NativeProtocol.connect(NativeProtocol.java:1452) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.NativeSession.connect(NativeSession.java:165) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.jdbc.ConnectionImpl.connectOneTryOnly(ConnectionImpl.java:955) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:825) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.jdbc.ConnectionImpl.<init>(ConnectionImpl.java:455) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.jdbc.ConnectionImpl.getInstance(ConnectionImpl.java:240) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at com.mysql.cj.jdbc.NonRegisteringDriver.connect(NonRegisteringDriver.java:199) ~[mysql-connector-java-8.0.16.jar!/:8.0.16] at java.sql.DriverManager.getConnection(DriverManager.java:664) ~[?:1.8.0_292] at java.sql.DriverManager.getConnection(DriverManager.java:208) ~[?:1.8.0_292] at org.springframework.jdbc.datasource.DriverManagerDataSource.getConnectionFromDriverManager(DriverManagerDataSource.java:155) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.springframework.jdbc.datasource.DriverManagerDataSource.getConnectionFromDriver(DriverManagerDataSource.java:146) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.springframework.jdbc.datasource.AbstractDriverBasedDataSource.getConnectionFromDriver(AbstractDriverBasedDataSource.java:205) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.springframework.jdbc.datasource.AbstractDriverBasedDataSource.getConnection(AbstractDriverBasedDataSource.java:169) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.springframework.jdbc.datasource.DataSourceUtils.fetchConnection(DataSourceUtils.java:158) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:116) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:79) ~[spring-jdbc-5.2.9.RELEASE.jar!/:5.2.9.RELEASE] at org.mybatis.spring.transaction.SpringManagedTransaction.openConnection(SpringManagedTransaction.java:82) ~[mybatis-spring-1.3.3.jar!/:1.3.3] at org.mybatis.spring.transaction.SpringManagedTransaction.getConnection(SpringManagedTransaction.java:68) ~[mybatis-spring-1.3.3.jar!/:1.3.3]
我们找到相关的代码看看
// sun.security.ssl.HandshakeContext protected HandshakeContext(SSLContextImpl var1, TransportContext var2) throws IOException { this.sslContext = var1; this.conContext = var2; this.sslConfig = (SSLConfiguration)var2.sslConfig.clone(); this.algorithmConstraints = new SSLAlgorithmConstraints(this.sslConfig.userSpecifiedAlgorithmConstraints); this.activeProtocols = getActiveProtocols(this.sslConfig.enabledProtocols, this.sslConfig.enabledCipherSuites, this.algorithmConstraints); if (this.activeProtocols.isEmpty()) { throw new SSLHandshakeException("No appropriate protocol (protocol is disabled or cipher suites are inappropriate)"); } else { // 省略部分代码 } }
看起来应该是没有可用的协议,我们继续往上找,看看这个activeProtocols是怎么计算出来的
public class ExportControlled { private static final String TLSv1 = "TLSv1"; private static final String TLSv1_1 = "TLSv1.1"; private static final String TLSv1_2 = "TLSv1.2"; private static final String[] TLS_PROTOCOLS = new String[] { TLSv1_2, TLSv1_1, TLSv1 }; protected static void transformSocketToSSLSocket(MysqlIO mysqlIO) throws SQLException { SocketFactory sslFact = new StandardSSLSocketFactory(getSSLSocketFactoryDefaultOrConfigured(mysqlIO), mysqlIO.socketFactory, mysqlIO.mysqlConnection); try { mysqlIO.mysqlConnection = sslFact.connect(mysqlIO.host, mysqlIO.port, null); String[] tryProtocols = null; // 可以通过enableTLSProtocols参数来配置可用的TLS协议版本 String enabledTLSProtocols = mysqlIO.connection.getEnabledTLSProtocols(); if (enabledTLSProtocols != null && enabledTLSProtocols.length() > 0) { tryProtocols = enabledTLSProtocols.split("\\\\s*,\\\\s*"); } else if (mysqlIO.versionMeetsMinimum(8, 0, 4) || mysqlIO.versionMeetsMinimum(5, 6, 0) && Util.isEnterpriseEdition(mysqlIO.getServerVersion())) { // 如果mysql server的版本大于等于8.0.4版本 或者 是大于5.6.0的企业版 // 就默认使用三种TLS版本 tryProtocols = TLS_PROTOCOLS; } else { // 其他的版本默认只支持到 TLSv1 and TLSv1.1 tryProtocols = new String[] { TLSv1_1, TLSv1 }; } // 总的tls协议 List<String> configuredProtocols = new ArrayList<String>(Arrays.asList(tryProtocols)); // jvm支持的tls协议 List<String> jvmSupportedProtocols = Arrays.asList(((SSLSocket) mysqlIO.mysqlConnection).getSupportedProtocols()); // 两者取交集,得到可用协议 List<String> allowedProtocols = new ArrayList<String>(); for (String protocol : TLS_PROTOCOLS) { if (jvmSupportedProtocols.contains(protocol) && configuredProtocols.contains(protocol)) { allowedProtocols.add(protocol); } } ((SSLSocket) mysqlIO.mysqlConnection).setEnabledProtocols(allowedProtocols.toArray(new String[0])); } } }
由于目标的MySQL Server版本是5.7.31-log,我们的JDK的版本是1.8.0_333,所以这里最终计算出来的enableProtocols[TLSv1_1, TLSv1]。并且这里取到的enableProtocols并不是所有的可用协议,还需要过滤:
private static List<ProtocolVersion> getActiveProtocols(List<ProtocolVersion> var0, List<CipherSuite> var1, AlgorithmConstraints var2) { boolean var3 = false; ArrayList var4 = new ArrayList(4); Iterator var5 = var0.iterator(); while(true) { while(var5.hasNext()) { ProtocolVersion var6 = (ProtocolVersion)var5.next(); if (!var3 && var6 == ProtocolVersion.SSL20Hello) { var3 = true; } else if (var2.permits(EnumSet.of(CryptoPrimitive.KEY_AGREEMENT), var6.name, (AlgorithmParameters)null)) { // 过滤主要在这一步 boolean var7 = false; EnumMap var8 = new EnumMap(SupportedGroupsExtension.NamedGroupType.class); Iterator var9 = var1.iterator(); while(var9.hasNext()) { CipherSuite var10 = (CipherSuite)var9.next(); if (var10.isAvailable() && var10.supports(var6)) { if (isActivatable(var10, var2, var8)) { var4.add(var6); var7 = true; break; } } else if (SSLLogger.isOn && SSLLogger.isOn("verbose")) { SSLLogger.fine("Ignore unsupported cipher suite: " + var10 + " for " + var6, new Object[0]); } } if (!var7 && SSLLogger.isOn && SSLLogger.isOn("handshake")) { SSLLogger.fine("No available cipher suite for " + var6, new Object[0]); } } } if (!var4.isEmpty()) { if (var3) { var4.add(ProtocolVersion.SSL20Hello); } Collections.sort(var4); } return Collections.unmodifiableList(var4); } }
里面配置了一个被禁用的算法列表,就包含了TLSv1.1TLSv1
notion image
官网的roadmap也能看出来,从jdk8u291版本开始,移除了TLS 1.0和TLS 1.1。禁用的原因就是这两个版本的TLS已经被证明不太安全了。
notion image
那我们是不是可以指定要用的TLS协议版本?
jdbc:mysql://xxx:3306/nhtg?characterEncoding=utf8&enabledTLSProtocols=TLSv1.2
发现如上的配置也可以正常工作,并且建立了SSL通道。当然也有办法来让jdk恢复支持TLS 1.0和TLS 1.1,但是个人觉得不优雅,这里就不展开讲了。

总结

MySQL客户端和服务端在建立连接的时候会协商是否需要升级到SSL/TLS通道,如果需要的话,会沟通好SSL/TLS的协议版本,启动SSL/TLS握手流程。此次我们的MySQL Server已经做好了SSL相关的配置,但是在客户端获取可用协议的时候,由于jdk8u291开始移除了TLS1.0和TLS1.1,导致没有可用协议。最终产生了问题。解决方案可以是:
  1. 客户端显式配置不启用SSL
  1. 客户端显式配置需要使用的SSL/TLS的协议版本

参考

  1. 技术分享 | MySQL : SSL 连接浅析
  1. 故障分析 | Java 连接 MySQL 8.0 排错案例
  1. SSL/TLS协议运行机制的概述
  1. MySQL · 源码分析 · 鉴权过程
  1. Connection Phase
  1. why-can-java-not-connect-to-mysql-5-7-after-the-latest-jdk-update-and-how-should
  1. JDK8版本过高引起MySQL连接失败:javax.net.ssl.SSLHandshakeException: No appropriate protocol
  1. tlsv1-tlsv1-1-disabled-java
MySQL 连接阶段一文说透批量SQL