半夜三点被手机震醒,看了一眼告警群,磁盘使用率98%。登上服务器排查,发现罪魁祸首不是业务数据,不是数据库binlog,而是一台测试机上Spring Boot应用疯狂滚动的日志文件,顺手一查,光logs/目录就占了40多个G。这种场景我相信很多做Java后端的人都不陌生——Spring Boot默认内置了Logback,但默认配置只能保证“能把日志打出来”,根本没法保证“日志不会成为事故本身”。
这篇内容就围绕Spring Boot + Logback自定义日志展开,覆盖配置文件的写法、日志级别控制、按天按大小滚动、MDC链路打点、异步日志、以及部署到服务器后才会遇到的实际问题。适合正在用Spring Boot做项目、想把手里的日志体系从“能用”升级到“扛得住生产环境”的开发者和运维同学参考。
1. Spring Boot为什么乖乖听logback的话:默认机制里的门道
1.1 SLF4J门面与Logback实现的组合逻辑
很多人第一次接触Spring Boot日志时,会看到LoggerFactory.getLogger(Xxx.class)这一行代码,然后引入slf4j-api依赖,以为日志框架就是SLF4J。其实这里面有两层关系:SLF4J是一套日志门面API,只负责定义接口;真正的输出动作由底层绑定具体实现。Spring Boot 2.x和3.x默认用的是Logback实现,spring-boot-starter-logging会同时把logback-classic和logback-core带进来,你不需要额外加Logback依赖,直接在application.yml或classpath下放配置文件就能起作用。
为什么Spring Boot要选Logback?倒不是说别的框架不行,而是Logback出自Log4j作者之手,早期版本就已经支持异步Appender、按时间和大小滚动、条件判断、MDC键值输出等功能,性能上也够看,和Spring Boot生态的集成最顺畅。你如果非要用Log4j2,可以排除spring-boot-starter-logging再单独引入,但没有必要,除非团队里有硬性规范要求。
1.2 logback.xml与logback-spring.xml:选错文件名的代价
Spring Boot识别Logback配置有两个路径:一个叫logback.xml,一个叫logback-spring.xml。两者都能被加载,但行为有明显差别。
logback.xml在类路径下被发现后,Logback会直接把它当作标准配置文件解析,Spring Boot没有机会在里面注入自己的属性,比如你在application.yml里设置了logging.level.com.example=DEBUG,然后希望logback.xml里的某个Logger读取这个动态级别,是拿不到的。logback-spring.xml则是由Spring Boot的LogbackLoggingSystem主动加载,支持<springProperty>标签读取环境变量,还支持<springProfile>标签按profile激活不同配置。
我个人的习惯是:项目里永远只放logback-spring.xml,不碰logback.xml。这样不仅可以延续Spring Boot的属性覆盖机制,还能在本地、测试、生产各环境共用一份配置的基础上做局部微调。一个例外是某些纯工具类SDK或中间件模块,它们本身不依赖Spring环境,才单独维护标准logback.xml。
1.3 从“默认行为”看Spring Boot日志进阶的起点
Spring Boot默认Logback行为是什么?控制台输出为主,日志格式是固定的%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{50} - %msg%n,只在控制台打印,不写文件。如果你不配任何东西,应用启动后屏幕上能看到一堆INFO日志,但磁盘上什么都没有,这对本地调试勉强够用,生产环境就不行了。
所以“自定义日志”真正要解决的问题有三个:第一,把日志按格式、按目的地(控制台、文件、远程收集)分开;第二,把滚动策略和保留策略定好,让日志不会无限增长;第三,让日志内容可控——哪些类输出到哪、什么级别、要不要带请求号和业务流水号。下面每一章都在回答这些问题的具体做法。
2. 先写一份扛得住生产的logback配置文件
2.1 完整配置示例:按天滚动、按大小强制切分、保留20天
我直接贴一份目前在用的基础版本,注释写得很细,方便直接抄:
<?xml version="1.0" encoding="UTF-8"?> <configuration scan="true" scanPeriod="30 seconds"> <!-- 统一读取Spring环境里的应用名 --> <springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="my-app"/> <!-- 日志根目录,默认当前目录下logs --> <property name="LOG_HOME" value="${LOG_HOME:-./logs}"/> <!-- 单个文件上限 --> <property name="MAX_FILE_SIZE" value="100MB"/> <!-- 最多保留20天 --> <property name="MAX_HISTORY" value="20"/> <!-- 保留文件总大小上限 --> <property name="TOTAL_SIZE_CAP" value="5GB"/> <!-- 控制台Appender:启动阶段和调试时用 --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{50}) - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 业务日志文件Appender:按日期和大小滚动 --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>${MAX_FILE_SIZE}</maxFileSize> <maxHistory>${MAX_HISTORY}</maxHistory> <totalSizeCap>${TOTAL_SIZE_CAP}</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{50} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- ERROR日志单独一份,排错不用去全量日志里捞 --> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}-error.log</file> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>${MAX_FILE_SIZE}</maxFileSize> <maxHistory>${MAX_HISTORY}</maxHistory> <totalSizeCap>${TOTAL_SIZE_CAP}</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{50} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 根Logger:INFO起步,别用DEBUG,不然日志量直接爆炸 --> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> <appender-ref ref="ERROR_FILE"/> </root> <!-- 本地开发时可以调高自己包的输出级别 --> <springProfile name="dev,local"> <logger name="com.example" level="DEBUG"/> </springProfile> <springProfile name="prod"> <logger name="com.example" level="INFO"/> </springProfile> </configuration>这份配置解决的核心问题是日志文件无限增长。maxFileSize控制单个文件大小,maxHistory控制保留天数,totalSizeCap控制所有历史文件加起来不超过5GB,三个参数配合使用,日志目录就不会变成事故现场。
2.2 每个关键参数背后的计算逻辑
看配置还要懂参数,这里把几个最容易踩坑的点拆开讲。
maxFileSize设为100MB,指的是每个日志片段最多100MB。滚动策略是SizeAndTimeBasedRollingPolicy,时间驱动为主,大小驱动为辅。比如某个业务日流量特别大,当天的app.log写满了100MB,就会生成app.2024-05-20.0.log、app.2024-05-20.1.log这样的分片,直到过了凌晨0点,日期切换后再开新的一天分片。注意:如果只按日期滚动,没有maxFileSize,当天的日志可能撑到几个G不切分,查日志的时候单个文件大到用grep都卡。
maxHistory=20配合fileNamePattern中的日期格式,Logback会在下一次滚动时检查历史文件。比如现在是6月20日,会删除40天之前的日志。这里有个细节我踩过坑:如果fileNamePattern是${APP_NAME}.%d{yyyy-MM-dd}.log,而maxHistory设为20,那Logback是按“天”数来算的,中间缺日志的日期也会被保留到20天后才清理。但如果fileNamePattern里没有.%i,那么同一个日期只能有一个文件,一旦日志量超过设定的maxFileSize,滚动会失效。这也是为什么SizeAndTimeBasedRollingPolicy必须同时配maxFileSize和%i占位符的原因。
charset也很重要。不指定时,本机默认字符集(一般Linux下是UTF-8,Windows是GBK),如果日志里有中文乱码,第一件事就是查这里是不是漏了UTF-8。别在程序里转码,配置里声明是最稳妥的。
2.3 为什么需要单独搞一个ERROR_FILE
很多团队的日志方案只有一个全量文件,线上排查问题时得先grep ERROR再按时间过滤,这对生产环境来说效率太低。单独落一个error.log的价值在于:你可以只关注异常趋势,配合日志平台的告警规则直接对接这个文件;归档时也可以给ERROR日志更长保留期,因为这类文件通常不大。
注意ThresholdFilter和LevelFilter的区别。ThresholdFilter设为ERROR,会把INFO、WARN一并过滤掉,只留ERROR及以上。如果你只想捕获Level.ERROR,已经够用了。但如果你还需要WARN,就需要改用LevelFilter配合两个OnMatch/OnMismatch循环,逻辑更长,所以我一般直接用ThresholdFilter,简单直接。
3. 代码里的日志控制:拆分Logger、MDC链路与动态级别
3.1 用Logger名称切分系统日志与业务日志的工程化做法
配置文件解决了输出目的地的问题,但代码里还有一个常见痛点:同一个服务里有订单服务、商品服务、支付回调,日志全打在一起。看起来没什么,排查时却非常痛苦——你要在几千行混在一起的INFO里找一条支付流水号。
工程上常见做法是给不同的业务模块建不同的Logger,在配置文件里单独为Logger指定Appender:
private static final Logger PAY_LOGGER = LoggerFactory.getLogger("payService"); private static final Logger ORDER_LOGGER = LoggerFactory.getLogger("orderService");然后在logback-spring.xml里:
<appender name="PAY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 配置类似FILE,只需要把文件名改成pay.log --> <file>${LOG_HOME}/pay.log</file> ... </appender> <logger name="payService" level="INFO" additivity="false"> <appender-ref ref="PAY_FILE"/> <appender-ref ref="ERROR_FILE"/> </logger>additivity="false"意味着这些日志不会继续传给root的Appender,避免一份日志打了两份。这种做法适合支付、风控等需要保留独立审计链路的场景。普通业务服务不建议每个模块都单独建文件,文件数量一多,管理和清理成本反而上去。
3.2 MDC:只需要加4行代码,每条日志自动带上请求号
日志里没有请求标识,是排查分布式问题时的最大痛点。服务器上看到一段WARN,你不知道这个日志对应哪个用户、哪笔订单,想根据代码逻辑反推,效率极低。MDC(Mapped Diagnostic Context)就是用来解决这个问题的,它的原理是往当前线程的ThreadLocal里塞键值对,Logback在打印日志时自动从MDC取出并写入pattern。
在过滤器里加这样一段逻辑:
@Component public class TraceIdFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { MDC.put("traceId", UUID.randomUUID().toString().replace("-", "")); MDC.put("requestUri", ((HttpServletRequest) request).getRequestURI()); chain.doFilter(request, response); } finally { MDC.remove("traceId"); MDC.remove("requestUri"); } } }然后在pattern里加上%X{traceId}和%X{requestUri}:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %X{traceId} %logger{50} - %msg%n</pattern>这样每条日志都会带一个独立请求号。如果接入了SkyWalking、OpenTelemetry这类链路追踪框架,TraceId可以替换成框架上下文里的ID,MDC负责承接,实现全链路检索。
说一个容易忽略的细节:MDC用完要remove。因为Tomcat的线程池是复用的,如果线程执行完不清掉MDC,下一次请求从线程池里捞到同一个线程时,会带上旧请求的traceId,出现“日志串号”。用try-finally是最稳的,别只try不finally。
3.3 本地联调时不想重启服务,怎么动态调日志级别
版本上线后想临时看DEBUG日志,最原始的做法是改配置、重启、复现、再重启,整个过程烦得一批。Spring Boot Actuator的loggers端点可以直接在线调整运行时日志级别:
POST /actuator/loggers/com.example.controller {"configuredLevel": "DEBUG"}生产环境开放这个端点需要谨慎,建议只暴露给内网或配合Spring Security限制。还有一种更轻量的做法:自己写一个HTTP接口,把Logger级别调整封装进去,鉴权自己控制。不管哪种方案,都要记住调整是内存级的,重启后失效。想持久化,最终还是要改配置文件。
4. 让MyBatis、Spring MVC这些“邻居”也跟着你的日志体系走
4.1 打印MyBatis SQL慢日志的正确姿势
Spring Boot项目里,MyBatis打印SQL通常是在application.yml里打开mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl。但这个东西是往System.out直接输出,不走Logback统一管理,格式丑且无法归档。更好的方式是用SLF4J实现:
mybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl这样一来,MyBatis的SQL日志会按照mapper接口的完整类名作为Logger打印,可以在Logback里单独配置Logger控制级别:
<logger name="com.example.mapper" level="DEBUG"/>如果需要记录慢SQL,可以在MyBatis拦截器里对PreparedStatement的执行时间做统计,执行超过设定阈值(如500ms)就单独用WARN打一条日志,比全局DEBUG打印更实用。
4.2 Spring Boot框架自身的日志浩如烟海怎么办
开发时经常看到spring-web、spring-security、hibernate打出来的一大片INFO日志,对排查业务问题几乎没用。在Logback配置里用logger标签精准降噪:
<logger name="org.springframework" level="WARN"/> <logger name="springfox" level="WARN"/> <logger name="org.apache.kafka" level="WARN"/>这样线上日志的混杂度会显著降低,关键线索不容易被无关日志淹没。但有一点注意:org.springframework不要一律调到WARN,有些模块比如事务和缓存自身有问题时,INFO级别才有线索,你可以按二级包名再细分。
4.3 异步Appender:日志IO不再是接口性能的隐形杀手
Logback同步Appender在每次输出日志时都执行文件IO操作,高并发场景下这有可能成为吞吐量瓶颈,尤其是当你想打印请求参数和响应体、日志量特别大的时候。推荐用AsyncAppender包一层:
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>1024</queueSize> <includeCallerData>false</includeCallerData> <appender-ref ref="FILE"/> </appender>参数逻辑要说明白。queueSize是阻塞队列长度,日志写入先丢队列,后台线程再消费队列写文件。discardingThreshold在队列剩余容量低于该比例时,会直接丢弃掉TRACE、DEBUG、INFO日志(ERROR和WARN不丢),默认值是队列容量的20%。如果你在本地测试时发现低级别日志频繁丢失,很可能就是这个参数在起作用。
includeCallerData默认false,性能最高,但代价是输出里的类名行号可能是假的或者为?。如果你要求精确到行号,就得开成true,但每个日志事件都要额外抓取一次调用栈,性能有一定损失。我一般是关掉,线上排查靠的是traceId和完整堆栈,不靠行号精确到几行。
5. 部署到服务器后才会遇到的那些坑
5.1 我用Docker部署Spring Boot后,日志时间全部偏移
第一次把Spring Boot应用打镜像推到服务器上,日志文件名字和内容里的时间都跟本地差了8个小时。排查下来是容器默认时区是UTC,而宿主机是本地时区。Logback格式化时读取的是JVM默认时区,容器里没设置就会跟着UTC走。
对应解决办法,在Dockerfile里加一行:
ENV TZ=Asia/Shanghai或者启动时挂载时区:
docker run -e TZ=Asia/Shanghai -v /etc/localtime:/etc/localtime:ro your-image这个问题很隐蔽,因为控制台看日志感觉只是慢了几小时,但滚动策略是按日期切分的,一旦时区错乱,凌晨0点对应的滚动时间也会错位,日志归档可能全挤在同一天。
5.2 只输出到控制台不落盘,Docker一重启日志就没了
有人图省事,在容器里只保留ConsoleAppender,日志全走标准输出让容器引擎收集。如果是生产环境,建议还是保留文件Appender,两种原因:第一,容器引擎的日志轮转策略不是Logback能控制的,一旦容器日志驱动配置不合理,磁盘可能直接被撑爆;第二,排查问题时登录容器直接看文件,比去日志平台翻记录更快。所以在容器里落盘+标准输出双写是我现在的标准配置。
顺带说一句,不要用spring-boot-maven-plugin打出来的jar直接java -jar跑,然后用nohup重定向输出。
5.3 最容易被忽视的日志敏感数据脱敏
自定义日志写起来以后,另一个课题是脱敏。很多公司的规范没到位,日志里身份证号、手机号、令牌这些敏感数据就原样打出来了,一旦日志外泄就是安全事故。Logback没有内置脱敏机制,常见的方案有三层:第一层,在输出前对日志消息里的关键字段打码,用正则替换手机号、身份证号的中间几位;第二层,封装一个日志工具类,统一处理敏感字段再传给Logger;第三层,接入日志平台后由平台侧的脱敏策略处理。
我自己的做法是在内网工具类里加一个脱敏方法,对logger.info的入参统一走格式化,至于说String.replace规则能不能覆盖所有场景,说实话覆盖核心字段就够了,不追求普遍完美。
一些长期用下来的体会
从我这些年维护多套Spring Boot服务的经验来看,日志配置这件事很容易被低估。很多人一开始觉得能跑就行,结果日志量一大,光处理文件磁盘告警就够折腾人。
有几个习惯建议大家尽早养成:配置文件的日志保留策略要每季度看一眼,确认总量控制在磁盘可承受范围内;新服务上线前主动检查Logback配置里是否已经包含滚动策略和ERROR独立文件;打日志时想想别人拿到这条日志有没有办法定位问题,而不是打一堆没用的中间变量。Logback本身不复杂,真正复杂的从来都是日志背后如何服务于排查和监控这个目的。