大量的日志会带来三个——也许是两个——或者一个半问题。
有时候会给排查问题带来一些麻烦,但相比没有日志可查,还是要好太多。所以,这其实不该算一个问题。
极端情况下,海量日志会影响系统性能。这可以通过异步日志等方式解决,必要时可以关闭一些比较次要的、或者已经稳定运行的功能中的日志。其实问题也不大,姑且算半个问题。
此外一个问题,是海量日志会显著的占用硬盘空间。当日志量从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日志性能。