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类。

注意:

  1. 开发阶段使用的是springboot内嵌的tomcat,所有类都是走app classloader加载所以无法复现这个问题。
  2. jdk模块化后,其自带的类加载器变了。因为模块化自带扩展性。

标签: none

添加新评论