大量的日志会带来三个——也许是两个——或者一个半问题。

有时候会给排查问题带来一些麻烦,但相比没有日志可查,还是要好太多。所以,这其实不该算一个问题。

极端情况下,海量日志会影响系统性能。这可以通过异步日志等方式解决,必要时可以关闭一些比较次要的、或者已经稳定运行的功能中的日志。其实问题也不大,姑且算半个问题。

此外一个问题,是海量日志会显著的占用硬盘空间。当日志量从50M骤然上升到50G的时候,运维团队一定会来找你的麻烦。如果有日志采集系统,那么用它来采集日志,然后腾空硬盘空间;如果没有……那你就得认真考虑考虑这个问题了。

操作手册

对public方法,在方法开始处用info级别记录入参;在方法返回处用info级别记录入参标记和出参。

如果方法没有入参,取方法中出现的第一个变量作为入参。

入参标记是指“上下文唯一标识”,可参见下文。

如果方法没有出参,可以记录“end”或“结束”。

对friendly/protected/private方法,在方法返回处用info级别记录入参和出参。

这类方法可以只记录一行日志,但要求把入参、出参都记录在内。

捕获到异常,或者发生错误但不需要抛出异常时,用error日志记录入参标记、错误信息;对异常,要求记录异常堆栈。

此类日志要求级别设置为error。

如果有异常,要求用LOGGER.error("fomat string", message, exception)的格式,记录异常堆栈信息。

对其它中间变量,用debug日志记录入参标记和变量值。

“入参标记”即“上下文唯一标识”。

系统间接口数据,用info记录发送数据、接收数据。

对系统间的接口调用,参照public方法来记录日志。

WHY

日志记录的是代码运行时的数据。有了这些数据,在查错时,我们就能将运行时的即时数据(包括但不限于数据库中的某个历史数据、内存中的某个瞬时数据)和代码逻辑结合起来,从而更加轻松、快捷的找到问题所在。

WHEN

日志的“生命周期”有三个比较重要的时间点:开发时记录日志;运行时生成日志;查错时使用日志。这里主要关注开发时记录日志的规范。

在编写代码时,请按照规范记录日志。这样,在运行时、在查错时,可以最大化日志的优势,并最小化日志带来的问题。

HOW

用info级别记录方法入参和出参

代码以方法为基本单位;而一个方法中的最重要数据,就是入参和出参。因此,这两笔数据应当记录下来。并且,为了保证在生产环境上也能记录下这两份数据,入参和出参的日志级别至少应为INFO。

对于没有入参的方法,可以记录下方法体内第一个入参;对于没有出参的方法,可以简单记录“执行完毕”或者“end”。

如以下示例,入参lendRequest、wholeSale,出参product,都记录在了info日志中。这样,如果出参有误,就可以根据入参来推断出错位置了。

题外话,使用单一出口方法,可以更加方便的记录出参。

//info级别入参、出参日志示例
public LoanProduct choose(LendRequest lendRequest, Boolean wholeSale) throws ServiceException {
    LoanProductChooseServiceAsChain.LOGGER.info("lr.id={},wholeSale={}",
    lendRequest.getId(), wholeSale);
    LoanProduct product = // 通过中间步骤,获得出参数据
    LoanProductChooseServiceAsChain.LOGGER.info("lr.id={},product.id={}",
    lendRequest.getId(), product.getId());
    return product;
}

用debug级别记录中间变量

中间变量的值也是查错所需的重要数据,同样需要记录下来。

但是,为了避免生产环境日志量过大,建议使用debug级别来记录这些中间变量的日志。

如以下示例,中间变量product.type,product.period,product.cycle,tmpRate,都记录在了debug日志中。这样,如果在开发或测试时发现correctProduct取值错误,就可以更加精准的根据这些中间变量来判断出错位置了。

// debug级别中间变量日志示例
public LoanProduct choose(LendRequest lendRequest, Boolean wholeSale) {
    // ...
    String tmpRate = this.chooseRate(lendRequest);
    LoanProductChooseServiceAsSkeleton.LOGGER.debug(
        "lr.id={},product.type={},product.period={},product.cycle={},tmpRate={}",
        lendRequest.getId(), product.getType(), product.getPeriod(), product.isCycle(), tmpRate);
    correctProduct = this.loanProductDAO.getCorrectProduct(
        product.getType(), product.getPeriod(),
        newBigDecimal(tmpRate), wholeSale, product.isCycle());
    // ...
    }

用error级别记录异常/错误日志

对查错来说,最重要的就是找到出错点的异常或错误信息了。因此,代码中必须保证把这些信息记录到日志中。所以必须使用较高的级别(至少是error)。

此外,异常/错误日志应当尽可能的记录下与这个异常/错误相关的上下文数据。这些上下文数据代表了“第一犯罪现场”,很可能让你事半功倍地找到“罪犯”。也因为这个原因,异常/错误日志应当在抛出异常的位置(而不是捕获异常的位置)进行记录。

如以下示例,在抛出异常之前,详细的记录下了出错时的进件id,或者相关的还款计划期数。这些数据在抛出异常后,都很难再找到。

关于异常处理的更多要求,参见:异常规范

//error级别记录异常/错误日志示例
if(repayPlan == null) {
    InAdvanceRepayCalculator.logger.error(
        "lr.id={},Plan to repay not found", plans.get(0)
        .getLendRequest().getId());
    throw new InvalidDataException("Plan to repay not found");
} else if(repayPlan.getPhase() == repayPlan.getLendRequest()
    .getPhases()) {
    InAdvanceRepayCalculator.logger.error(
        "repayPlan.getPhase()={},repayPlan.getLendRequest().getPhases()={},Last phase",
        repayPlan.getPhase(), repayPlan.getLendRequest().getPhases());
    throw new InvalidDataException("Last phase");
}

日志中应记录“上下文唯一标识”

web工程是在多线程、多用户环境下运行的。这就要求我们所记录的日志,能够在这种上下文中,梳理出单线程运行、甚至单用户操作的“轨迹”。

虽然这个任务可以通过记录当前线程id来完成,但线程id无法记录用户多次交互的轨迹。

因此,日志中应记录必要的“上下文唯一标识”。

在controller中,我们可以使用当前登陆用户来做“唯一标识”。在service层方法中,我们可以使用某个入参来做“唯一标识”。如果没有入参,可以使用第一个中间变量来充当“入参”。

如下示例,每一行日志中都记录有入参数据“lr.id”。

//日志中记录“上下文唯一标识”
public LoanProduct choose(LendRequest lendRequest, Boolean wholeSale) {
    LoanProductChooseServiceAsSkeleton.LOGGER.info("lr.id={},wholeSale={}",
        lendRequest.getId(), wholeSale);
    LoanProductChooseServiceAsSkeleton.LOGGER.debug(
        "lr.id={},product.id={}", lendRequest.getId(), product.getId());
    LoanProductChooseServiceAsSkeleton.LOGGER.info(
        "lr.id={},chooser={}", lendRequest.getId(), this.getClass());
    LoanProductChooseServiceAsSkeleton.LOGGER
        .debug("lr.id={},product.type={},product.period={},product.cycle={},tmpRate={}",
        lendRequest.getId(), product.getType(),product.getPeriod(), product.isCycle(), tmpRate);
    LoanProductChooseServiceAsSkeleton.LOGGER.info(
        "lr.id={},correctProduct.id={}", lendRequest.getId(), correctProduct.getId());
} 

为复杂对象创建适当的toString()方法

当日志中记录复杂对象时,Jvm往往会自动调用该对象的toString()方法来生成日志字符串。

如果使用默认的toString()方法,日志中会记录类似“Person@182f0db”的字符串。这种日志不包含数据,对我们查错没有什么意义。

注意避免日志中抛出异常

为了避免日志中记录过多的数据(这些“无谓”数据不仅会增加日志文件的大小,而且会增加查找日志的难度),我们常常会在日志中记录某个复杂对象的部分属性。这时,应注意避免日志中抛出NullPointerException异常。

如下示例。在日志中记录product.id之前,判断并处理了product==null的情况。

//避免日志中抛出异常
if(product == null) {
    LoanProductChooseServiceAsChain.LOGGER.debug("lr.id={},product=null", lendRequest.getId());
    product = lendRequest.getLoanProduct();
}
LoanProductChooseServiceAsChain.LOGGER.info("lr.id={},product.id={}",
    lendRequest.getId(), product.getId());

注意避免日志记录影响代码执行效率

日志中不可避免的会出现大量的字符串拼接、文件输出等操作。这些操作都可能影响代码执行效率,进而影响系统性能。

文件输出操作的效率,可以通过配置异步日志等方式进行改进。字符串拼接操作效率,应当在代码中进行优化。

简单的方式,即如下示例,使用形如”LOGGER.info("日志格式",日志数据1,日志数据2...)”的方式记录日志,而不是LOGGER.info("日志格式1"+日志数据1+",日志格式2"+日志数据2...)。

LOGGER.info("打印日志,占位符1:{}, 占位符2:{}",data1,data2);

或者,在每一条日志之前,判断该LOGGER是否支持相应的日志级别。这样的代码……难看得多。

此外,还需要注意日志打印级别对代码执行效率的影响。如果代码中记录日志时的级别过低(低于配置中指定的可打印级别),那么为了拼接日志字符串而付出的性能代价就完全浪费了

// 建议这样记录日志
LoanProductChooseServiceAsSkeleton.LOGGER.debug(
    "lr.id={},product.type={},product.period={},product.cycle={},tmpRate={}",
    lendRequest.getId(), product.getType(),product.getPeriod(), product.isCycle(), tmpRate);

// 不建议这样记录日志
LoanProductChooseServiceAsSkeleton.LOGGER.debug(
    "lr.id="+lendRequest.getId()+",product.type=" + product.getType()+",product.period="
    + product.getPeriod()+",product.cycle=" + product.isCycle()+",tmpRate);

// 除非你愿意这样记录日志
if(LoanProductChooseServiceAsSkeleton.LOGGER.isDebugEnabled()){
    LoanProductChooseServiceAsSkeleton.LOGGER.debug("lr.id="+lendRequest.getId()+",product.type="
        + product.getType()+",product.period="+ product.getPeriod()+",product.cycle="
        + product.isCycle()+",tmpRate);
}

可以参考: Java日志性能