多項目寫入同一Logback日誌文件導致的滾動混亂問題(修改Logback源碼)

背景:
最近打算將主要幾個項目配置負載均衡策略,由於當前業務用戶不多,不存在併發流量問題,我們目的只是爲了實現不停機部署以及進程級別的故障轉移而已。

通過Jenkins動態傳入端口選項參數,啓動多實例項目,配合nginx的upstream策略將對應域名請求分發到不同端口。當然,首先我們得考慮項目中的服務狀態以及資源共享問題,確保多實例部署不會對業務流程造成影響。

這些操作配置不難,重點還是在於要充分考慮同一項目多實例會不會帶來新的問題。比如,這次我就沒考慮到 日誌共享 時的滾動問題。



一、問題來源

開發環境:

JDK:1.8
操作系統:CentOS 7.4
web框架:SpringBoot 1.5.9
日誌框架:Logback 1.1.11

問題描述:

我們負載均衡的兩個相同項目(端口9990和8099)是用的是同樣的Logback配置,寫入的日誌名稱以及滾定策略都是一模一樣。

兩個項目啓動運行時都沒問題,日誌都是順序合併打印在stdout.log文件中,但是在第二天00:00:00這個時間點後,兩個項目都去嘗試將之前一天的stdout.log改名爲stdout.log.2019-10-28.log,然後再創建新的stdout.log。

最終結果是這樣:
首先日誌文件stdout.log和stdout.log.2019-10-28.log都是正常生成,8099端口這個項目正常在新的stdout.log中打印,但是9990端口的這個項目日誌卻在stdout.log.2019-10-28.log這個文件打印,並且2019-10-28這一天的日誌內容(原stdout.log)消失了,也就是說stdout.log.2019-10-28.log現在只有9990端口29號的日誌內容了,28號的日誌文件都被覆蓋了

這種現象乍一看比較詭異,日誌滾動過程中到底發生了什麼導致這種現象發生呢?我們直接看源碼來分析下吧!

注: 我在此強調一下,跟蹤源碼時不要太摳細節,我們接觸的開源框架背後都是一個團隊數年以上不停迭代更新的產物,蘊含大量的抽象層次、設計模式、功能模塊、歷史兼容等。我們一般只需要聚焦自己的關注點,慢慢展開分析,遇到看不懂的很正常,先不要深挖。


二、源碼跟蹤

在跟蹤源碼之前我們需要明確一點:

並不是每天到達0點時,項目就會自動去重命名備份舊日誌,產生新的日誌。而是每有一條日誌打印時,都會去判斷是否需要滾動,發現滿足條件後才執行滾動操作。也就是第二天0點後的第一條日誌打印時,此刻纔會觸發滾動操作


項目Logback部分配置:

logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <property name="LOG_PATH" value="/home/dev/log/xxx" />

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <File>${LOG_PATH}/stdout.log</File>
        <encoder>
            <pattern>%date [%level] [%thread] %logger{60} [%file : %line] %msg%n</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 添加.gz 歷史日誌會啓用壓縮 大大縮小日誌文件所佔空間 -->
            <fileNamePattern>${LOG_PATH}/stdout.log.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory><!--  保留30天日誌 -->
        </rollingPolicy>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

上面只配置根節點Logger[ROOT]的打印級別(INFO)以及兩個appender,所有類都按照其配置打印日誌。


Debug入口:com.xxx.controller.xxxController:

private static final Logger logger = LoggerFactory.getLogger(this.getClass());

logger.info("Logback源碼跟蹤");

logger.info方法開始debug。


ch.qos.logback.classic.Logger:

public void info(String msg) {
    filterAndLog_0_Or3Plus(FQCN, null, Level.INFO, msg, null, null);
}
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);
}
private void buildLoggingEventAndAppend(final String localFQCN, final Marker marker, final Level level, final String msg, final Object[] params,
                final Throwable t) {
    LoggingEvent le = new LoggingEvent(localFQCN, this, level, msg, t, params);
    le.setMarker(marker);
    callAppenders(le);
}
/**
 * Invoke all the appenders of this logger.
 * 
 * @param event
 *          The event to log
 */
public void callAppenders(ILoggingEvent event) {
    int writes = 0;
    // 這裏會從Logger[com.xxx.controller.xxxController](也就是logger.info入口所在類的Logger)一直往上遍歷(Logger.parent)
    // 也就是從Logger[com.xxx.controller.xxxController] -> Logger[com.xxx.controller] -> Logger[com.xxx] -> Logger[com] -> Logger[ROOT]
    // 判斷每一層Logger的aai屬性是否不爲空,aai指的是AppenderAttachableImpl,這是一個Appender的列表結構,可以包含多個Appender
    // 我們logback-spring.xml文件配置的各種Logger下的appender都存在於AppenderAttachableImpl中
    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);
    }
}

private int appendLoopOnAppenders(ILoggingEvent event) {
	// 由於我們只配置根節點Logger[ROOT]的appender,所以會一直遍歷到Logger[ROOT]時aai纔不爲null,進入aai.appendLoopOnAppenders(event)方法
    if (aai != null) {
        return aai.appendLoopOnAppenders(event);
    } else {
        return 0;
    }
}

ch.qos.logback.core.spi.AppenderAttachableImpl:

/**
 * Call the <code>doAppend</code> method on all attached appenders.
 */
public int appendLoopOnAppenders(E e) {
    int size = 0;
    // 此數組中保存了我們在Logger[ROOT]配置的兩個appender:CONSOLE 和 FILE
    final Appender<E>[] appenderArray = appenderList.asTypedArray();
    final int len = appenderArray.length;
    for (int i = 0; i < len; i++) {
        appenderArray[i].doAppend(e);
        size++;
    }
    return size;
}

我們跟蹤到File appenderdoAppend方法:
ch.qos.logback.core.UnsynchronizedAppenderBase:

public void doAppend(E eventObject) {
    // WARNING: The guard check MUST be the first statement in the
    // doAppend() method.

    // prevent re-entry.
    if (Boolean.TRUE.equals(guard.get())) {
        return;
    }

    try {
        guard.set(Boolean.TRUE);

        if (!this.started) {
            if (statusRepeatCount++ < ALLOWED_REPEATS) {
                addStatus(new WarnStatus("Attempted to append to non started appender [" + name + "].", this));
            }
            return;
        }

        if (getFilterChainDecision(eventObject) == FilterReply.DENY) {
            return;
        }

        // ok, we now invoke derived class' implementation of append
        this.append(eventObject);

    } catch (Exception e) {
        if (exceptionCount++ < ALLOWED_REPEATS) {
            addError("Appender [" + name + "] failed to append.", e);
        }
    } finally {
        guard.set(Boolean.FALSE);
    }
}

不必關心細枝末節,直接進入this.append(eventObject)方法!

在這裏插入圖片描述
ch.qos.logback.core.OutputStreamAppender:

@Override
protected void append(E eventObject) {
    if (!isStarted()) {
        return;
    }

    subAppend(eventObject);
}

終於來到我們相對熟悉點的類了:
ch.qos.logback.core.rolling.RollingFileAppender:

@Override
protected void subAppend(E event) {
    // The roll-over check must precede actual writing. This is the
    // only correct behavior for time driven triggers.

    // We need to synchronize on triggeringPolicy so that only one rollover
    // occurs at a time
    synchronized (triggeringPolicy) {
    	// 判斷是否需要觸發滾動日誌的操作
        if (triggeringPolicy.isTriggeringEvent(currentlyActiveFile, event)) {
            rollover();
        }
    }

    super.subAppend(event);
}

來到了我們自己配置的滾動策略類(重點關注):

在這裏插入圖片描述

ch.qos.logback.core.rolling.TimeBasedRollingPolicy:

public boolean isTriggeringEvent(File activeFile, final E event) {
    return timeBasedFileNamingAndTriggeringPolicy.isTriggeringEvent(activeFile, event);
}

在這裏插入圖片描述

爲了更好分析下面源碼,我們假設現在時間爲:2019年10月31日00:00:01,這時日誌還未滾動(此時日誌目錄下有兩個文件:stdout.log、stdout.log.2019-10-29.log),然後突然來了10月31日第一條日誌打印(即調用了logger.info方法)

ch.qos.logback.core.rolling.DefaultTimeBasedFileNamingAndTriggeringPolicy:

public boolean isTriggeringEvent(File activeFile, final E event) {
    long time = getCurrentTime();	// 獲取當前時間戳(ms),也就是2019年10月31日00:00:01對應的1572451201000
    
    // nextCheck是TimeBasedFileNamingAndTriggeringPolicyBase類的成員變量,表示的是下一次的滾動時間點
    // 此時nextCheck=1572451200000(2019-10-31 00:00:00)
    if (time >= nextCheck) {	// 如果當前時間大於等於該下一次滾動時間點,則執行下面邏輯,此時2019-10-31 00:00:01確實大於2019-10-31 00:00:00,即需要滾動
        Date dateOfElapsedPeriod = dateInCurrentPeriod;	// 上一條日誌的時間,我們假設爲2019-10-30 23:59:59
        addInfo("Elapsed period: " + dateOfElapsedPeriod);
        // 根據上一條日誌時間,算出上一個時間段對應文件名,用於將2019-10-30的stdout.log文件重命名爲stdout.log.2019-10-30.log
        elapsedPeriodsFileName = tbrp.fileNamePatternWithoutCompSuffix.convert(dateOfElapsedPeriod);	
        setDateInCurrentPeriod(time);	// 將dateOfElapsedPeriod更新爲當前日誌時間2019-10-31 00:00:01
        computeNextCheck();	// 計算下次滾動時間點,即將nexCheck更新爲2019-11-01 00:00:00
        return true;
    } else {
        return false;
    }
}

此處的nextCheck比較關鍵,是否需要滾動就看它和當前時間的對比。
該值是由computeNextCheck()方法賦值計算的:

ch.qos.logback.core.rolling.TimeBasedFileNamingAndTriggeringPolicyBase:

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

可以看到該值又是在dateInCurrentPeriod屬性基礎上計算的。
我們再看start()方法如何初始化這些屬性:
在這裏插入圖片描述
可以看到dateInCurrentPeriod取的是stdout.log的最後修改時間!這個非常關鍵,能防止一些奇怪的錯誤。

好的,判斷完滾動後,就要執行真正的滾動邏輯了:

ch.qos.logback.core.rolling.RollingFileAppender:

// 此方法需要同步,因爲它在關閉老文件,然後重新打開新目標文件時需要獨佔訪問
public void rollover() {
    lock.lock();
    try {
    	// 必須確保當前的文件stdout.log已經關閉,因爲在windows下無法對已經打開的文件重命名
        this.closeOutputStream();
        attemptRollover();
        attemptOpenFile();
    } finally {
        lock.unlock();
    }
}

下面來分析最關鍵的attemptRollover()attemptOpenFile()方法:

private void attemptRollover() {
    try {
        rollingPolicy.rollover();
    } catch (RolloverFailure rf) {
        addWarn("RolloverFailure occurred. Deferring roll-over.");
        // we failed to roll-over, let us not truncate and risk data loss
        this.append = true;
    }
}

ch.qos.logback.core.rolling.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.

	// 將上面isTriggeringEvent方法計算得到的elapsedPeriodsFileName賦值到此處,值爲:/home/dev/log/xxx/stdout.log.2019-10-30.log
    String elapsedPeriodsFileName = timeBasedFileNamingAndTriggeringPolicy.getElapsedPeriodsFileName();

	// 只取文件名:stdout.log.2019-10-30.log
    String elapsedPeriodStem = FileFilterUtil.afterLastSlash(elapsedPeriodsFileName);

	// 判斷是否壓縮
    if (compressionMode == CompressionMode.NONE) {
        if (getParentsRawFileProperty() != null) {
        	// 將/home/dev/log/xxx/stdout..log重命名爲/home/dev/log/xxx/stdout.log.2019-10-30.log
            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);
    }
}

注意的是此處的rename方法底層調用的就是JDK自帶File類的renameTo()方法。

此處存在一個坑:

本人在windows(10)、linux(centos7)環境下測試jdk1.8的File-renameTo()方法時,發現執行結果並不相同

其實之前源碼裏的註釋已經稍微暗示過了:
Renaming under windows does not work for open files.

我的測試結果是:
window :
1.在關閉源文件之前,進行重命名操作,返回 false,重命名失敗;
2.目標文件存在時,返回false,重命名失敗。

linux:
1.在關閉源文件之前,進行重命名操作,返回 true,重命名成功;
2.目標文件存在時,返回true,覆蓋已存在的同名目標文件,重命名成功。

其中第一點的話,源碼裏已經強調並做過close工作了:make sure to close the hereto active log file! 所以不會有啥問題。

但第二點我測試時就發現問題,因爲導致多項目共用日誌混亂的直接原因就是rename操作:多次rename滾動日誌。

換句話說,我在我本地IDE調試(windows環境)是不會出現這種bug的,因爲windows環境的rename很嚴格!而linux服務器上的項目就悲劇了。

繼續看源碼:

private void attemptOpenFile() {
    try {
        // 得到當前活躍文件對象,即我們配置文件中指定的\home\dev\log\xxx\stdout.log
        currentlyActiveFile = new File(rollingPolicy.getActiveFileName());	

        // This will also close the file. This is OK since multiple close operations are safe.
        this.openFile(rollingPolicy.getActiveFileName());
    } catch (IOException e) {
        addError("setFile(" + fileName + ", false) call failed.", e);
    }
}
public void openFile(String file_name) throws IOException {
    lock.lock();
    try {
        File file = new File(file_name);
        // 確保stdout.log的父目錄已創建
        boolean result = FileUtil.createMissingParentDirectories(file);
        if (!result) {
            addError("Failed to create parent directories for [" + file.getAbsolutePath() + "]");
        }

        ResilientFileOutputStream resilientFos = new ResilientFileOutputStream(file, append, bufferSize.getSize());
        resilientFos.setContext(context);
        setOutputStream(resilientFos);
    } finally {
        lock.unlock();
    }
}

這些方法顧名思義就是openFile而已,rollover方法中執行了重命名操作,那麼創建新的stdout.log文件並且open,往裏寫日誌就是這裏了!

我們可以發現以上的很多方法中都有線程同步操作,比如lock.lock()synchronized 等,但是對於我們兩個進程級的項目來說,都是徒勞的。所以纔會發生各種意料之外的問題,需要特別注意。

至此,源碼分析完畢。


三、問題產生原因

相信看到這裏,大家已經明白問題中的現象是如何發生的了:

我們假設當前時間爲:2019-10-30 23:59:59,這時日誌目錄裏只存在活躍打開狀態的stdout.log文件,9990和8099端口項目都在往其中寫入日誌。

然後時間來到了 2019-10-31 00:00:02,這時9990端口項目過來了一條日誌打印,我們通過isTriggeringEvent方法進行判斷是否需要滾動,結果滿足滾動條件(time >= nextCheck),於是將nextCheck屬性加1天, stdout.log文件關閉,重命名爲stdout.log.2019-10-30.log,再新建stdout.log文件,往其中寫入日誌。

時間又來到了 2019-10-31 00:00:04,這時8099端口項目過來了一條日誌打印,由於nextCheck屬性都是存在各自內存中,9990項目在滾動時修改了自己的nextCheck,但是8099不知道,所以判斷依舊滿足滾動條件,開始滾動!於是將新的stdout.log文件重命名爲stdout.log.2019-10-30.log,這時9990已經備份過的老的stdout.log.2019-10-30.log就被8099重命名後新的stdout.log.2019-10-30.log覆蓋!然後8099創建屬於自己新的stdout.log文件,往其中寫入日誌。

所以,這就出現了這種奇怪的現象。。


四、解決問題(修改源碼)

其實的話,一般負載均衡都是多機器多實例,不會放在一臺機器上,即使放在一臺機器上,其日誌也是分開打印,然後再採集彙總、過濾、分析等。我們爲了方便強行使其打印在同一個文件中,纔出現問題。

不過,每一步的技術變遷都是隨着業務發展,隨着用戶量、數據量的增加而升級,我們也不會擔心啥,見招拆招罷了。當然,適當的未雨綢繆也是可行的,前提是對業務發展有相對清晰的認識。

那麼,當下的這個問題要如何快速解決呢?我選擇嘗試修改源碼。

大多數開源框架都支持我們繼承某個類,重寫方法修改屬性等,實現我們自定義的功能需求,所謂開閉原則。但是,這得在開源框架開發者的允許範圍內才行。

比如我們想實現分鐘級別的日誌滾動,可以這樣:

public class MyTimeBasedFileNamingAndTriggeringPolicy<E> extends DefaultTimeBasedFileNamingAndTriggeringPolicy<E> {
   //這個用來指定時間間隔
   private Integer multiple = 1;

    @Override
    protected void computeNextCheck() {
        nextCheck = rc.getEndOfNextNthPeriod(dateInCurrentPeriod, multiple).getTime();
    }

    public Integer getMultiple() {
        return multiple;
    }

    public void setMultiple(Integer multiple) {
        if (multiple > 1) {
            this.multiple = multiple;
        }
    }
}

然後將我們自定義的MyTimeBasedFileNamingAndTriggeringPolicy類,配置在logback.xml配置文件中,實現自定義擴展修改功能。

我們可以看到只有在某個類的方法或者屬性是public或者protected時,我們才允許重寫,說明這些屬性方法是開發者允許我們擴展的東西。

在這裏插入圖片描述
而我們這裏問題所需要修改的邏輯,是不在允許範圍裏的,所以我們要做的不是簡單的擴展,而是對源碼進行真正的修改。

修改源碼的方式有:

1.直接將修改後的源碼編譯成class文件,替換jar包裏的對應class文件,再運行即可

2.下載整個源碼,修改後,將其打包上傳到私服,在項目中使用私服地址引入即可

3.在自己代碼的根目錄中添加與開源框架包路徑相同的類,這也是我暫時選擇的簡單方法,具體操作如下:

我要修改ch.qos.logback.core.rolling.TimeBasedRollingPolicy類,於是我在項目目錄src.main.java下創建與com目錄同級的ch.qos.logback.core.rolling.TimeBasedRollingPolicy類,自己就可以隨意修改了。

在這裏插入圖片描述

修改後的 rollover()方法:

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 && !new File(elapsedPeriodsFileName).exists()) {
            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);
    }
}

如上所示,我只是簡單的在重命名之前增加一步判斷:如果已經存在需要重命名的目標文件,就放棄重命名操作。

經過測試,暫時沒啥問題了。。以後有問題再說吧,夜深了。。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章