Skip to content

微服务日志治理:ELK vs Loki + Grafana,结构化日志与全链路日志关联

提出问题

微服务架构下,日志分散在几十个甚至上百个服务实例上,每个实例都写本地文件。排查问题时,你的第一反应是"ssh 到每台机器上 grep 日志"——这在 3 个服务时还能忍,30 个服务时已经不可能了。更糟糕的是,一个请求跨 5 个服务,每个服务都打了一条日志,但日志分散在不同的机器上,TraceId 没有透传,你根本不知道哪个日志属于同一个请求。

日志治理要解决的核心问题很明确:统一收集 → 集中存储 → 快速检索 → 关联分析。但落到方案选型上,ELK 和 Loki+Grafana 两条路怎么选?结构化日志到底怎么做才能跟链路追踪打通?很多团队把日志扔到 Elasticsearch 里就不管了,结果存储成本爆炸、检索速度慢、ERROR 日志泛滥没人看——日志治理变成了"垃圾场",而不是"排查利器"。

Agent 工程场景延伸:当你开始做 LLM Agent 应用后,日志治理会面临三个新挑战:

  • 每次 LLM 调用的请求/响应(Prompt + Completion)可能上千 Token,日志量暴增 10 倍
  • 需要记录 Token 消耗、模型选择、延迟等指标,传统的日志模型不够用
  • Agent 的多步推理(ReAct 循环)可能产生 5-10 轮 LLM 调用,日志关联从"跨服务"变成"跨步骤"

统一日志收集:从 Filebeat 到 OpenTelemetry Collector

一条日志从产生到被工程师看到,完整链路如下:

App 进程 (logback/json) → 磁盘文件 (/var/log/app/*.log)
    → 采集 Agent (Filebeat/OTel Collector) 读取文件
    → 缓冲/批处理 (内存队列 5s/1000条)
    → 传输到后端 (ES HTTP / Loki gRPC)
    → 存储 & 索引
    → 查询 (Kibana / Grafana)

采集 Agent 的选择经历过三个阶段:

阶段组件内存占用适用场景踩坑点
1.0Filebeat< 20MB纯日志转发不支持预处理,一行日志报错整条丢弃
2.0Fluentd50-100MB需要过滤/转换/脱敏Ruby 插件生态,性能瓶颈在正则解析
3.0OpenTelemetry Collector30-80MB日志+指标+Trace 统一采集配置复杂,CRD 模式学习成本高

真实案例:我接手过一个 40 个微服务的系统,每天日志量约 300GB。团队用 Fluentd 做预处理(脱敏手机号、过滤 DEBUG 日志),但 Fluentd 的 Ruby 正则引擎在日志量突增时(秒级 5000+ 行)CPU 冲到 90%,导致日志积压,采集延迟从 3 秒飙升到 2 分钟。换 OTel Collector 后,Go 实现的 CPU 开销降低 60%,延迟稳定在 5 秒以内。

另一个案例(Agent 场景):一个 LLM 聊天应用,每次用户请求触发 3 轮 ReAct 循环,每轮输出 2000+ Token 的日志。使用 Filebeat 采集时,日志文件轮转前被 Filebeat 读到一半就截断了,导致日志残缺。换成 OTel Collector 的 filelog receiver(带 multilinefingerprint 配置)后,才解决了大块日志的完整性采集问题。

OTel Collector 的 pipeline 模式配置:

yaml
# OpenTelemetry Collector 配置示例:日志采集 + 过滤 + 输出到 Loki
receivers:
  filelog:
    include:
      - /var/log/app/*.log
    operators:
      - type: json_parser
        parse_from: body
        timestamp:
          parse_from: attributes.timestamp
          layout: '%Y-%m-%dT%H:%M:%S.%LZ'

processors:
  filter:
    error_mode: ignore
    logs:
      log_record:
        - 'attributes."level" == "DEBUG"'
  batch:
    timeout: 5s
    send_batch_size: 1000

exporters:
  loki:
    endpoint: "http://loki:3100/loki/api/v1/push"
    labels:
      resource:
        - service.name
        - k8s.pod.name
      attributes:
        - level

service:
  pipelines:
    logs:
      receivers: [filelog]
      processors: [filter, batch]
      exporters: [loki]

批处理参数说明timeout: 5ssend_batch_size: 1000 哪个先到就发送。如果日志量小(< 200 条/秒),5 秒才发一批,端到端延迟 5 秒;如果日志量大(> 5000 条/秒),每秒发 5 批,延迟 1 秒内。这个参数调太大→延迟高,调太小→频繁 HTTP 请求浪费带宽。

ELK vs Loki + Grafana:存储模型决定选型

ELK 和 Loki 的根本差异在于索引策略,这是选型的核心决策点。

ELK 的倒排索引:每条日志进来,ES 对日志内容的每个词做分词 → 建倒排索引(类似书籍的目录,列出每个词出现在哪些文档)。搜索"订单失败"时,ES 直接查倒排索引返回所有匹配文档,毫秒级响应。代价是索引体积通常是原始数据的 1.5-2 倍,加上副本是 3-4 倍。ES 的 JVM heap 按数据量的 1:1 估算是个经验值——1TB 日志需要约 1TB 内存,否则 GC 频繁导致查询慢。

Loki 的标签索引:Loki 只对标签(service name、pod name、level 等)建索引,日志内容用 gzip/zstd 压缩后丢对象存储(S3/MinIO)。查询时,先按标签过滤出日志流(比如 {service="order-service", level="error"}),然后在这批日志里做正则匹配。标签筛选是 O(1) 的,但内容匹配是 O(n) 扫描——所以 Loki 查询慢的核心原因是标签粒度过粗,导致扫描量太大。

真实对比数据:我们做了 500GB/天日志量的 A/B 测试:

维度ELK (3节点 32C/128G)Loki + Grafana (3节点 16C/64G + S3)
存储占用1.8TB(含副本,7天)280GB(压缩后,7天)
按 traceId 查询500ms-2s200ms-800ms(Loki 更快,因为标签过滤)
全文搜索"orderId=xxx"200ms-1s3-15s(需要扫描大量日志流)
ERROR 聚合统计支持,ES 聚合 API 秒级不支持,需拉日志到外部计算
月成本(阿里云)~¥12,000~¥3,500

看到这个数据,Loki 在存储成本上碾压 ELK,但有两个致命短板

  1. 全文搜索能力弱:你不能搜"包含某个关键词的所有日志",必须先按标签缩小范围。如果团队排查问题的习惯是"在 Kibana 里搜关键词 → 看前后文 → 点 traceId 串起来",Loki 的第一步就卡住了。
  2. 不支持聚合分析:不能像 ES 一样按 level 做饼图、按时间做趋势线。ERROR 数量的 7 天趋势图在 Loki 里必须用 LogQL 的 count_over_time 函数,或者把日志拉到 Prometheus 里算。

选型决策矩阵

场景推荐方案理由
日志量 < 100GB/天,全文搜索频繁ELK存储成本可控,搜索体验好
日志量 > 500GB/天,按 traceId 查为主Loki存储成本差 5 倍,traceId 查询更快
已经有 Prometheus + Grafana 监控Loki统一 Grafana 面板,减少运维组件
需要复杂日志分析(聚合+趋势+异常检测)ELKELK 的聚合分析能力无可替代
中小团队(< 10 人),预算有限Loki + 单节点 ES 备份Loki 做主存储,ES 做辅助搜索

Agent 场景的特殊考量:LLM 调用日志通常包含大量 Token 级别的信息,按 Prompt 内容做全文搜索的场景非常频繁(比如"找所有包含 system prompt 中 xxx 关键字的日志")。这种情况下,纯 Loki 方案会很难受。推荐方案是:Loki 存结构化的元数据(traceId、model_name、token_count、latency),ES 存 Prompt 全文。需要查 Prompt 内容时走 ES,查链路统计时走 Loki。

结构化日志:让日志变成可查询的数据

结构化日志的核心要求是统一输出格式,不能再用 System.out.println("订单创建成功: " + orderId) 这种非结构化字符串。非结构化日志在 ES 里只能做全文模糊匹配,无法按字段过滤。你搜"订单创建成功"可能搜到 1000 条,但你想过滤 orderId = "ord20260720001" 的,非结构化日志做不到。

每个日志条目必须是 JSON 格式,包含固定的元数据字段:

json
{
  "timestamp": "2026-07-20T14:30:00.123+08:00",
  "level": "ERROR",
  "logger": "com.example.order.OrderService",
  "thread": "http-nio-8080-exec-10",
  "traceId": "abc123def456",
  "spanId": "span789",
  "userId": "u10086",
  "message": "订单创建失败: 库存不足",
  "duration": 1523,
  "orderId": "ord20260720001",
  "skuId": "sku888"
}

Agent 场景的结构化日志扩展字段

json
{
  "timestamp": "2026-07-22T10:15:30.456+08:00",
  "level": "INFO",
  "logger": "com.example.agent.LLMService",
  "traceId": "agent_abc123",
  "sessionId": "sess_xyz789",
  "turnIndex": 2,
  "model": "gpt-4o",
  "promptTokens": 1520,
  "completionTokens": 380,
  "totalTokens": 1900,
  "latencyMs": 2840,
  "toolCall": "search_tool",
  "toolResult": true,
  "message": "Agent 第 2 轮 ReAct 调用完成,搜索商品 SKU 信息"
}

必填字段设计原则

字段必填作用对应链路追踪字段
timestamp日志产生时间,非采集时间-
level告警过滤的入口-
traceId跨服务串联对应 OpenTelemetry 的 traceId
spanId服务内方法调用串联对应 OpenTelemetry 的 spanId
logger快速定位代码位置-
message业务描述,包含关键参数-
userId业务场景按用户维度排查-
duration性能场景慢请求分析对应 span 的 duration

Java 中使用 Logback 的 JSON 布局(logstash-logback-encoder)自动输出结构化日志:

xml
<!-- logback-spring.xml 配置 JSON 格式输出 -->
<configuration>
  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <!-- 包含 MDC 中的 traceId 和 spanId -->
      <includeMdc>true</includeMdc>
      <!-- 自定义字段 -->
      <customFields>{"service":"order-service","env":"production"}</customFields>
    </encoder>
  </appender>

  <appender name="ASYNC_JSON" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="JSON" />
    <queueSize>1024</queueSize>
    <neverBlock>true</neverBlock>  <!-- 队列满时不阻塞业务线程 -->
  </appender>

  <root level="INFO">
    <appender-ref ref="ASYNC_JSON" />
  </root>
</configuration>

AsyncAppender 的坑neverBlock: true 意味着队列满时新日志直接丢弃。我见过一个线上事故:某个服务高峰期 QPS 5000,日志打印量每秒 20000 条,AsyncAppender 的 1024 队列瞬间填满,超过 60% 的日志被静默丢弃。排查问题时发现链路断了,以为是 TraceId 没透传,查了 3 天才发现是日志被丢了。解决方案:把队列大小根据业务 QPS 算好,或者用 DiscardingAsyncAppender(Logback 的推荐替代,有阈值保护)。

LogstashEncoder 的另一个坑LogstashEncoder 默认会把 MDC 中所有键值对都输出到 JSON。如果 MDC 里不小心放了敏感信息(比如用户密码明文),就会全部写入日志,然后采集到 ES/Loki,带来数据泄露风险。解决方案:配置 includeMdc: true 的同时,用 MdcKeyFilterblackListMdcKeyNames 排除敏感字段:

xml
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
  <includeMdc>true</includeMdc>
  <!-- 黑名单:这些 MDC key 不会写入日志 -->
  <blackListMdcKeyNames>password,secret,token,creditCard</blackListMdcKeyNames>
</encoder>

全链路日志关联:TraceId 透传是基石

日志再结构化,如果每行日志没有 traceId,你仍然无法把同一个请求的日志串联起来。全链路日志关联的前提是 TraceId 跨服务透传

TraceId 的生命周期时序:

用户请求进入 Gateway
  → 无 TraceId → Sleuth 生成 (traceId, spanId)
  → HTTP Header 传入 downstream 服务
    → 下游服务从 header 提取 traceId
    → 设置到 MDC → 所有日志带上 traceId
    → 调用 gRPC 服务 → 通过 gRPC Metadata 传递
    → 发送 MQ 消息 → 手动在消息体携带 traceId
    → 线程池异步执行 → MDC 装饰器传递
  → 返回响应 → TraceId 生命周期结束

关键点:如果 MQ 或线程池没有传递 traceId,traceId 链就断了。从日志看,前半段有 traceId,后半段是空值,你就以为这是两个无关的请求。

Agent 场景的 TraceId 传递:LLM Agent 应用中,一个用户请求可能触发 3-5 轮 LLM 调用,每轮调用可能触发多个 Tool 调用。TraceId 的粒度需要精确到每个 Agent 步骤(turn):

用户请求 "帮我查一下 iPhone15 的价格并下单"
  → TraceId=T1, SpanId=S1 (主入口)
    → Turn 1: 调用 LLM 解析意图 → TraceId=T1, SpanId=S1_1
    → 调用搜索工具搜 iPhone15 价格 → TraceId=T1, SpanId=S1_2
    → Turn 2: 调用 LLM 生成下单参数 → TraceId=T1, SpanId=S2_1
    → 调用下单服务 → TraceId=T1, SpanId=S2_2
    → Turn 3: 调用 LLM 确认结果 → TraceId=T1, SpanId=S3_1

如果每个 Turn 的 spanId 不做层级区分,日志里只看得到 3 条 spanId 相同的日志,看不出哪个 LLM 调用是哪个步骤。建议:用 turnIndex 作为自定义字段,跟 traceId 一起放在日志里。

在 Spring Boot 应用中,通过 Spring Cloud Sleuth(或 Micrometer Tracing)自动注入 TraceId 到 MDC:

java
// 在代码中,直接通过 MDC 取 traceId
import org.slf4j.MDC;

public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public Order createOrder(CreateOrderRequest request) {
        log.info("创建订单请求, userId={}, skuId={}, traceId={}",
            request.getUserId(), request.getSkuId(), MDC.get("traceId"));
        // 业务逻辑...
        log.info("订单创建成功, orderId={}, 耗时={}ms", order.getId(), duration);
    }
}

TraceId 透传必须覆盖所有通信方式:

通信方式透传方式自动/手动常见遗漏
HTTPtraceparent HeaderSleuth 自动自定义 HTTP 客户端忘记加 Header
gRPCgRPC Metadata拦截器自动自定义拦截器忘记传递
MQ (RocketMQ/Kafka)消息体/properties手动消费者不提取,生产者不放入
线程池MDC 装饰器手动线程池复用导致 TraceId 错乱
定时任务 (@Scheduled)手动生成 TraceId手动定时任务没有 TraceId,所有日志为空
LLM 调用手动传递 sessionId+traceId手动Agent 多轮对话没有 sessionId,无法关联同一用户的多次请求

线程池场景下的 MDC 传递

java
// 线程池场景下的 MDC 传递
public class MdcAwareTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable task) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            try {
                MDC.setContextMap(contextMap);
                task.run();
            } finally {
                MDC.clear();  // 重要:不清除会导致下一个任务复用 TraceId
            }
        };
    }
}

// 配置线程池
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(new MdcAwareTaskDecorator());
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);
    return executor;
}

MQ 场景的 TraceId 传递(RocketMQ 示例):

java
// 生产者:写入消息时放入 TraceId
public class OrderProducer {
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void sendOrderMessage(Order order) {
        Message<String> msg = MessageBuilder
            .withPayload(JSON.toJSONString(order))
            .setHeader("traceId", MDC.get("traceId"))  // 关键:手动放入
            .setHeader("spanId", MDC.get("spanId"))
            .build();
        rocketMQTemplate.send("order-topic", msg);
    }
}

// 消费者:从消息中提取并设置到 MDC
@Component
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-group")
public class OrderConsumer implements RocketMQListener<MessageExt> {
    private static final Logger log = LoggerFactory.getLogger(OrderConsumer.class);

    @Override
    public void onMessage(MessageExt message) {
        String traceId = message.getProperty("traceId");
        if (traceId != null) {
            MDC.put("traceId", traceId);  // 设置到 MDC,后续日志自动带上
        }
        try {
            log.info("接收到订单消息, msgId={}", message.getMsgId());
            // 业务处理...
        } finally {
            MDC.clear();
        }
    }
}

日志治理的落地经验

1. 保留策略:按级别分层

我们生产环境的保留策略:

级别保留时长存储策略理由
DEBUG24 小时本地文件,不采集仅供开发调试,线上不打开
INFO7 天采集到 Loki/ES日常排查够用
WARN30 天采集到 Loki/ES潜在问题回溯
ERROR90 天采集到 Loki/ES + 对象存储归档事故复盘需要

存储成本控制:日志量大的接口按 1:10 降采样。比如某个接口每秒打印 1000 条 INFO 日志,只保留 100 条。降采样规则在 OTel Collector 的 processors 里配置:

yaml
processors:
  tail_sampling:
    policies:
      - type: rate_limiting
        config:
          rate: 100  # 每秒只保留 100 条
      - type: status_code
        config:
          status_code: ERROR  # ERROR 级别不降采样,全量保留

2. ERROR 日志治理:减少噪音

很多团队的 ERROR 日志泛滥:一个 NPE 异常 stack trace 打印 30 行,每天几万条,没有人看。ERROR 日志应该只打真正需要人工介入的异常

黄金法则:如果 ERROR 日志不需要人工处理,就不该是 ERROR。

实际场景合适的级别原因
用户请求参数校验失败WARN业务预期情况,不是系统错误
第三方 API 超时(有重试)WARN重试成功后自动恢复
第三方 API 超时(重试 3 次后仍失败)ERROR需要人工介入
数据库连接失败ERROR需要 DBA 介入
缓存穿透(没有命中)DEBUG正常业务路径
缓存数据不一致WARN需要关注,但不紧急
LLM 返回空/无效响应WARN可能是模型问题,但业务有兜底逻辑
Prompt 注入攻击检测ERROR安全事件,需要记录完整 Prompt 内容

3. 日志量突增的熔断

生产环境最怕的是:某个 Bug 导致日志量暴增 10 倍,ES/Loki 被打满,所有人的日志都写不进去,排查问题的日志也丢了。

解决方案:在采集 Agent 侧做日志量熔断。

yaml
# OTel Collector 的 memory_limiter processor
processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128
    # 内存超过 512MB 时,开始丢弃日志

同时,Logback 侧可以配置日志量限流(Logback 1.3+ 支持):

xml
<appender name="THROTTLE" class="ch.qos.logback.core.ConsoleAppender">
  <filter class="ch.qos.logback.core.filter.EvaluatorFilter">
    <evaluator class="ch.qos.logback.classic.boolex.JaninoEventEvaluator">
      <expression>return (System.currentTimeMillis() - previousLogTime) > 1000;</expression>
    </evaluator>
    <OnMismatch>DENY</OnMismatch>
    <OnMatch>NEUTRAL</OnMatch>
  </filter>
  <!-- ... -->
</appender>

4. 生产事故案例:日志丢失导致线上 Bug 定位延迟 8 小时

事故背景:某电商平台双十一零点,订单量在 10 秒内从 500 QPS 飙到 8000 QPS。日志系统用的是 ELK 7.10,5 节点 32C/128G。

事故经过

  1. 00:00-00:02:日志量从 500MB/分钟暴涨到 8GB/分钟,ES 集群的写入队列瞬间填满
  2. 00:02-00:05:ES 的 Bulk API 返回 429 Too Many Requests,Logstash 开始丢弃日志
  3. 00:05-00:10:大量订单创建失败,用户反馈下单后页面空白
  4. 00:10-00:30:开发团队登上去看日志,发现 ERROR 日志全部丢失,只知道"日志写不进去了",但不知道具体是什么错误
  5. 00:30-01:00:紧急扩容 ES 到 10 节点,但写队列积压了 30 分钟的数据
  6. 01:00-08:00:逐行解读本地日志文件,花了 8 小时才定位到"库存扣减服务超时导致订单创建失败"
  7. 08:00:在日志里找到了原因:库存服务的一个 Redis 连接池配置错误,导致高并发下连接池耗尽

根因:日志系统本身没有容错,写入压力大时直接丢弃日志,导致排查事故所需的日志反而是最先被丢掉的。更讽刺的是,Redis 连接池的异常日志在本地文件里,但日志采集系统把它丢掉了。

事后改进

  1. 日志采集链路增加两级缓冲区:本地文件(保留 24 小时)+ 远程采集
  2. ES 写入限流:当队列长度超过 80% 时,降级为只采集 ERROR 和 WARN 日志
  3. 日志系统本身配置业务日志告警:当日志采集延迟超过 1 分钟时,立刻告警
  4. 所有 ERROR 日志在本地文件保留 7 天,不依赖远程采集的完整性

总结

日志治理的三个关键落地原则:

  1. 结构化是前提:JSON 格式统一输出,包含 timestampleveltraceIdloggermessage 五个必选字段,业务字段按需添加。不用 logstash-logback-encoder 的团队,90% 的日志都是不可查询的字符串。Agent 场景还需要额外记录 modeltokenCountturnIndex 等字段。
  2. TraceId 透传是核心:HTTP、gRPC、MQ、线程池四种场景必须全覆盖,漏一个场景就断一条链。排查事故时,80% 的时间花在"找日志"上,而不是"看日志"上。Agent 场景还要覆盖 LLM 调用和 Tool 调用。
  3. 存储策略决定成本:DEBUG 日志保留 24 小时,INFO 保留 7 天,WARN 保留 30 天,ERROR 保留 90 天;日志量大的接口按 1:10 降采样,避免存储成本爆炸。ELK 的存储成本是 Loki 的 3-5 倍,但全文搜索能力也是 Loki 的 10 倍——选型看团队排查习惯。

面试话术示例:"ELK 和 Loki 的选型不只看存储成本,更要看团队的排查习惯。如果团队习惯全文搜索,Loki 的 LogQL 体验会让他们觉得不如 ES 顺手。我建议初期用 Loki + Grafana,因为大多数排查场景是 '按 traceId 查一次请求的所有日志',这种场景下 Loki 的标签过滤足够了,存储成本只有 ES 的 1/3。但团队需要明确知道 Loki 的全文搜索弱,必要时搭配一个单节点 ES 做辅助搜索。"

面试话术示例(Agent 方向):"LLM Agent 的日志治理跟传统微服务有两个关键区别:一是日志量暴增(每次 LLM 调用可能产生上千 Token 的日志),二是需要按 Agent 多轮对话的 turn 维度做关联。我建议核心日志(Prompt/Completion 内容)走 ES 全文索引,元数据(Token 数、延迟、模型名)走 Loki 做统计。同时每个 LLM 调用日志必须带上 sessionIdturnIndextraceId 三个字段,才能做到跨轮对话的关联分析。"

参考:Elastic 官方 Logging Best Practices、Grafana Labs Loki 文档、OpenTelemetry Collector 日志处理文档

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。