1. 先分清 logback-spring.xml 与 logback.xml 的加载差别
1.1 谁在什么时候读这两个文件
很多人第一次配日志都有过这个疑惑:同样是放在src/main/resources下面的 XML,为什么有人叫logback.xml,有人叫logback-spring.xml,两个文件里写的东西还差不多。实际上差别不在内容,而在谁解析它、什么时候解析它。
Logback 自己是有一套默认查找逻辑的:它在类路径下按logback-test.xml→logback.xml的顺序找配置文件,找到就直接用 Logback 原生解析器(Joran)解析。这个过程发生在应用启动的非常早期,早到 Spring 的Environment还没准备好,属性占位符还没解析完。
logback-spring.xml走的是另一条路:Spring Boot 的LoggingApplicationListener在环境准备完成后才开始初始化日志系统,这时它会优先去找带-spring后缀的那个文件,并且用 Spring Boot 定制的 Joran 配置器去解析——这个定制解析器额外注册了<springProfile>和<springProperty>两个标签。所以结论很直接:想用 Spring 的 profile 和配置属性,就必须用logback-spring.xml;用了logback.xml就只能写纯 Logback 的原生语法。
1.2 springProfile 和 springProperty 这两个标签的价值在哪
先说<springProfile>。没有 Boot 之前,要区分开发和生产环境的日志级别,常见做法是在代码里判断,或者维护两份 XML 用构建工具替换。有了这个标签就简单了:
<springProfile name="dev | test"> <root level="DEBUG"> <appender-ref ref="CONSOLE"/> </root> </springProfile>name属性支持三种写法:单个 profile 名、用|连接多个(命中任意一个即生效)、前面加!表示取反(比如!prod表示当前不是生产环境时生效)。这里有个容易被忽略的点:|前后建议留空格,dev|test虽然多数情况下也能跑,但不同版本对空格容忍度不一致,写成dev | test是最稳的。
再说<springProperty>,它是从 Spring 的Environment里读配置,等价于把application.yml里的值搬到 logback 里用:
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="demo-app"/>这里scope一定要写context。写local(默认值)的时候,这个属性只在当前<configuration>的直接子节点里可见,一旦你在<appender>内部或者<property value="${APP_NAME}"/>里引用,就会取不到值,最后日志路径变成${APP_NAME}这一串字面量,写完文件你自己都找不到。这个坑我见过至少三次不同的项目踩。
1.3 三种文件命名该怎么选
| 文件名 | 解析方 | 支持 springProfile | 支持 springProperty | 适用场景 |
|---|---|---|---|---|
logback.xml | Logback 原生 | 否 | 否 | 纯 Logback 项目、SDK、单元测试 |
logback-spring.xml | Spring Boot | 是 | 是 | 绝大多数 Spring Boot 应用 |
logback-spring.xml+logging.config指定 | Spring Boot | 是 | 是 | 需要按环境加载不同配置文件 |
如果你的项目是标准的 Spring Boot 应用,直接选第二个,不要犹豫。另外注意别把两个文件同时丢进 classpath,Logback 的原生查找和 Spring Boot 的查找是两套逻辑,同时存在时实际生效的是哪一个取决于加载顺序,属于典型的"配置写了但没生效"来源。
2. 一份可以原样粘贴的完整配置,以及每一块为什么这么写
2.1 头部声明、全局属性与滚动参数
先把完整配置贴出来,后面逐块解释。这份配置我用了很多个项目,改动最多的只有包名和路径:
<?xml version="1.0" encoding="UTF-8"?> <configuration scan="true" scanPeriod="60 seconds" debug="false"> <springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="demo-app"/> <property name="LOG_HOME" value="${LOG_HOME:-./logs}"/> <property name="CHARSET" value="UTF-8"/> <property name="MAX_FILE_SIZE" value="100MB"/> <property name="MAX_HISTORY" value="30"/> <property name="TOTAL_SIZE_CAP" value="10GB"/> <property name="CONSOLE_PATTERN" value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%-5level) [%thread] %clr(%logger{40}){cyan} - %msg%n"/> <property name="FILE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{60} - %msg%n"/>scan="true"配合scanPeriod表示每 60 秒检查一次配置文件有没有被改动,改了不用重启。这个功能在开发阶段很香,但要注意:生产环境建议关掉。一是留一个定时扫描线程没必要,二是容器环境下配置文件通常不会变,三是万一扫描时文件正好被改坏,会把日志系统带崩。我一般的做法是开发环境留scan,生产改成false,或者干脆通过${LOG_SCAN:-false}做成可变量。
LOG_HOME用了${LOG_HOME:-./logs}这种带默认值的写法,冒号加短横线表示"取不到就用后面的默认值"。这样你在本地跑就是项目目录下的logs,在服务器上用-DLOG_HOME=/data/logs或者设置同名环境变量就能覆盖,不用改一行 XML。
2.2 控制台 appender:什么时候该用 %clr
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${CONSOLE_PATTERN}</pattern> <charset>${CHARSET}</charset> </encoder> </appender>ConsoleAppender默认写System.out,这个默认值是合理的,但如果你要和某些老系统做 stdout/stderr 分流,可以用<target>System.err</target>切过去。charset一定要写,尤其是 Windows 本地开发时控制台默认编码不是 UTF-8,中文日志会变成乱码。
关于%clr(...),需要额外说明一句:它不是 Logback 自带的转换符,是 Spring Boot 注册的彩色转换器。正常跑 Spring Boot 应用能直接用,但如果你把配置文件拿去给非 Spring 场景用,或者在某些裁剪过的运行环境里,可能会看到这个报错:
There is no conversion class registered for conversion word [clr]遇到这种报错有两个办法,我更推荐第二个:
- 把
%clr(%-5level)换成 Logback 自带的%highlight(%-5level),这个转换符不需要 Spring Boot 支持; - 在
<configuration>里显式补上转换器声明:
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/> <conversionRule conversionWord="wEx" converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>顺带说一句,%wEx也是 Spring Boot 提供的异常堆栈转换器,它会在异常堆栈前后自动加空行、去掉多余空白,让堆栈在日志里看起来清爽很多。这个同样依赖 Spring Boot 的注册,遇到"找不到转换词"的报错就按上面的思路处理。
2.3 文件 appender 与 SizeAndTimeBasedRollingPolicy 的参数口径
<appender name="FILE_ALL" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}/${APP_NAME}.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/${APP_NAME}/history/${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>${FILE_PATTERN}</pattern> <charset>${CHARSET}</charset> </encoder> </appender>这段里最值得讲的是SizeAndTimeBasedRollingPolicy。它同时按时间和大小滚动,比单纯的TimeBasedRollingPolicy更实用——因为很多线上服务单日日志能到几个 G,光按天分会切出一个巨大的文件,出问题时用grep拉都拉不动。
三个参数的口径一定要搞清楚,这是最容易理解错的地方:
maxFileSize:单个文件的大小上限。注意它限制的是"当前正在写的这个文件",滚出去的历史文件都是小于等于这个值的。设100MB是个比较通用的平衡点,既能保证单文件打开快,也不会滚出太多小文件。maxHistory:保留的周期数,不是文件个数。周期粒度由%d决定,%d{yyyy-MM-dd}就是按天算。所以maxHistory=30表示保留 30 天的归档。同一天因为超过maxFileSize滚出的多个文件,是算在同一个周期里的。很多人以为maxHistory=30是留 30 个文件,然后发现磁盘占用远超预期,就是踩了这个理解偏差。totalSizeCap:所有历史文件的总体积上限,这个是硬约束,超过就开始从最旧的删。它比maxHistory更可靠,因为它是按实际空间算的。
fileNamePattern里的%i是必须的。如果你用了SizeAndTimeBasedRollingPolicy却漏了%i,启动时 Logback 会直接报错说模式里缺少整数标记,日志系统初始化失败——整个应用连日志都没有,排查起来非常难受。
.gz后缀会自动触发压缩,压缩比通常在 8:1 到 15:1 之间。按这个比例可以反推一下容量:假设某服务每天产生 2GB 原始日志,压缩后大约 150 到 250MB,30 天就是 4.5 到 7.5GB,totalSizeCap设10GB刚好留了余量。要是你设的是5GB,那就意味着实际可能只保住 20 天左右的历史,出问题回溯超过三周就查不到了。这个账最好在配置的时候心里过一遍。
2.4 错误日志单独落一个文件
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}/${APP_NAME}-error.log</file> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>ERROR</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> ... </appender>线上出故障的时候,最怕的就是全量日志文件几个 G,ERROR埋在几千行INFO中间。单独把错误日志导一份出来,运维和开发都能秒查。这里用的是LevelFilter,它的语义是"精确匹配某个级别":onMatch=ACCEPT表示命中 ERROR 就收,onMismatch=DENY表示其它级别一律拒绝。
如果你想要的是"WARN 及以上",应该换成ThresholdFilter:
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>WARN</level> </filter>这两个过滤器的差别值得记住:LevelFilter只认你写的那一个级别,ThresholdFilter认的是"大于等于"。另外ThresholdFilter对不满足条件的事件返回的是DENY,所以单独用一个就能实现"只要 WARN 以上",不需要额外配onMismatch。
2.5 AsyncAppender 包装:队列丢日志的真相
<appender name="ASYNC_ALL" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>1024</queueSize> <discardingThreshold>0</discardingThreshold> <neverBlock>false</neverBlock> <includeCallerData>false</includeCallerData> <appender-ref ref="FILE_ALL"/> </appender>AsyncAppender的作用是把磁盘 IO 从业务线程里摘出去,交给一个后台线程写。看起来只是套一层,实际直接影响接口的 P99 延迟——日志文件所在磁盘一旦抖动,同步写就可能把业务线程卡住几十毫秒。
三个关键参数:
queueSize:队列长度,默认只有 256。这个默认值在 QPS 稍高的服务里根本不够,稍微来一波流量队列就满。我一般按经验值起步 1024,日志量特别大的服务会调到 4096 到 8192,同时盯着内存占用。discardingThreshold:这个参数名字很唬人,默认值是queueSize / 5,意思是当队列剩余容量低于这个阈值时,自动丢弃 TRACE、DEBUG、INFO 三个级别的日志。这就是很多人遇到的"日志莫名少了几行"的元凶——不是日志系统坏了,是异步队列主动丢的。所以只要你的日志不是纯粹为了压测吞吐,就把它设成0,表示永远不主动丢弃。代价是队列填满后开始阻塞业务线程,这个取舍后面第 5 节还会说。neverBlock:设true表示队列满了直接丢事件而不是阻塞。这个参数和discardingThreshold=0是一对矛盾项:一个说"绝不主动丢",一个说"绝不阻塞"。两个都想要是不可能的,必须根据业务取舍。支付类、订单类这种日志不能丢的场景选"阻塞不丢";纯统计类、埋点类可以接受丢一小部分,选neverBlock=true。includeCallerData:是否采集调用方信息(类名、方法名、行号)。默认false,采集这个非常慢,因为它要遍历整个栈帧。除非你确定要用%caller输出调用位置,否则保持false。
关于异步还有两点容易踩的:一是MDC 和线程名在异步场景下不会丢,Logback 在事件入队前会调用一次预处理把 MDC 快照和线程名固化到事件对象上,所以%X{traceId}和%thread照样能打出来,这点可以放心;二是不要在配置里手动加<shutdownHook/>,让 Spring Boot 自己管日志系统的生命周期就行,重复注册关闭钩子会让关闭阶段的顺序变得不可控,异步队列里的尾巴日志有可能刷不出去。
2.6 profile 拆分与第三方包降噪
<logger name="org.springframework" level="WARN"/> <logger name="org.apache" level="WARN"/> <logger name="com.zaxxer.hikari" level="INFO"/> <springProfile name="prod"> <root level="INFO"> <appender-ref ref="ASYNC_ALL"/> <appender-ref ref="ASYNC_ERROR"/> </root> </springProfile>降噪这件事看着不起眼,实际很影响排查效率。Spring 的INFO级别经常打一堆 bean 相关的内容,org.apache下面各种组件的启动信息也很啰嗦,把它们压到WARN之后,日志文件里剩下的大多是你自己的业务日志,翻起来舒服很多。
生产环境特别要注意的是:不要把CONSOLEappender 挂到生产的 root 上。容器化部署的时候,标准输出会被 Docker 或 Kubernetes 收集成一套独立的日志,同时你又在写文件,等于同一份日志存两遍,磁盘和采集通道都被浪费一倍。所以生产只挂文件 appender,控制台留给开发和测试环境。
3. 让控制台吐出 MyBatis 的 SQL:logger 名字到底写什么
3.1 MyBatis 用哪个字符串当 logger 名
这是我被问得最多的问题:配置文件里<logger name="???">到底该填什么才能打出 SQL。答案不复杂,但要知道背后的机制:MyBatis 在执行语句时,用的 logger 名字是mapper 接口的全限定名——因为 XML 里的namespace通常就写成了接口的全限定名,两者是同一个东西。
假设你的 mapper 接口在com.example.demo.mapper包下,那么:
<logger name="com.example.demo.mapper" level="DEBUG"/>这一行就能把所有 mapper 的 SQL 打出来。打开之后你会看到三种输出:
==> Preparing: select id, name from user where id = ? ==> Parameters: 1(Long) <== Total: 1分别对应准备语句、参数、影响行数。注意它不会把查询结果集的内容打出来,所以不用担心日志里泄露业务数据,这一点比很多人的预期要安全。
常见的几个"配了没效果",原因通常是这几类:
- logger 名字写成了 XML 文件路径,比如
mapper/UserMapper.xml或者classpath:mapper/*.xml。这是不对的,MyBatis 用的是接口全限定名,跟你 XML 放哪没关系。 - 名字写成了
org.mybatis。这个包名下面打的是框架自己的初始化信息,不是 SQL。org.mybatis.spring.SqlSessionUtils调到 DEBUG 能看到会话的创建和销毁,容易误以为是在打 SQL。 - 包名层级写漏了。
com.example.demo和com.example.demo.mapper是两个不同的 logger,Logback 的 logger 是层级继承的,但level在多级继承里用的是最近的那个非空级别。如果你在com.example.demo上设了INFO,又在com.example.demo.mapper上设了DEBUG,mapper 上的 DEBUG 会生效,这是对的;反过来只设父包是 DEBUG,子包不设,也会生效。但如果子包被别的配置设成了INFO,父包的DEBUG就盖不过来了。 - 还配了
logging.level.*。Spring Boot 会在加载完 XML 之后,把Environment里logging.level.开头的属性再应用一遍。所以两处同时配且值不一致时,以 yaml 或启动参数里的为准。我一般建议 SQL 级别只在一个地方配,要么全在 yaml,要么全在 XML,别两边都写。
3.2 additivity 和日志重复输出的关系
配到一定程度一定会遇到这个问题:加了<appender-ref>之后,同一行日志在控制台出现了两次。
原因在于 Logback 的 logger 继承机制。每个 logger 默认additivity="true",意思是"我处理完自己的 appender 之后,还要把事件往上交给父 logger 的 appender 处理一遍",一层层交到 root。所以如果你在某个业务 logger 上挂了CONSOLE,而 root 上也挂了CONSOLE,同一条日志就被打了两次。
解决方式就是关掉向上传递:
<logger name="com.example.demo.mapper" level="DEBUG" additivity="false"> <appender-ref ref="FILE_SQL"/> </logger>这个写法适合"某个包的日志只想单独落一个文件、不想混进主日志"的场景。但要注意:一旦设成false,这个 logger 下面的所有子 logger 也都不会再往 root 传了,你可能会突然发现某些日志不见了。所以加additivity="false"之前,先确认这个包下面没有别的需要走主日志的类。
顺带说一个反直觉的点:只配 level 不配 appender-ref 是完全可以的。因为additivity默认是true,事件会自然冒泡到 root,root 上挂的 appender 会处理它。这也就是为什么前面那份配置里只写了<logger name="com.example.demo.mapper" level="DEBUG"/>就够了,不需要额外挂 appender。
3.3 换 ORM 框架后该配什么
现在项目里除了 MyBatis,MyBatis-Plus 和 JPA 也很常见,logger 名字各不相同,做个对照方便直接抄:
| 框架 | 打印 SQL | 打印参数 | 备注 |
|---|---|---|---|
| MyBatis | mapper 接口包名DEBUG | 同上(同步打印) | 最省事,一行搞定 |
| MyBatis-Plus | mapper 接口包名DEBUG | 同上 | MP 沿用了 MyBatis 的日志通道 |
| Hibernate 6(Boot 3.x) | org.hibernate.SQLDEBUG | org.hibernate.orm.jdbc.bindTRACE | 参数要 TRACE 才出来 |
| Hibernate 5(Boot 2.x) | org.hibernate.SQLDEBUG | org.hibernate.type.descriptor.sqlTRACE | 注意包名差异 |
| Druid 连接池 | druid.sql.StatementDEBUG | 同左 | 走的是连接池自己的代理 |
多数据源场景下,最清晰的做法是按包名分组。比如订单库的 mapper 在com.example.order.mapper,用户库的在com.example.user.mapper,那就配两条独立的 logger,各调各的级别,互不干扰。比在同一个包下靠EvaluatorFilter判断堆栈要可靠得多。
如果你需要的是"完整可执行 SQL"(参数已经拼进去的那种),logger 级别这条路走不通,得靠 SQL 代理工具,比如 p6spy 或者数据源自带的日志代理。不过这类工具会额外引入一层代理,性能上有损耗,只在排查阶段临时开,别常驻。
4. pattern 里的占位符逐个拆,以及 traceId 打通的完整链路
4.1 常用转换符清单与对齐技巧
pattern是决定日志好不好读的关键,把常用转换符列个表,遇到忘了就查:
| 转换符 | 含义 | 常用写法 |
|---|---|---|
%d | 时间 | %d{yyyy-MM-dd HH:mm:ss.SSS} |
%p/%level | 级别 | %-5level(左对齐补到 5 位) |
%t/%thread | 线程名 | %15.15t(超过 15 字符截断) |
%logger | logger 名 | %logger{40}(超长从中间省略) |
%m/%msg | 日志内容 | %msg |
%n | 换行 | 必写,不写所有日志挤成一行 |
%X{key} | MDC 值 | %X{traceId:-} |
%ex/%throwable | 异常堆栈 | 一般不用写,pattern 末尾会自动带 |
%clr(...) | 配色 | Spring Boot 专用 |
%highlight | 按级别配色 | Logback 自带 |
两个实用技巧。第一个是对齐:%-5level里的5是指定最小宽度,-是左对齐。不写这个的话,INFO和ERROR宽度不同,日志的级别列会参差不齐,看起来很难受。同理%logger{40}会保证 logger 列宽度相对稳定。
第二个是截断:%15.15t中两个数字分别是"最小宽度"和"最大宽度"。有些框架的线程池线程名长得离谱,比如pool-3-thread-12-executor-abc12345,不加截断会把整行日志撑得很长,在终端里频繁折行。截断到 15 个字符足够区分了。
%logger{40}的省略规则是从中间省,保留包名开头和类名,比从头砍掉前面要好得多,因为类名才是你最关心的。
4.2 把 traceId 串起来:三步走
分布式环境下没有 traceId 的日志基本等于没用。完整做法分三步。
第一步,写一个过滤器往 MDC 里塞值。用 Servlet 的 Filter,在请求进来时生成一个 ID:
@Component public class TraceIdFilter extends OncePerRequestFilter { private static final String TRACE_ID = "traceId"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); } MDC.put(TRACE_ID, traceId); try { response.setHeader("X-Trace-Id", traceId); chain.doFilter(request, response); } finally { MDC.remove(TRACE_ID); } } }两个细节:一是优先取上游传过来的X-Trace-Id,这样整条调用链共用一个 ID;二是finally里一定要MDC.remove,因为线程池会复用线程,不清理干净,下一个请求可能带着上一个请求的 traceId,排查时会被彻底带偏。
第二步,在 pattern 里用%X{traceId:-}取出来。冒号加短横线是默认值语法,取不到时显示为空,比显示null干净。
第三步,跨线程和跨服务传递。跨服务通过 HTTP 头或者消息队列的头属性传,自己在拦截器里读;跨线程池要注意 MDC 是基于 ThreadLocal 的,用@Async或者手写线程池时不传上下文就会丢,需要在提交任务时手动把 MDC 的值取出来再塞进去。这一块内容不少,核心就一句话:MDC 不会自动跟着线程走,凡是换线程的地方都要手动搬一次。
4.3 彩色日志到底要不要带到生产
我的答案是:文件日志里不要,控制台里可以有。
文件里的 ANSI 颜色码是一堆\u001B[31m这样的转义字符,用cat、grep、less看的时候全是乱码,而且白白占用存储。所以文件 pattern 用纯文本,控制台 pattern 用%clr或%highlight上色,两边分开配置——这正是前面那份配置把CONSOLE_PATTERN和FILE_PATTERN拆成两个变量的原因。
再补一点,即使是在控制台,也要看终端环境。有些 CI 的日志输出不支持 ANSI,会把颜色码原样打出来,看起来更乱。如果你的日志会走 CI 的界面展示,建议也把颜色去掉。
5. 改完之后怎么验证,以及四类高频故障的排查路径
5.1 先看 Logback 自己的内部状态
配置改了但感觉没生效的时候,第一件事不是猜,是让 Logback 把自己的加载过程打出来。两个办法:
一是把<configuration debug="true">打开,重启后会看到成片的 Logback 内部状态,包括每个 appender 是否启动成功、每个 logger 的最终级别、每个文件的实际路径。这个输出很啰嗦,但信息量极大,排查完之后记得改回false。
二是运行时挂一个状态监听器,不用改主配置:
<statusListener class="ch.qos.logback.core.status.OnConsoleStatusListener"/>我的习惯是:本地排查用debug="true",容器里排查用监听器,因为容器改文件不方便,加个环境变量控制更灵活,比如debug="${LOG_DEBUG:-false}"。
5.2 文件不生成、路径不对、历史被误删
这类的排查链路我总结成一张表,按顺序对一遍基本就定位到了:
| 现象 | 最可能的原因 | 确认方法 |
|---|---|---|
| 文件根本没生成 | 用了相对路径,实际写在启动目录而不是你预期的位置 | 用pwd看进程工作目录,或者直接改成绝对路径 |
| 文件生成了但是空的 | appender-ref 没挂到 root 或对应 logger 上 | 打开debug="true"看 appender 有没有被引用 |
| 启动直接报错 | fileNamePattern缺%i,或者某个转换符不存在 | 看启动日志里的 StatusManager 报错 |
| 历史文件被莫名删掉 | cleanHistoryOnStart或totalSizeCap触发 | 核对实际大小是否超过 cap |
| 权限被拒 | 容器内以非 root 用户运行,挂载目录无写权限 | 检查docker run的 user 和挂载目录权限 |
相对路径这一条我单独强调一下。./logs的含义是"进程的当前工作目录",不是你项目源码的目录,也不是 jar 包所在目录。用java -jar启动时,工作目录是执行命令时所在的目录;用 systemd 启动时,工作目录可能是/,于是日志就跑到了根目录下。所以生产环境一定用绝对路径,通过-DLOG_HOME=或者环境变量注入。
cleanHistoryOnStart这个参数比较隐蔽,它表示"启动时先做一次历史清理"。看似无害,但如果你是因为磁盘告警临时调小了totalSizeCap,重启一次就会立刻把超出部分全部删掉,本来还想保留的历史可能就没了。要用的话心里得清楚这一点。
5.3 异步丢日志与关闭时丢日志
异步丢日志有两种,要分开处理。
运行中丢:前面说过的discardingThreshold是主因。默认值等于队列的五分之一,只要队列消耗到那个水位就开始丢INFO以下的事件。如果你发现日志"断断续续少一些",先把它设成0,再看队列是否经常被打满。如果打满频繁,说明queueSize太小或者磁盘写得太慢,这两个方向分别调。
关闭时丢:应用停止的时候,异步队列里可能还压着几百条事件,如果直接退出就没了。正常情况下 Logback 会在 context 停止时把队列里的内容刷完,但如果你手动加了<shutdownHook/>,或者用kill -9强杀,就没这个机会了。所以:一是别手动加关闭钩子;二是优雅停机要配到位,容器里给足terminationGracePeriodSeconds;三是紧急重启尽量用kill -15而不是kill -9。
5.4 运行时改级别:不用重启的办法
日志级别调整最高频的需求就是"线上出问题了,想临时把某个包调到 DEBUG"。重启一次代价太大,用 Actuator 的loggers端点可以在运行时改:
management: endpoints: web: exposure: include: loggers然后:
curl -X POST http://localhost:8080/actuator/loggers/com.example.demo.mapper \ -H "Content-Type: application/json" \ -d '{"configuredLevel":"DEBUG"}'改完立刻生效,排查完再调回INFO。这个端点默认不暴露,需要显式打开,而且在生产环境一定要配合权限控制,不然等于给外部开了一个能随意改日志级别的口子。
提示:运行时的级别调整只对当前进程有效,重启后会回到配置文件里的值。所以排查完之后,别忘了把配置文件的级别也一并改回去,否则下次重启又回到 DEBUG 了。
5.5 最后说几个实操里反复验证过的细节
第一,<springProperty>的defaultValue一定要写。本地开发时spring.application.name可能没配,没有默认值的话日志路径里就会出现${APP_NAME}这种字面量。
第二,日志路径只在一个地方定义。如果 yaml 里配了logging.file.name或者logging.file.path,XML 里又自己拼了一套路径,两套逻辑会让人分不清到底哪个生效。我倾向于全在 XML 里用LOG_HOME控制,yaml 里不写日志路径相关的配置。
第三,<root>的级别别设成 DEBUG。root 是所有 logger 的兜底,设成 DEBUG 会把你依赖的所有第三方库全部放开,日志量能涨十倍以上,磁盘和采集都得遭殃。要调级别就在具体的包里调。
第四,本地开发和生产的配置差异,尽量靠<springProfile>解决,而不是维护两份文件。两份文件最大的问题是长期不同步,改了一份忘一份,最后两边行为不一致,排查时还得先确认用的是哪份配置。
第五,改完配置跑一次压测。日志的写入路径对性能有实际影响,尤其是开了异步、调过队列大小之后,一定要观察一下队列有没有长时间处于高水位。我吃过一次教训:把一个高频接口上的日志从DEBUG放开,队列持续打满,同步阻塞开始出现,接口耗时从 20ms 涨到了 200ms,最后是连着加queueSize和把不必要的日志降级一起解决的。日志这块,能少打就少打,真的需要的时候再打开,比重启一次便宜得多。