☰
一行log.info引发P1事故:日志打印背后的性能陷阱与线程堆栈排查
2026/10/20 14:18:49 网站建设 项目流程

那起事故被复盘了三次,每次都是从最开始那一行log日志讲起。某个周二的下午,订单服务开始大面积超时,告警群被刷屏,线上工单直接打到 P1。所有人第一反应都是去看新发布的业务代码,七行变更,看不出任何能引发事故的点,其中一行还是log.info("pushResult:{}", resultDTO);。当时团队里没有一个人把那行日志当回事——日志而已,能出什么大事?结果就是这行日志,硬生生让核心服务在业务高峰前无法恢复,也彻底改掉了我对“日志只是不起眼的配角”这个认知。这篇文章我会从事故现场、线程堆栈、变更 diff、根因分析到修复预防,完整复盘一遍。如果你还在用“日志不可能出事”的直觉写代码,建议你认真看完。

1. 事故现场:P1 工单、全链路 Trace 和最初被甩的锅

1.1 那天的监控曲线长什么样

事故发生当天,服务本身运行了十几天都没问题,故障窗口正好落在一轮小版本发布之后。监控大屏上的曲线非常吓人:核心接口的 P99 从平时的 80ms 直接涨到 20s 以上,错误率从 0.2% 一路爬到 17%,并且还在继续往上走。最直接的用户体感就是下单流程卡死、支付回调迟迟不返回,业务群里的“是不是出事了”还没问完,P1 工单就派下来了。

我们团队的第一反应和所有研发一样:先把发布窗口里的代码 diff 翻出来看。这次发布只涉及一个服务,七行代码改动,其中一个变量命名微调,一个异常日志追加,还有一行新增的log.info。从表面判断,这七行代码怎么都不可能把 CPU 干到 100%,也不可能让下游连接池被打满。可事实就摆在眼前,服务确实在这轮发布后开始抖动。

1.2 最初怀疑的几条线全部被排除

复盘第一步是排除常见嫌疑。我们先查了数据库慢查询,DBA 那边反馈最近的慢 SQL 曲线和平常没什么区别,索引命中情况也正常;然后看 GC 日志,老年代增长和回收频次都在合理范围内,没有明显的 Full GC 风暴;再看中间件依赖,Redis 的 latency 没有异常,消息队列堆积量也没有波动。最后所有人的目光都落在了依赖的下游支付通道上,因为 trace 显示我们调用某个支付通道的成功率在下降,而且对端连接数突然暴涨。

这里差点犯了经验主义错误。如果我们按这个方向继续查下去,可能就会给对端发工单,然后变成“两个团队互打乒乓球”。真正把方向拉回来的是全链路 trace 里的一段耗时分解:从入口到出口的整条调用链上,数据库耗时、Redis 耗时、下游 HTTP 调用的耗时全部正常,但某一层 filter 和组件调用占了整条链路的 97% 以上。也就是说,请求几乎没在做业务,绝大部分时间都消耗在了一个“看起来不像业务逻辑”的地方。

1.3 全链路 Trace 暴露出的关键异常

Trace 里那段莫名高耗时的点,在调用链路上对应的是一段日志处理逻辑。因为我们的服务为了统一日志安全,接了一个日志脱敏组件,会在日志写出前对内容做序列化和字段脱敏。当时大家看到这一段都不太相信:日志组件就算慢,最多也就是丢几条日志,怎么可能让业务线程卡死?但数据是不会骗人的——耗时占比 97%,而且这个比例在所有异常请求里都存在,这已经不能再用“偶发慢调用”来解释了。

我们决定直接上服务器抓现场。这才是整个事故排查里最关键的一步,也是后面所有结论的基础。

2. 第一次反转:线程堆栈把矛头指向日志组件

2.1 用 top 和 jstack 抓取现场

排查 CPU 类问题,不要盲猜,第一步永远是看线程在跑什么。我先找到异常节点的进程 PID,然后执行top -H -p <pid>观察该进程下所有线程的 CPU 占用。当时的结果非常直观:大量线程的 CPU 占用率都异常高,而且这些高占用线程的名字高度相似,基本集中在 HTTP 请求处理线程组里。

接着连续执行三次jstack <pid> > /tmp/jstack_$(date +%s).txt,每次间隔 10 秒左右。抓线程栈一定要抓多次,单次抓拍只能看到瞬间状态,连续抓几次才能区分“偶发路过”和“长期卡住”。三次 dump 出来后,比对结果让我挺意外的:几十个业务线程全部卡在同一个方法栈上,栈帧里几乎都是日志序列化和脱敏组件的内部方法。

2.2 那些线程到底卡在了哪里

摘一段当时抓到的线程栈(伪代码格式)来看:

"http-nio-8080-exec-472" prio=5 tid=0x00007f8a5c01a800 nid=0x2b3d runnable at com.example.sanitize.JsonSanitizer.sanitize(JsonSanitizer.java:87) at com.example.log.LogSanitizeFilter.doFilter(LogSanitizeFilter.java:45) at org.example.logging.core.appender.AbstractOutputStreamAppender.append(...)

这个栈反复出现在几十个线程里。如果只是个别线程卡在这里,还可以怀疑是偶发死锁或线程池调度问题;但几乎所有流量线程都卡在同一个位置,说明这个日志脱敏链路已经被打成一个全站瓶颈了。于是我回到代码变更里去翻,看看最近有没有改动过日志配置或者脱敏规则——结果并没有。那问题到底出在哪?

2.3 从“下游问题”到“自己问题的认知转变”

这一刻很多人容易犯一个错误:看到日志组件卡住,第一反应是“日志组件坏了,重启一下就好”。但我们没有急着重启,而是先想了一个问题:为什么这个组件跑了好几天都没事,偏偏在新增日志之后出问题?日志组件本身不会突然变慢,变慢一定有触发条件,而这个条件很可能就藏在那七行代码里。带着这个怀疑,我们开始逐行审视那次变更的代码,重点就是那行看起来人畜无害的log.info。

到这一步,事故方向已经彻底反转:不是下游的问题,不是数据库的问题,是我们自己代码里的一行日志。

3. 第二次反转:问题代码就藏在七行变更里

3.1 那行 log 日志到底打印了什么

七行变更里真正新增的日志只有一行,大概长这样:

log.info("push result:{}", resultDTO);

resultDTO是一个业务结果对象,里面包含了用户下单的概要信息、商品信息、渠道返回码、扩展字段等等。表面上看这只是一次非常常规的信息打印,因为常规,所以 code review 的时候没有任何人提出异议。但问题恰恰藏在这个对象的内部结构里。

我后来在复盘文档里写过一句话:日志本身不会惹事,惹事的是你在日志参数里传过去的东西。log.info("xxx {}", obj)这行代码虽然只有一行,但它实际做的事情是把obj对象作为参数传入日志框架。而在很多日志框架的实现里,如果你传的是对象而不是字符串,它最终要把对象转为字符串输出——这个“转字符串”的动作,有时候会比你想象中复杂得多。

3.2 为什么之前没出事,加了一行就出事

任何“以前没事、加了点东西就出事”的神奇故障,本质都是新增代码和某个既有隐患产生了叠加。这次的叠加也很典型:我们的resultDTO在前几个版本里一直包含一个“扩展属性”字段,这个字段内部持有了一份配置快照,而配置快照又持有上下文引用,上下文引用在容错设计时又指向了resultDTO本身。也就是说,这个对象图从某个字段开始是可以成环的。

在此之前,这条链路上的对象从没被完整序列化过,因为业务代码只读取其中的基础字段,从来没把它整个打成字符串。而新增的那行日志,恰好第一次把完整的resultDTO丢给了日志框架,促使脱敏组件对它做整树遍历和深拷贝。这一遍历,就把那个隐蔽的环给踩中了。

3.3 不要让日志参数在入口处做重活

这里要特意说一下日志参数求值时机的问题。很多开发对“日志很轻”有误解,觉得log.info("xxx {}", obj)这个调用很快。实际上,在大多数主流日志框架里,方法调用的时刻,参数就已经全部求值完成了。你写log.info("result:{}", JSON.toJSONString(resultDTO)),那么JSON.toJSONString(resultDTO)在执行log.info之前就已经跑完了。哪怕你最后日志级别是 WARN,这条 JSON 序列化也已经付出去了。

所以当你决定打印一个复杂对象时,真正的成本可能发生在进入日志框架之前,这也就是为什么线程栈里会出现大段序列化代码,而业务代码本身看起来完全正常。日志框架只是“背锅侠”,真正干活的是你传进去的那个对象。

4. 根因解剖:脱敏组件、对象图和压死骆驼的第三根稻草

4.1 第一颗雷:对象图成环,脱敏组件却没有做环检测

我们内部那个脱敏组件的设计目标很明确:在日志写出前,对日志内容做必要的脱敏处理,比如把手机号、身份证号、银行卡号等字段用正则替换成掩码,同时为了避免原始对象被修改,还需要做一次深拷贝。深拷贝和字段替换本质上都是遍历对象图。

问题就出在这个遍历上。它从一个对象出发,沿着 getter 拿到的属性逐一往下层递归。如果对象图是一棵树,这种做法没问题;但我们的resultDTO已经形成了一个图,里面有环。遍历算法没有维护“已访问节点集合”,遇到环就会在同一组对象之间反复进出,递归无法自然终止。虽然最终可能因为栈深度限制抛出异常,但在抛出异常前,CPU 已经被大量无效递归和序列化操作吃掉了。

这类问题最恶心的地方在于:它不是必现的。如果对象里某个字段恰好为空,环可能就断开了;如果某次请求的响应对象没有挂上配置快照,也不会触发。只有当数据恰好满足成环条件时,才会出现 CPU 打满。这也是为什么本地环境很难复现,因为本地测试数据通常比较简单。

4.2 第二颗雷:为了“看不见的性能优化”把日志改成了异步,队列满了会怎样

我们当时还有一个自以为很聪明的配置:为了让业务线程不回因为日志拖慢,把日志输出改成了异步模式,希望日志在后台线程池里慢慢写。这个设计的初衷没问题,但很多人忽略了异步模式下还有一个有界队列。

当日志量突然暴增,或者单条日志处理极慢的时候,生产者生产日志的速度远大于消费者消费的速度,队列很快就会被填满。队列满了之后,不同框架的默认行为不一样,有的丢弃,有的等待。而我们配置的行为是等待——结果就是业务线程在提交日志时被阻塞,异步日志从“优化性能”变成了“拖垮性能”。刚开始我们还没意识到这个队列水位问题,后来查监控才发现故障高峰期异步日志队列长期处于 99% 满的状态。

这里想强调一个容易被忽略的点:异步不等于免费。它只是把开销从业务线程转移到了消费线程,但一旦消费不过来,代价依然会回到业务线程身上。日志系统也有自己的容量上限,不可能无限兜底。

4.3 第三颗雷:脱敏组件的正则表达式在高并发下被放大

如果只是成环,可能故障会以栈溢出的形式出现,还不会一下子打垮整个服务。真正把问题放大到 P1 的,是脱敏组件里那些基于正则的字段替换规则。正则表达式在数据量小的时候毫秒级完成,但在对象被重复遍历多次、字段数量暴增的情况下,匹配次数呈几何级增长。

打个简单的比方:别人打印日志只做一次字符串拼接,而我们的日志链路要做“对象遍历 + 深拷贝 + 正则替换 + 字段掩码”,等于把一次本该是 O(1) 的日志操作做成了 O(n²) 甚至更夸张的复杂度。平时 QPS 低,这几种开销还能被异步队列吸收;但那天下午流量略微上涨,这棵“日志树”就在瞬间被打爆了。

4.4 用一段代码看明白环遍历为什么可怕

为了让大家更直观地感受这个问题,我写一个极简的模拟。假设我们有一个节点类:

public class Node { private String name; private Node child; // getter / setter 省略 }

如果我们的脱敏遍历逻辑长这样:

public void sanitize(Object node) { if (node == null) { return; } // 处理当前节点字段 for (Field field : getAllFields(node.getClass())) { Object value = field.get(node); if (shouldSanitize(field)) { replace(value); } // 继续递归,且没有记录已经访问过的节点 sanitize(value); } }

当 A.child = B,B.child = A 时,这段逻辑会在 A 和 B 之间无限进出,无法自行结束。正确做法是加一层访问标记:

public void sanitize(Object node, Set<Object> visited) { if (node == null || visited.contains(node)) { return; } visited.add(node); // 后续处理 }

就这么一行判断,就能让脱敏组件从“可能打满 CPU”变成“安全返回”。可惜的是,我们当时的组件版本里并没有这层保护。

5. 同样被一行日志打穿过的其他场景

5.1 日志量暴增导致磁盘被写满

有一个非常经典的事故类型,不涉及任何特殊对象,就是因为某天 debug 日志被误开,或者循环里打印了大对象,日志文件在几个小时内把磁盘写满。日志写不进去之后,很多框架默认的 IO 错误处理是同步抛出,于是业务线程也被拖死。

这种事故的可怕之处在于它“静悄悄”:监控如果只盯 CPU 和内存,磁盘水位不常看,等发现问题时磁盘可能已经 100% 了。我见过不止一个团队因此在半夜爬起来清磁盘,一边删日志,一边追查哪些日志在刷屏。

5.2 toString 里藏着慢查询

如果业务对象重写了toString(),而且实现里不小心调用了外部服务,那会让每一次日志打印都变成一次远程调用。之前有个服务就是这么出事的:某个对象的toString()会从数据库中查询最新状态,开发写的时候只是为了方便联调查看,结果日志里一旦打印这个对象,每一条日志都触发一次 SQL,高峰期直接把数据库连接池打穿。

这个坑和正则遍历类似,都属于“打印日志的隐藏成本”。写业务代码时很难注意到,但线上压测一跑就会现出原形。

5.3 日志脱敏触发灾难性回溯

正则表达式的一个经典问题:如果规则写成了类似(a+)+$这种嵌套量词,在对某些特殊字符串做匹配时可能触发灾难性回溯,让 CPU 直接烧掉。日志脱敏组件里天然会用到很多正则规则,一旦某条规则覆盖了一个超长且不符合预期的字段值,回溯时间会从微秒级变成秒级。这同样属于日志链路上被忽略的风险点。

5.4 日志打点被安全合规叫停

还有一类“事故”不会体现在技术上,但级别同样是 P1,那就是日志里把不该打印的数据打了出来。比如密码、验证码、身份证号、银行卡号等敏感字段进入日志,通报出来之后,相关部门要求全链路整改,服务被临时限制。这类问题虽然不是“打爆 CPU”,但影响完全不亚于技术故障。

我把上面这些场景整理成一个表格,方便以后排查时对照:

事故类型P1 表现定位手段止损动作
对象图成环/复杂序列化线程卡在同一栈帧,CPU 飙高连续 jstack 采栈去掉危险日志,修复环检测
日志量撑爆磁盘磁盘 100%,日志阻塞业务df -h,日志目录大小临时清日志/切级别,修循环打点
toString 内部调用数据库或下游连接池打满trace 分析耗时占比修改 toString,日志改为延迟求值
正则灾难性回溯单请求耗时暴涨,CPU 高火焰图定位正则线程简化正则/加超时/换实现
敏感数据泄露外部通报/合规整改grep 日志关键词日志脱敏规则升级,全链路排查

6. 修复与预防:让日志只干活不惹事

6.1 当时是怎么止损的

事故现场不可能等你把根因全部理清才动手。我们当时做的止损动作就三步:第一,把风险日志的级别从 INFO 临时下调,或者直接注释掉那行日志;第二,确认脱敏组件可以关闭深度扫描开关,临时关掉;第三,把异步日志的队列策略从“等待”改成“丢弃”,优先保住业务线程。三步做完,服务在几分钟内恢复了稳定。

这里想提醒一句:止损和根因修复是两件事。线上恢复以后,不要急着把日志打开,更不要以为“临时关一下就好了”,一定要带着根因去修代码。否则下一次换个姿势还会炸。

6.2 修复脱敏组件:加环检测和深度上限

组件层面的修复核心是两个能力:一是环检测,也就是维护visited集合;二是深度上限,遍历超过比如 20 层就停止,防止未来再出现形状更诡异的对象图。这两个能力本身实现成本很低,但对日志场景非常有效,因为日志本来就不要求 100% 完整呈现对象,超深部分可以直接输出占位符。

脱敏组件还应该增加一个安全兜底:如果某次脱敏执行超过一定时间,例如单次超过 50ms,就放弃本次脱敏并输出原始内容的截断版本。日志链路可以有损耗,但业务响应不能等。

6.3 业务代码怎么安全打印复杂对象

对于业务侧,最重要的一条原则就是:高频路径上,不要打印复杂对象。如果确实需要打印,最好先裁剪,只打印关键字段,而不是整个对象。可以把resultDTO转换成一个只包含 id、状态码、耗时等核心字段的轻量字符串:

log.info("push result, id={}, code={}, cost={}ms", requestId, resultDTO.getCode(), System.currentTimeMillis() - startTime);

还可以利用日志框架的延迟求值特性。很多日志框架支持传入Supplier,比如:

log.info("push result: {}", () -> JSON.toJSONString(resultDTO));

这样写的话,只有当日志级别真正需要输出时,才会执行JSON.toJSONString。当线上日志级别被调高、或者某些请求根本不记录 INFO 时,这部分开销就真正省下来了。不过也要注意:用 Supplier 只是延迟了开销,并没有消除开销;一旦日志真的需要输出,该付出的序列化代价还是会付出,所以裁剪对象和避免成环才是根本。

6.4 给复杂对象写安全的 toString

如果项目里存在大量需要日志输出的复杂对象,比较好的实践是写一个工具方法,统一做对象的“浅层摘要输出”。比如:

public static String summarize(Object obj) { try { return String.format("%s@%x(...)", obj.getClass().getSimpleName(), System.identityHashCode(obj)); } catch (Exception e) { return "<unprintable>"; } }

或者借助 JSON 库的深度限制配置,输出深度控制在 2-3 层以内,这样既能看清关键信息,又不会触发深层遍历。我们在事故复盘后把所有高频日志里打印的对象全部换成了这种摘要式输出,线上日志量直接降了 60%,更重要的是彻底消除了再踩对象图深坑的可能。

6.5 预防清单里必须有的六件事

事故过去一周后,我把日志安全相关的预防项写成了团队内的强制检查清单,这里也分享给你:

  • code review 时,凡是新增日志,都要问一句:这个对象完整序列化是什么代价?有没有成环风险?
  • 压测时不能只看业务接口 TPS,还要关注日志 QPS、异步队列水位和 CPU,日志链路必须一起压。
  • 给日志部署加磁盘水位告警,磁盘使用率超过 70% 就要提醒,超过 85% 必须有人处理。
  • 高频路径(每秒超过几百次)不允许打印完整对象,只能打印按需裁剪的摘要信息。
  • 日志脱敏组件的每次升级都要跑一次对象图测试用例,专门构造环形对象和超深对象,验证不会被打爆。
  • 对生产事故里用到的线程栈抓取命令,提前写进应急预案文档,不能等到现场再翻手册。

7. 复盘之后的一点实操心得

如果你现在正好在排查类似问题,我先给你一个最省时间的建议:不要一上来就改代码,先抓线程栈。连续抓三份 jstack,对比哪些线程卡在同一栈帧,基本上就能锁定方向。像这次事故,如果不是线程栈把日志序列化链路暴露出来,我们可能还在和下游通道扯皮。

我个人的另一个体会是,日志一定要当作“线上可观测系统的一部分”来对待,而不是随手写的装饰代码。你打印的每一行日志,都会被采集、传输、存储、检索,任何一个环节都有成本和风险。一个团队对日志的态度,几乎能反映出这个团队的工程化水平。没出事的时候日志是廉价的数据,出事的时候日志可能就成为事故的导火索。

最后再说一个很实用的小技巧:给所有日志组件和工具类加上“快速失败”的意识。日志组件可以丢日志,但不能阻塞业务;可以输出截断内容,但不能导致 CPU 飙高;可以脱敏失败,但不能把原始敏感数据漏出去。把这些边界条件提前想清楚,很多“一行 log 引发 P1”的故事就不会发生在你身上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询