日志:结构化事件的叙事力量

上一篇结尾留了一道墙:指标把一万个请求坍缩成一个 +N,那些请求作为个体的全部细节,在落盘前就消失了。于是它能告诉你”错误率 8%“,却答不出”是哪些请求、哪些用户、走的哪条路”。

日志,是经典范式给这道墙的第一个回答。它不聚合——它为每一个值得记下的事件留一条完整的记录,带着那个事件的全部上下文。这是这套范式里,天平第一次往”留住个体”的方向挪动。但它挪得不彻底,也正因为不彻底,日志成了一个脚踏两个时代的奇特信号:它属于经典范式,却已经伸出一只脚,踏进了 可观测性的门槛。这一篇,是我们走完经典范式的最后一站。

一、留下个体:日志在做什么

指标坍缩个体,日志留下个体

把指标和日志放在同一个场景下看,区别一目了然。一个用户登录失败了:

  • 指标的反应:login_failures_total 这条序列的值 +1。这个用户是谁、从哪登的、为什么失败——全部被这次 +1 吞掉了。
  • 日志的反应:写下一条记录——“2026-06-06 14:32:07,用户 8f3a 从 1.2.3.4 登录失败,原因:密码错误,这是他第三次尝试。”

指标存的是计数,日志存的是事件。指标问”发生了多少次”,日志问”那一次到底是怎么回事”。这是经典范式里第一次,个体没有被坍缩——它被原样留了下来。

日志的定义:一个事件的完整记录

收紧成定义:一条日志,是系统在某个时刻发生的一个离散事件的完整记录。 它天然带着三样东西——什么时候(时间戳)、什么事(事件描述)、相关的上下文(谁、哪里、什么结果)。

这里要引入一个影响深远的视角,来自 12-factor app日志是一个事件流(event stream),不是一个文件。 应用不该关心日志写到哪、怎么轮转、怎么归档——它只管把事件一行行地、按时间顺序,吐到标准输出。“日志去哪、怎么存”是运行环境的事。这个看似简单的观念转变,是后面整个云原生日志管道的地基(第三章会接上)。

力量与边界同源

和前两篇一样,日志的力量和边界,也是同一件事的两面:

日志知道”发生了什么”,但不知道”和谁有关”。

力量在于它保留了单个事件的完整上下文——出了问题,你能翻到那一条,看清当时的现场。边界在于每一条日志都是孤立的:它详尽地记录了自己,却不知道自己和别的日志之间有没有因果关系。在一个单体应用里这不是问题(一次调用的来龙去脉都在一个进程的日志里);可一旦请求跨越十个服务,这个”孤立”就会变成致命伤——这是第五章的主题。

二、从文本到数据:结构化这一跳

日志能不能成为有用的信号,几乎完全取决于一件事:它是给读的,还是给机器查的。这就是结构化与非结构化的分野,也是日志这一篇最关键的工程决策。

非结构化日志的原罪

传统的日志长这样:

2026-06-06 14:32:07 ERROR User 8f3a failed login from 1.2.3.4 after 3 attempts

它为人眼优化——一句通顺的英文,扫一眼就懂。但当你想做任何稍微聚合一点的分析时,灾难就来了。“过去一小时,哪个 IP 的登录失败最多?“——你只能写正则去 grep、去切分这句话,从自由格式的文本里把 1.2.3.4 抠出来。每一种日志格式都要写一套解析规则,格式稍变规则就失效。非结构化日志的原罪:它把数据腌进了散文里,机器要费很大劲才能再抠出来。 grep 能找到一行,但回答不了”按 X 分组统计 Y”。

结构化日志:字段即维度

结构化日志把同一个事件写成机器能直接读的格式——通常是 JSON:

{
  "timestamp": "2026-06-06T14:32:07Z",
  "level": "error",
  "event": "login_failed",
  "user_id": "8f3a",
  "source_ip": "1.2.3.4",
  "reason": "wrong_password",
  "attempt": 3
}

变化是质变。现在”哪个 IP 失败最多”不再是正则游戏,而是一次普通的聚合查询——按 source_ip 分组、计数。每一个字段都成了一个可查询、可聚合的维度。 日志从”可搜索的文本”,升级成了”可查询的数据”。这一跳,决定了日志能不能回答 指标答不出的那个"哪一个"——只要你在写日志时,把”谁”作为一个字段记了下来。

日志级别的语义:把级别当注意力阀门

日志有级别——DEBUG / INFO / WARN / ERROR / FATAL——但很多人把它用乱了。级别的本质,是 注意力阀门在日志上的投影:它在回答”这条事件,值不值得打扰一个人”。

  • DEBUG:只在排查时才看,平时关掉——开发者的私货。
  • INFO:系统正常运转的叙事——启动了、连上了、处理完了。出事后用来回溯”当时在干嘛”。
  • WARN:不正常但还能撑——重试了、降级了、用了兜底。它是”现在没事,但你该看看”。
  • ERROR:一个操作失败了,需要有人知道。
  • FATAL:进程活不下去了。

划级别的纪律,和 监控的阈值是同一种判断:定低了(什么都 ERROR),告警和日志一起变成噪声,真问题被淹没;定高了(什么都 INFO),出事时翻不到有用的线索。级别不是日志的装饰,是你对未来那个排障的人的注意力的预先分配。

字段设计的纪律:该记什么、绝不该记什么

结构化的自由是有代价的——你得管好字段。两条硬纪律:

  • 绝不该记的:密码、token、密钥、完整的信用卡号、身份证——一旦写进日志,就等于把敏感数据复制进了一个长期保留、广泛可读、容易被遗忘的地方。日志是数据泄露最常见的源头之一。 PII(个人身份信息)也要按合规要求脱敏。
  • 要警惕的:高基数字段虽然能记(日志不像指标那样会因此爆炸——这正是日志比指标贵也比指标强的地方),但你得想清楚检索和存储成本。request_iduser_id 该记,但把整个请求体塞进每条日志,账单会让你后悔。

字段设计本质上是在问:未来排障的人,会需要哪些维度来切分和定位? 这又是一次预判——只不过预判的不是”会出什么故障”,而是”出故障时我会想按什么来问”。

三、经典实践:ELK 的全文索引世界

理论落地。日志领域统治了十年的,是 ELK——Elasticsearch + Logstash + Kibana(以及它们周边的 Beats)。看懂它,就看懂了”全文索引”这条技术路线的力量与代价。

12-factor:应用只管写 stdout

回到前面那个观念。在 ELK 的世界里,应用不负责把日志写到文件、不负责轮转、不负责发送——它只把结构化日志吐到 stdout。剩下的,交给管道。这个解耦至关重要:应用代码里没有一行和”日志基础设施”耦合的东西,今天用 ELK、明天换 Loki,应用一行不改。

采集管道:从机器到中心

日志从应用流到能查询的地方,要穿过一条管道:

应用 stdout → Filebeat/Fluentd(采集,每台机器一个)→ Logstash(解析、富化、转换)→ Elasticsearch(存储 + 索引)→ Kibana(查询 + 可视化)
  • Filebeat / Fluentd:轻量采集器,在每台机器(或每个 K8s 节点)上跑,负责把日志收上来、可靠地送走。
  • Logstash:管道的”加工车间”——把非结构化日志解析成字段(用 grok 正则)、补充元数据(加上主机名、环境)、做格式转换。如果应用已经输出结构化 JSON,这一步可以很轻;如果是遗留系统的文本日志,Logstash 的 grok 解析就是把前面说的那个”原罪”补救回来的地方。
  • Elasticsearch:存储和检索的核心。
  • Kibana:查询与可视化的前端。

Elasticsearch 的倒排索引:全文可搜,又重又贵

ELK 的力量和代价,都来自 Elasticsearch 的核心机制——倒排索引(inverted index)。它把每条日志的每一个词都建进索引:哪个词出现在哪些文档里,一查即得。这是从搜索引擎那里继承来的本事,于是你能对日志做全文搜索——任意关键词、任意字段、任意组合,毫秒级返回。

代价是这套倒排索引又重又贵:它通常比原始日志还大,吃大量内存和磁盘,集群运维复杂(分片、副本、冷热分层都是学问)。你为”任意词都能搜”这个能力,付了一笔不小的存储与运维税。 这笔税值不值,取决于你是否真的需要全文检索——这个问题,下一章 Loki 会给出一个截然相反的答案。

Kibana:从搜索到聚合

光能搜还不够,前面说过结构化让日志可聚合,这个能力在 Kibana 里兑现。借助 Elasticsearch 的 aggregation,你能把日志当数据来统计:按 source_ip 分组数失败次数、按 reason 画饼图、看 login_failed 事件随时间的趋势。这一步,日志开始干一部分指标的活——从结构化字段里实时聚合出统计量。日志和指标的边界,在这里第一次变得模糊。

四、云原生实践:Loki 的”只索引标签”哲学

2018 年,Grafana 团队推出 Loki,副标题是”像 Prometheus,但用于日志”。它对 ELK 做了一次釜底抽薪的反向取舍——而这个取舍,恰好是 指标那篇“存储汇率”标尺的又一次现身。

Loki 的反向取舍:不索引日志体

ELK 索引每一个词,Loki 反其道而行:它只索引一小组标签(label),完全不索引日志正文。

一条 Loki 日志,结构上像这样:一组 label({app="checkout", env="prod", level="error"})+ 一大坨没被索引的原始日志文本。Loki 只为 label 建索引,正文则被压缩成块、扔进对象存储(S3 之类)便宜地躺着。

这个取舍的账很清楚:牺牲”任意全文检索”的速度,换极低的存储成本和极简的运维。 你不能再期待”任意关键词毫秒返回”,但你的日志存储账单可能只有 ELK 的零头。Loki 把日志这根标尺,从 ELK 的”求查询能力”端,狠狠拨回了”省资源”端。

采集:label 怎么打,基数幽灵重现

Loki 的采集端是 Promtail(或新一代的 Grafana Alloy),把日志收上来、打上 label、送进 Loki:

scrape_configs:
  - job_name: kubernetes-pods
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      - source_labels: [__meta_kubernetes_namespace]
        target_label: namespace
      - source_labels: [__meta_kubernetes_pod_label_app]
        target_label: app          # app、namespace、level 这类低基数维度,适合做 label

注意这里 基数的幽灵又回来了:因为 Loki 像 Prometheus 一样为 label 组合建索引,你绝不能把 user_idrequest_idtrace_id 这种高基数字段塞进 label——否则 Loki 的索引会像前面讲过的 Prometheus 一样爆炸。它们应该留在日志正文里(正文不索引,存高基数无妨),查询时再用 grep 式的过滤去捞。“什么进 label、什么留正文”,是用 Loki 的核心纪律。

LogQL:先缩范围,再 grep

Loki 的查询语言 LogQL,结构上完美体现了它的取舍——两段式

# 第一段:用 label 把搜索范围缩到极小(走索引,快)
{app="checkout", env="prod", level="error"}
  # 第二段:在缩小后的日志流里,做文本/字段过滤(不走索引,但数据已经很少了)
  |= "login_failed"
  | json
  | source_ip = "1.2.3.4"

先用 label {app="checkout", level="error"} 把要扫的数据从 TB 缩到 MB(这一步走索引,极快),再在这一小撮里 |= 关键词过滤、| json 解析字段。它没有全文索引,但靠”先用 label 把范围缩到足够小”,让事后的暴力扫描变得可接受。 这是一种很聪明的”穷人的查询”——用结构换索引。

ES vs Loki:同一根标尺的两端

把两者并排,你会发现它们不是”谁更好”,而是 存储汇率标尺上的两个端点:

Elasticsearch(ELK)Loki
索引什么每个词(全文倒排索引)只索引 label
查询能力任意全文检索,强label 缩范围 + 暴力扫,弱
存储成本高(索引常比原文还大)低(正文压缩进对象存储)
运维复杂度
适合安全审计、需要任意检索、查询频繁成本敏感、查询模式已知、和 Grafana/Prometheus 一套

选哪个,又回到那个永恒的问题:你愿意为”任意提问的能力”付多少存储? 查询频繁、需要全文检索的安全审计场景,ELK 的税付得值;只是偶尔翻日志排障、又想省钱的,Loki 的取舍更划算。没有标准答案,只有你的汇率。

五、边界:孤立的日志与缺失的因果链

日志保留了个体,能聚合分析,索引策略还能丰俭由人。它看起来很全能。但开篇埋的那颗雷,在分布式系统里会准时引爆。

★ 故障剧场:拼不出的旅程

一个用户报告:“我的结账失败了。“你去查日志。

这个请求经过了五个服务:网关 → 订单 → 库存 → 支付 → 通知。每个服务都老老实实记了日志。于是你在五个服务的日志流里,分别看到了一些 error。问题来了:

  • 网关在 14:32:07 记了一条 request received
  • 支付服务在 14:32:09 记了一条 payment declined
  • 库存服务在 14:32:08 记了一条 lock timeout……

这些日志里,哪几条属于这一个用户的这一次请求? 同一秒里,系统处理了上千个请求,每个服务的日志流里混着上千条记录。你想把”这一次结账”的完整旅程拼出来——网关收到后调了谁、谁慢了、最终错在哪——但你手上的每条日志都只知道自己,没有任何字段告诉你”我们是同一次请求的一部分”。 你对着五屏日志,拼不出那一条因果链。

孤立性:每条日志只知道自己

这就是日志的根本边界——孤立性。一条日志是一个点,详尽地描述了某个时刻的某个事件,但点和点之间没有线。在单体应用里,一次请求的所有事件都在一个进程、一份日志里按顺序排列,线是隐含的(时间顺序 + 同一个线程)。可一旦请求跨进程、跨服务、跨机器,时间顺序会因为并发而错乱,线程的概念也断了,隐含的因果线就彻底丢失了。

trace_id:把日志串起来的那根线

解法其实呼之欲出:既然问题是”日志不知道彼此属于同一次请求”,那就给同一次请求的所有日志,打上同一个标识。这个标识,就是 trace_id

请求进入系统的第一刻(网关),生成一个全局唯一的 trace_id;之后这个 id 跟着请求,穿过每一个服务,每个服务记日志时都带上它。于是前面那场拼不出旅程的灾难迎刃而解:{trace_id="abc-123"} 一查,五个服务里属于这次结账的所有日志,按时间排好,旅程重现。

{ "timestamp": "...", "service": "gateway", "trace_id": "abc-123", "event": "request_received" }
{ "timestamp": "...", "service": "payment", "trace_id": "abc-123", "event": "payment_declined", "reason": "insufficient_funds" }

但请你看清这一步的本质:当你开始用 trace_id 把孤立的日志串成因果链,你已经在亲手搭建追踪(tracing)的雏形了。 trace_id 是日志世界里通往下一个时代的桥——你一旦走上它,就走出了经典范式。

脚踏两个时代

所以日志是个奇特的信号。一方面,它仍属于经典范式:它是机会主义的(开发者觉得重要才记,不是系统性地记录每一跳),是靠人预判的(记什么字段、打什么 label,都是人预先决定的)。另一方面,它又是经典范式里唯一保留了个体的信号——而”保留个体”正是 可观测性的起点。

日志一只脚站在监控的世界(预判、人决定记什么),另一只脚已经踏进了可观测性的门槛(留下个体、可事后提问)。 它是这两个时代之间的渡口。走过它,我们就要换挡了。

六、小结:坐标与去向

日志在五维标尺上的坐标

那把尺子给日志定位,并和前两篇连成一条线:

维度监控指标日志
① 故障预判权人预判人预判开始松动——记了细节,事后能问出没预判的东西
② 存储汇率只存状态极致预聚合中间地带——留个体,但 ES/Loki 间可调丰俭
③ 信号分工布尔健康量化”多少""发生了什么”——第二类信号上场
④ 注意力阀门基于原因基于原因日志级别——次要的告警来源
⑤ 仪器化负担手写检查手写埋点手写 log 语句——仍要人决定记什么

看这张表的演进:从监控到指标到日志,①(预判权)开始松动、②(存储)从极致预聚合滑向中间、③(信号)从布尔到数字再到事件——天平正在一格一格地,从纯资源端往”留住信息”端挪。 日志是经典范式里这个挪动的终点,但还没越过那条线:它留下了个体,却还没系统性地串起因果。

去向:换挡

日志的边界——孤立性——精确地指向了下一个时代的入口:

  • 当你决心不再机会主义地记日志,而是系统性地记录每一次请求在每一跳的因果,并用 trace_id 把它们串成完整旅程——你就走进了 分布式追踪
  • 而当你把”保留个体、事后任意提问”从日志的副作用,升级成一个刻意的系统设计目标——你就跨过了那条分界线,进入了 可观测性的世界。
  • 至于指标、日志、追踪这三类信号最终如何被统一成一套语言、用 trace_id 这样的桥彼此关联——那是 OpenTelemetry的故事。

那个贯穿四篇的问题,经典范式的最后一次:日志把天平推向了哪边?它仍偏资源、仍靠人预判,但第一次,把”个体”留在了桌上。 而下一篇,我们将正式跨进云原生范式——在那里,资源变得廉价、系统变得分布,天平会彻底倒向另一端:不再预判,留下一切,事后任意提问。

经典范式到此走完。换挡。

返回 可观测性与运维工程 MOC | 上一篇 03-指标-时间序列的量化哲学 | 下一篇 05-可观测性-从已知失败到任意提问