
凌晨两点生产环境报警你睡眼惺忪地打开Kibana发现关键服务的内存以肉眼可见的速度飙升。你点开日志映入眼帘的是满屏的INFO和挤成一行几千字符的JSON却找不到一条能告诉你“哪个用户、哪个请求、哪个方法”出错的线索。这不是运维的锅这是你写日志的方式出了问题。日志不是留给未来的自己看的是留给凌晨两点的自己看的。如果你的日志连当时的上下文都无法还原那记录它就毫无意义。很多Java开发者把日志当成System.out.println的替代品以为打印得越多越好。实则不然垃圾日志比没有日志更可怕——它们会淹没真正的异常拖垮系统吞吐还会让你在排查问题时怒骂三个小时。今天我们不谈理论框架只讲那些你用得上、能落地、且立竿见影的实用技巧。日志级别不是装饰品是你诊断的分诊台错误地使用日志级别是所有混乱的根源。ERROR是系统需要人工介入的严重故障WARN是可能有问题但当前请求仍能继续的意外情况INFO是业务关键节点的状态变更DEBUG是你调试时才需要用到的细粒度信息。可惜的是很多团队把INFO当成默认的“我都要”把DEBUG当成了调试用的System.out。一个没有级别意识的日志系统就是一场没有红绿灯的交通混乱。更务实的一点是日志级别必须在运行时动态可调。不要因为排查问题就重新部署代码你会被全公司骂死。使用Spring Boot的话Actuator里直接支持修改logging.level.com.yourpackageDEBUGArthas里也可以。关键生产请求出问题时你应该能在几十秒内把指定包的日志级别调到TRACE观察到完整链路后再调回来。如果做不到这一点你的日志系统就是个死档案别提什么可观测性。占位符是你的救星字符串拼接是悲剧的前奏新手最爱写log.info(user: userId , price: price)。表面上看没啥问题但Java里字符串拼接会创建中间对象而且哪怕日志级别为OFF这行代码依然会执行拼接操作。性能杀手往往藏在日志里尤其是那些高频调用的方法中。正确的姿势是用占位符log.info(user{}, price{}, userId, price);这不仅仅是为了性能。占位符的意义在于它将日志格式与实际数据分离后续如果接入日志聚合系统你可以明确地告诉系统哪些字段是用户ID哪些是价格而不是在一条长字符串里用正则去抠。同时占位符天然支持{}作为字段锚点Logstash、Elasticsearch等工具可以依赖它做结构化解析但如果你直接拼接一切结构化提取都得靠猜。还有一个隐藏技巧占位符不要超过四个。参数过多时可读性和可搜索性都会大幅下降。一条日志超过200个字符就已经不是在记录而是在写小说了。你需要的是紧凑、可检索、含关键字段的一句话而不是把整个请求对象toString出来。记日志先想到“你失去了什么”如果你在业务代码中捕获了异常然后打一条log.error(failed)你丢掉了堆栈。没有堆栈的异常日志等于没写因为你根本不知道错在哪一行。请务必保留堆栈信息log.error(operation failed for order: {}, orderId, exception)。注意第三个参数这就把异常对象传进去了框架会自动追加堆栈。永远不要自己e.printStackTrace()那是终端玩家干的事不是生产级应用干的事。但反过来堆栈日志也可能成为噪音。你应该知道什么时候需要全堆栈什么时候只需要一条摘要信息。比如网络超时通常是每几分钟批量出现每个请求都打全堆栈会产生大量重复日志。这时就可以打WARN级别只记录消息和关键属性不记录堆栈。判断标准很简单这条日志是给程序看的还是给人看的给程序看的比如监控系统判断告警要结构化、精简给人看的比如最后排查根因要完整、含上下文。MDC你必须现在就用的上下文粘合剂如果你还没用过SLF4J的MDCMapped Diagnostic Context那你的日志系统还停留在“瞎子摸象”阶段。MDC让你在日志中自动附加请求ID、用户ID、租户ID等上下文信息而不需要在每个日志方法里手动传参。这有多重要想象一下一个请求经过网关、订单服务、支付服务最后败在某个数据库连接池的角落。如果每条日志都有同一个traceId你可以在日志平台里一秒拉出这条请求的完整生命线。实现方式很简单在过滤器或拦截器里进入时MDC.put(traceId, UUID.randomUUID().toString())退出时MDC.remove(traceId)。配置对应的pattern%X{traceId}。但要注意MDC的清理必须放在finally块中否则线程池里的线程会“串味”把上一个请求的traceId带到下个请求里。这个坑我亲眼见过多次一个MDC.remove的缺失足以让整个日志链路变成一场身份错位的闹剧。更进一步分布式环境里需要把traceId透传到下游服务。可以在HTTP头或RPC上下文里传递然后在下一跳的入口处重新放回MDC。跨服务的日志追踪才是MDC的完整形态否则它只是单机的自嗨。至于OpenTelemetry之类那是后话但MDC永远是这些自动埋点工具的基础。日志格式结构与可读性是同一件事纯文本日志找业务字段只能靠肉眼结构化日志才有质变。从今天开始别再写散文式日志试试JSON格式输出。比如用Logback的LogstashEncoder产出{level:ERROR,message:...,traceId:abc,userId:123}。这样你在ELK或Splunk里可以直接玩字段查询而不是对着一条文本猜哪里是用户ID。但JSON格式并不是万能药。控制台调试时可读性差且JSON序列化本身有性能开销。所以更优雅的做法是开发环境用人类可读的Pattern格式生产环境用JSON格式用Spring Profile控制两套logback-spring.xml。你不需要一份配置打天下你需要的是让日志在正确的地方以正确的形态出现。同时日志时间戳统一使用ISO 8601格式并带上时区。别用默认的2023-04-01 13:00:00它没有毫秒没有时区在跨时区排查时会让你的判断错乱百出。一个字段的缺失可能导致你花两小时去换算时间问题最后发现只是格式选错了。异步日志用性能换回来的清晰度同步日志在IO密集或高并发时会阻塞业务线程。一次磁盘flush可能让请求延迟增加毫秒级但你在压力测试中根本察觉不到它只会悄悄地侵蚀你的QPS。解决方案是使用Logback的AsyncAppender或Log4j2的LMAX Disruptor。请务必记住一个核心原则异步队列的丢弃策略绝不能是“无脑丢弃”而应该是“丢弃量过载类型保留ERROR”。因为ERROR日志是你排查问题的生命线把它们丢了你不如不写日志。具体配置中queueSize和discardingThreshold需要合理设置。队列太大消耗内存队列太小则频繁触发丢弃。最好的做法是给异步队列加上监控看一眼Discarded计数器如果频繁丢弃说明你的日志量已经超过了处理能力要么增加队列要么减少无关日志的开发量——后者往往才是问题根源。有个常被忽略的细节异步日志里的堆栈信息有时会不完整因为异常对象在线程间传递时可能被引用但序列化到后端时会出现丢失。生产环境要确保异常的所有cause都能被完整记录不然排查时看到 “Caused by: null” 会让你抓狂。你可以在PatternLayout中配置%ex{full}来强制输出完整堆栈同时注意异步Appender自身的配置项比如includeCallerData代价是性能但必要时这口血得出。别让日志把敏感信息卖出去日志是业务的诚实镜子但也是隐私的泄洪口。写入日志前你要永远假设这条日志会被全公司人看到甚至会被供应商看到。用户密码、银行卡号、身份证、手机号这些字段一旦进了日志就是事故。技术上你可以在字段序列化时做脱敏比如银行卡号只保留后四位但更靠谱的是在日志方法这一层就强制规范。有一个实用的方案写一个LogUtil工具类提供sensitive(params)方法对所有入参做白名单清洗。但这还远远不够你应该在代码审查阶段就拉黑那些直接打日志的调用把敏感字段的输出变成一个红线问题。没有人能在凌晨三点想起脱敏逻辑但工具和规范可以帮你兜底。另一个容易忽视的敏感点错误消息里的异常参数。数据库异常里可能带着完整SQLSQL里可能就包含用户手机号。所以别给异常消息加业务数据只能加异常类型和代码位置。如果你想带上参数请单独打印一个脱敏后的参数快照而不是假装那个异常对象里什么都有。条件日志昂贵的日志连一句废话都别写日志方法本身就是开销。就算走异步也要承担参数求值和日志对象创建。在低级别的DEBUG或TRACE日志里尤其离谱的是对象toString()的实现成本。如果你的对象里有一个超大List而toString把它全输出出来那一条DEBUG日志的消耗可能堪比一次数据库查询。这时你需要用isDebugEnabled()做前置判断if (log.isDebugEnabled()) { log.debug(order detail: {}, order); }这种写法有些丑但绝不亏。高生产频率、复杂对象输出的场景里这种判断是守卫你系统的护城河。而且请记住Java的布尔短路特性isDebugEnabled()才是第一个判断后续字符串拼接根本不会执行。更推荐的做法是使用Lambda表达式log.debug(order detail: {}, () - order)。SLF4J 2.x 支持这样只有日志级别真正开启时才会执行字符串构造和对象序列化。简洁、安全、性能好。如果你的项目还没升级SLF4J 2抓紧了这是2024年最值得的日志升级之一。日志测试写了日志就要对它负责日志代码也是代码但它往往没有测试覆盖。你写了一个重要业务逻辑打了一行ERROR日志却从不验证这条日志是否真的会输出。直到线上故障时才发现日志里什么都没有因为拼写错误或条件不满足。为自己的日志编写单元测试这听起来很荒谬但值得做。测试点包括异常发生时日志方法是否被调用日志级别是否准确日志消息中是否包含必要字段比如traceId以及敏感信息是否被脱敏。你可以用ListAppender捕获日志事件然后断言。这种测试成本低、收益高它把日志从“顺手写”变成了“有意的契约”。有时候日志本身就是一种契约你想让监控系统根据某条关键字报警那这条关键字就不能天天变。写个测试把它固定下来当你重构代码时测试会告诉你“别碰这个日志格式”。好的日志设计不会因为一个字段名的改动就全盘崩溃。构建日志矩阵从单点到全局依赖以上技巧你最终会得到一个日志矩阵横向是日志级别TRACE到ERROR纵向是业务模块订单、支付、库存、用户。你需要明确每个模块在哪个级别下应该输出什么信息而不是谁想打什么就打什么。比如订单模块的INFO只记录订单创建、支付回调、状态流转支付模块的ERROR只记录支付通道异常和签名校验失败。不定义什么是“必须记录的日志”就相当于什么都没定义。有了这样的矩阵你就能让监控自动对接。日志不是给Kibana自己看的而是给告警规则吃的。你会在ERROR级别里捞出来的每一条日志写一个对应的告警条件比如一分钟内超过五次就调用PagerDuty。如果日志里到处都是无用的ERROR比如业务预期内的404你的告警就成了狼来了。每一条ERROR都必须有一个能接住它的人否则它就配不上这个级别。最后回看那些凌晨两点的时刻你真正需要的不是一个无敌的框架而是一套让日志为你所用的思维方式。从今天起每次写一行日志前问自己三个问题如果我只有这一条日志我能还原这个请求的前因后果吗这条日志是我的同事能看懂的还是只有我自己才懂的黑话它是否能在五分钟之内指引我找到根因如果你的答案是迟疑的那这行日志就不够好。日志的记录成本很低可维护成本却高得惊人。也正因如此高级开发者在写日志时的克制比他在写代码时的慷慨更让人尊敬。少打空泛的日志多打有价值的上下文不要向队友展示“我在这里排查了三次”而是要向未来的自己证明“今天的日志值得明天的信任”。