Spring Boot 日志最佳实践:接入、分级、异常记录与滚动拆分

日志不是"程序运行时打印出来的文字",而是定位故障、观察系统状态和审计业务行为的重要数据。一个合理的日志体系至少需要解决以下问题:

  • 项目如何接入并统一记录日志?
  • DEBUG、INFO、WARN、ERROR 应该如何选择?
  • 系统异常和业务错误应该如何记录?
  • 如何记录 HTTP 请求接入日志?
  • 如何按业务、日期和文件大小拆分日志?
  • 如何保留当日日志,并自动归档历史日志?
  • 如何避免日志泄露密码、令牌等敏感信息?

本文以 Spring Boot 默认使用的 SLF4J + Logback 为例,给出一套适用于生产环境的实践方案。


Spring Boot 如何接入日志

Spring Boot Web 项目通常已经通过 spring-boot-starter 或 spring-boot-starter-web 间接引入日志组件,因此一般不需要再手动添加 Logback。

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Spring Boot 默认的日志组合是:

  • SLF4J:日志门面,业务代码面向它编程。
  • Logback:默认日志实现,负责日志输出、文件拆分和滚动归档。

代码中不要直接依赖 Logback 的具体类,而应该使用 SLF4J:

java 复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private static final Logger log =
            LoggerFactory.getLogger(OrderService.class);

    public void createOrder(Long userId) {
        log.info("开始创建订单, userId={}", userId);
    }
}

如果项目使用 Lombok,也可以通过 @Slf4j 简化代码:

typescript 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OrderService {

    public void createOrder(Long userId) {
        log.info("开始创建订单, userId={}", userId);
    }
}

建议业务代码始终使用参数占位符:

c 复制代码
log.info("订单创建成功, orderId={}, userId={}", orderId, userId);

不要使用字符串拼接:

c 复制代码
// 不推荐
log.info("订单创建成功, orderId=" + orderId + ", userId=" + userId);

占位符可以避免不必要的字符串构造,并能保持日志格式统一。


日志级别应该如何选择

常见日志级别从低到高依次为:

vbnet 复制代码
TRACE < DEBUG < INFO < WARN < ERROR

生产环境通常以 INFO 为默认级别,只针对必要的模块临时开启 DEBUG。

TRACE

TRACE 用于非常细粒度的执行过程,例如循环内部状态、协议字段解析过程等。

它产生的日志量很大,在普通业务系统中很少使用,也不应该在生产环境长期打开。

c 复制代码
log.trace("解析协议字段, index={}, value={}", index, value);

DEBUG

DEBUG 用于开发和问题排查,记录对理解程序执行过程有帮助的内部信息。

lua 复制代码
log.debug("订单优惠计算完成, orderId={}, originalAmount={}, discount={}",
        orderId, originalAmount, discount);

适合记录:

  • 方法的重要输入。
  • 条件分支结果。
  • 中间计算结果。
  • 外部接口响应摘要。
  • 缓存是否命中。

不要把每一步代码执行都打印成 DEBUG,否则真正有价值的信息会被大量噪声淹没。

对于计算成本较高的日志参数,可以先判断级别:

lua 复制代码
if (log.isDebugEnabled()) {
    log.debug("订单完整快照: {}", buildOrderSnapshot(order));
}

普通占位符参数一般不需要额外判断。

INFO

INFO 用于记录正常且具有业务或运维价值的重要事件。

c 复制代码
log.info("订单创建成功, orderId={}, userId={}, amount={}",
        orderId, userId, amount);

适合记录:

  • 应用启动和关闭。
  • 重要配置加载结果。
  • 业务流程开始或完成。
  • 订单创建、支付成功、退款完成等关键状态变化。
  • 定时任务的开始、结束和处理统计。
  • 外部服务调用结果摘要。

INFO 不应该记录每一次普通方法调用,否则生产日志会迅速膨胀。

WARN

WARN 表示系统出现了异常情况,但当前请求仍然可以继续,或者系统已经完成降级、重试和兜底。

c 复制代码
log.warn("查询商品缓存失败,降级查询数据库, productId={}", productId);

适合记录:

  • 参数不符合预期,但可以使用默认值。
  • 第三方服务调用失败后成功降级。
  • 重试中的前几次失败。
  • 数据状态异常但没有中断业务。
  • 资源使用率接近阈值。
  • 非关键配置缺失。

WARN 应该具有可操作性。如果一个警告长期存在且任何人都不需要处理,它很可能不应该是 WARN。

ERROR

ERROR 表示当前操作失败,通常需要开发或运维人员关注。

lua 复制代码
log.error("订单支付处理失败, orderId={}, paymentNo={}",
        orderId, paymentNo, exception);

适合记录:

  • 未预期的程序异常。
  • 数据库操作失败。
  • 核心外部服务不可用。
  • 数据损坏或状态严重不一致。
  • 请求无法继续完成。
  • 补偿、降级和重试全部失败。

ERROR 并不等于"代码进入了 catch"。只有真正影响系统或请求结果、需要关注的异常才应记录为 ERROR。


系统异常和业务错误不能混为一谈

实际项目中最常见的问题,是把所有异常都记录为 ERROR。这会造成监控系统持续报警,却很难找到真正的系统故障。

什么是系统异常

系统异常通常不是用户正常操作导致的,例如:

  • 数据库连接中断。
  • Redis 不可用。
  • 空指针异常。
  • 数据转换异常。
  • 第三方服务超时。
  • 文件读写失败。
  • 代码状态不一致。

这类错误通常应该记录为 ERROR,并保留完整异常栈:

php 复制代码
try {
    paymentClient.pay(request);
} catch (Exception exception) {
    log.error("调用支付服务失败, orderId={}, paymentNo={}",
            orderId, paymentNo, exception);
    throw exception;
}

注意,异常对象应该作为日志方法的最后一个参数传入。

c 复制代码
// 正确:会输出完整异常栈
log.error("支付失败, orderId={}", orderId, exception);

以下写法通常只能记录异常描述,无法保留完整调用栈:

c 复制代码
// 不推荐
log.error("支付失败: {}", exception.getMessage());

// 不推荐
log.error("支付失败: " + exception);

异常栈包含异常类型、发生位置和调用链,是定位系统故障最重要的信息之一。

什么是业务错误

业务错误是系统正常运行时可以预期的拒绝结果,例如:

  • 登录密码错误。
  • 商品库存不足。
  • 优惠券已过期。
  • 订单状态不允许取消。
  • 用户余额不足。
  • 请求的数据不存在。
  • 用户没有操作权限。

这些通常不属于系统故障,不应该全部记录为 ERROR。

erlang 复制代码
if (stock < quantity) {
    log.info("库存不足,订单创建被拒绝, productId={}, requested={}, available={}",
            productId, quantity, stock);
    throw new BusinessException("库存不足");
}

业务错误应该使用什么级别,需要根据它的价值判断:

  • 正常且常见的业务拒绝:通常使用 INFO,也可以不记录。
  • 值得观察的异常业务状态:使用 WARN。
  • 业务状态已经严重不一致,需要人工处理:使用 ERROR。

例如,用户输入错误密码是正常业务结果,不应记录为 ERROR:

c 复制代码
log.info("用户登录失败, username={}, reason=INVALID_PASSWORD", username);

但同一账号短时间内连续失败很多次,可能涉及安全风险,可以聚合后记录为 WARN:

c 复制代码
log.warn("账号短时间内多次登录失败, userId={}, failureCount={}",
        userId, failureCount);

如果订单显示支付成功,但支付流水不存在,这虽然表现为业务数据问题,却需要立即处理,可以记录为 ERROR:

lua 复制代码
log.error("订单支付状态不一致, orderId={}, orderStatus={}, paymentRecord={}",
        orderId, orderStatus, paymentRecord);

因此,日志级别不应该简单根据"是不是业务异常"决定,而应该根据影响范围和是否需要处理决定。


避免同一个异常被重复记录

下面是一种常见但错误的写法:

csharp 复制代码
public void createOrder() {
    try {
        inventoryService.deduct();
    } catch (Exception exception) {
        log.error("扣减库存失败", exception);
        throw exception;
    }
}

上层又记录一次:

php 复制代码
try {
    orderService.createOrder();
} catch (Exception exception) {
    log.error("创建订单失败", exception);
    throw exception;
}

全局异常处理器再记录一次:

php 复制代码
@ExceptionHandler(Exception.class)
public Result<?> handle(Exception exception) {
    log.error("系统异常", exception);
    return Result.fail("系统繁忙");
}

一次异常由此产生三份几乎相同的异常栈,不仅浪费存储,也会干扰告警统计。

推荐遵循一个原则:

异常只在能够最终处理它,或者能够补充关键上下文的位置记录一次。

如果当前方法只是继续向上抛出异常,而且不能增加有价值的信息,可以不记录:

csharp 复制代码
public void createOrder() {
    inventoryService.deduct();
}

最后在全局异常处理器中统一记录:

kotlin 复制代码
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException exception) {
        log.info("业务请求未完成, code={}, message={}",
                exception.getCode(), exception.getMessage());

        return Result.fail(exception.getCode(), exception.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleSystemException(Exception exception) {
        log.error("未处理的系统异常", exception);
        return Result.fail("SYSTEM_ERROR", "系统繁忙,请稍后重试");
    }
}

如果底层必须记录,例如异常即将被转换、吞掉或降级,则应补充调用对象、关键参数和降级结果:

kotlin 复制代码
try {
    return recommendationClient.query(userId);
} catch (Exception exception) {
    log.warn("推荐服务调用失败,返回默认推荐列表, userId={}", userId, exception);
    return defaultRecommendations();
}

日志内容应该包含什么

一条有效日志通常需要回答几个问题:

  • 发生了什么?
  • 在哪个请求中发生?
  • 涉及哪个用户或业务对象?
  • 执行结果是什么?
  • 失败原因是什么?
  • 是否执行了重试、降级或补偿?

推荐采用"固定事件描述 + 结构化字段"的形式:

c 复制代码
log.info("订单创建成功, traceId={}, orderId={}, userId={}, amount={}",
        traceId, orderId, userId, amount);

重要字段可以包括:

  • traceId:一次请求的链路标识。
  • userId:用户标识。
  • orderId:订单等业务实体标识。
  • requestId:请求标识。
  • clientIp:客户端地址。
  • costMs:执行耗时。
  • result:处理结果。
  • errorCode:标准错误码。
  • retryCount:重试次数。

不推荐只打印模糊描述:

lua 复制代码
log.error("执行失败");
log.warn("数据不正确");
log.info("进入方法");

这种日志无法帮助定位具体请求和业务数据。

同时,也不要简单序列化整个对象:

c 复制代码
// 可能包含密码、Token、身份证号或大字段
log.info("注册请求: {}", request);

应该只记录定位问题所需的安全字段:

less 复制代码
log.info("收到注册请求, username={}, channel={}",
        request.getUsername(), request.getChannel());

使用 MDC 记录 Trace ID

如果每条日志都手动传递 traceId,代码会非常繁琐。可以使用 SLF4J 的 MDC 保存当前请求的上下文信息。

java 复制代码
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.UUID;

@Component
public class TraceIdFilter implements Filter {

    private static final String TRACE_ID = "traceId";

    @Override
    public void doFilter(
            ServletRequest request,
            ServletResponse response,
            FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;

        String traceId = httpRequest.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }

        try {
            MDC.put(TRACE_ID, traceId);
            chain.doFilter(request, response);
        } finally {
            // Web 容器通常会复用线程,必须清理 MDC。
            MDC.remove(TRACE_ID);
        }
    }
}

之后在 Logback 输出格式中加入 %X{traceId}:

perl 复制代码
<pattern>
    %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level
    [%X{traceId:-NO_TRACE}] %logger{50} - %msg%n
</pattern>

日志效果如下:

ini 复制代码
2026-08-27 10:21:32.318 [http-nio-8080-exec-2] INFO
[84b830c095be45e7a284fac4580895d0] com.example.OrderService -
订单创建成功, orderId=10001, userId=20001

这样便可以通过 traceId 检索一次请求产生的全部日志。

如果使用线程池或异步任务,需要额外传递和清理 MDC。普通 ThreadLocal 上下文不会自动传播到另一个线程。


记录 HTTP 接入日志

业务日志和 HTTP 接入日志的职责不同。

业务日志描述系统做了什么;接入日志描述哪个客户端在什么时间访问了哪个接口,以及请求最终返回了什么结果。

一条接入日志通常包括:

  • 请求方法。
  • 请求路径。
  • HTTP 状态码。
  • 客户端 IP。
  • User-Agent。
  • Trace ID。
  • 请求耗时。
  • 用户标识。
  • 响应大小。

可以使用过滤器统一记录:

java 复制代码
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Slf4j(topic = "ACCESS_LOG")
@Component
public class AccessLogFilter implements Filter {

    @Override
    public void doFilter(
            ServletRequest request,
            ServletResponse response,
            FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        long start = System.nanoTime();

        try {
            chain.doFilter(request, response);
        } finally {
            long costMs = TimeUnit.NANOSECONDS.toMillis(
                    System.nanoTime() - start);

            log.info(
                    "method={}, path={}, status={}, clientIp={}, costMs={}, userAgent={}",
                    httpRequest.getMethod(),
                    httpRequest.getRequestURI(),
                    httpResponse.getStatus(),
                    getClientIp(httpRequest),
                    costMs,
                    httpRequest.getHeader("User-Agent")
            );
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String forwardedFor = request.getHeader("X-Forwarded-For");

        if (forwardedFor != null && !forwardedFor.isBlank()) {
            return forwardedFor.split(",")[0].trim();
        }

        return request.getRemoteAddr();
    }
}

这里通过单独的 Logger 名称 ACCESS_LOG 输出接入日志,后续可以在 Logback 中将它写入独立文件。

需要注意:只有当请求确实经过可信的反向代理,并且代理正确覆盖 X-Forwarded-For 时,才能信任这个请求头。否则客户端可以伪造它。

接入日志一般不要打印完整请求体和响应体,原因包括:

  • 可能包含密码、Token 和个人隐私。
  • 文件上传或大响应会造成日志急剧膨胀。
  • 流式请求和响应不适合直接读取。
  • 重复读取请求体可能影响业务处理。
  • 高频序列化会增加接口耗时。

确实需要记录时,应设置白名单、大小限制和脱敏规则。


Logback 生产环境配置

在 Spring Boot 项目的 src/main/resources 下创建:

复制代码
logback-spring.xml

之所以推荐使用 logback-spring.xml,是因为它能够使用 Spring Boot 提供的扩展能力,例如读取 Spring 属性和按 Profile 配置日志。

下面是一套基础生产配置:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <springProperty
            scope="context"
            name="APP_NAME"
            source="spring.application.name"
            defaultValue="spring-app"/>

    <springProperty
            scope="context"
            name="LOG_PATH"
            source="logging.file.path"
            defaultValue="./logs"/>

    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-NO_TRACE}] %logger{50} - %msg%n"/>

    <!-- 控制台输出 -->
    <appender name="CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 应用综合日志 -->
    <appender name="APP_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <!-- 始终表示正在写入的当日日志 -->
        <file>${LOG_PATH}/${APP_NAME}.log</file>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <!-- 日期变化或文件达到 100MB 时归档 -->
            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ERROR 独立日志 -->
    <appender name="ERROR_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-error.log</file>

        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>90</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- HTTP 接入日志 -->
    <appender name="ACCESS_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-access.log</file>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-access.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>200MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>20GB</totalSizeCap>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ACCESS_LOG 只写接入日志文件,不重复传递到 root -->
    <logger name="ACCESS_LOG"
            level="INFO"
            additivity="false">
        <appender-ref ref="ACCESS_FILE"/>
    </logger>

    <!-- 根据项目实际情况调整第三方组件级别 -->
    <logger name="org.springframework" level="INFO"/>
    <logger name="org.hibernate" level="WARN"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="APP_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>

</configuration>

在 application.yml 中配置应用名和日志路径:

yaml 复制代码
spring:
  application:
    name: order-service

logging:
  file:
    path: ./logs

生产环境也可以通过启动参数覆盖日志路径:

css 复制代码
--logging.file.path=/data/logs/order-service

运行后目录大致如下:

lua 复制代码
logs/
├── order-service.log
├── order-service-error.log
├── order-service-access.log
└── archive/
    ├── order-service.2026-08-26.0.log.gz
    ├── order-service.2026-08-26.1.log.gz
    ├── order-service-error.2026-08-26.0.log.gz
    └── order-service-access.2026-08-26.0.log.gz

当日日志和历史日志如何滚动

配置中的核心是:

bash 复制代码
<file>${LOG_PATH}/${APP_NAME}.log</file>

以及:

bash 复制代码
<fileNamePattern>
    ${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
</fileNamePattern>

两者配合后的行为是:

  1. 当前正在写入的日志始终叫 order-service.log。
  2. 日期变化时,旧文件会被归档到 archive 目录。
  3. 同一天文件超过 maxFileSize 后,会按照序号继续拆分。
  4. 历史文件使用 .gz 压缩,减少磁盘占用。

例如,某一天日志量超过 100 MB 后可能生成:

c 复制代码
order-service.2026-08-27.0.log.gz
order-service.2026-08-27.1.log.gz
order-service.2026-08-27.2.log.gz

这里的 %d{yyyy-MM-dd} 负责按日期滚动,%i 负责同一天按大小编号。

只配置日期滚动而不限制单文件大小,可能在流量异常时生成数 GB 的单个日志文件。因此生产环境更推荐同时按日期和大小滚动。

几个关键配置的含义如下:

xml 复制代码
<maxFileSize>100MB</maxFileSize>

单个归档文件最大 100 MB。

xml 复制代码
<maxHistory>30</maxHistory>

最多保留 30 天的历史归档。

xml 复制代码
<totalSizeCap>10GB</totalSizeCap>

所有历史归档总大小不超过 10 GB。即使还没有达到 30 天,超过容量限制时也可能清理旧文件。

bash 复制代码
<cleanHistoryOnStart>true</cleanHistoryOnStart>

应用启动时检查并清理超过保留策略的旧日志。

不要只配置 maxHistory 而忽略 totalSizeCap。某一天发生日志风暴时,即使只保留几天,也可能耗尽磁盘。


下面这部分可直接替换原文的"按日志级别拆分文件"章节。该方案不修改 Java 代码,仅修改 logback-spring.xml,即可将 INFO、WARN、ERROR 精确拆分到不同文件。

仅修改配置文件实现日志级别分离

如果项目已经统一使用 SLF4J 记录日志,那么不需要修改任何业务代码,只需调整 logback-spring.xml,就可以按照日志级别生成独立文件:

lua 复制代码
logs/
├── order-service.log
├── order-service-info.log
├── order-service-warn.log
├── order-service-error.log
└── archive/
    ├── order-service-info.2026-08-27.0.log.gz
    ├── order-service-warn.2026-08-27.0.log.gz
    └── order-service-error.2026-08-27.0.log.gz

推荐保留一份包含全部日志的综合文件,同时生成精确级别文件:

  • order-service.log:包含 INFO、WARN、ERROR 等全部有效日志。
  • order-service-info.log:只包含 INFO。
  • order-service-warn.log:只包含 WARN。
  • order-service-error.log:只包含 ERROR。

这样既可以通过综合日志还原完整请求过程,也可以快速查看警告和错误。

LevelFilter 和 ThresholdFilter 的区别

实现日志分离时,需要特别注意两种过滤器的区别。

LevelFilter 表示精确匹配某个级别:

xml 复制代码
<filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
</filter>

该过滤器只接受 INFO,不会接受 WARN 和 ERROR,因此适合日志级别拆分。

ThresholdFilter 表示接受指定级别及其以上的所有日志:

xml 复制代码
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
    <level>WARN</level>
</filter>

它会同时接受:

vbnet 复制代码
WARN
ERROR

因此,如果目标是让 warn.log 只包含 WARN,就不能使用 ThresholdFilter,而应该使用 LevelFilter。

完整配置示例

下面的配置仅依赖 Logback,不需要修改 Controller、Service 或其他 Java 代码。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- 从 Spring Boot 配置中读取应用名称 -->
    <springProperty
            scope="context"
            name="APP_NAME"
            source="spring.application.name"
            defaultValue="spring-app"/>

    <!-- 从 Spring Boot 配置中读取日志目录 -->
    <springProperty
            scope="context"
            name="LOG_PATH"
            source="logging.file.path"
            defaultValue="./logs"/>

    <!-- 统一日志格式 -->
    <property
            name="LOG_PATTERN"
            value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-NO_TRACE}] %logger{50} - %msg%n"/>

    <!-- 控制台日志 -->
    <appender name="CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 综合日志:包含 INFO、WARN、ERROR 等全部有效级别 -->
    <appender name="ALL_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <!-- 当前正在写入的日志文件 -->
        <file>${LOG_PATH}/${APP_NAME}.log</file>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <!-- 按日期和文件大小滚动 -->
            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- INFO 日志:只接收 INFO -->
    <appender name="INFO_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-info.log</file>

        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-info.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- WARN 日志:只接收 WARN -->
    <appender name="WARN_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-warn.log</file>

        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>WARN</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-warn.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>60</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ERROR 日志:只接收 ERROR -->
    <appender name="ERROR_FILE"
              class="ch.qos.logback.core.rolling.RollingFileAppender">

        <file>${LOG_PATH}/${APP_NAME}-error.log</file>

        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <rollingPolicy
                class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

            <fileNamePattern>
                ${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz
            </fileNamePattern>

            <maxFileSize>100MB</maxFileSize>
            <maxHistory>90</maxHistory>
            <totalSizeCap>10GB</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>

        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 降低第三方组件的日志噪声 -->
    <logger name="org.springframework" level="INFO"/>
    <logger name="org.hibernate" level="WARN"/>

    <!--
        root 只决定允许产生的最低日志级别。
        日志具体写入哪个文件,由各 Appender 的过滤器决定。
    -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ALL_FILE"/>
        <appender-ref ref="INFO_FILE"/>
        <appender-ref ref="WARN_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>

</configuration>

对应的 application.yml 配置如下:

yaml 复制代码
spring:
  application:
    name: order-service

logging:
  file:
    path: ./logs

应用重新启动后,日志将自动按照级别写入不同文件,不需要修改原有的日志代码:

lua 复制代码
log.info("订单创建成功, orderId={}", orderId);
log.warn("库存即将不足, productId={}", productId);
log.error("订单创建失败, orderId={}", orderId, exception);

对应关系为:

日志代码 综合日志 INFO 文件 WARN 文件 ERROR 文件
log.info(...) 写入 写入 不写入 不写入
log.warn(...) 写入 不写入 写入 不写入
log.error(...) 写入 不写入 不写入 写入

为什么 Root Logger 要配置多个 Appender

一条日志产生后,会被发送给 Root Logger 引用的所有 Appender。每个 Appender 再通过自己的过滤器判断是否接收。

例如,一条 WARN 日志的处理过程如下:

markdown 复制代码
WARN 日志
    ├── CONSOLE:接收并输出到控制台
    ├── ALL_FILE:接收并写入综合日志
    ├── INFO_FILE:级别不匹配,拒绝
    ├── WARN_FILE:级别匹配,接收
    └── ERROR_FILE:级别不匹配,拒绝

因此,业务代码不需要选择日志文件。代码只负责使用正确的日志级别,日志配置负责决定最终写入位置。

如果不需要综合日志

如果希望不同级别完全独立,不需要 ${APP_NAME}.log 综合文件,可以从 Root Logger 中删除:

csharp 复制代码
<appender-ref ref="ALL_FILE"/>

修改后:

ini 复制代码
<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="INFO_FILE"/>
    <appender-ref ref="WARN_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

此时每条日志只会出现在对应的级别文件中。

不过,生产环境通常建议保留综合日志。排查一次请求时,往往需要按照时间顺序同时查看 INFO、WARN 和 ERROR。如果日志完全分散在不同文件中,还原完整调用过程会更加困难。

如果需要拆分 DEBUG 日志

开发或测试环境可能需要单独保存 DEBUG 日志,可以增加:

xml 复制代码
<appender name="DEBUG_FILE"
          class="ch.qos.logback.core.rolling.RollingFileAppender">

    <file>${LOG_PATH}/${APP_NAME}-debug.log</file>

    <filter class="ch.qos.logback.classic.filter.LevelFilter">
        <level>DEBUG</level>
        <onMatch>ACCEPT</onMatch>
        <onMismatch>DENY</onMismatch>
    </filter>

    <rollingPolicy
            class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

        <fileNamePattern>
            ${LOG_PATH}/archive/${APP_NAME}-debug.%d{yyyy-MM-dd}.%i.log.gz
        </fileNamePattern>

        <maxFileSize>100MB</maxFileSize>
        <maxHistory>7</maxHistory>
        <totalSizeCap>5GB</totalSizeCap>
    </rollingPolicy>

    <encoder>
        <pattern>${LOG_PATTERN}</pattern>
        <charset>UTF-8</charset>
    </encoder>
</appender>

同时需要将 Root Logger 的最低级别调整为 DEBUG,并引用该 Appender:

ini 复制代码
<root level="DEBUG">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ALL_FILE"/>
    <appender-ref ref="DEBUG_FILE"/>
    <appender-ref ref="INFO_FILE"/>
    <appender-ref ref="WARN_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

需要注意:只有代码中原本存在 log.debug(...),并且 Logger 允许 DEBUG 级别时,才能产生 DEBUG 日志。配置文件可以决定已有日志是否输出、输出到哪里,但不能把代码中的 log.info(...) 自动变成 DEBUG。

生产环境不建议长期将 Root Logger 设置为 DEBUG,否则 Spring、数据库驱动及其他依赖可能产生大量日志。更安全的方式是只为项目业务包开启:

ini 复制代码
<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ALL_FILE"/>
    <appender-ref ref="INFO_FILE"/>
    <appender-ref ref="WARN_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

<logger name="com.example.order"
        level="DEBUG"
        additivity="true">
    <appender-ref ref="DEBUG_FILE"/>
</logger>

这里的 com.example.order 应替换为项目实际的业务包名。

仅通过配置还可以按包拆分日志

除了按级别分离,如果项目的 Controller、Service、Mapper 位于不同包中,也可以在不修改代码的情况下按包输出日志。

例如,将 Mapper 日志写入独立文件:

ini 复制代码
<logger name="com.example.order.mapper"
        level="DEBUG"
        additivity="false">
    <appender-ref ref="MAPPER_FILE"/>
</logger>

其中,MAPPER_FILE 的定义方式与其他滚动文件 Appender 相同。

需要注意:

  1. 按包拆分不需要修改 Java 代码。
  2. 前提是现有代码的包结构能够区分不同模块。
  3. 如果多个业务模块混合在同一个包中,仅靠配置无法准确识别日志属于订单、支付还是库存。
  4. 如果希望按照任意业务类型拆分,通常需要代码使用不同的 Logger 名称或 Marker。

因此,"按日志级别拆分"和"按现有包名拆分"可以完全通过配置实现;"按照代码中不存在的业务标签拆分"则无法仅依靠配置凭空完成。

配置时的注意事项

不要让同一个文件被多个 Appender 写入

每个滚动文件应该只由一个 Appender 负责写入。多个 Appender 同时操作同一路径,可能造成日志丢失、滚动失败或文件竞争。

不要混淆 Logger 级别和 Appender 过滤器

Logger 级别决定日志事件是否能够产生:

ini 复制代码
<root level="INFO">

这表示 TRACE 和 DEBUG 会在进入 Appender 前被丢弃。

Appender 过滤器决定已经产生的事件写入哪个文件:

xml 复制代码
<level>ERROR</level>

因此,如果 Root Logger 是 INFO,即使配置了 DEBUG_FILE,也不会收到 Root Logger 拦截掉的 DEBUG 日志。

精确分级必须使用 LevelFilter

如果希望文件之间不存在级别交叉,应统一使用:

ini 复制代码
<filter class="ch.qos.logback.classic.filter.LevelFilter">

不要用 ThresholdFilter 实现精确级别分离。

ERROR 文件可以保留更长时间

不同级别可以设置不同的保留策略,例如:

vbnet 复制代码
INFO:保留 30 天
WARN:保留 60 天
ERROR:保留 90 天
DEBUG:保留 7 天

但仍应为每类日志设置 totalSizeCap,防止某个级别突然暴增并耗尽磁盘。

验证日志分离是否正确

配置完成并重启应用后,可以通过现有接口触发不同日志,或者使用项目中已有的日志调用进行验证。

重点检查:

  1. INFO 是否只进入综合日志和 INFO 文件。
  2. WARN 是否只进入综合日志和 WARN 文件。
  3. ERROR 是否只进入综合日志和 ERROR 文件。
  4. 日期变化后是否生成历史归档文件。
  5. 单文件达到 maxFileSize 后是否继续按照 %i 拆分。
  6. 历史日志是否成功压缩为 .gz。
  7. 超过保留天数或总容量后是否清理旧文件。
  8. 启动过程中是否出现 Logback 配置错误。

如果同一条日志在同一个文件中出现两次,应重点检查:

  1. Logger 是否同时引用了 Appender。
  2. Root Logger 是否也引用了同一个 Appender。
  3. 子 Logger 的 additivity 是否设置正确。

对于自定义 Logger,如果不希望日志继续传递到 Root Logger,应配置:

ini 复制代码
additivity="false"

小结

Spring Boot 项目已经使用 SLF4J 和 Logback 时,日志级别分离完全可以通过修改 logback-spring.xml 实现,不需要改动业务代码。

核心做法是:

  1. 为 INFO、WARN、ERROR 分别创建一个 RollingFileAppender。
  2. 每个 Appender 使用 LevelFilter 精确匹配日志级别。
  3. Root Logger 同时引用这些 Appender。
  4. 使用 SizeAndTimeBasedRollingPolicy 实现按日期和大小滚动。
  5. 同时设置保留天数和总容量上限。
  6. 根据排查需要决定是否保留一份综合日志。

配置负责日志的输出位置,代码负责选择正确的日志级别。两者职责分离后,既能做到不侵入业务代码,也能获得清晰、可维护的生产日志目录。


按业务拆分日志

对于支付、审计、消息消费等重要场景,可以建立独立 Logger。

java 复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PaymentLog {

    public static final Logger LOGGER =
            LoggerFactory.getLogger("PAYMENT_LOG");

    private PaymentLog() {
    }
}

业务代码中使用:

css 复制代码
PaymentLog.LOGGER.info(
        "支付状态变更, orderId={}, paymentNo={}, from={}, to={}",
        orderId, paymentNo, oldStatus, newStatus);

Logback 中增加对应的 Appender 和 Logger:

xml 复制代码
<appender name="PAYMENT_FILE"
          class="ch.qos.logback.core.rolling.RollingFileAppender">

    <file>${LOG_PATH}/${APP_NAME}-payment.log</file>

    <rollingPolicy
            class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">

        <fileNamePattern>
            ${LOG_PATH}/archive/${APP_NAME}-payment.%d{yyyy-MM-dd}.%i.log.gz
        </fileNamePattern>

        <maxFileSize>100MB</maxFileSize>
        <maxHistory>180</maxHistory>
        <totalSizeCap>20GB</totalSizeCap>
    </rollingPolicy>

    <encoder>
        <pattern>${LOG_PATTERN}</pattern>
        <charset>UTF-8</charset>
    </encoder>
</appender>

<logger name="PAYMENT_LOG"
        level="INFO"
        additivity="false">
    <appender-ref ref="PAYMENT_FILE"/>
</logger>

additivity="false" 非常重要。它表示这条日志不会继续传递给父 Logger,否则同一条支付日志可能同时出现在支付日志和应用综合日志中。

不过,业务日志文件不应该代替数据库中的业务记录。日志适合检索、排错和审计辅助,不适合作为唯一的交易事实来源。


开发和生产环境使用不同配置

开发环境通常希望:

  1. 输出到控制台。
  2. 可以临时开启 DEBUG。
  3. 日志格式便于阅读。

生产环境通常希望:

  1. 默认使用 INFO。
  2. 输出到文件或标准输出采集系统。
  3. 控制第三方框架的日志量。
  4. 启用滚动、压缩和清理策略。

可以在 logback-spring.xml 中使用 Spring Profile:

ini 复制代码
<springProfile name="dev">
    <logger name="com.example" level="DEBUG"/>

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

<springProfile name="prod">
    <logger name="com.example" level="INFO"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="APP_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>
</springProfile>

也可以在不同的配置文件中设置级别:

yaml 复制代码
# application-dev.yml
logging:
  level:
    com.example: DEBUG
yaml 复制代码
# application-prod.yml
logging:
  level:
    root: INFO
    com.example: INFO
    org.springframework: INFO
    org.hibernate: WARN

是否应该使用异步日志

同步写日志会占用请求线程。在日志量较大的系统中,可以通过 AsyncAppender 降低文件写入对业务线程的影响:

xml 复制代码
<appender name="ASYNC_APP_FILE"
          class="ch.qos.logback.classic.AsyncAppender">

    <queueSize>8192</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <neverBlock>false</neverBlock>

    <appender-ref ref="APP_FILE"/>
</appender>

然后让 Root Logger 引用异步 Appender:

ini 复制代码
<root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ASYNC_APP_FILE"/>
    <appender-ref ref="ERROR_FILE"/>
</root>

异步日志并非没有代价:

  1. 应用异常退出时,队列中尚未写出的日志可能丢失。
  2. 队列过小时,高峰期可能阻塞或丢弃日志。
  3. 队列过大会占用更多内存。
  4. 如果同一个实际文件被多个 Appender 同时写入,可能产生冲突。

对于错误日志、审计日志和支付关键日志,应根据可靠性要求谨慎决定是否异步写入。日志不能替代可靠消息、数据库流水或审计存储。


日志脱敏与安全

日志经常被开发、测试、运维或外部日志平台访问,因此不能默认它是安全的。

以下信息不应该直接写入日志:

  • 密码和支付密码。
  • Access Token、Refresh Token。
  • Cookie、Session ID。
  • Authorization 请求头。
  • 银行卡号和安全码。
  • 身份证号。
  • 手机号、邮箱和家庭地址等个人信息。
  • 加密私钥和数据库密码。
  • 完整请求体或第三方原始响应中的敏感字段。

错误示例:

less 复制代码
log.info("用户登录, username={}, password={}",
        request.getUsername(), request.getPassword());

log.debug("请求头 Authorization={}",
        request.getHeader("Authorization"));

正确做法是完全不记录秘密信息,并对必要的个人信息脱敏:

less 复制代码
log.info("用户登录请求, username={}, mobile={}",
        request.getUsername(), maskMobile(request.getMobile()));

同时需要防止日志注入。来自用户的文本可能包含换行符,导致伪造多行日志。应对自由文本进行清理、限制长度,或者使用成熟的结构化日志方案。


常见错误实践

所有异常都记录为 ERROR

业务校验失败并不代表系统故障。大量无意义的 ERROR 会让真正的故障告警失去价值。

只记录异常消息

lua 复制代码
log.error("处理失败: {}", exception.getMessage());

这会丢失异常类型、代码位置和完整调用链。系统异常应传入异常对象。

同一异常层层打印

异常在 DAO、Service、Controller 和全局异常处理器中重复记录,会造成日志污染和告警重复。

打印完整请求和响应

这容易泄露敏感数据,也可能导致磁盘、网络和 CPU 压力。

生产环境长期打开 DEBUG

高流量系统开启 DEBUG 后可能迅速耗尽磁盘,并影响应用性能。

日志没有业务标识

只写"订单处理失败"远远不够,至少应该包含 orderId、userId、traceId 和错误原因。

没有设置容量上限

只有按天滚动,没有单文件限制和总容量限制,仍然可能因为日志风暴耗尽磁盘。

使用日志代替业务数据

日志可能清理、丢失或重复,不能作为订单、支付、余额等关键数据的唯一来源。


推荐的日志决策方式

写日志之前,可以依次判断:

  1. 这条信息对排错、监控、审计或业务分析是否有价值?
  2. 它是正常事件、可恢复异常,还是操作失败?
  3. 是否已经在其他层记录过?
  4. 是否包含足够的请求和业务上下文?
  5. 是否包含敏感信息?
  6. 在生产流量下,这条日志会产生多大规模?
  7. 看到这条 WARN 或 ERROR 后,是否有人能够采取行动?

可以采用以下级别参考:

场景 建议级别
订单创建成功 INFO
用户输入错误密码 INFO 或不记录
库存不足 INFO
非关键服务失败,已成功降级 WARN
外部服务第一次重试失败 WARN
核心服务重试全部失败 ERROR
未预期的空指针异常 ERROR
数据库连接失败 ERROR
支付状态严重不一致 ERROR
方法内部中间计算结果 DEBUG
高频循环内的执行细节 TRACE 或不记录

这张表不是绝对规则。最终级别取决于业务影响、发生频率和是否需要人工关注。


生产环境落地清单

一套可用的 Spring Boot 日志体系,建议至少完成以下配置:

  • 业务代码统一使用 SLF4J。
  • 生产环境默认日志级别为 INFO。
  • 使用占位符输出参数,避免字符串拼接。
  • 接入 Trace ID,并在请求结束时清理 MDC。
  • HTTP 接入日志与普通业务日志分离。
  • 应用综合日志和 ERROR 日志分离。
  • 重要支付或审计业务使用独立 Logger。
  • 按日期和文件大小同时滚动。
  • 历史日志启用压缩。
  • 同时设置 maxHistory 和 totalSizeCap。
  • 系统异常记录完整异常栈。
  • 正常业务错误不滥用 ERROR。
  • 避免同一个异常被重复记录。
  • 对密码、Token 和个人信息执行隐藏或脱敏。
  • 对日志目录配置磁盘空间监控。
  • 对 ERROR 数量、接口失败率和高耗时请求建立告警。
  • 定期检查日志是否仍然具有定位价值。

总结

优秀的日志体系并不是记录得越多越好,而是在正确的位置,以正确的级别,记录足够且安全的上下文。

正常业务流程使用 INFO,开发排查信息使用 DEBUG,已经降级或值得关注的异常使用 WARN,真正导致操作失败、需要处理的系统故障使用 ERROR。系统异常应记录完整异常栈,普通业务拒绝则不应制造无意义的错误告警。

配合 Trace ID、HTTP 接入日志、业务日志拆分,以及按日期和大小滚动的 Logback 配置,才能让日志在生产故障发生时真正发挥作用,而不是变成占满磁盘却无法检索的文本。

相关推荐
程序员老赵2 小时前
Docker 部署 Dolibarr:轻松搭建开源 ERP/CRM 平台
运维·前端·后端
量化分析码农3 小时前
【Python量化系统工程实战 #06】从本地脚本到云端部署:量化系统的最小可运行架构
后端
136096757233 小时前
页面打不开不是 Nginx 的错
前端·后端
ALONE阿龙太原微码3 小时前
RBAC 以及主流权限模型
后端
斑鸠喳喳3 小时前
线程本地存储 ThreadLocal
java·后端
你顶住我先撤3 小时前
RocketMQ 消息类型
后端
高频因子挖掘机5 小时前
批量行情返回后,怎样把请求失败的股票单独挑出来?
后端·github·api
小蒜学长5 小时前
在线保险服务与管理平台的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·服务平台·在线保险