Java开发记录日志的正确方法和最佳实践详解

后端 潘老师 2周前 (04-08) 18 ℃ (0) 扫码查看

Java开发日志记录是一项极为重要的技能,但令人惊讶的是,很多程序员在这方面存在欠缺。有的是主观上不想打日志,有的是没有意识到打日志的重要性,还有一部分是真的不知道该如何正确打日志。

在之前的模拟面试中,我询问了几位应届Java开发同学在项目里打日志的情况,得到的回应大多含糊不清,甚至有人表示直接用System.out.println()打印一下就完事。但实际上,日志在系统出现问题时,是快速定位错误的关键工具。要是没有详细的日志信息,一旦程序报错,开发者往往会陷入不知所措的困境。而且,日志还能记录业务信息,比如用户的每一步操作,这不仅有助于分析和优化系统,在遇到非法操作时,也能凭借日志快速锁定问题源头。鉴于此,本文将分享Java开发中记录日志的方法和最佳实践,希望能给大家带来帮助。

一、日志记录的方法

(一)日志框架选型

在Java开发里,有不少日志框架和工具库能助力我们高效完成日志记录,用一行代码就能搞定。在学习专业日志框架之前,很多人习惯用System.out.println来输出信息调试程序,它确实简单方便。然而,这种方式存在严重弊端。System.out.println是同步方法,每次调用都会触发I/O操作,非常耗时。要是频繁使用,会对应用程序的性能造成极大影响,所以在生产环境中并不适用。而且,它只能将简单信息输出到标准控制台,无法灵活设置日志级别、格式和输出位置等关键参数。

因此,我们通常会选用专业的Java日志框架或工具库,像经典的Apache Log4j及其升级版Log4j 2,还有Spring Boot默认集成的Logback库。这些框架不仅能让我们更快捷地记录日志,还具备灵活调整格式、设置日志级别、将日志写入文件以及压缩日志等功能。

还有个叫SLF4J(Simple Logging Facade for Java)的工具,从名字就能看出,它并非具体的日志实现,而是为各种日志框架提供统一接口的日志门面(也就是抽象层)。打个比方,当我们要记录日志时,先找到SLF4J这个“前台接待”,它会让我们选择日志级别(如debug、info、warn、error)并提供日志内容。确认后,SLF4J自己并不实际处理,而是把任务交给具体的日志实现框架,比如Logback,由Logback来完成日志写入操作。

这种设计的好处在于,无论选择哪种日志框架,或者后期需要更换框架,调用日志的方法始终保持一致,无需修改代码,比如不用把log.info改成log.printInfo

既然SLF4J只是起到抽象和转接的作用,那么在Log4j、Log4j 2和Logback之间该如何抉择呢?值得一提的是,SLF4J、Log4j和Logback都是俄罗斯程序员Ceki Gülcü的作品。首先,Log4j已经停止维护,基本可以排除。Log4j 2和Logback在功能上都能满足常见需求,主要从性能、稳定性和易用性方面进行考量。

从性能来看,Log4j 2和Logback都支持异步日志,但Log4j 2基于LMAX Disruptor高性能异步处理库实现,性能更胜一筹。在稳定性方面,虽然这些日志库都曾被曝出漏洞,但Log4j 2的漏洞相对更严重,在这一点上Logback略占优势。易用性上,二者相差不大,不过Logback是SLF4J的原生实现,而Log4j 2需要额外使用SLF4J绑定器。再加上Spring Boot默认集成Logback,如果没有特殊的性能要求,对于初学者而言,Logback是更合适的选择,连额外的库都不用引入。

(二)使用日志框架

日志框架的使用并不复杂,一般要先获取Logger日志对象,然后调用logger.xxx(比如logger.info)就能输出日志。传统做法是通过LoggerFactory手动获取Logger,示例代码如下:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyService {
    private static final Logger logger = LoggerFactory.getLogger(MyService.class);

    public void doSomething() {
        logger.info("执行了一些操作");
    }
}

在这段代码里,通过调用日志工厂并传入当前类,创建了一个logger。但由于每个类都要写这么一行代码,复制过程中很容易忘记修改类名。所以,我们可以用this.getClass动态获取当前类的实例来创建Logger对象:

public class MyService {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    public void doSomething() {
        logger.info("执行了一些操作");
    }
}

每个类都复制这行代码,就能顺利记录日志了。不过,还有更简便的方法,借助Lombok工具库提供的@Slf4j注解,可以自动为当前类生成一个名为log的SLF4J Logger对象,简化了Logger的定义过程。示例代码如下:

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MyService {
    public void doSomething() {
        log.info("执行了一些操作");
    }
}

这也是我比较推荐的方式,能有效提高开发效率。

另外,通过修改日志配置文件(如logback.xmllogback-spring.xml),可以设置日志输出的格式、级别、输出路径等。日志配置文件的语法比较繁杂,不用强行记忆,需要用时查阅即可。以logback.xml为例:

<?xml version="1.0" encoding="UTF-8"?>
<!--日志级别从低到高分为TRACE<DEBUG<INFO <WARN <ERROR <FATAL,如果设置为WARN,则低于WARN的信息都不会输出-->
<!--scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true-->
<!--scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为-->
<!-- debug:当此属性设置为true时, 将打印出Logback内部日志信息,实时查看Logback运行状态。默认值为false。-->
<configuration scan="true" scanPeriod="10 seconds">
    <!--name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到Logger上下文中。定义变量后,可以使"${}"来使用变量。-->
    <property name="log.path" value="Logs"/>
    <!--日志最大的历史 30天-->
    <property name="maxHistory" value="30"/>
    <!--单个日志文件大小-->
    <property name="totalSizeCap" value="5GB"/>
    <property name="maxFileSize" value="30MB"/>
    <!--控制台输出日志-->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.sss] [%X{traceId}] [%thread] %highlight (%-5Level) %msg%n</pattern>
            <charset class="java.nio.charset.Charset">UTF-8</charset>
        </encoder>
    </appender> 

二、日志记录的最佳实践

掌握了日志记录的基本方法后,下面分享一些实际开发中记录日志的经验技巧。这些内容较多,大家可以先了解,开发时根据实际需求选用。

(一)合理选择日志级别

日志级别用于标识日志的重要程度,常见的级别有TRACE、DEBUG、INFO、WARN、ERROR、FATAL。TRACE是最详细的信息,一般仅在开发阶段用于跟踪程序执行路径;DEBUG用于记录调试信息,比如程序运行时的内部状态和变量值;INFO记录系统关键运行状态和业务流程;WARN表示可能存在潜在问题,但系统仍能继续运行;ERROR表示出现了影响系统功能的问题,需要及时处理;FATAL则代表致命错误,意味着系统可能无法继续运行,必须立即关注。实际开发中,DEBUG、INFO、WARN和ERROR这几个级别使用频率较高。

建议在开发环境使用低级别日志(如DEBUG),这样能获取详细信息,便于调试。而在生产环境,为减少日志量,降低性能开销,避免重要信息被大量无用日志淹没,通常使用高级别日志(如INFO或WARN)。需要注意的是,日志级别并非固定不变。如果程序出错但从现有日志中找不到有效信息,可能就需要降低日志输出级别来排查问题。

(二)正确记录日志信息

当日志内容包含变量时,推荐使用参数化日志,即在日志信息中使用占位符(如{}),由日志框架在运行时替换为实际参数值。比如记录用户登录日志:

// 不推荐
logger.debug("用户ID:" + userId + " 登录成功。");

// 推荐
logger.debug("用户ID:{} 登录成功。", userId);

这种方式不仅让日志更加清晰易读,而且当日志级别低于当前记录级别时,不会执行字符串拼接操作,避免了字符串拼接带来的性能开销以及潜在的NullPointerException问题。所以,建议在所有日志记录中都采用参数化方式替代字符串拼接。

在输出异常信息时,最好同时记录上下文信息和完整的异常堆栈信息,方便排查问题,示例如下:

try {
    // 业务逻辑
} catch (Exception e) {
    logger.error("处理用户ID:{} 时发生异常:", userId, e);
}

(三)控制日志输出量

过多的日志会占用大量磁盘空间,增加系统I/O负担,影响系统性能。除了根据环境设置合适的日志级别外,还要避免在循环中频繁输出日志。可以添加条件进行控制,例如在批量处理时,每处理1000条数据记录一次日志:

if (index % 1000 == 0) {
    logger.info("已处理 {} 条记录", index);
}

或者在循环中利用StringBuilder进行字符串拼接,循环结束后统一输出:

StringBuilder logBuilder = new StringBuilder("处理结果:");
for (Item item : items) {
    try {
        processItem(item);
        logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
    } catch (Exception e) {
        logBuilder.append(String.format("失败[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
    }
}
logger.info(logBuilder.toString());

如果参数的计算开销较大,且当前日志级别不需要输出,应在记录前进行级别检查,避免多余的参数计算:

if (logger.isDebugEnabled()) {
    logger.debug("复杂对象信息:{}", expensiveToComputeObject());
}

此外,还能通过更改日志配置文件,整体过滤掉特定级别的日志,防止日志刷屏,例如:

<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
    <!-- 只允许 INFO 级别及以上的日志通过 -->
    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
        <level>INFO</level>
    </filter>
    <!-- 配置其他属性 -->
</appender>

(四)把控时机和内容

很多开发者,尤其是线上经验不足的,没有养成记录日志的习惯,觉得日志不重要,等到出问题无法排查时才后悔。一般来说,在系统的关键流程和重要业务节点都需要记录日志,像用户登录、订单处理、支付等核心业务,建议详细记录。

对于重要方法,在入口和出口记录关键参数和返回值,方便快速还原现场、复现问题。对于调用链较长的操作,要确保每个环节都有日志,便于定位问题所在。如果不想区分这么多情况,前期可以多记录一些日志,后期再逐步移除不需要的部分。比如利用AOP切面编程,在每个业务方法执行前输出执行信息:

@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* com.example.service..*(..))")
    public void logBeforeMethod(JoinPoint joinPoint) {
        Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
        logger.info("方法 {} 开始执行", joinPoint.getSignature().getName());
    }
}

利用AOP还能自动打印每个Controller接口的请求参数和返回值,这样就不会遗漏任何调用信息。但要注意,千万不要在日志中记录敏感信息,比如用户密码。一旦日志泄露,大量用户信息就会面临风险。

(五)日志管理

随着日志文件不断增多,可能会耗尽磁盘空间,影响系统正常运行,所以需要制定日志管理策略。

首先是设置日志的滚动策略,可以根据文件大小或日期自动切分日志文件。比如按文件大小滚动:

<!-- 按大小滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
    <maxFileSize>10MB</maxFileSize>
</rollingPolicy>

当日志文件大小达到10MB时,Logback会将当前日志文件重命名为app.log.1(具体命名模式由配置决定),并创建新的app.log文件继续写入日志。

也可以按照时间日期滚动:

<!-- 按时间滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
    <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>

上述配置表示每天创建一个新的日志文件,文件名中的%d{yyyy-MM-dd}表示按日期命名,如app-2024-11-21.log

还能通过maxHistory属性限制保留的历史日志文件数量或天数:

<maxHistory>30</maxHistory>

这样就能按天数查看指定日志,而且单个日志文件不会过大,提高了日志检索效率。

对于用户量较大的企业级项目,日志增长速度很快,建议开启日志压缩功能以节省磁盘空间,示例如下:

<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
    <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>

上述配置表示每天生成一个新的日志文件,旧的日志文件会被压缩存储。

除了配置日志切分和压缩,还需要定期审查日志,检查日志的有效性和空间占用情况,从日志中发现系统问题,清理无用的日志信息。如果想偷懒,也可以编写自动化清理脚本,定期清理过期的日志文件,释放磁盘空间,例如:

# 每月清理一次超过 90 天的日志文件
find /var/log/myapp/ -type f -mtime +90 -exec rm {} ;

(六)统一日志格式

统一的日志格式对日志的解析、搜索和分析至关重要,在分布式系统中体现得更为明显。下面通过示例感受一下:
统一的日志格式:

2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 用户ID:12345 登录成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 用户ID:12345 登录失败,原因:密码错误
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 执行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置项 `timeout` 使用默认值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 应用启动成功,耗时:2.34秒

这样的日志整齐清晰,支持按照时间、线程、级别、类名和内容进行搜索。

再看看不统一的日志格式:

2024/11/21 14:30 登录成功 用户ID: 12345
2024-11-21 14:30:16 错误 用户12345登录失败!密码不对
DEBUG 执行SQL SELECT * FROM users WHERE id=12345
Timeout = default
应用启动成功

这种格式的日志,阅读和查找信息都非常困难。

建议每个项目都明确约定并配置一套日志输出规范,确保日志包含时间戳、日志级别、线程、类名、方法名、消息等关键信息。例如在logback中配置控制台日志输出格式:

<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <!-- 日志格式 -->
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
</appender>

也可以直接采用标准化格式,如JSON,确保所有日志遵循相同结构,便于后续分析处理:

<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
    <!-- 配置 JSON 编码器 -->
</encoder>

此外,还能通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如用户 ID、请求 ID 等,方便追踪。在 Java 代码中,可以为 MDC 变量设置值:

<!-- 文件日志配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
   <encoder>
       <!-- 包含 MDC 信息 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
   </encoder>
</appender>

这样,每个请求、每个用户的操作一目了然。

(七)使用异步日志

对于追求性能的操作,可以使用异步日志,将日志的写入操作放在单独的线程中,减少对主线程的阻塞,从而提升系统性能。

除了自己开线程去执行 log 操作之外,还可以直接修改配置来开启 Logback 的异步日志功能:

<!-- 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
   <queueSize>500</queueSize> <!-- 队列大小 -->
   <discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
   <neverBlock>true</neverBlock> <!-- 队列满时是否阻塞主线程,true 表示不阻塞 -->
   <appender-ref ref="CONSOLE" /> <!-- 生效的日志目标 -->
   <appender-ref ref="FILE" />
</appender>

上述配置的关键是配置缓冲队列,要设置合适的队列大小和丢弃策略,防止日志积压或丢失。

(八)集成日志收集系统

在比较成熟的公司中,我们可能会使用更专业的日志管理和分析系统,比如 ELK(Elasticsearch、Logstash、Kibana)。不仅不用每次都登录到服务器上查看日志文件,还可以更灵活地搜索日志。

但是搭建和运维 ELK 的成本还是比较大的,对于小团队,建议是不要急着搞这一套。

以上就是Java开发记录日志的正确方法和最佳实践详解全部内容,希望对你有帮助!


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/back/17012.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】