当前位置: 首页 > news >正文

Java 日志打印:别再 log.info(“dto:{}“, dto) 了,可能比你想的更坑

写在前面

处方物流信息同步优化:从 36 秒到亚秒级的踩坑记录

最近在排查一个线上接口性能问题,处理时间从正常的几百毫秒飙到了 30 多秒。排查过程中翻了一遍代码,发现一处很"不起眼"的日志:

log.info("药品订单回调,参数:{}", JSONUtil.toJsonPrettyStr(dto));

当时第一反应是:日志而已,能有多耗时?但当我去掉这行重新测试,接口耗时直接降了一个量级(我发现这个dto对象字段几十个,全打印有点多啊!!)。这件事让我开始认真审视"日志打印对象"这件每天都在做的小事,越研究越发现里面的坑比想象的多。

这篇文章把我在排查过程中梳理的几个核心问题记录下来,希望对同行有参考价值。

一、完整对象打印 vs 字段打印:到底差在哪

先看两种最常见的写法:

// 写法 A:完整对象打印 log.info("dto:{}", JSONUtil.toJsonPrettyStr(dto)); ​ // 写法 B:指定字段打印 log.info("hosCode:{}, recipeSize:{}, deliveryStatus:{}", dto.getInput().getHosCode(), dto.getInput().getRecipeList().size(), dto.getInput().getDeliveryStatus());

很多同事看到这两行代码,会觉得"不就是日志多一行、少一行的事吗?能有多大区别"。但实际上差距远不止于此。

1.1 信息完整性的差异

维度完整对象打印字段打印
信息覆盖全部字段,理论上面面俱到仅打印选定字段
信息噪声高,大量无关字段混杂其中低,聚焦关键信息
可读性差,大段 JSON 需要逐行扫描好,关键信息一目了然

完整对象打印看似"信息更全",但实际排查问题时,90% 的字段是用不上的。而真正有用的那几个字段,反而淹没在几十行 JSON 里,需要肉眼搜索。我之前排查一个订单问题,光是从日志里找到orderSeq这一个字段,就翻了好几屏。

1.2 性能消耗的差异(重点)

这才是核心。完整对象打印在性能上有四个层面的开销:

第一层:序列化开销

JSONUtil.toJsonPrettyStr(dto)要做的事情:

  • 用反射遍历 dto 的所有字段

  • 遇到嵌套对象递归序列化

  • 遇到 List/Map 集合逐个元素序列化

  • 最后还要加上缩进、换行、空格等格式化处理

如果 dto 里包含 50 个处方对象,每个对象 20 个字段,序列化一次可能就要几十毫秒。这在高频接口里是实打实的 CPU 开销。

第二层:内存分配开销

序列化生成的字符串可能从几百字节到几兆不等。JVM 要在堆上分配这些大字符串,用完之后又迅速变成垃圾,触发频繁的 Minor GC。压测时观察 GC 日志,去掉大对象日志打印后,GC 频率能下降一半。

第三层:磁盘 I/O 开销

Logback 写日志是要加锁的。一个大字符串写文件,意味着:

  • buffer 被占满触发更频繁的 flush

  • 磁盘 IO 带宽被日志吃掉

  • 多线程争抢同一个 appender 锁

第四层:日志收集传输开销

如果用了 ELK/Filebeat 这类日志收集系统,大日志会导致:

  • Filebeat 传输压力增大

  • Logstash 解析大 JSON 卡顿

  • Elasticsearch 存储膨胀,查询变慢

1.3 一个容易被忽视的陷阱:SLF4J 占位符救不了你

有人会说:"SLF4J 用{}占位符,如果日志级别不够,不会拼接字符串。"

这话本身没错,但对JSONUtil.toJsonPrettyStr(dto)这种写法完全无效。原因看下面:

log.info("dto:{}", JSONUtil.toJsonPrettyStr(dto));

执行顺序是:

  1. JVM 先求值方法参数 → 调用JSONUtil.toJsonPrettyStr(dto)

  2. 序列化已经发生,大字符串已经生成

  3. 把生成好的字符串传给log.info

  4. SLF4J 才判断日志级别

也就是说,无论当前日志级别是 INFO 还是 ERROR,序列化都已经在第 1 步完成了。SLF4J 的延迟求值机制对已经生成的参数无能为力。

正确做法是用isDebugEnabled守卫:

if (log.isDebugEnabled()) { log.debug("dto:{}", JSONUtil.toJsonStr(dto)); }

二、直接log.info("{}", dto)行不行?

既然JSONUtil.toJsonPrettyStr(dto)有性能问题,那直接把序列化去掉,写成下面这样是不是就 OK 了?

log.info("dto:{}", dto);

不行。这种写法可能比JSONUtil.toJsonPrettyStr更坑。

原因在于:当日志框架处理{}占位符时,会调用dto.toString()把对象转成字符串。而toString()的行为完全取决于 DTO 的实现,常见三种情况,每一种都有问题。

2.1 情况一:DTO 用了 Lombok @ToString

这是最常见的场景:

@Data @ToString public class SyncDeliveryDTO { private Input input; // 嵌套对象 private List<RecipeIdDTO> recipeList; // 列表 }

Lombok 生成的toString()会递归打印所有字段,输出类似:

SyncDeliveryDTO(input=Input(hosCode=VM001, deliveryStatus=1, recipeList=[...]), recipeList=[RecipeIdDTO(recipeId=123456, ...), RecipeIdDTO(...)])

如果recipeList有 50 个元素,这个字符串照样 KB 级甚至更大。和JSONUtil.toJsonPrettyStr相比只是格式从 JSON 换成了 Lombok 风格,体积没有本质区别,性能开销一点没省

2.2 情况二:DTO 没有覆写 toString()

如果 DTO 是个纯 POJO,没加@ToString也没手写toString(),那调用的就是Object.toString()

// Object 默认实现 public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); }

日志输出变成:

dto:com.chinaunicom.medical.ihm.api.patient.model.dto.SyncDeliveryDTO@6a5fc7f7

这确实很小、很快,但完全没有任何业务信息。出问题排查时,等于日志白打了,反而误导排查方向。

2.3 情况三:toString() 触发副作用(最危险)

这是最隐蔽的坑。如果 DTO 是 JPA/Hibernate 实体,或者字段里有懒加载代理:

@Entity @Data public class Order { @OneToMany(fetch = FetchType.LAZY) private List<OrderPrescription> prescriptions; }

当你log.info("order:{}", order)时:

  1. 日志框架调用order.toString()

  2. Lombok 的@ToString会调用prescriptions.toString()

  3. prescriptions是懒加载代理,触发真正的 DB 查询

  4. 一次日志打印,悄悄执行了 N 条 SQL

这种问题排查起来极其困难——你在业务代码里根本找不到那条 SQL 是哪里发出的,因为它来自日志打印时触发的懒加载。

类似的副作用还有:

  • 循环引用:A 持有 B,B 持有 A,toString()递归调用导致StackOverflowError

  • 代理对象:Spring AOP 代理的toString()行为不可控

  • 敏感信息泄露:密码、token 等字段被 toString 输出到日志

2.4 三种写法的横向对比

写法可读性信息准确性性能开销调试便捷性副作用风险
JSONUtil.toJsonPrettyStr(dto)好(格式化JSON)高(反射+格式化)好(结构清晰)
log.info("{}", dto)+ @ToString一般(Lombok格式)高(递归toString)一般中-高(懒加载/循环引用)
log.info("{}", dto)无toString差(仅类名+hash)极差
字段打印log.info("k:{}", dto.getK())极低

三、不同场景下的最佳实践

讨论了这么多坑,那到底该怎么打日志?结合实际工程经验,我总结了几个场景的最佳实践。

3.1 生产环境 INFO 日志:只打关键字段

这是最推荐的做法。核心原则是只打印排查问题必需的最小信息集

// 反例 log.info("处理订单 dto:{}", JSONUtil.toJsonPrettyStr(dto)); ​ // 正例 log.info("syncOrderDeliveryInfo 开始 hosCode:{}, recipeSize:{}, deliveryStatus:{}", dto.getInput().getHosCode(), CollUtil.size(dto.getInput().getRecipeList()), dto.getInput().getDeliveryStatus());

选择字段的依据:

  • 业务主键:orderSeq、recipeId、patientId 等

  • 状态字段:orderStatus、deliveryStatus 等

  • 规模信息:list.size() 而不是 list 本身

  • 上下文:userId、tenantId 等

3.2 确实需要看完整对象:用 DEBUG + 守卫

某些排查场景确实需要完整对象,这时应该用 DEBUG 级别 +isDebugEnabled守卫:

if (log.isDebugEnabled()) { log.debug("完整请求 dto:{}", JSONUtil.toJsonStr(dto)); }

注意几点:

  • JSONUtil.toJsonStr而不是toJsonPrettyStr,去掉缩进换行,体积小一半

  • 必须isDebugEnabled守卫,否则 DEBUG 关闭时序列化照样发生

  • 生产环境默认关闭 DEBUG,需要时临时开启

3.3 需要追踪完整请求链路:用专门的 Trace 机制

如果是分布式追踪需求,不要靠业务日志扛,应该用:

  • SkyWalking / Zipkin:自动埋点追踪调用链

  • MDC + TraceId:在日志里注入 traceId 串联请求

  • 专门的上报系统:把完整请求体单独上报,不混在业务日志里

3.4 异常场景:打印异常栈而不是对象

很多同学打异常日志时喜欢把相关对象也序列化进去:

// 反例 log.error("处理失败 dto:{}", JSONUtil.toJsonPrettyStr(dto), e); // 正例 log.error("处理失败 orderSeq:{}, deliveryStatus:{}", orderSeq, deliveryStatus, e);

异常日志的核心是异常栈,对象信息打关键字段足矣。把大对象塞进异常日志,会让异常栈这种本该优先保证的信息被冲淡。

3.5 对象本身就该打日志:覆写一个精简的 toString()

如果某个 DTO 频繁出现在日志里,可以为它定制一个精简的toString()

@Data public class Order { private Long id; private String orderSeq; private Integer orderStatus; private List<OrderPrescription> prescriptions; // 大字段 // ... 其他几十个字段 @Override public String toString() { return "Order{id=" + id + ", orderSeq=" + orderSeq + ", orderStatus=" + orderStatus + "}"; } }

这样log.info("order:{}", order)既安全又有用。但要注意:不要在 toString() 里访问懒加载字段或调用有副作用的方法

四、回到那个 36 秒的接口

最后回到开头的那个性能问题。syncDeliveryInfo接口里,V1 代码有这两行日志:

log.info("药品订单回调,参数:{}", JSONUtil.toJsonPrettyStr(dto)); log.info("处方信息:{}", prescriptionList);

每次 HIS 回调都会执行,Dto 里包含整个物流请求体(含处方列表、地址信息等),单次序列化就可能耗时几十到上百毫秒。叠加 N+1 查询和事务内外部调用等问题,最终导致接口可能耗时 36~50 秒。

V2 优化时改成了:

log.info("syncOrderDeliveryInfoV2 开始 hosCode:{}, recipeSize:{}, deliveryStatus:{}", dto.getInput().getHosCode(), CollUtil.size(dto.getInput().getRecipeList()), dto.getInput().getDeliveryStatus());

只取 3 个关键字段,彻底移除反射序列化开销。这只是 7 项优化中的一项,但单独这一项的收益就已经很明显。

五、总结

把全文核心观点浓缩成几条:

  1. 日志打印对象的真正开销不在log.info这一行,而在传给它的字符串是怎么构造的JSONUtil.toJsonPrettyStr(dto)作为方法参数,在调用log.info之前就已经完成了序列化,SLF4J 占位符救不了。

  2. 直接log.info("{}", dto)不等于"省掉了序列化"。它只是把序列化从 JSON 换成了toString(),而toString()的行为完全取决于 DTO 实现——可能是巨大的 Lombok 字符串,可能是无意义的类名+hash,最危险的是可能触发懒加载副作用。

  3. 生产环境 INFO 日志只打关键字段,完整对象交给 DEBUG 级别 +isDebugEnabled守卫,或者专门的 Trace 系统。

  4. 判断一个日志写法是否有问题,看三点:序列化是否在log.info之前发生、toString()是否有副作用、生成的字符串体积是否可控。

  5. 性能问题往往藏在"不起眼"的地方。一行日志看起来无害,但在高频接口里乘以 QPS 就是实打实的开销。这次排查的 36 秒接口,日志只是其中一个因素,但积少成多。

性能优化的本质,很多时候就是"把不必要的开销去掉"。日志这件小事,值得每位 Java 开发者认真对待。

http://www.cnnetsun.cn/news/3172534.html

相关文章:

  • 2026最新7款AI编程助手基础版免费实测合集
  • 深入理解C++ Workflow源码(1)
  • 支持中文、英文等多语种创作的AI视频工具怎么选?跨境电商运营实测Seedance 2.5 值得首选
  • 百度网盘秒传脚本终极指南:彻底解决文件分享失效的完整方案
  • 国内EMBA哪个好?2026综合实力TOP5权威评测榜单
  • Huiwen Han —— 论文与预印本目录 2026年7月
  • OC7141 PWM 调光 LED 驱动器:3A 输出下 60uA 静态电流的 PCB 布局 3 要点
  • 大模型外呼机器人好用吗?千创云呼在教育培训招生场景中怎么提升转化率?
  • Python实现跨境电商AI图片批量翻译流程解析
  • Linux服务器DDoS防御实战:从内核调优到Nginx限流的纵深防护体系
  • 基于51单片机的红外测温报警系统刷卡识别身份认证设计系统非接触32(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_
  • Xilinx 7系列FPGA DDR3 PCB布线:1866Mbps速率下CK-DQS 1600ps偏差约束与实测
  • AI 商品图为什么总翻车?从电商运营视角看 gpt-image-2 的素材、品牌词和后处理
  • 前端转大模型:从工具接入到项目提效
  • 2026年艺术类教育小程序开发平台有哪些?艺术类教育小程序开发平台推荐
  • ArcGIS 用地适宜性评价:3个常见权重赋值误区与AHP层次分析法校正
  • CIFAR-10图像分类项目:PyTorch Lightning重构60分钟教程的5个效率提升点
  • 3款主流翻译工具对比:ChatGPT-4o vs DeepL vs Google Translate 处理《大学英语》Unit 1-8 译文质量评测
  • 电影票房预测:5种回归模型Stacking融合实战,RMSE降低至0.2934
  • REPENTOGON终极配置指南:深度解锁《以撒的结合》脚本扩展器高级功能
  • 如何快速实现离线音频转录:面向初学者的完整指南
  • OpenStack依赖分析神器:openstack-sig-tool帮你轻松搞定版本冲突问题
  • DVWA靶场实战:文件上传漏洞与Webshell攻防全解析
  • 告别卡顿:用Winhance中文版让Windows系统重获流畅体验
  • 终极解决方案:5个SMAPI模组彻底解决星露谷物语农场管理痛点
  • Python3-函数得作用域-003篇-专项练习题
  • 无感FOC控制原理与Python仿真实践
  • 深度估计新范式:像素级扩散模型与语义引导优化
  • FCOS目标检测算法:原理、实现与优化技巧
  • YOLOv5轻量化:深度可分离卷积在目标检测中的应用与优化