[JAVA][国产化奇奇怪怪的问题]XXL调度/锁表/调度逻辑排查/驱动乱

282 阅读6分钟

背景

  • 生产环境, XXL-JOB, 会假死, 应用还活着 但已经不调度了
  • 数据库为磐维分布式数据

定位

  • 运维排查得知, 假死时数据库存在多xxl_job_info的锁表
  • 存在几十个xxl_job_info的配置, 也不多啊, 总不能几十个xxl_job就有并发卡死这种大bug吧?
    • 但理清楚XXL-JOB原理也好, 等下看看理出来
  1. 梳理XXL-JOB调度逻辑
  • 入口
JobScheduleHelper.getInstance().start();
  • 主流程

目前我的理解到:

(1)最多默认支持6000任务配置

(2)调度有效时间5秒和预读取时间范围一致, 可能是人为规定的当前时间严重超过期望执行时间则放弃本次, 等待下次
不过这个影响不大, 只是个设计, 也可能我还理解不到位

(3)其它场景, 要么轻微过期立刻调度, 要么加入时间轮

  • 时间轮其实有线程安全问题, 时间轮本体是ConcurrentMap没啥问题, 但是!

    • pushTimeRing和时间轮本体while调度执行的remove, 没有考虑极端情况, 比如999ms时执行到pushTimeRing, 然后remove那里正好取走了......吗 ?
      显然xxl-job考虑到了, 所以冗余向前取一个刻度 image.png
    • 接着上一点, 确实考虑了极端整秒的情况, 但如果jobScheduler线程和时间轮线程赶巧了, jobScheduler执行ringData.get后, 时间轮立刻ringData.remove, 这俩同时操作一个list, 非常极端情况下可能出现: 时间轮addAll的不全, jobScheduler正好往后面又追加了几个jobInfo, 此时确实丢了.....

    image.png 但对于半夜的定时任务来说没那么多, 预读取5秒呢, 不可能cost时间足够触发这个场景
    高频的任务丢一个就丢了, 又不是不能用

    • 好, 很好, 锻炼了逻辑分析能力, 但是这并不会导致我们的生产问题......

Suggestion.gif

public void start(){

    // schedule thread
    scheduleThread = new Thread(new Runnable() {
        @Override
        public void run() {

            try {
                TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
            } catch (InterruptedException e) {
                if (!scheduleThreadToStop) {
                    logger.error(e.getMessage(), e);
                }
            }
            logger.info(">>>>>>>>> init xxl-job admin scheduler success.");

            // pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
            //todo 默认预读取:300*20=6000条定时任务配置, 也就是xxl-job默认支持最多6000个配置, 我们显然达不到
            //todo
            int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;

            while (!scheduleThreadToStop) {

                // Scan Job
                long start = System.currentTimeMillis();

                Connection conn = null;
                Boolean connAutoCommit = null;
                PreparedStatement preparedStatement = null;

                boolean preReadSuc = true;
                try {

                    conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
                    connAutoCommit = conn.getAutoCommit();
                    conn.setAutoCommit(false);

                    preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
                    preparedStatement.execute();

                    // tx start

                    // 1、pre read
                    long nowTime = System.currentTimeMillis();
                    //todo 预读取5s内要执行的任务  xxl_job_info.trigger_next_time<=?
                    //todo 可能存在gc导致的时间问题? 不至于吧也不是什么业务服务只是个调度super demo
                    List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
                    if (scheduleList!=null && scheduleList.size()>0) {
                        // 2、push time-ring
                        for (XxlJobInfo jobInfo: scheduleList) {

                            // time-ring jump
                            //todo 有`2个时间段
                            //todo  1   nowTime|--------|           一个是nowTime+PRE_READ_MS : 扫描的范围  默认扫描5秒内,然后更新下次时间
                            //todo  2               |--------|       nowTime 不能大于这个的右侧 否则放弃这次调度,
                            // 但为什么要这么设计呢? 即使依旧正常调度, 不也可以更新下次调度时间然后继续定时扫调度吗?
                            // 感觉这个只是人为定义的5秒就过期, 少一次调度

                            if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
                                // 2.1、trigger-expire > 5s:pass && make next-trigger-time
                                logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());

                                // fresh next
                                refreshNextValidTime(jobInfo, new Date());

                            } else if (nowTime > jobInfo.getTriggerNextTime()) {
                                //todo 当前时间已经过了, 赶紧直接调度
                                // 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time

                                // 1、trigger
                                JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
                                logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );

                                // 2、fresh next
                                refreshNextValidTime(jobInfo, new Date());

                                // next-trigger-time in 5s, pre-read again
                                if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {

                                    // 1、make ring second
                                    int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

                                    // 2、push time ring
                                    pushTimeRing(ringSecond, jobInfo.getId());

                                    // 3、fresh next
                                    refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                                }

                            } else {
                                //todo 没到时间, 加入时间轮
                                // 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time

                                // 1、make ring second
                                int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

                                // 2、push time ring
                                pushTimeRing(ringSecond, jobInfo.getId());

                                // 3、fresh next
                                refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                            }

                        }

                        // 3、update trigger info
                        for (XxlJobInfo jobInfo: scheduleList) {
                            //todo 沿朝定位为这里不断出现锁表
                            //todo 但这玩意是单线程的啊.... 上面调度执行也有分布式锁, 起多少个实例都没用
                            //todo 沿朝说降低cron的频率就好了, 但降不降调度频率, xxl的扫表都是1秒1扫的啊...然后单线程串行调度完执行这个更新
                            //todo 只是说 更多的时间只会是1秒空扫, 每5s扫到调度一批;  然后降低了频率变成了每20秒;  所以这个update的锁5s都不够磐维同步????
                            XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
                        }

                    } else {
                        preReadSuc = false;
                    }

                    // tx stop


                } catch (Exception e) {
                    if (!scheduleThreadToStop) {
                        logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}", e);
                    }
                } finally {

                    //todo 那就只能猜想commit出现问题了?  或者就是分布式节点锁同步有问题?  不可能呀
                    // commit
                    if (conn != null) {
                        try {
                            conn.commit();
                        } catch (SQLException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                        try {
                            conn.setAutoCommit(connAutoCommit);
                        } catch (SQLException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                        try {
                            conn.close();
                        } catch (SQLException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                    }

                    // close PreparedStatement
                    if (null != preparedStatement) {
                        try {
                            preparedStatement.close();
                        } catch (SQLException e) {
                            if (!scheduleThreadToStop) {
                                logger.error(e.getMessage(), e);
                            }
                        }
                    }
                }
                long cost = System.currentTimeMillis()-start;


                // Wait seconds, align second
                //todo cost小于1秒啦, 1秒是最小cron最小间隔, 就主动等等
                if (cost < 1000) {  // scan-overtime, not wait
                    try {
                        // pre-read period: success > scan each second; fail > skip this period;
                        TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
                    } catch (InterruptedException e) {
                        if (!scheduleThreadToStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                }

            }

            logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
        }
    });
    scheduleThread.setDaemon(true);
    scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
    scheduleThread.start();


    // ring thread
    ringThread = new Thread(new Runnable() {
        @Override
        public void run() {

            // align second
            try {
                TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000 );
            } catch (InterruptedException e) {
                if (!ringThreadToStop) {
                    logger.error(e.getMessage(), e);
                }
            }

            while (!ringThreadToStop) {

                try {
                    // second data
                    List<Integer> ringItemData = new ArrayList<>();
                    int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
                    for (int i = 0; i < 2; i++) {
                        List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
                        if (tmpData != null) {
                            ringItemData.addAll(tmpData);
                        }
                    }

                    // ring trigger
                    logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
                    if (ringItemData.size() > 0) {
                        // do trigger
                        for (int jobId: ringItemData) {
                            // do trigger
                            JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                        }
                        // clear
                        ringItemData.clear();
                    }
                } catch (Exception e) {
                    if (!ringThreadToStop) {
                        logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
                    }
                }

                // next second, align second
                try {
                    TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000);
                } catch (InterruptedException e) {
                    if (!ringThreadToStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
            }
            logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
        }
    });
    ringThread.setDaemon(true);
    ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
    ringThread.start();
}
  • 开头提到了, 运维检查发现是数据库锁, 我们理了xxl-job的逻辑其实有些自己的私心啦, 所以看后面的finally的代码, commit, close
  • 锁表会很明显的导向"是不是没commit"这个疑问, 因为国产化嘛呵呵呵呵, 所以点进去看下源码吧
  • 一点就发现问题了, 这两个都是磐维方提供的驱动!!!!!一个集中式的一个分布式的

image.png

  • 检查了nacos配置, 没有用错, 是pg那个分布式的版本驱动

结论

  1. 检查驱动
  • 这里是之前帮助别的省定位问题时遇到过, 所以快速得到结论了, 正常来说下一步是加日志看这个connection的各步骤信息了, 最后才能发现是实际生效的驱动和指定的驱动类不一致
  • 结论为:[JAVA][国产化奇奇怪怪的问题]事后谈-数据库驱动混乱/未按预期连接服务端背景: AndDB和GoldenDB, 原 - 掘金 (juejin.cn)
  • pom顺序符合触发这个问题的条件: 即现加载panwei.Driver(集中式) 后加载的 opengauss(分布式) image.png
  • 还是套壳套的不同驱动不同服务端数据库,能够混乱的握手成功导致的
  • 驱动瞎猫碰死耗子连上了, 但出现些锁的处理的不同也很合理, 对吧! 黑盒的事不要纠结, 拉数据库厂商上会支持就完事了!🤭
  • 把不同分支仅保留自己需要的那个驱动, 收工!

规范总结

  • pom依赖应去除非必要项
  • 数据库驱动应尽早确定分布式/集中式, 并留痕邮件