
1. 日志落盘这件事为什么值得单独拎出来讲刚入行那会儿我对日志的理解就是System.out.println后来学会用日志框架了也基本停留在“控制台能打印出来就行”的阶段。直到有一次帮朋友排查一个线上问题服务跑在容器里控制台输出被平台收走了他想翻三天前的报错信息结果什么都找不到。那一刻我才真正意识到日志不落盘等于没日志。SpringBoot 默认使用 Logback 作为日志实现这一点很多人知道但真正把日志规规矩矩写到本地文件、并且做到“按天切分、按大小切分、保留指定天数、异步不阻塞业务”的人其实没那么多。大部分项目就是加一行logging.file.name完事结果文件越滚越大磁盘被撑爆或者日志格式乱七八糟排查问题时根本没法用。这篇内容就是围绕“SpringBoot 通过 Logback 把日志写到本地文件”这个主题把从零配置到生产级调优的完整链路讲清楚。涉及的核心关键词包括Logback、logback-spring.xml、RollingFileAppender、日志滚动策略、异步日志、MDC、日志级别。不管你是刚接触 SpringBoot 的新手还是想把手头项目日志规范整理一遍的老手都能从里面找到能直接抄作业的配置和踩坑经验。我下面讲的方案都是基于实际项目反复验证过的不是从文档里抄一遍就完事。每个配置项为什么这么写、不这么写会出什么问题我都会说清楚。2. 先搞清楚 Logback 在 SpringBoot 里的加载逻辑2.1 为什么推荐用 logback-spring.xml 而不是 logback.xml这是第一个容易踩的坑。SpringBoot 对 Logback 的支持分两种文件名logback.xml被 Logback 框架自身直接加载加载时机非常早此时 Spring 的 Environment 还没准备好。logback-spring.xml由 SpringBoot 的LogbackLoggingSystem加载可以配合springProfile、springProperty这些 Spring 特有的标签使用。区别在哪举个实际场景。你有多环境配置开发环境想输出到控制台生产环境想写到文件如果用logback.xml你没法根据spring.profiles.active来切换只能写死或者用系统变量绕。而logback-spring.xml可以直接这么写springProfile namedev root levelDEBUG appender-ref refCONSOLE/ /root /springProfile springProfile nameprod root levelINFO appender-ref refFILE/ /root /springProfile所以我的建议很明确只要你是 SpringBoot 项目一律用logback-spring.xml放在src/main/resources目录下。别用logback.xml也别用application.yml里那几行简单的 logging 配置去凑合那点配置能力根本不够用。2.2 SpringBoot 默认配置到底做了什么如果你什么都不配SpringBoot 会有一套默认行为日志只输出到控制台级别是 INFO格式是带颜色高亮的 pattern。这套默认配置对应的其实是 Logback 的base.xml里面定义了两个 appenderCONSOLE和FILE但FILE默认没被引用。这里有个细节很多人不知道SpringBoot 默认其实已经准备好了一个文件 appender只是没挂到 root 上。你只要在application.yml里写一句logging.file.name./logs/app.log它就会启用文件输出。但这种方式的控制粒度太粗滚动策略、格式、异步都没法调所以只适合临时用。一旦你在 resources 下放了logback-spring.xmlSpringBoot 的默认配置就完全失效了一切以你的文件为准。这一点要记住否则会出现“我明明配了文件输出怎么控制台也有”或者“怎么两个文件都在写”的困惑。2.3 配置文件的基本骨架长什么样一个完整的logback-spring.xml骨架大致是这样几块?xml version1.0 encodingUTF-8? configuration !-- 1. 属性定义 -- property nameLOG_HOME value./logs/ !-- 2. appender 定义 -- appender nameCONSOLE class....../appender appender nameFILE class....../appender !-- 3. logger 定义 -- logger namecom.example levelDEBUG/ !-- 4. root 定义 -- root levelINFO appender-ref refCONSOLE/ appender-ref refFILE/ /root /configuration顺序上property要在最前面因为后面要用appender要在logger和root之前因为引用关系。这个顺序错了启动时会报找不到 appender 的错误。我见过有人把 root 写在最上面结果一直报错找不到 FILE折腾半天。3. RollingFileAppender 的滚动策略参数一个都不能瞎填3.1 文件路径和文件名模式的门道写文件日志核心就是RollingFileAppender。它有两个关键属性file和fileNamePattern。appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/app.log/file rollingPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy fileNamePattern${LOG_HOME}/app.%d{yyyy-MM-dd}.%i.log/fileNamePattern maxFileSize100MB/maxFileSize maxHistory30/maxHistory totalSizeCap10GB/totalSizeCap /rollingPolicy ... /appenderfile是当前正在写的活动文件名fileNamePattern是滚动后归档文件的命名模式。这里有个坑file和fileNamePattern不能指向同一个文件否则会报错。活动文件叫app.log归档文件叫app.2024-01-15.0.log这样才清晰。%d{yyyy-MM-dd}是按天滚动%i是当同一天内文件超过maxFileSize时的索引号从 0 开始递增。如果你不加%i同一天内超过大小限制时旧文件会被直接覆盖日志就丢了。这个细节非常关键很多人配了maxFileSize却没加%i结果日志莫名其妙少了。3.2 maxFileSize、maxHistory、totalSizeCap 三者的配合这三个参数是控制磁盘占用的铁三角必须一起配缺一个都可能出问题。参数作用不配的后果maxFileSize单个文件最大体积单文件无限增长打开都卡maxHistory保留多少天的历史历史文件无限堆积磁盘爆满totalSizeCap所有归档文件总大小上限即使有 maxHistory总量仍可能超限我一般这么设maxFileSize100MBmaxHistory30 天totalSizeCap10GB。这样即使某天日志量暴涨总量到 10GB 后最老的文件会被自动删除不会把磁盘写满。注意totalSizeCap必须大于maxFileSize否则会失效。而且它的清理是在滚动时触发的不是实时监控所以实际占用可能略超设定值留点余量。3.3 按天滚动还是按小时滚动怎么选fileNamePattern里的时间格式决定了滚动粒度。%d{yyyy-MM-dd}是按天%d{yyyy-MM-dd_HH}是按小时。怎么选看你的日志量。如果一天日志不到 100MB按天就够了。如果一天几个 G按天会导致单文件太大建议按小时。但按小时也有代价文件数量多查找时得先定位到小时稍微麻烦点。我的经验是中小项目按天大流量项目按小时配合 maxFileSize 兜底。这样即使某一小时量特别大也会被 maxFileSize 切成多个文件不会出现单个超大文件。3.4 归档文件的清理逻辑别指望它实时Logback 的清理是在每次滚动时执行的。也就是说如果服务一直没触发滚动比如日志量很小一天都没写满那过期文件就不会被清理。这个特性导致一个现象低流量服务的历史日志可能保留得比 maxHistory 更久。如果你对磁盘占用非常敏感可以额外加一个定时任务去清理或者用操作系统的日志轮转工具配合。但大多数场景下Logback 自带的清理已经够用了不用过度设计。4. 日志格式怎么定才能让排查效率翻倍4.1 pattern 里每个占位符的含义日志格式由encoder里的pattern决定。一个生产级 pattern 大概长这样encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n/pattern charsetUTF-8/charset /encoder逐个拆解%d{yyyy-MM-dd HH:mm:ss.SSS}时间戳精确到毫秒。排查并发问题时毫秒级很重要。[%thread]线程名。异步场景下能看出是哪个线程打的日志。%-5level日志级别左对齐占 5 位。对齐后日志看起来整齐。%logger{50}logger 名称也就是类名超过 50 字符会缩写。%msg日志内容。%n换行符。charset一定要显式设成 UTF-8否则在有些系统默认编码不是 UTF-8 的环境下中文日志会乱码。这个坑我踩过日志文件里全是问号排查半天才发现是编码问题。4.2 加上 traceId让日志能串起来单条日志好看没用关键是能把一次请求的多条日志串起来。这就得靠 MDCMapped Diagnostic Context。做法是在请求入口处往 MDC 里放一个唯一 IDComponent public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().replace(-, ).substring(0, 16); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.clear(); } } }然后在 pattern 里加上%X{traceId}pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n/pattern这样同一次请求的所有日志都带同一个 traceId用 grep 一搜就能把整条链路捞出来。这个技巧在微服务场景下价值极高尤其是跨服务调用时traceId 能一路透传。注意MDC 是基于 ThreadLocal 的异步线程里拿不到父线程的 MDC。如果用了线程池需要手动传递或者用 TransmittableThreadLocal 这类方案。这个坑不踩一次很难记住。4.3 控制台和文件的格式可以不一样开发时看控制台希望有颜色、简洁点文件里则希望信息全、方便检索。所以两个 appender 可以用不同的 pattern。控制台用带颜色的pattern%d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%logger{36}) - %msg%n/pattern文件用完整的pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n/pattern%highlight和%cyan是 Logback 内置的颜色转换符只在支持 ANSI 的终端生效写到文件里会变成乱码所以千万别在文件 appender 里用。5. 异步日志性能提升明显但有两个坑必须避开5.1 为什么需要 AsyncAppender默认情况下日志是同步写的。也就是说业务线程调用log.info()时要等日志真正写到磁盘才返回。磁盘 IO 慢的时候这会直接拖慢接口响应。AsyncAppender的作用是把日志事件丢到一个阻塞队列里由单独的线程去写盘业务线程立即返回。实测下来高并发场景下 QPS 能提升不少尤其是日志量大的服务。配置方式是把原来的 appender 包一层appender nameASYNC_FILE classch.qos.logback.classic.AsyncAppender appender-ref refFILE/ queueSize1024/queueSize discardingThreshold0/discardingThreshold includeCallerDatafalse/includeCallerData neverBlocktrue/neverBlock /appender然后 root 里引用ASYNC_FILE而不是FILE。5.2 queueSize 和 discardingThreshold 怎么设queueSize是队列容量默认 256。队列满了之后默认行为是丢弃 TRACE、DEBUG、INFO 级别的日志只保留 WARN 和 ERROR。这个行为由discardingThreshold控制默认是 queueSize 的 20%。我一般把queueSize设成 1024 或 2048discardingThreshold设成 0。设成 0 的意思是队列满了也不丢弃任何日志而是阻塞业务线程。这看起来违背了异步的初衷但日志完整性比那点性能更重要。如果日志都能丢那排查问题时就是抓瞎。当然如果你的业务对延迟极度敏感可以保留默认的丢弃策略但要清楚代价是什么。5.3 neverBlock 和 includeCallerData 的取舍neverBlocktrue表示队列满时直接丢弃日志绝不阻塞业务线程。这个参数和discardingThreshold0是矛盾的两个一起配实际效果是 neverBlock 优先还是会丢。includeCallerData控制是否记录调用者的类名、方法名、行号。开启后能精确定位日志来源但代价是每次都要获取堆栈信息性能开销不小。我一般设成 false因为%logger已经能定位到类了行号不是必须的。提示异步日志在服务关闭时可能丢日志因为队列里还有没写完的。可以在关闭钩子里调用LoggerContext的stop()方法让 Logback 把队列刷完再退出。SpringBoot 默认会做这件事但如果你自己管理生命周期要留意。6. 分级别、分模块输出到不同文件6.1 为什么要把 ERROR 单独拎出来生产环境日志量大如果所有级别混在一个文件里找一条 ERROR 得翻半天。把 ERROR 单独输出到一个文件排查问题时直接看这个文件效率高很多。做法是定义一个只接收 ERROR 的 appender配合LevelFilterappender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/error.log/file filter classch.qos.logback.classic.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter rollingPolicy class....../rollingPolicy encoder.../encoder /appenderonMatchACCEPT表示匹配 ERROR 就接收onMismatchDENY表示其他级别直接拒绝。这样 error.log 里就只有 ERROR 日志干净利落。6.2 按包名分流到不同文件大型项目里不同模块的日志混在一起也很乱。可以按包名定义 logger让不同包的日志写到不同文件。appender nameSERVICE_FILE class... file${LOG_HOME}/service.log/file ... /appender logger namecom.example.service levelDEBUG additivityfalse appender-ref refSERVICE_FILE/ /logger关键是additivityfalse。如果不设这个日志会同时写到 SERVICE_FILE 和 root 的 appender 里造成重复。这个属性默认是 true很多人忘了改结果日志写了两遍。6.3 多 appender 组合的实战配置把上面的东西组合起来一个完整的生产配置大概是这样configuration property nameLOG_HOME value./logs/ property namePATTERN value%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n/ appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%logger{36}) - %msg%n/pattern charsetUTF-8/charset /encoder /appender appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/app.log/file rollingPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy fileNamePattern${LOG_HOME}/app.%d{yyyy-MM-dd}.%i.log/fileNamePattern maxFileSize100MB/maxFileSize maxHistory30/maxHistory totalSizeCap10GB/totalSizeCap /rollingPolicy encoder pattern${PATTERN}/pattern charsetUTF-8/charset /encoder /appender appender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/error.log/file filter classch.qos.logback.classic.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter rollingPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy fileNamePattern${LOG_HOME}/error.%d{yyyy-MM-dd}.%i.log/fileNamePattern maxFileSize100MB/maxFileSize maxHistory60/maxHistory totalSizeCap5GB/totalSizeCap /rollingPolicy encoder pattern${PATTERN}/pattern charsetUTF-8/charset /encoder /appender appender nameASYNC_FILE classch.qos.logback.classic.AsyncAppender appender-ref refFILE/ queueSize1024/queueSize discardingThreshold0/discardingThreshold includeCallerDatafalse/includeCallerData /appender appender nameASYNC_ERROR classch.qos.logback.classic.AsyncAppender appender-ref refERROR_FILE/ queueSize512/queueSize discardingThreshold0/discardingThreshold /appender springProfile namedev root levelINFO appender-ref refCONSOLE/ appender-ref refASYNC_FILE/ /root /springProfile springProfile nameprod root levelINFO appender-ref refASYNC_FILE/ appender-ref refASYNC_ERROR/ /root /springProfile /configuration这份配置直接拿去用基本没问题改改包名和路径就行。7. 那些年我在日志配置上踩过的坑7.1 日志文件不生成先查这三个地方配了文件 appender 但文件死活不出现按这个顺序排查路径权限./logs是相对路径相对于进程的工作目录。如果服务以某个受限用户运行可能没权限创建目录。换成绝对路径试试。appender 没被引用定义了 appender 但 root 里没appender-ref等于白定义。检查 root 和 logger 的引用。配置文件没被加载文件名必须是logback-spring.xml或logback.xml且放在 classpath 根目录。放错位置 SpringBoot 找不到。我遇到过一次配置文件放在resources/config/下面结果一直不生效。后来才知道必须在resources根目录或者用logging.config显式指定路径。7.2 日志重复输出两遍的根因日志写了两遍八成是additivity的问题。当你给某个包定义了 logger 并引用了 appender如果没设additivityfalse这个 logger 处理完还会往上传递给 rootroot 的 appender 又处理一遍。解决办法就是给所有自定义 logger 加上additivityfalse。这个属性不设默认是 true属于“不配就出错”的典型。7.3 磁盘被日志写满的应急处理线上磁盘满了第一反应是删日志。但直接rm正在写的文件磁盘空间不会立即释放因为文件句柄还被进程占着。正确做法是# 清空而不是删除 app.log或者用truncate -s 0 app.log。这样文件句柄还在空间立即释放。长期方案还是把maxHistory和totalSizeCap配好别等出事了再救火。另外可以加个磁盘监控告警日志目录使用率超过 80% 就通知。7.4 时间格式写错导致滚动异常fileNamePattern里的时间格式如果写错比如写成%d{yyyy-MM-DD}DD 是年中的天不是月中的天滚动行为会变得很奇怪。正确的是%d{yyyy-MM-dd}小写 dd 才是月中的天。还有%d{yyyy-MM-dd HH:mm}这种带分钟的会导致每分钟滚动一次文件数量爆炸。除非你确实需要否则别用。8. 几个让日志更好用的进阶技巧8.1 用 springProperty 读取配置中心的参数日志路径、级别这些有时候希望从application.yml里读方便不同环境用不同值。可以用springPropertyspringProperty scopecontext nameLOG_HOME sourcelogging.file.path defaultValue./logs/然后在 appender 里用${LOG_HOME}。这样application.yml里配logging.file.path/var/log/myapp日志就写到那里去了。默认值./logs保证没配的时候也能跑。8.2 敏感信息脱敏别把密码写进日志日志里经常不小心打出手机号、身份证、密码。可以在 encoder 里加一个自定义的 converter 做脱敏或者更简单点在 pattern 里对%msg做处理。不过最靠谱的还是从源头控制打日志时别把敏感字段拼进去。我见过有人把整个请求体log.info(request: {}, JSON.toJSONString(req))打出来里面带着密码这就很危险。8.3 日志文件被其他进程占用怎么办Windows 下如果日志文件被记事本打开Logback 滚动时会失败因为文件被锁了。Linux 下一般没这个问题但如果用了某些文件同步工具也可能锁文件。解决办法是排查时用tail -f而不是直接打开文件或者复制一份再看。生产环境尽量别用会锁文件的工具去读日志。8.4 容器环境下日志路径的选择服务跑在容器里日志写到容器内部的文件系统容器一重启就没了。所以要么挂载外部卷要么把日志输出到标准输出由平台收集。如果选择写文件路径建议用环境变量注入比如LOG_HOME/var/log/app然后把这个目录挂载到宿主机。这样容器重启日志还在排查历史问题方便。9. 关于日志配置我个人的几条经验配置日志这件事看起来简单但真正做好需要兼顾性能、可维护性和排查效率。我自己的习惯是项目一开始就把logback-spring.xml建好别等到出问题了再补。模板化的配置直接复制改改包名和路径就能用省得每次重新想。另外日志级别别乱开。生产环境 root 用 INFO业务包可以用 DEBUG但千万别全局 DEBUG那日志量能把你磁盘写爆。需要临时排查时可以通过配置中心动态调级别排查完再调回来。还有一点日志内容要写清楚上下文。log.info(error)这种日志等于没写要带上关键参数比如log.info(order create failed, orderId{}, userId{}, orderId, userId)。这样排查时一眼就能看出是哪个订单、哪个用户出的问题。最后定期 review 一下日志配置。业务在发展日志量在变半年前配的maxFileSize和maxHistory可能早就不合适了。我一般每季度看一次日志目录的占用情况该调的调该清的清。