[bugfix]shardingsphere内部spi与tomcat部署导致的bug
shardingsphere内部spi与tomcat war部署的类加载器层级导致的bug。
起因是部署线上发现接口时好时坏,发现报错日志如下:Cannot support database type 'MySQL'
Caused by: java.lang.UnsupportedOperationException: Cannot support database type 'MySQL'
at org.apache.shardingsphere.sql.parser.core.parser.SQLParserFactory.newInstance(SQLParserFactory.java:55)
at org.apache.shardingsphere.sql.parser.core.parser.SQLParserExecutor.towPhaseParse(SQLParserExecutor.java:55)
at org.apache.shardingsphere.sql.parser.core.parser.SQLParserExecutor.execute(SQLParserExecutor.java:47)
at org.apache.shardingsphere.sql.parser.SQLParserEngine.parse0(SQLParserEngine.java:79)
at org.apache.shardingsphere.sql.parser.SQLParserEngine.parse(SQLParserEngine.java:61)
at org.apache.shardingsphere.underlying.route.DataNodeRouter.createRouteContext(DataNodeRouter.java:97)
at org.apache.shardingsphere.underlying.route.DataNodeRouter.executeRoute(DataNodeRouter.java:89)
at org.apache.shardingsphere.underlying.route.DataNodeRouter.route(DataNodeRouter.java:76)
at org.apache.shardingsphere.underlying.pluggble.prepare.PreparedQueryPrepareEngine.route(PreparedQueryPrepareEngine.java:54)
at org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine.executeRoute(BasePrepareEngine.java:96)
at org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine.prepare(BasePrepareEngine.java:83)
at org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement.prepare(ShardingPreparedStatement.java:183)
at org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement.execute(ShardingPreparedStatement.java:143)
at org.apache.ibatis.executor.statement.PreparedStatementHandler.update(PreparedStatementHandler.java:46)
at org.apache.ibatis.executor.statement.RoutingStatementHandler.update(RoutingStatementHandler.java:74)
at sun.reflect.GeneratedMethodAccessor1037.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
查看代码可以看到是在找不到mysql的SQLParserConfiguration类:
org.apache.shardingsphere.sql.parser.core.parser.SQLParserFactory#newInstance
public static SQLParser newInstance(final String databaseTypeName, final String sql) {
for (SQLParserConfiguration each : NewInstanceServiceLoader.newServiceInstances(SQLParserConfiguration.class)) {
if (each.getDatabaseTypeName().equals(databaseTypeName)) {
return createSQLParser(sql, each);
}
}
throw new UnsupportedOperationException(String.format("Cannot support database type '%s'", databaseTypeName));
}
SQLParserConfiguration是在缓存中获取的:
org.apache.shardingsphere.spi.NewInstanceServiceLoader#newServiceInstances
@SneakyThrows
@SuppressWarnings("unchecked")
public static <T> Collection<T> newServiceInstances(final Class<T> service) {
Collection<T> result = new LinkedList<>();
if (null == SERVICE_MAP.get(service)) {
return result;
}
for (Class<?> each : SERVICE_MAP.get(service)) {
result.add((T) each.newInstance());
}
return result;
}
SQLParserConfiguration是在调用register方法之后通过ServiceLoader放入缓存SERVICE_MAP的:
org.apache.shardingsphere.spi.NewInstanceServiceLoader#register
private static final Map<Class, Collection<Class<?>>> SERVICE_MAP = new HashMap<>();
public static <T> void register(final Class<T> service) {
for (T each : ServiceLoader.load(service)) {
registerServiceClass(service, each);
}
}
@SuppressWarnings("unchecked")
private static <T> void registerServiceClass(final Class<T> service, final T instance) {
Collection<Class<?>> serviceClasses = SERVICE_MAP.get(service);
if (null == serviceClasses) {
serviceClasses = new LinkedHashSet<>();
}
serviceClasses.add(instance.getClass());
SERVICE_MAP.put(service, serviceClasses);
}
而register是在SQLParserFactory静态代码块中被调用的。
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class SQLParserFactory {
static {
NewInstanceServiceLoader.register(SQLParserConfiguration.class);
}
}
排查
首先我们排查org.apache.shardingsphere.sql.parser.mysql.MySQLParserConfiguration存不存在。我们pom.xml里面找到了对应的配置,可以知道MySQLParserConfiguration是必然引入的。从我们有些节点可用,有些接节点不可用可以佐证这一点。
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-sql-parser-mysql</artifactId>
<version>4.1.1</version>
</dependency>
同时我们下载回来war包在lib目录找到了shardingsphere-sql-parser-mysql的jar包。
我们可以保证每个节点都是一致的,因为每个节点使用的是同一个镜像。也就是说理论上每一个节点都是可以加载到MySQLParserConfiguration,而由于某些原因导致有些节点能加载到有些节点加载不到这个类。很显然,如果外部条件没有变化的情况下,那只能是多线程问题。
那为什么有些节点能加载到有些节点加载不到呢?只能是当前类加载器所负责的那片区域下面没有这个类。这就牵扯到tomcat的类加载逻辑了。
为了隔离各个webapp同时共享lib,tomcat在原来java的三层类加载上面扩展了新的类加载器,详情请查看文章。
jdk自带三类类加载器:bootstrap classloader(c/cpp实现)加载jre下的类,ext classloader 加载jre\lib\ext下面的类,app classloader加载classpath下面的类。同时为了保证类唯一性,使用双亲委派模型,先让类加载器的类加载器尝试加载,如果加载不到再由当前类加载器加载。
tomcat是一个servlet引擎,一个web容器,可以同一个引擎下面部署多个webapp(默认放在webapps目录下面),为了隔离各个web服务,需要为每一个web服务都使用一个classloader,保证他们的类是不会共享干扰的。
bootstrap
|_ system
|_ common
|_ Webapp
|_ webapp
对于tomcat,他将jdk的bootstrap和ext classloader合称bootstrap,system也就是jdk的app classloader,在启动的时候会在启动脚本里面设置正确的classpath。common负责加载tomcat下面lib目录的下面的共享类(路径是可以配置的)。而对于每一个webapp,他们都有自己的webapp classlaoder负责加载当前webapp下面的classes和lib目录下面的类。
那么MySQLParserConfiguration没有加载到的原因是没有使用当前webapp classlaoder去加载,不然应当是可以扫描到的。
为什么加载不到呢?因为SPI优先使用当前线程上下文的ClassLoader,实际上破坏了双亲委派模型。
@CallerSensitive
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
}
tomcat用来处理连接的是一个共享的线程池,org.apache.catalina.Executor,它可以在xml里面配置。也就是说的web app的连接绑定的线程都是tomcat容器级别的,tomcat通过解析path路由到不同的webapp处理请求。SPI获取到的当前连接线程的线程上下文的ClassLoader并不是webapp classloader,这就导致了它无法加载到MySQLParserConfiguration类,最终导致了报错不支持MySQL.
当由外部连接先触发SPI加载SQLParserConfiguration实现类的时候,会加载不到缓存内,导致接口失败;当内部初始化任务先触发SPI加载SQLParserConfiguration实现类的时候,由于是web app内部触发的,获取到正确的classloader,可以正确加载到缓存,服务便正常了。
解决
解决这个问题需要保证触发SPI的时候一定从当前上下文获取到webapp classloader。我们这里在Spring的Configuration类上面提前触发加载。
@Slf4j
@Configuration
public class MultiDataSourceConfiguration {
static {
NewInstanceServiceLoader.register(SQLParserConfiguration.class);
}
}
在加载MultiDataSourceConfiguration 这个类的时候,会执行静态代码块里面的代码,触发SPI加载SQLParserConfiguration实现类,MultiDataSourceConfiguration肯定是在初始化webapp初始化阶段由webapp classlaoder加载的,因为他在classes目录下面,且是spring的Configuration类。
注意:
- 开发阶段使用的是springboot内嵌的tomcat,所有类都是走app classloader加载所以无法复现这个问题。
- jdk模块化后,其自带的类加载器变了。因为模块化自带扩展性。