在 M1 芯片的机器上搭建 Java 开发环境, 如果要选择 1.8 版本的 JDK, 网上推荐 zulu OpenJDK 的比较多, Oracle 官方没有提供二进制的版本。想着既然能用那就没必要自己去编译,之前试过编译 OpenJDK, 要踩的坑不少, 遂安装之。

今天在启动一个项目的时候, 提示数据库连不上, 从异常类名称初步推断是在建立 SSL 握手阶段遇到协议不匹配的问题了

1
2
3
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)
at sun.security.ssl.ClientHandshakeContext.<init>(ClientHandshakeContext.java:103)

我感到十分诧异, 因为另外一个项目连的是同一个数据库服务器, 只是使用的库不同, 如果是 TLS 协议这种层面的问题, 不应该一个项目正常连接数据库而另外一个项目抛出错误信息。我第一时间想到了 JDBC url 有一个参数 useSSL 可以指定是否使用 TLS 协议建立连接, 果然, 将这个参数改为 useSSL=false 后, 连接正常建立了, 这当然并不奇怪。我又比较了数据库客户端 Dbeaver 的连接参数, 发现了一个跟 TLS 有关的参数 enabledTLSProtocols, 其默认值为 TLSv1,TLSv1.1,TLSv1.2, 我尝试在不改 useSSL 的前提下加入 enabledTLSProtocols=TLSv1,TLSv1.1,TLSv1.2, 结果也可以修复这个问题, 进一步排除, 发现只要加了
enabledTLSProtocols=TLSv1.2 即可。但是, 这些明显都不是最优解, 问题的原因还没有被找到。

我开始比较两个项目的差异。同样都是 Spring Boot 项目, 项目初始化的时候基本都是按照固定的模板搭建和引入依赖的,所以基本上差异很小。这个时候我发现报错的那个项目之前我改造过, 因为有同时连接 MySQL 和 SQL Server 数据库的需求, 我写过一个动态数据源配置, 改变了 Spring 连接数据源的默认行为, 会不会是这个原因呢? 于是我检查了一遍代码, 这边在建立两个 HikariDataSource 对象, 加入到 DynamicDataSource 中… 看到一半, 突然想到, 既然是数据库连不上, 那就写一个只连接数据库, 别的什么都不干的代码, 这样排查起来会方便很多。

Java 中用最少和最依赖最小的代码实现连接数据库, 那自然是借助 JDBC 和 Mysql Connector/J 了

1
2
3
4
5
6
7
8
9
10
11
12
import java.sql.Connection;
import java.sql.DriverManager;

public class Application {
public static void main(String[] args) throws Throwable {
String url = "jdbc:mysql://***.***.***.***:3306/mysql?useSSL=true";
String user = "root";
String password = "********";
Connection connection = DriverManager.getConnection(url, user, password);
connection.close();
}
}

在两个项目中将 main 方法修改为上述代码运行, 结果仍然是一个正常另一个报相同的错误信息无法连接。在确定了两个项目使用的 JDK 相同后, 我基本下定论认为肯定是 Mysql Connector/J 的版本问题了。果然, 查看 IDE 的启动参数发现, 报错的项目指定的 Connector/J 的版本是 mysql-connector-java-8.0.17.jar, 而正常运行的项目指定的版本是更高一点的 mysql-connector-java-8.0.21.jar, 原来是两个项目的 Spring Boot 版本不同, 报错的项目是 2.1.7.RELEASE 依赖的是 8.0.17 版本的 Connector/J 而另一个项目是 2.3.4.RELEASE 依赖的是 8.0.21 版本的 Connector/J。

由于 mysql-connector-java-8.0.21.jar 是 Oracle 提供的开发包, 不方便查看它的源代码去确认它改了什么, 于是我进一步实验, 在 8.0.17 到 8.0.21 这两个版本之间, 一定有一个版本对这个问题做了修复。经过实验得出的结论是: 从 mysql-connector-java-8.0.19.jar 开始, 这个问题不再出现。

一般软件都有版本的 changelog, Connector/J 也不例外。通过搜索引擎找到 Connector/J 8.0.19 的更新日志如下

1
2
3
4
5
The allowable versions of TLS protocol used for connecting to the server, when no restrictions have been set using the connection properties enabledTLSProtocols, have been changed to:

TLSv1, TLSv1.1, TLSv1.2, and TLSv1.3 for MySQL Community Servers 8.0, 5.7.28 and later, and 5.6.46 and later, and for all commercial versions of MySQL Servers.

TLSv1 and TLSv1.1 for all other versions of MySQL Servers.

到这里基本上已经算破案了, 我们似乎有理由相信, 这是一个由 Mysql Connector/J 驱动引起的 bug。

然而, 我忽略了一个基本的事实, 那就是没有进行对照实验。这一切的实验环境都是 zulu OpenJDK , 我原来电脑使用的是 Oracle JDK, 服务器 Docker 容器上用的是 OpenJDK, 对于这个有问题的项目, 之前它们都是正常运行的啊!

于是问题的焦点重新回到 zulu OpenJDK 身上, 一开始猜测是 OpenJDK 和 Oracle JDK 的差异, 但服务器容器上也是 OpenJDK 正常运行, 我自己编译了 OpenJDK 的代码, 在 Ubuntu 机器上也是正常连接数据库。经过对 zulu OpenJDK 和自行编译的 OpenJDK 源码的漫长的断点调试和比对, 确定了问题出现在下面这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
private static List<ProtocolVersion> getActiveProtocols(
List<ProtocolVersion> enabledProtocols,
List<CipherSuite> enabledCipherSuites,
AlgorithmConstraints algorithmConstraints) {
boolean enabledSSL20Hello = false;
ArrayList<ProtocolVersion> protocols = new ArrayList<>(4);
for (ProtocolVersion protocol : enabledProtocols) {
if (!enabledSSL20Hello && protocol == ProtocolVersion.SSL20Hello) {
enabledSSL20Hello = true;
continue;
}

if (!algorithmConstraints.permits(
EnumSet.of(CryptoPrimitive.KEY_AGREEMENT),
protocol.name, null)) {
// Ignore disabled protocol.
continue;
}

boolean found = false;
Map<NamedGroupType, Boolean> cachedStatus =
new EnumMap<>(NamedGroupType.class);
for (CipherSuite suite : enabledCipherSuites) {
if (suite.isAvailable() && suite.supports(protocol)) {
if (isActivatable(suite,
algorithmConstraints, cachedStatus)) {
protocols.add(protocol);
found = true;
break;
}
} else if (SSLLogger.isOn && SSLLogger.isOn("verbose")) {
SSLLogger.fine(
"Ignore unsupported cipher suite: " + suite +
" for " + protocol);
}
}

if (!found && (SSLLogger.isOn) && SSLLogger.isOn("handshake")) {
SSLLogger.fine(
"No available cipher suite for " + protocol);
}
}

if (!protocols.isEmpty()) {
if (enabledSSL20Hello) {
protocols.add(ProtocolVersion.SSL20Hello);
}
Collections.sort(protocols);
}

return Collections.unmodifiableList(protocols);
}

这个 getActiveProtocols() 方法返回当前所支持的协议, zulu OpenJDK 返回一个空的 list 而自行编译的 OpenJDK 返回 TLSv1.0 和 TLSv1.1。

在下面这段判断中, zulu OpenJDK 都是直接走 continue 导致后面的代码都被跳过了

1
2
3
4
5
6
if (!algorithmConstraints.permits(
EnumSet.of(CryptoPrimitive.KEY_AGREEMENT),
protocol.name, null)) {
// Ignore disabled protocol.
continue;
}

走 continue 的原因则是 algorithmConstraints.permits(EnumSet.of(CryptoPrimitive.KEY_AGREEMENT), 'TLSv1.0', null)algorithmConstraints.permits(EnumSet.of(CryptoPrimitive.KEY_AGREEMENT), 'TLSv1.1', null) 均返回 false。这个 permits 返回的是根据协议有没有被禁用来决定的, 协议被禁用返回 false 走 continue 反之不走 continue 继续往下执行。协议有没有被禁用则是根据 CryptoPrimitive.KEY_AGREEMENT 的值去配置文件查询得到的, CryptoPrimitive.KEY_AGREEMENT 代表的是属性 jdk.tls.disabledAlgorithms 的值, 全局搜索在 jre/lib/security/java.security 文件中发现了这个属性的值, zulu OpenJDK 的如下

1
2
3
jdk.tls.disabledAlgorithms=SSLv3, TLSv1, TLSv1.1, RC4, DES, MD5withRSA, \
DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL, \
include jdk.disabled.namedCurves

而编译的 OpenJDK 的配置如下

1
2
3
jdk.tls.disabledAlgorithms=SSLv3, RC4, DES, MD5withRSA, DH keySize < 1024, \
EC keySize < 224, 3DES_EDE_CBC, anon, NULL, \
include jdk.disabled.namedCurves

很明显可以看到, zulu OpenJDK 将 TLSv1 和 TLSv1.1 都加入了禁用列表, 而在没有指定使用 TLSv1.2 的情况下 Java 获取到的协议恰好只有 TLSv1.0 和 TLSv1.1, 这两个都被禁用了, 当然就没有可以使用的协议建立连接了。

后来我试了另一个 OpenJDK 发行版 Temurin 家的 OpenJDK 也是将 TLSv1.0 和 TLSv1.1 直接拉黑了, 目前从 https://www.injdk.cn/ 下载的大多数的 OpenJDK 发行版还是没有禁用这两个协议的。