日志不是给人看的,是给查询用的

举报
yd_251777447 发表于 2026/11/08 18:14:50 2026/11/08
【摘要】 有一次线上问题,我们四个人对着一份 2GB 的日志文件看了二十分钟,最后是靠 grep 一个用户 ID 才找到那几行。事后我意识到一个挺别扭的事实: 日志写的时候是给人看的,用的时候却是给机器查的 。这两件事从一开始就没对上。 那天的问题本身不复杂:某个用户下单失败,但错误日志里只有一句「订单创建失败」。谁失败了、哪一步、参数是什么、上游返回了什么,全都没有。于是只能靠时间窗口

有一次线上问题,我们四个人对着一份 2GB 的日志文件看了二十分钟,最后是靠 grep 一个用户 ID 才找到那几行。事后我意识到一个挺别扭的事实:日志写的时候是给人看的,用的时候却是给机器查的。这两件事从一开始就没对上。

那天的问题本身不复杂:某个用户下单失败,但错误日志里只有一句「订单创建失败」。谁失败了、哪一步、参数是什么、上游返回了什么,全都没有。于是只能靠时间窗口去猜,把前后几秒所有日志拉出来人肉拼上下文。

一句「失败」能有多少种含义

改造前,代码大致长这样:

try {
    orderService.create(req);
} catch (Exception e) {
    log.error("订单创建失败: " + e.getMessage());
}

这句话在任何一次故障里都会出现,但它回答不了排查时真正要问的问题:

  • 是哪一个请求失败了?用户、订单号、幂等键是什么?
  • 失败在哪一步?参数校验、库存扣减、还是支付调用?
  • 是偶发还是全量?同一分钟内同类错误有多少条?
  • 是我们的问题还是下游的?下游的状态码和耗时在哪?

于是每一次排查都要重新去代码里找上下文,再回到日志里对时间。日志明明打了,却没省下任何时间。

先改的不是格式,是「一条日志该带什么」

我们定了一条很朴素的规矩:任何一条 ERROR 日志,必须能单独回答「谁、做什么、卡在哪、上游怎么说」。不要求读起来像句子,要求字段齐全。

log.error("order.create.failed userId={} orderId={} step={} upstream={} costMs={} err={}",
          req.getUserId(), req.getOrderId(), "reserve-stock",
          stockResp == null ? "null" : stockResp.getCode(),
          System.currentTimeMillis() - t0, e.toString());

后来换成结构化输出,字段直接变成可检索的键:

{"ts":"2026-11-08T14:22:31.408+08:00","level":"ERROR","event":"order.create.failed",
 "traceId":"4bf92f3577b34da6a3ce929d0e0e4736","userId":"u_88213","orderId":"SO-20261108-7731",
 "step":"reserve-stock","upstream":"STOCK_503","costMs":3021,"service":"order-api","host":"pod-7"}

变化不只是「从文本变成 JSON」。真正有用的是现在可以直接问平台:

event:order.create.failed AND step:reserve-stock | count by upstream, 1m

一句话就能看出是某个下游在 14:22 集中返回 503,还是各步骤都在报错。从"读日志"变成"查日志",排查方式才真的变了。

traceId 是性价比最高的一项

如果只能改一件事,我会选 traceId。

没有它的时候,一个请求跨三个服务,你得靠时间戳和用户 ID 把三份日志对齐,遇到同一用户并发请求就彻底乱了。有了它,入口生成一次,通过 HTTP header / MQ 消息一路透传,每个服务打印时都带上,任何一条日志都能反查整条链路。

// 入口过滤器:没有就生成,有就沿用
String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
        .filter(StringUtils::hasText).orElseGet(() -> UUID.randomUUID().toString().replace("-", "").substring(0, 16));
MDC.put("traceId", traceId);
try { chain.doFilter(req, resp); } finally { MDC.clear(); }

有两个坑我们踩过:异步线程里 MDC 会丢(要手动把上下文传给线程池,或用 TTL 包装);调用外部服务时别忘了把 header 写进请求,否则链路在边界处断掉,比没有还让人困惑。

级别不是「严重程度」,是「谁该被叫醒」

以前团队把 ERROR 当成「比较重要的信息」,结果告警一响就是几百条,大家学会了忽略。后来重新约定:

| 级别 | 含义 | 是否告警 |

|---|---|---|

| ERROR | 请求失败且需要人工介入 | 是 |

| WARN | 系统自己兜住了(重试成功、降级生效) | 只看趋势 |

| INFO | 关键业务节点,一次请求最多 2~3 条 | 否 |

| DEBUG | 排查期临时打开 | 否 |

关键是 WARN 不告警。重试第一次失败、缓存未命中走 DB、限流触发降级,这些系统已经处理好了,写 WARN 供事后分析趋势即可。真正需要半夜叫人的是「用户操作失败且没有自动恢复」。

改完之后告警量掉了九成,剩下的每一条都值得看。

别把日志当垃圾桶

还有几件小事,看着琐碎,出事时特别要命:

别打整对象。 log.info("req={}", req) 在参数里带了手机号、证件号、token,日志一采集就等于把敏感数据复制到了另一个系统,权限还更松。要么白名单字段,要么在序列化层统一脱敏。

别在循环里打 INFO。 一次批量任务打十万条,真正有用的那一条被淹了,存储账单还涨一截。批量场景打开始和汇总,异常单独打。

异常别只打 message。 e.toString() 丢了堆栈,log.error("{}", e) 又可能只打出一行类名。用 log.error("event=xxx ...", e) 把异常对象作为最后一个参数交给框架,堆栈才会完整。

时间带时区和毫秒。 跨服务对齐时,14:22:31 和 14:22:31.408+08:00 的差别,就是「大概那几秒」和「就是这条」的差别。

消息用稳定的事件名。 order.create.failed 这种标识符比中文句子好聚合,也方便后来做告警规则。中文描述可以放在单独的 msg 字段里,不参与检索。

检验标准其实很简单

改完之后我们有个自测办法:随便挑一条最近的错误日志,只看这一条,不打开代码、不翻相邻行,问自己四个问题——谁的请求、哪一步、上游返回什么、能不能按这个条件统计出同类有多少条。

四个都答得上来,这套日志就算能用了。答不上来,就还只是把 System.out 换了个更漂亮的壳。

日志写在代码里,但读者从来不是写代码的那个人,而是某个凌晨两点盯着屏幕、只想尽快缩小范围的陌生人。替那个人多写几个字段,成本很低,回报在他最需要的时候。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。