
半夜三点被手机震醒看了一眼告警群磁盘使用率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.exampleDEBUG然后希望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 version1.0 encodingUTF-8? configuration scantrue scanPeriod30 seconds !-- 统一读取Spring环境里的应用名 -- springProperty scopecontext nameAPP_NAME sourcespring.application.name defaultValuemy-app/ !-- 日志根目录默认当前目录下logs -- property nameLOG_HOME value${LOG_HOME:-./logs}/ !-- 单个文件上限 -- property nameMAX_FILE_SIZE value100MB/ !-- 最多保留20天 -- property nameMAX_HISTORY value20/ !-- 保留文件总大小上限 -- property nameTOTAL_SIZE_CAP value5GB/ !-- 控制台Appender启动阶段和调试时用 -- appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{50}) - %msg%n/pattern charsetUTF-8/charset /encoder /appender !-- 业务日志文件Appender按日期和大小滚动 -- appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/${APP_NAME}.log/file rollingPolicy classch.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 charsetUTF-8/charset /encoder /appender !-- ERROR日志单独一份排错不用去全量日志里捞 -- appender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/${APP_NAME}-error.log/file filter classch.qos.logback.classic.filter.ThresholdFilter levelERROR/level /filter rollingPolicy classch.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 charsetUTF-8/charset /encoder /appender !-- 根LoggerINFO起步别用DEBUG不然日志量直接爆炸 -- root levelINFO appender-ref refCONSOLE/ appender-ref refFILE/ appender-ref refERROR_FILE/ /root !-- 本地开发时可以调高自己包的输出级别 -- springProfile namedev,local logger namecom.example levelDEBUG/ /springProfile springProfile nameprod logger namecom.example levelINFO/ /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都卡。maxHistory20配合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-8Windows是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指定Appenderprivate static final Logger PAY_LOGGER LoggerFactory.getLogger(payService); private static final Logger ORDER_LOGGER LoggerFactory.getLogger(orderService);然后在logback-spring.xml里appender namePAY_FILE classch.qos.logback.core.rolling.RollingFileAppender !-- 配置类似FILE只需要把文件名改成pay.log -- file${LOG_HOME}/pay.log/file ... /appender logger namepayService levelINFO additivityfalse appender-ref refPAY_FILE/ appender-ref refERROR_FILE/ /loggeradditivityfalse意味着这些日志不会继续传给root的Appender避免一份日志打了两份。这种做法适合支付、风控等需要保留独立审计链路的场景。普通业务服务不建议每个模块都单独建文件文件数量一多管理和清理成本反而上去。3.2 MDC只需要加4行代码每条日志自动带上请求号日志里没有请求标识是排查分布式问题时的最大痛点。服务器上看到一段WARN你不知道这个日志对应哪个用户、哪笔订单想根据代码逻辑反推效率极低。MDCMapped 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可以替换成框架上下文里的IDMDC负责承接实现全链路检索。说一个容易忽略的细节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-implorg.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 namecom.example.mapper levelDEBUG/如果需要记录慢SQL可以在MyBatis拦截器里对PreparedStatement的执行时间做统计执行超过设定阈值如500ms就单独用WARN打一条日志比全局DEBUG打印更实用。4.2 Spring Boot框架自身的日志浩如烟海怎么办开发时经常看到spring-web、spring-security、hibernate打出来的一大片INFO日志对排查业务问题几乎没用。在Logback配置里用logger标签精准降噪logger nameorg.springframework levelWARN/ logger namespringfox levelWARN/ logger nameorg.apache.kafka levelWARN/这样线上日志的混杂度会显著降低关键线索不容易被无关日志淹没。但有一点注意org.springframework不要一律调到WARN有些模块比如事务和缓存自身有问题时INFO级别才有线索你可以按二级包名再细分。4.3 异步Appender日志IO不再是接口性能的隐形杀手Logback同步Appender在每次输出日志时都执行文件IO操作高并发场景下这有可能成为吞吐量瓶颈尤其是当你想打印请求参数和响应体、日志量特别大的时候。推荐用AsyncAppender包一层appender nameASYNC_FILE classch.qos.logback.classic.AsyncAppender discardingThreshold0/discardingThreshold queueSize1024/queueSize includeCallerDatafalse/includeCallerData appender-ref refFILE/ /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 TZAsia/Shanghai或者启动时挂载时区docker run -e TZAsia/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本身不复杂真正复杂的从来都是日志背后如何服务于排查和监控这个目的。