logback log.info耗时异常,RollingFileAppender+TimeBasedRollingPolicy配置踩坑

我喜欢把核心内容放开头

此次log.info耗时异常升高,是由于日志量过大(5G甚至以上),并且使用同步阻塞的RollingFileAppender+TimeBasedRollingPolicy,导致log.info一直等待日志文件滚动,造成了异常。解决方式很简单:

  1. 修改logback.xml里logger配置,准确定义日志打印的包甚至类,控制日志量
  2. 使用异步非阻塞的Appender,例如:AsyncAppender
  3. 注意appender-file(即日志打印路径)与rollingPolicy-fileNamePattern中的路径是否一致,这次的异常可能与两个路径不同导致,因为底层在滚动旧日志时使用java.io.File#renameTo,这个方法(linux环境下)在路径不同时会移动文件再重命名,耗时明显增长,尤其在容器环境中两个路径挂载在不同的硬盘上更会拉长时间。

背景

公司使用xxl-job框架实现的分布式任务调度。
在这里插入图片描述

xxl-job主要分两块,一个管理端,若干执行器。管理端将任务派发给执行器执行,调度方式是管理端通过http请求执行器的接口实现调度。

现象

自从一次上线后管理端每天00:00的任务会调度失败,且过了00:00之后调度就恢复了。
在这里插入图片描述
调度失败的日志显示是因为接口超时:
在这里插入图片描述

源码解析

  1. logback配置文件加载

配置文件加载与本次的异常无关,只是之前没研究过logback相关的源码,所以这次从头看了一下。

小伙伴们应该都知道logback会默认从工程目录中寻找logback.xml配置,并根据配置加载logger等组件。但是什么时候开始加载logback.xml,logback又是如何配置appender与rollingPolicy的呢?
我将断点打在start:65, TimeBasedRollingPolicy (ch.qos.logback.core.rolling) ,沿着堆栈向上查看。
slf4j作为门面框架通过LoggerFactory.getLogger第一次被调用时初始化整个日志框架。
初始化默认日志上下文:
在这里插入图片描述
默认为logback.
然后ContextInitializer会在本地找logback配置文件:
在这里插入图片描述
这里可以看到logback.xml的加载由Classloader.getResource获取:
在这里插入图片描述

这里引入另一个踩过的坑,如果您使用start.sh等通过java命令启动的项目,需要小心classpath中jar包的顺序,务必将您自己的项目jar放在classpath最前面,否则可能出现由其他jar包中的logback.xml配置日志打印,出现驴唇不对马嘴的日志打印效果。

找到logback.xml之后开始加载配置文件,装配组件:
在这里插入图片描述
在这里插入图片描述
logback选择SAXParser流式处理,xml会被读取成startevent-bodyevent-endevent这样的结构:
在这里插入图片描述
随后根据这些event进行解析

    public void play(List<SaxEvent> aSaxEventList) {
        eventList = aSaxEventList;
        SaxEvent se;
        for (currentIndex = 0; currentIndex < eventList.size(); currentIndex++) {
            se = eventList.get(currentIndex);

            if (se instanceof StartEvent) {
                interpreter.startElement((StartEvent) se);
                // invoke fireInPlay after startElement processing
                interpreter.getInterpretationContext().fireInPlay(se);
            }
            if (se instanceof BodyEvent) {
                // invoke fireInPlay before characters processing
                interpreter.getInterpretationContext().fireInPlay(se);
                interpreter.characters((BodyEvent) se);
            }
            if (se instanceof EndEvent) {
                // invoke fireInPlay before endElement processing
                interpreter.getInterpretationContext().fireInPlay(se);
                interpreter.endElement((EndEvent) se);
            }

        }
    }

例如logger的appender、level参数和logger根据name形成的树状结构都是在StartEvent进行设置的。
而appender的file、filter、encoder、rollingPolicy参数都是在bodyevent进行设置的:

    public void body(InterpretationContext ec, String body) {

        String finalBody = ec.subst(body);
        // get the action data object pushed in isApplicable() method call
        IADataForBasicProperty actionData = (IADataForBasicProperty) actionDataStack.peek();
        switch (actionData.aggregationType) {
        case AS_BASIC_PROPERTY:
        	//这里的setproperty通过反射调用参数对应的setter方法,将参数传递进去
            actionData.parentBean.setProperty(actionData.propertyName, finalBody);
            break;
        case AS_BASIC_PROPERTY_COLLECTION:
            actionData.parentBean.addBasicProperty(actionData.propertyName, finalBody);
            break;
        default:
            addError("Unexpected aggregationType " + actionData.aggregationType);
        }
    }

当然TimeBasedRollingPolicy的fileNamePattern这样设置进来的。
随后到endevent由ch.qos.logback.core.joran.action.NestedComplexPropertyIA.end调用LifeCycle的start方法,这里的nestedcomplexproperty就是配置的TimeBasedRollingPolicy
在这里插入图片描述TimeBasedRollingPolicy.start方法主要做了以下几件事:

  • 初始化时间相关的配置:它会根据配置(如 fileNamePattern)解析并初始化与时间滚动相关的参数。
  • 设置当前活动文件:基于当前时间和配置的文件名模式,start() 方法会计算当前应该使用的日志文件名,并设置该文件为当前活动的日志文件。
  • 处理文件压缩:如果配置了文件压缩(如通过 maxHistory 和 totalSizeCap 参数),start() 方法可能还会设置相关的压缩机制,以便在旧文件不再需要时自动压缩它们以节省空间。
  • 验证配置
  1. 日志打印

日志打印相信大家经常接触,简单看一下源码。
首先在org.slf4j.LoggerFactory#getLogger时,经过了前面logback.xml配置的解析,会通过ch.qos.logback.classic.LoggerContext#getLogger获取logger,注意方法中的while循环:

    @Override
    public final Logger getLogger(final String name) {

        if (name == null) {
            throw new IllegalArgumentException("name argument cannot be null");
        }

        // if we are asking for the root logger, then let us return it without
        // wasting time
        if (Logger.ROOT_LOGGER_NAME.equalsIgnoreCase(name)) {
            return root;
        }

        int i = 0;
        Logger logger = root;

        // check if the desired logger exists, if it does, return it
        // without further ado.
        Logger childLogger = (Logger) loggerCache.get(name);
        // if we have the child, then let us return it without wasting time
        if (childLogger != null) {
            return childLogger;
        }

        // if the desired logger does not exist, them create all the loggers
        // in between as well (if they don't already exist)
        String childName;
        while (true) {
            int h = LoggerNameUtil.getSeparatorIndexOf(name, i);
            if (h == -1) {
                childName = name;
            } else {
                childName = name.substring(0, h);
            }
            // move i left of the last point
            i = h + 1;
            synchronized (logger) {
                childLogger = logger.getChildByName(childName);
                if (childLogger == null) {
                    childLogger = logger.createChildByName(childName);
                    loggerCache.put(childName, childLogger);
                    incSize();
                }
            }
            logger = childLogger;
            if (h == -1) {
                return childLogger;
            }
        }
    }

我们一般在logback中这样配置一个logger,name为我们的包名,while循环里的LoggerNameUtil.getSeparatorIndexOf(name, i)会将name根据‘.’或‘$’进行切割,组成以root为根logger的logger树:

    <logger name="com.abc.xyz" additivity="false" level="info">
        <appender-ref ref="info"/>
        <appender-ref ref="error"/>
    </logger>

在这里插入图片描述

这棵logger树存放在loggerCache中,当我们在类中使用@Slf4j或手写的org.slf4j.LoggerFactory#getLogger获取logger会尝试从loggerCache中获取,没有则新建,并按同样逻辑插入树中。

前期准备总算介绍得差不多了,下面说实际打印

ch.qos.logback.classic.Logger.info调用到ch.qos.logback.classic.Logger.filterAndLog_0_Or3Plus
filterAndLog_0_Or3Plus这个方法做两件事:1.过滤日志等级 2.调用appender打印日志


    /**
     * The next methods are not merged into one because of the time we gain by not
     * creating a new Object[] with the params. This reduces the cost of not
     * logging by about 20 nanoseconds.
     */

    private void filterAndLog_0_Or3Plus(final String localFQCN, final Marker marker, final Level level, final String msg, final Object[] params,
                    final Throwable t) {

        final FilterReply decision = loggerContext.getTurboFilterChainDecision_0_3OrMore(marker, this, level, msg, params, t);

        if (decision == FilterReply.NEUTRAL) {
            if (effectiveLevelInt > level.levelInt) {
                return;
            }
        } else if (decision == FilterReply.DENY) {
            return;
        }

        buildLoggingEventAndAppend(localFQCN, marker, level, msg, params, t);
    }

可以看出logger会沿树遍历parent:


    /**
     * Invoke all the appenders of this logger.
     * 
     * @param event
     *          The event to log
     */
    public void callAppenders(ILoggingEvent event) {
        int writes = 0;
        for (Logger l = this; l != null; l = l.parent) {
            writes += l.appendLoopOnAppenders(event);
            if (!l.additive) {
                break;
            }
        }
        // No appenders in hierarchy
        if (writes == 0) {
            loggerContext.noAppenderDefinedWarning(this);
        }
    }

最终来到目标appender:
在这里插入图片描述
可以看到这里RollingFileAppender处理事件的逻辑,首先锁住triggeringPolicy,当triggeringPolicy判断触发了滚动事件则滚动,最后调用父类向文件中写入日志。

  1. 日志滚动

这里就可以看出RollingFileAppender通过同步调用实现日志滚动和日志打印,而正好TimeBasedRollingPolicy被设置为这样:

 <fileNamePattern>${logPath}/info.log.%d{yyyy-MM-dd}</fileNamePattern>

也就是每天滚动一次,判断是否滚动的逻辑:


ch.qos.logback.core.rolling.DefaultTimeBasedFileNamingAndTriggeringPolicy#isTriggeringEvent
    public boolean isTriggeringEvent(File activeFile, final E event) {
        long time = getCurrentTime();
        if (time >= nextCheck) {
            Date dateOfElapsedPeriod = dateInCurrentPeriod;
            addInfo("Elapsed period: " + dateOfElapsedPeriod);
            elapsedPeriodsFileName = tbrp.fileNamePatternWithoutCompSuffix.convert(dateOfElapsedPeriod);
            setDateInCurrentPeriod(time);
            computeNextCheck();
            return true;
        } else {
            return false;
        }
    }

通过当前时间晚于检查时间点时返回true,而nextCheck则在TimeBasedRollingPolicy#start最后和isTriggeringEvent中确认要滚动后调用computeNextCheck();生成的。

    protected void computeNextCheck() {
        nextCheck = rc.getNextTriggeringDate(dateInCurrentPeriod).getTime();
    }

在这里插入图片描述
可以看到这里生成的就是明天的00:00。

1720022400000是一个根据你给出的fileNamePattern中时间表达式计算出的下一个满足条件的时间戳,
也就解释了为什么只有在00:00时log.info变慢。
再来看一下确认要滚动后做了什么
从RollingFileAppender这边看总共三步:

  1. 停止当前日志输出
  2. 日志滚动
  3. 创建新日志文件

    /**
     * Implemented by delegating most of the rollover work to a rolling policy.
     */
    public void rollover() {
        lock.lock();
        try {
            // Note: This method needs to be synchronized because it needs exclusive
            // access while it closes and then re-opens the target file.
            //
            // make sure to close the hereto active log file! Renaming under windows
            // does not work for open files.
            this.closeOutputStream();
            attemptRollover();
            attemptOpenFile();
        } finally {
            lock.unlock();
        }
    }

TimeBasedRollingPolicy这边日志滚动具体操作:

    public void rollover() throws RolloverFailure {

        // when rollover is called the elapsed period's file has
        // been already closed. This is a working assumption of this method.

        String elapsedPeriodsFileName = timeBasedFileNamingAndTriggeringPolicy.getElapsedPeriodsFileName();

        String elapsedPeriodStem = FileFilterUtil.afterLastSlash(elapsedPeriodsFileName);

        if (compressionMode == CompressionMode.NONE) {
            if (getParentsRawFileProperty() != null) {
                renameUtil.rename(getParentsRawFileProperty(), elapsedPeriodsFileName);
            } // else { nothing to do if CompressionMode == NONE and parentsRawFileProperty == null }
        } else {
            if (getParentsRawFileProperty() == null) {
                compressionFuture = compressor.asyncCompress(elapsedPeriodsFileName, elapsedPeriodsFileName, elapsedPeriodStem);
            } else {
                compressionFuture = renameRawAndAsyncCompress(elapsedPeriodsFileName, elapsedPeriodStem);
            }
        }

        if (archiveRemover != null) {
            Date now = new Date(timeBasedFileNamingAndTriggeringPolicy.getCurrentTime());
            this.cleanUpFuture = archiveRemover.cleanAsynchronously(now);
        }
    }

也是分三步:

  1. 生成滚动后日志文件名
  2. 根据压缩策略进行滚动
  3. 如果设置了maxhistory则删除超出设置的日志

压缩策略CompressionMode是根据fileNamePattern结尾来判断的,一共三种:gz/zip/none


    /**
     * Given the FileNamePattern string, this method determines the compression
     * mode depending on last letters of the fileNamePatternStr. Patterns ending
     * with .gz imply GZIP compression, endings with '.zip' imply ZIP compression.
     * Otherwise and by default, there is no compression.
     * 
     */
    protected void determineCompressionMode() {
        if (fileNamePatternStr.endsWith(".gz")) {
            addInfo("Will use gz compression");
            compressionMode = CompressionMode.GZ;
        } else if (fileNamePatternStr.endsWith(".zip")) {
            addInfo("Will use zip compression");
            compressionMode = CompressionMode.ZIP;
        } else {
            addInfo("No compression will be used");
            compressionMode = CompressionMode.NONE;
        }
    }

gz和zip就不解释了,看一下None,当压缩策略为none时会进行重命名:


    /**
     * A relatively robust file renaming method which in case of failure due to
     * src and target being on different volumes, falls back onto
     * renaming by copying.
     *
     * @param src
     * @param target
     * @throws RolloverFailure
     */
    public void rename(String src, String target) throws RolloverFailure {
        if (src.equals(target)) {
            addWarn("Source and target files are the same [" + src + "]. Skipping.");
            return;
        }
        File srcFile = new File(src);

        if (srcFile.exists()) {
            File targetFile = new File(target);
            createMissingTargetDirsIfNecessary(targetFile);

            addInfo("Renaming file [" + srcFile + "] to [" + targetFile + "]");

            boolean result = srcFile.renameTo(targetFile);

            if (!result) {
                addWarn("Failed to rename file [" + srcFile + "] as [" + targetFile + "].");
                Boolean areOnDifferentVolumes = areOnDifferentVolumes(srcFile, targetFile);
                if (Boolean.TRUE.equals(areOnDifferentVolumes)) {
                    addWarn("Detected different file systems for source [" + src + "] and target [" + target + "]. Attempting rename by copying.");
                    renameByCopying(src, target);
                    return;
                } else {
                    addWarn("Please consider leaving the [file] option of " + RollingFileAppender.class.getSimpleName() + " empty.");
                    addWarn("See also " + RENAMING_ERROR_URL);
                }
            }
        } else {
            throw new RolloverFailure("File [" + src + "] does not exist.");
        }
    }

最终调用的是java.io.File#renameTo,这个方法(linux环境下)在路径不同时会移动文件再重命名,碰巧这次失误将的配置中路径写错,导致日志路径与滚动路径不同,耗时明显增长。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/767119.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

innovus:timing报告的精度如何设置

我正在「拾陆楼」和朋友们讨论有趣的话题&#xff0c;你⼀起来吧&#xff1f; 拾陆楼知识星球入口 innovus设置timing报告精度常用方法: set_global report_precision 6 report_timing&#xff0c;report_net&#xff0c;report_cell_instance_timing&#xff0c;report_cloc…

记录前端发现问题之 mock接口无返回数据导致所有后续接口调用报错:网络异常

1. 背景 就更新了代码&#xff0c;发现新涉及的页面&#xff0c;切换tab 之后会报错网络异常&#xff0c;再次切换其他没涉及的功能页面&#xff0c;继续报错网络异常 测试环境&#xff1a;纯前端代码&#xff0c;后端是前端mock的数据&#xff0c;仅供demo 2. 问题报错 手动…

如何构建智能聊天系统

聊天分为听、思考、读&#xff0c;简单的通过ASR、LLM、TTS三类模型的组合可以实现&#xff0c;最近openai推出支持多模态的GPT-4o模型&#xff0c;可以把三个模型真正融合成在一起。 现在市面上的模型百花齐放&#xff0c;各有所长。要实现可落地的方案&#xff0c;需要结合业…

ffmpeg在powershell和ubuntu终端下的不同格式

在win10下的powershell中&#xff0c;如果想运行一个exe文件&#xff0c;就不能再像cmd命令行一样用名字来直接运行了&#xff0c;否则会提示格式不对。 正确的做法是&#xff1a; . \ffmpeg.exe -re -i video-test.mpr -rtsp_transport tcp -vcodec h264 -f rtsp rtsp://您的…

模拟算法系列|替换所有的问号|提莫攻击|种花问题|Z字形变换|兼具大小写的英文字母|删除字符使频率相同

大家好,我是LvZi,今天带来模拟算法系列|替换所有的问号|提莫攻击|种花问题|Z字形变换|兼具大小写的英文字母|删除字符使频率相同 一.基本概念 模拟算法就是根据题意 模拟出代码的过程,模拟算法的题意往往都很简单,考验的是将思路转化为代码的能力,十分的锻炼代码能力,且能很好…

Zigbee智能家居数据中心:微信小程序实时掌控家居传感器信息

摘要&#xff1a; 本文将介绍如何构建一个基于Zigbee和微信小程序的智能家居网关&#xff0c;实现对家居传感器数据的采集、汇总和展示。用户可通过微信小程序实时查看家中温湿度、光照等环境数据&#xff0c;为智能家居系统提供数据支撑。 关键词&#xff1a; Zigbee&#xf…

信创测试与性能测试的差别是什么?

信创测试和性能测试在多个方面存在显著的区别。 首先&#xff0c;信创测试是一个更为全面和系统的测试过程&#xff0c;它主要针对信创工程项目中的产品、系统等进行测试和验证&#xff0c;以确保其自主可控和满足性能要求。这包括适配测试、功能测试、性能测试、安全测试、兼…

Spring Boot集成geode快速入门Demo

1.什么是geode&#xff1f; Apache Geode 是一个数据管理平台&#xff0c;可在广泛分布的云架构中提供对数据密集型应用程序的实时、一致的访问。Geode 跨多个进程汇集内存、CPU、网络资源和可选的本地磁盘&#xff0c;以管理应用程序对象和行为。它使用动态复制和数据分区技术…

【postgresql】索引

见的索引类型&#xff1a; B-tree 索引&#xff1a;这是最常用的索引类型&#xff0c;适用于大多数查询。B-tree索引可以高效地处理范围查询。 Hash 索引&#xff1a;适用于等值查询&#xff0c;但不支持范围查询。 GiST 索引&#xff1a;通用搜索树&#xff08;GiST&#xf…

Django学习第二天

启动项目命令 python manage.py runserver 动态获取当前时间 javascript实现数据动态更新代码 <script>setInterval(function() {var currentTimeElement document.getElementById(current-time);var currentTime new Date();currentTimeElement.textContent Curren…

基于Java废物回收机构管理系统详细设计和实现(源码+LW+调试文档+讲解等)

&#x1f497;博主介绍&#xff1a;✌全网粉丝10W,CSDN作者、博客专家、全栈领域优质创作者&#xff0c;博客之星、平台优质作者、专注于Java、小程序技术领域和毕业项目实战✌&#x1f497; &#x1f31f;文末获取源码数据库&#x1f31f; 感兴趣的可以先收藏起来&#xff0c;…

基于康养大模型和健康设备平台的智能蓝牙语音合成芯片VTX326

AI健康监护智能体是基于康养大模型和**智能语音芯片技术的健康设备平台&#xff0c;**旨在应对我国日益严峻的老龄化挑战。当前&#xff0c;中国总人口约为14.12亿&#xff0c;其中60周岁及以上老年人口占比19.8%&#xff0c;65周岁及以上老年人口占比14.9%&#xff0c;且老年人…

苹果p12证书最简单最新申请流程

使用uniapp打包&#xff0c;在ios上打正式包需要苹果的p12证书和证书profile文件&#xff0c;点进去uniapp的ios证书申请教程&#xff0c;通篇就是使用mac电脑申请的教程&#xff0c;假如没有mac电脑就无法继续了。 因此&#xff0c;假如没有mac电脑的同志们&#xff0c;可以参…

MATLAB—— 流程语句(1)

一、if elseif else end 语句 例子 x 88; % x表示成绩 if x>90 && x < 100 dj 1; % 等级为1级 elseif x>80 && x < 90 dj 2; % 等级为2级 elseif x>60 && x < 80 dj 3; % 等级为…

第4章 第一个程序

第4章 第一个程序 4.1 一个源程序从写出到执行的过程 第一步&#xff1a;编写汇编程序第二步&#xff1a;对源程序进行编译连接第三步&#xff1a;执行可执行文件中的程序 4.2.源程序 汇编语言中包含两种指令&#xff1a;汇编指令 和 伪指令 汇编指令&#xff1a;有对应机器…

中国国产AI芯片的崛起

一、CUDA的垄断 当讨论半导体行业面临的挑战时&#xff0c;你首先想到的是什么&#xff1f;光刻机&#xff1f;3纳米或者5纳米技术&#xff1f;我们无法生产的完美方形芯片&#xff1f;是的&#xff0c;但也不完全是。 人们经常把半导体芯片归类为硬件产业&#xff0c;但实际上…

C语言----文件操作

1.为什么使用文件&#xff1f; 如果没有⽂件&#xff0c;我们写的程序的数据是存储在电脑的内存中&#xff0c;如果程序退出&#xff0c;内存回收&#xff0c;数据就丢失了&#xff0c;等再次运⾏程序&#xff0c;是看不到上次程序的数据的&#xff0c;如果要将数据进⾏持久化…

递归(二)—— 初识暴力递归

如何理解暴力递归&#xff1f; 字面意思就是——“暴力的递归”&#xff0c;就是——“别纠结细节&#xff0c;开整&#xff08;递归&#xff09;&#xff01;” 暴力递归就是尝试。即&#xff1a;只要满足递归条件就不断的递归下去&#xff0c;直到达到base case&#xff0c…

力扣习题--哈沙德数

一、前言 本系列主要讲解和分析力扣习题&#xff0c;所以的习题均来自于力扣官网题库 - 力扣 (LeetCode) 全球极客挚爱的技术成长平台 二、哈沙德数 1. 哈沙德数 如果一个整数能够被其各个数位上的数字之和整除&#xff0c;则称之为 哈沙德数&#xff08;Harshad number&…

LeetCode刷题之HOT100之除自身以外数组的乘积

2024 7/3 今天天气依旧很好&#xff0c;想起来做一题。 1、题目描述 2、算法分析 给定一个数组&#xff0c;要返回初自身以外数组的乘积。咋做呢&#xff1f;是的&#xff0c;我只能想到暴力解法&#xff0c;这不符合时间复杂度O(n)的要求&#xff0c;所以我只能看一下题解了…