
凌晨两点半群里甩进来一条告警线上支付回调成功率掉了一截。我第一反应是去翻日志结果生产日志里躺着一行孤零零的pay callback failed——没有订单号、没有回调内容、没有堆栈、没有耗时。对着这一行日志我连请求从哪个上游进来、为什么失败都看不出来。这种打了日志等于没打的体验干过几年后端的人都懂。日志是软件系统里最重要、也最容易被敷衍的工程产物。它不像接口文档要评审不像代码要走查很多时候随手一行logger.info就交差了等到线上出事才想起来日志没好好打。这篇把我在Java后端、中间件和日志平台建设过程中总结出的十条军规整理出来覆盖日志级别、上下文、敏感信息、结构化、异步、审计这些维度适合所有写业务代码、做框架组件的开发者参考。前面的教训是真金白银换出来的能听进去一条排障时就少熬一次夜。1. 一条好日志和一条废日志的差距军规到底在解决什么问题新项目上线前我都会问团队同一个问题如果你的代码明天出故障现有日志能支撑你在半小时内定位到根因吗大多数时候答案是不能。原因归结起来就三个字——废日志。1.1 废日志的三大通病看不见、搜不到、不敢信先说看不见。日志打错了位置或者干脆没打。比如只在成功路径打日志catch住异常之后既不打印也不往上抛再比如有人习惯用logger.debug记录关键业务参数生产环境只开INFOdebug日志完全消失在虚空里。还有更隐蔽的一种底层方法吃了异常高层又吞掉只返回一个错误码整个过程不留任何痕迹等想查的时候现场已经没了。其次是搜不到。日志内容全是操作失败系统异常这种毫无区分度的描述没有订单号、用户ID、设备号、TraceId在ELK里检索如同大海捞针。我见过最夸张的代码一行日志打了三百多个字符但关键业务ID一个都没带grep都不知道拿什么当关键字。最后是不敢信。服务器时区错乱、容器内用的是UTC时间、日志里记录的结果和实际不一致——比如异常被吞掉后还打了个INFO success。日志可信度一旦崩塌整个团队的排障习惯都会跟着崩塌最后变成出了问题先猜猜不出来再上服务器看。1.2 高质量日志的四类服务对象日志不是打给自己一个人看的它要服务至少四类场景军规的每一条都对应这些场景里的具体痛点。开发排障这是最直接的用途。快速还原现场定位是哪条链路、哪个方法、哪个参数出了问题。监控告警基于日志的错误数、ERROR占比、慢请求数量驱动监控体系。没有规范的级别和结构化字段监控规则根本写不出来。业务审计风控、合规、事后责任认定要求流水完整、时间准确、关键业务节点都有记录也就是常说的行为日志。安全溯源应急响应时通过auth.log、访问日志、Windows安全事件日志还原攻击路径判断入侵时间、影响范围这些都依赖日志保留了足够多的溯源信息。把日志当成写给未来排障者的一封信你就知道为什么下面前十条军规每一条都不可缺少。2. 前五条军规给日志装上信息量和安全边界2.1 军规一日志级别必须有一致的准入标准级别不是装饰每一级都要有一个团队内公认的准入条件。最忌讳的是全项目都用INFO或者该打ERROR的地方打WARN该告警的问题被静默吞掉。我通常按下面这个标准来定级别准入条件典型场景配套动作DEBUG开发调试用生产环境默认关闭打印SQL参数、内存状态、临时调试输出定位问题时可临时开启问题解决后立刻关闭INFO关键业务节点记录一次请求的进和出用户下单、订单创建成功、支付回调到达必须带业务ID和关键参数方便检索WARN可恢复的异常非预期但系统能兜住重试即将耗尽、缓存穿透、外部接口超时关注频率高频WARN也要排查ERROR需要立即人工介入的故障数据库不可用、消息丢失、核心流程异常必须关联告警紧急时直接触发电话/短信FATAL整个服务或进程不可用的灾难启动失败、配置错误导致无法对外服务极少用一般伴随进程退出常见翻车案例有人把异常catch住之后打了logger.info(订单创建失败)——信息没错级别错了。INFO的日志通常不会被告警系统盯上这个失败就无声无息地吞掉了。判断标准很简单如果这条日志丢失会让你错过一次线上故障它至少应该是WARN或ERROR。2.2 军规二一条日志必须能回答谁、何时、何地、何事、结果如何我把它叫日志的5W1H原则。一条能用的日志读一遍就应该让一个没接触过业务的新同事大致还原现场。谁用户ID、操作者账号、设备信息至少有一个。何时精确到毫秒的时间戳由日志框架统一输出不要自己在消息里拼时间字符串。何地哪个类、哪个方法这个由日志框架的Logger名字天然携带。何事发生了什么事用简洁、无歧义的描述。结果如何成功还是失败耗时多少关键返回结果是什么。举个反例logger.info(order failure)——读完之后你还是什么都不知道。换成logger.info(createOrder failed, userId{}, orderId{}, reason{}, userId, orderId, reason)问题就清晰了是哪个用户、哪个订单、因为什么原因失败。日志里多打几个参数排障时可能少查一小时数据库。2.3 军规三只用占位符参数化输出字符串拼接是慢性毒药Slf4j的占位符语法是行业标准Java项目基本都支持但很多人还是习惯写字符串拼接// 错误示范 logger.info(user login success, userId userId , ip ip); // 正确示范 logger.info(user login success, userId{}, ip{}, userId, ip);字符串拼接有三个隐藏成本。第一userId如果是对象拼接过程会隐式调用toString()一旦toString()实现有坑为了打日志反而把业务代码搞崩了。第二即使当前级别不需要输出拼接操作已经执行了日志从排障助手变成了性能杀手。第三拼接出来的临时字符串会产生大量垃圾对象在高并发场景给GC增加不少压力。我踩过一个特别典型的坑有人打印一个JSON解析失败的对象日志里做字符串拼接结果那个对象的toString()又抛了一次异常把原始异常盖住了排查方向被带偏了整整一个下午。2.4 军规四异常必须带完整堆栈但同一异常只准打一遍异常日志的道道最多。先说最常见的问题——只打e.getMessage()// 错误示范NPE时getMessage()是null日志只有一行 null logger.error(query order failed, e.getMessage()); // 正确示范 logger.error(query order failed, orderId{}, orderId, e);NullPointerException的getMessage()经常是null只打它等于什么都没打。异常真正的价值在于堆栈它告诉你哪一层、哪个方法、哪一行触发的。所以ERROR级别的异常日志务必把Throwable对象作为最后一个参数传进去。但同样要注意——同一个异常不要在很多层里重复打印。一次请求进来DAO层打一次、Service层打一次、Controller层又打一次同一个堆栈在日志文件里重复七八遍这就是日志风暴的雏形。正确的做法是底层只记录必要的业务上下文和参数到最外层统一打印完整堆栈或者干脆只在顶层打一次中间层全部用throw传递。如果担心底层信息丢失可以把关键入参拼到异常消息里再抛出去这样最外层打一次就能看到完整链路。2.5 军规五敏感字段一律脱敏密钥和明文的账密永不落日志这一条出过太多事故。密码、token、sessionId、身份证号、手机号、银行卡号任何一条出现在日志里一旦日志平台被拖库或者被内部无关人员看到都是安全生产事件。常见泄露途径有四个打印请求参数时连password带token一起输出打印HTTP Header时把Authorization原样输出打印SQL日志时把参数值全量打印Feign调用日志里把下游的敏感响应体整个打出来。脱敏手段从轻到重这么几层日志框架层面写一个MessageConverter或者配合PatternLayout识别常见敏感关键字自动打码。工具类层面MaskUtil.maskPhone(13800000000)返回138****0000打印参数前先过一层。对象设计层面DTO的toString()重写时直接排除敏感字段或者用注解标记后由全局序列化处理。另外要认识到接入了EFK、ELK这类集中日志平台后能看日志的人比你想象的多得多。今天图省事打出来的明文密钥明天可能就变成舆情通报上的反面教材。3. 后五条军规在分布式和容器化环境里让日志真正可用3.1 军规六全链路注入同一个TraceId让日志形成时间线在微服务架构里一个请求要经过网关、A服务、B服务、Redis、MQ最后再回来。每个服务各打各的日志没有统一的关联标识根本串不起来一次请求的完整路径。解决办法就是全链路TraceId。最轻量的一套方案是基于MDCMapped Diagnostic Contextpublic class TraceFilter implements Filter { private static final String TRACE_ID traceId; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { String traceId UUID.randomUUID().toString().replace(-, ); MDC.put(TRACE_ID, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(TRACE_ID); } } }然后在日志pattern里加上[%X{traceId}]所有日志自动带TraceId。跨服务传递时把它塞进Header下游从Header里取出来继续放入MDC。如果不想自己维护这套机制直接上Spring Cloud Sleuth或者OpenTelemetry也行原理都是一样的。有了TraceId用户投诉一笔订单异常你只需要拿订单号查出TraceId就能把该请求经过所有服务的日志拉出来排成一条时间线。没有它分布式排障基本等于大海捞针。3.2 军规七日志必须可被程序消费用结构化字段代替散文人到排障时习惯用眼睛看日志但日志的更大价值在于被程序自动消费检索、聚合、告警、生成报表。这就要求日志内容尽量结构化。我推荐两种风格可以混合使用keyvalue风格适合传统日志文件人在终端里直接grep也很友好。orderId123 userId456 amount99.8 statuscreated costMs23JSON风格适合日志采集到ES后按字段聚合Kibana里可以做各种Filter和图表。{orderId:123,userId:456,amount:99.8,status:created,costMs:23}不管用哪种有几个规则必须坚持日志里不要输出换行符不要把整个HTTP响应体、大段HTML、超长报文塞进一行日志容器环境里宁可拆成多条有requestId关联的日志也不要写一条几百KB的大团圆日志。否则filebeat做多行采集时会错乱ES里索引也会爆炸。以filebeat为例Java异常堆栈本来就是多行的需要配置多行合并规则filebeat.inputs: - type: filestream enabled: true paths: - /app/logs/*.log multiline: type: pattern pattern: ^\d{4}-\d{2}-\d{2} negate: true match: after这段配置的意思是只有日期开头的行才是一行日志的起始行异常堆栈的后续行都合并到起始行里。没有这个配置一条堆栈会被拆成几十条碎片日志看着就头大。3.3 军规八能动态调级别、能控制体积日志量也要做容量管理日志量失控是最容易被低估的故障源头。磁盘爆掉、采集端IO飙高、ES索引膨胀、日志查询超时这些我都经历过。控制日志量不是不打印而是让日志更具可调度性。第一个常用手段是动态调整级别。logback支持通过代码或JMX动态修改某类logger的级别排查问题时不用改代码发版直接调级别ch.qos.logback.classic.Logger logger (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(com.example.order); logger.setLevel(Level.DEBUG);第二个手段是分层控制。生产环境第三方开源库的日志一律WARN起步自己业务的日志INFO起步只有重点链路开DEBUG。我见过有人把Hibernate的SQL日志开到DEBUG之后一上午磁盘就满了教训相当深刻。第三个手段是抽样。超高吞吐的链路比如网关访问日志、推荐点击日志每一条都记录成本太高可以按百分比抽样或者按TraceId的hash做采样。日志的容量管理和数据库日志清理是同一个道理日志不是不能删但要确认已经完成轮转或备份再删。SQL Server的simple恢复模式、MySQL的binlog保留天数、Oracle的监听日志清理都是容量管理的一部分但动手前要看清楚服务依赖关系。3.4 军规九异步日志要开但丢日志的边界必须心里有数同步打日志意味着业务线程要等日志写完才继续跑。当磁盘IO变慢或者日志量短时飙升同步打印会让请求延迟暴涨甚至拖垮整个服务。所以生产环境基本都会上异步日志。logback的写法是AsyncAppenderlog4j2支持AsyncLogger底层用的是无锁队列或Disruptor性能确实好。但异步带来的代价是丢日志的边界。默认配置下队列满了之后新的日志事件会被直接丢弃应用进程被kill -9时内存队列里还没刷盘的日志也会全部丢失。所以下面这几个参数值得认真调appender nameASYNC classch.qos.logback.core.AsyncAppender discardingThreshold0/discardingThreshold queueSize8192/queueSize neverBlockfalse/neverBlock appender-ref refFILE/ /appenderqueueSize默认是256对高并发服务来说太小我一般调到4096到8192。discardingThreshold表示队列剩余容量低于这个比例时只保留ERROR级别日志业务日志直接丢弃如果你更看重完整性可以设为0但要做好极端情况下线程阻塞的心理准备。还有一点进程优雅停机时最好在ShutdownHook里调用logger相关资源的stop()把队列里的日志刷完。Python FastAPI环境里uvicorn日志丢失、worker退出时日志没落盘本质上也是异步刷盘的时机问题。3.5 军规十一条日志要能闭环业务链路关键时刻它就是审计证据最后一条军规是前面所有规则的集大成者日志不能只记录单个方法调用要能还原一次完整的业务操作闭环。从用户发起请求到参数校验、业务处理、外部调用、落库、回执每个关键节点都有日志且用同一个业务流水号串起来。以支付为例一次支付操作应该有这样的日志链路请求进入payment request received, userId123, amount99.8风控校验risk check pass, paymentNoP20250213001调用支付渠道channel call start, paymentNoP20250213001, channelwechat渠道回调channel callback received, paymentNoP20250213001, statusSUCCESS落库结果payment status updated, paymentNoP20250213001, statusPAID, costMs328这套日志就是行为日志。出了纠纷、遇到审计、应急响应时它就是唯一可信的证据链。Windows安全日志、Linux的auth.log、Nginx访问日志、数据库慢查询日志本质上都是同一回事——系统在关键时刻留下的证据。平时不觉得等需要溯源内鬼、分析攻击路径、排查凌晨的异常关机时这些日志的价值就体现出来了。4. 军规之外的工作日志采集、轮转与安全审计配套光会打日志还不够日志从应用落到可供检索的平台上中间还有一段路要走。这一节是配套动作可以说是军规落地的最后一公里。4.1 日志采集链路filebeat怎么读直接影响日志能不能搜现在主流方案是EFK或者ELK应用日志写文件filebeat采集Logstash或Kafka做缓冲和清洗最后进ElasticsearchKibana展示。这条链路最容易翻车的地方有三个。第一多行日志错乱解决方法是前面军规七里说的filebeatmultiline配置。第二时区不一致容器内UTC、宿主机北京时间导致Kibana里搜索出来的时间对不上解决方法是统一设置容器时区同时让Elasticsearch正确识别日志时间字段。第三字段类型错误比如orderId在ES里被当成数字类型遇到带前缀的单号就索引失败可以在索引模板里把这类业务字段固定映射为keyword。采集配置建议尽早把字段清洗规则定下来保留关键字段、丢弃无用字段、统一时间格式。等到数据量涨起来再改索引那才是真的痛苦。4.2 日志轮转与清理哪些能删、哪些不能删、怎么安全删日志清理是运维日常。我见过最惨痛的一次事故是Nginx访问日志没有配置轮转一个周末被扫描流量写满了500GB的磁盘进程直接挂掉。所以轮转是底线不是加分项。Linux下最常用的就是logrotate/app/logs/*.log { daily rotate 14 compress missingok copytruncate }配置含义是按天轮转、保留14份、旧日志压缩、轮转时不中断正在写入的进程。注意copytruncate参数——先复制当前日志再清空原文件对nginx这类保持文件句柄的进程特别重要。清理时也要分清楚对象业务日志、访问日志可以做轮转压缩数据库的binlog、SQL Server事务日志能不能删、怎么删取决于恢复模式和备份策略。SQL Server报该数据库不可以执行非日志模式的大容量复制请联系数据库所有者(dbo)时说明你把数据库切成了simple恢复模式这时日志模式对某些大容量操作是有限制的不能为了省日志空间盲目切换模式。遇到问题之前先弄明白日志模式影响的是可恢复性再决定怎么清理。4.3 系统级日志与应急响应把系统日志变成破案证据业务日志之外系统级日志也要纳入视野。排查电脑莫名关机要先看Windows事件查看器里的系统事件ID分析Linux入侵先看/var/log/auth.log里的登录记录数据库慢查询日志配合索引优化Redis日志能暴露持久化失败和OOM。这些组件日志和业务日志是两张网叠在一起才完整。应急响应时常用到这些分析统计登录失败次数grep Failed password /var/log/auth.log | awk {print $1, $2, $3} | sort | uniq -c查看crontab执行记录journalctl -u cron --since 2025-02-01分析Web访问异常按IP聚合Nginx访问日志高频且异常的IP优先排查。这些操作背后的原则和业务日志军规一致保留足够的上下文、时间准确、可检索、完整闭环。日志平台建设得越规范关键时刻的应急响应就越高效。5. 把日志打满磁盘和线程池四个印象深刻的翻车现场前四条军规说再多理论不如看几个真实翻车现场来得直观。这些案例都是我实际遇到过或跟过的问题现在回忆起来还挺肉疼。5.1 案例一日志里只有一个null异常堆栈被吞掉的惨案那是某个定时任务批量处理数据某天突然失败。大家上去一看日志只看到一行task failed, reason: null。代码里写的是logger.error(task failed, reason: e.getMessage())而那次抛的正好是NPEgetMessage()返回null。没有堆栈、没有具体参数、没有数据ID所有人都懵了。最后靠回放数据、构造现场才勉强定位。这个事的教训就是军规四异常日志一定要把Throwable传进去完整堆栈就是案发现场getMessage()这种东西根本不够用。5.2 案例二容器时区错乱告警时间和日志时间差了八个小时K8s里很多基础镜像默认是UTC时区业务代码用new Date()打印本地时间日志里显示的是凌晨3点但实际故障发生在北京时间11点。监控告警触发后按告警时间去查日志那个时间段干干净净什么都没有。几个人排查了半小时才发现是时区差。后来在镜像里统一设置TZAsia/Shanghai业务日志统一使用带时区偏移的ISO8601格式采集端再用日志里的业务时间字段建立索引。时间对不齐日志字段再全也是废的。5.3 案例三异步队列过载关键错误日志被静默丢弃高并发秒杀场景瞬时流量触发了一波异常风暴日志系统先打到CPU飙高然后logback默认配置的异步队列满了直接开始丢弃日志事件。等我们灾后复盘时发现日志里只有下标前的少量记录真正能还原故障高峰的ERROR日志反而丢了。后来做了三处调整队列加大到8192、discardingThreshold设为0保证ERROR不丢、错误日志拆到单独的同步Appender里。异步是优化手段关键日志一条都不能丢这个原则要用配置来兜底。5.4 案例四访问日志不轮转一个周末写满500GB磁盘Nginx的access log没有任何定时轮转也没有大小上限。周末流量突然涨起来日志文件快速增长直接把磁盘写满了。服务挂掉后最尴尬的是删日志的时候进程还占着文件句柄rm了之后空间也没释放。后来统一用logrotate配置copytruncate再配合磁盘监控才算根治。日志轮转这件事没有任何技术门槛纯粹是上线前漏了配置上线后就要用血的代价来补。回头看我这些年维护业务线的经历真正让我少掉头发的不是更快的debug技巧而是把日志当成一个产品来设计。十条军规看起来是约束实际上是在给未来的自己和同事写备忘录。最后一句话送给大家日志这事前期花多少心思排障的时候就少熬多少夜。我现在的习惯是每写一个核心方法前先想清楚两件事——这个方法的关键路径上需要哪些日志三天后的我拿到这些日志能让人顺着走完整个业务链路吗想清楚这两点军规也就内化了。