目录

问题描述

问题

排查思路

确认Listener状态

Java异常体系

捕获Throwable

ThreadPoolExecutor的线程无故丢失问题

Java Heap OOM

解决方案:


问题描述

    代码不方便展示,只大概介绍一下sqlserver cdc的实现原理:

    源码使用的是FlinkS,并对其sqlserver-connector进行的改造,其中SqlServer CDC的设计模式为生产者/消费者模型,通过一个LinkedBlockingQueue作为Channel。

    消费者:Flink InputFormat的主线程负责从Channel中消费CDC捕获的数据,使用的方法是poll(timeOfMilis)。

    生产者:Flink InputFormat在openInternal()方法中,通过java的ThreadExecutorPool创建了一个core size和max size都为1的线程池,用于启动CDC监听线程并捕获数据,写入Channel中。这块的源码如下:

@Override
    protected void openInternal(InputSplit inputSplit) {
        ThreadFactory namedThreadFactory =
                new ThreadFactoryBuilder().setNameFormat("cdcListener-pool-%d").build();
        executor =
                new ThreadPoolExecutor(
                        1,
                        1,
                        0L,
                        TimeUnit.MILLISECONDS,
                        new LinkedBlockingQueue<>(1024),
                        namedThreadFactory,
                        new ThreadPoolExecutor.AbortPolicy());
        queue = new LinkedBlockingDeque(1000);
        executor.submit(new SqlServerCdcListener(this));
        running = true;
        LOG.info("SqlserverCdcInputFormat[{}]open: end", jobName);
    }

    SqlServerCdcListener:启动了一个死循环,不断的定时轮询变化的数据,并写入到Channel中,大致的源码如下:

@Override
    public void run() {
        LOG.info("SqlServerCdcListener start running.....");
        try {
            while (true) {
                try {
                    //CDC的相关逻辑
                } catch (Exception e) {
                    String errorMessage = ExceptionUtil.getErrorMessage(e);
                    LOG.error(errorMessage, e);
                }
            }
        } catch (Exception e) {
            String errorMessage = ExceptionUtil.getErrorMessage(e);
            LOG.error(errorMessage, e);
        }
    }

问题

    在提交给Yarn后,经过不到1分钟的时间,就发现SqlServerListener不再输入任何日志信息,生产者挂掉

排查思路

确认Listener状态

    通过Flink的WEB UI,查看TaskMananger的Thread dump,如下图:

flinkcdc处理mysql数据demo flink cdc sqlserver_java

注意看上图的红色字体,上面的截图是正常工作的线程堆栈,当存在问题时,实际的堆栈信息如图中文字描述。

    cdcListener-pool-0是之前创建的ThreadPoolExecutor创建出来的线程名称,其状态为WAITING,而stackTrace显示的是ThreadExecutorPool阻塞在获取下一个Task Thread中,因此说明SqlServerListener线程已经工作结束

    而对于SqlServerListener.run(),其内部已经捕获了所有的Exception,而Flink TaskMananger中没有任何的ERROR日志,因此怀疑run()方法中抛出了Exception以外的异常。

Java异常体系

    这里需要了解一个知识点,java的异常体系,是一种继承关系,或者看作一颗树形结构,位于Root节点的是java.lang.Throwable,Root节点的子节点有两个:java.lang.Error和Java.lang.Exception。

    通常java.lang.Error不会由编码部分抛出,而是由JVM抛出。

捕获Throwable

    通过上面的java异常体系,可以猜测SqlServerListener.run()可能抛出了Throwable或者Error,因此尝试通过设置currentThread.setUncaughtExceptionHandler(handler)来捕获没有被try-catch捕获到的异常,大概的源码如下:

Thread t = new Thread(()->{
           Thread.currentThread().setUncaughtExceptionHandler((t1, e) -> {
               System.out.println("获取线程未捕获的异常:"+e.getMessage());
           });
           throw new RuntimeException("aaa");
        });
        t.start();

    重启Flink任务,观察TaskMannager的Log,居然没有任何的异常信息输出,要知道,这个UncaughtExceptionHandler可是处理所有的Throwable的,这是什么原因???

ThreadPoolExecutor的线程无故丢失问题

    通过debug,发现ThreadPoolExecutor确实没有抛出异常,因为ThreadPoolExecutor的

flinkcdc处理mysql数据demo flink cdc sqlserver_flink_02

方法中,Throwable是null,下面看一下ThreadPoolExecutor对这个方法的调用:

//这个方法就是ThreadPoolExecutor真正执行Thread.run()方法的地方
final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;
        w.firstTask = null;
        w.unlock(); // allow interrupts
        boolean completedAbruptly = true;
        try {
            while (task != null || (task = getTask()) != null) {
                w.lock();
                if ((runStateAtLeast(ctl.get(), STOP) ||
                     (Thread.interrupted() &&
                      runStateAtLeast(ctl.get(), STOP))) &&
                    !wt.isInterrupted())
                    wt.interrupt();
                try {
                    beforeExecute(wt, task);
                    Throwable thrown = null;
                    try {
                        //NOTE:这里最终调用的就是SqlServerListener.run()方法
                        task.run();
                    } catch (RuntimeException x) {
                        thrown = x; throw x;
                    } catch (Error x) {
                        thrown = x; throw x;
                    } catch (Throwable x) {
                        thrown = x; throw new Error(x);
                    } finally {
                        //NOTE:线程池在线程执行完后,会调用after方法
                        afterExecute(task, thrown);
                    }
                } finally {
                    task = null;
                    w.completedTasks++;
                    w.unlock();
                }
            }
            completedAbruptly = false;
        } finally {
            processWorkerExit(w, completedAbruptly);
        }
    }

     从上图可以看出,ThreadPoolExecutor如果执行了SqlServerListener.run()方法,那么理论上通过UncaughtExceptionHandler和afterExecute方法都应该能正常获取到Throwable对象,但实际上这两个方法都没有获取到Throwable

    导致这个的原因,出在ThreadPoolExecutor.submit(Runnable)方法上,其出问题的源码如下:

public Future<?> submit(Runnable task) {
        if (task == null) throw new NullPointerException();
        //NOTE:线程池对我们的Runnable进行了包装
        RunnableFuture<Void> ftask = newTaskFor(task, null);
        execute(ftask);
        return ftask;
    }

    //包装类的run()方法,ThreadPoolExecutor就是调用的这个方法
    public void run() {
        if (state != NEW ||
            !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                         null, Thread.currentThread()))
            return;
        try {
            Callable<V> c = callable;
            if (c != null && state == NEW) {
                V result;
                boolean ran;
                try {
                    result = c.call();
                    ran = true;
                } catch (Throwable ex) {
                    //NOTE:这里并没有向上抛出任何异常!!!!
                    result = null;
                    ran = false;
                    setException(ex);
                }
                if (ran)
                    set(result);
            }
        } finally {
            runner = null;
            int s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }

    上面的源码说明,ThreadPoolExecutor.submit()提交的任务,其对Runnable对象的wrapper class,吃掉了所有的Throwable,只能通过Future.getException()获取,这就导致了UncaughtExceptionHandler和ThreadPoolExecutor.afterExecute()无法获取Throwable问题。

Java Heap OOM

    上面知道了异常被吞掉后,直接修改SqlServerListener.run()方法,使用try-catch捕获Throwable,重启后发现输出了java heap out of memory错误。

    异常堆栈显示,问题出在SqlServer驱动上,具体问题如下:java - Connection to MSSQL consumes too much memory - Stack Overflow 

    而通过Using adaptive buffering - JDBC Driver for SQL Server | Microsoft Docs 其原文有一段:

  • Avoid executing more than one statement on the same connection simultaneously. Executing another statement before processing the results of the previous statement may cause the unprocessed results to be buffered into the application memory.

    微软原文说明,同一个connection同时打开多个statement的时候,可能导致查询结果提前被缓存在JVM中,通过测试代码,复现问题:

public class SqlServerTest {
    public static void main(String[] args) throws ClassNotFoundException {
        String sql = "select * from table1";
        Class.forName("com.microsoft.sqlserver.jdbc.SQLServerDriver");
        try(Connection connection = DriverManager.getConnection(
                "jdbc:sqlserver://xxxxxxxxxx",
                "username","password");
            ) {
            PreparedStatement preparedStatement1 = connection.prepareStatement(sql);
            ResultSet resultSet1 = preparedStatement1.executeQuery();
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            ResultSet resultSet = preparedStatement.executeQuery();
            int i = 0;
            while(resultSet.next()) {
                i++;
                if(i%100==0) {
                    System.out.println(i);
                }
            }
        } catch (SQLException sqlException) {
            sqlException.printStackTrace();
        }
    }
}

设置JVM Options:-Xmx512m,运行代码后,会发现堆内存无法回收,线性增长。

解决方案:

    SqlServer官方给出的方案(二选一):

  1. jdbc url增加selectMethod=cursor,例如:
1. jdbc:sqlserver://127.0.0.1;DatabaseName=test;selectMethod=cursor;这个方案支持同一个Connection创建多个Statement,但是有弊端: if your application routinely processes short result sets with a few rows, creating, reading, and closing a server cursor for each result set will use more resources on both client-side and server-side than is the case where the selectMethod is not set to cursor。
  1. 禁止代码中出现同一个Connection创建多个Statement。