问题:一条请求穿过了 6 个系统,日志散落在 6 个地方

MySQL / Doris / ES 三角选型 描述的架构中,一条用户请求的链路是这样的:

1
2
3
4
5
用户请求 → Spring AI 应用 → MySQL(写入订单)
→ Canal(监听 binlog)
→ Kafka(消息队列)
→ Doris Routine Load(消费写入)
→ ES(双写更新索引)

加上 AI 调用:

1
2
3
→ Spring AI ChatClient → LLM API(OpenAI/Anthropic)
→ RAG 检索(ES kNN 查询)
→ Tool 调用(查 MySQL)

一条请求可能穿过 8 个系统。 当用户反馈”我的订单查不到”时,你怎么定位是哪一环出了问题?

日志在每个系统里:

  • Spring AI 应用的日志在应用服务器
  • MySQL 的 binlog 在数据库服务器
  • Canal 的日志在 Canal 实例
  • Kafka 的消费 lag 在 Kafka 管理台
  • Doris 的导入状态在 Doris FE 日志
  • ES 的写入日志在 ES 集群

没有 Trace ID 串联,排查一个问题可能要翻 8 套日志系统。

Trace ID 是什么

Trace ID 是一个全局唯一标识符,在请求入口生成,沿着整条链路传递。每个系统在记录日志时带上这个 ID,排查时只需搜索一个 ID 就能看到整条链路的全部日志。

1
2
3
4
5
6
7
8
9
Trace ID: a1b2c3d4e5f6

Spring AI: [a1b2c3d4e5f6] 收到用户请求,查询订单状态
→ MySQL: [a1b2c3d4e5f6] SELECT * FROM orders WHERE id = 123
→ Canal: [a1b2c3d4e5f6] 读取 binlog,order_id=123 INSERT 事件
→ Kafka: [a1b2c3d4e5f6] 消息投递到 topic=orders, partition=2
→ Doris: [a1b2c3d4e5f6] Routine Load 消费成功,label=load_123_001
→ ES: [a1b2c3d4e5f6] 索引更新 order/123
→ LLM: [a1b2c3d4e5f6] 调用 GPT-4,输入 500 tokens,输出 200 tokens

一眼看到:哪一步慢、哪一步出错、哪一步丢了数据。

在数据密集型 AI 后端里串 Trace ID 的三个层次

第一层:应用内 Trace(容易)

Spring Boot + OpenTelemetry,自动注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Spring Boot 3.x+ 自动配置 OpenTelemetry
// 只要在请求入口生成 Trace ID,后续传播自动处理

@GetMapping("/order/{id}")
public OrderStatus getOrder(@PathVariable Long id) {
// Trace ID 自动从 HTTP Header 提取
// 所有日志自动带上 trace_id
log.info("查询订单: id={}", id);

Order order = mysqlRepo.findById(id);
esClient.update("orders", id, order); // ES 调用也自动传播

return OrderStatus.from(order);
}

OpenTelemetry SDK 会自动在 HTTP 请求、gRPC 调用、Kafka 消息中注入和提取 Trace ID。你只需要加依赖和配置。

这一层解决了 Spring AI 应用内部的追踪。

第二层:跨数据系统 Trace(难)

MySQL、Doris、ES 这些系统不原生支持 OpenTelemetry。你不能给 MySQL 发一个 SQL 查询然后在 MySQL 的 slow log 里看到你的 Trace ID。

解决方案:在应用层手动传播 Trace ID,并在每个系统留痕。

MySQL 留痕

在应用里执行 SQL 时,把 Trace ID 写入一个审计表或日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 方式 1:在审计日志表留痕
@Autowired
private TraceContext traceContext;

public Order findById(Long id) {
String traceId = traceContext.getCurrentTraceId();
log.info("[{}] 查询 MySQL, order_id={}", traceId, id);

Order order = jdbcTemplate.queryForObject(
"SELECT * FROM orders WHERE id = ?",
new Object[]{id},
new OrderRowMapper()
);

// 写入审计表(异步,不影响主流程)
auditRepo.log(traceId, "MYSQL_QUERY", "orders", id, System.currentTimeMillis());

return order;
}

方式 2:用 MySQL 的 session_variables(更优雅)

1
2
3
4
// 在连接上设置 session 变量,MySQL slow log 和 performance_schema 能看到
jdbcTemplate.execute("SET @trace_id = '" + traceId + "'");
// 后续查询的 slow log 里会带上 @trace_id
List<Order> orders = jdbcTemplate.query("SELECT * FROM orders WHERE id = ?", ...);

Canal 留痕

Canal 监听 binlog 时,它不知道原始请求的 Trace ID。但可以在消费 Canal 事件的下游应用里重新关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Canal 消费者
@KafkaListener(topics = "canal_order_events")
public void onCanalEvent(CanalMessage msg) {
// Canal 事件里有 binlog 的 position 和 timestamp
// 用 order_id 关联到原始请求的 Trace ID
String orderId = msg.getData().get("id");
String traceId = traceIdCache.get(orderId); // 从缓存查原始 Trace ID

if (traceId != null) {
log.info("[{}] Canal 事件: order_id={}, op={}, binlog_pos={}",
traceId, orderId, msg.getEventType(), msg.getBinlogPosition());
}
}

关键设计: 用业务主键(order_id)作为关联键,在缓存中维护 order_id → trace_id 的映射。Canal 事件到达时,通过 order_id 找到原始 Trace ID。

Kafka 留痕

Kafka 消息自带 Header,可以把 Trace ID 放在 Header 里传播:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 生产者:在消息 Header 里放 Trace ID
ProducerRecord<String, String> record = new ProducerRecord<>(
"order_events",
orderId,
orderJson
);
record.headers().add("trace_id", traceId.getBytes(StandardCharsets.UTF_8));
kafkaTemplate.send(record);

// 消费者:从 Header 里取 Trace ID
@KafkaListener(topics = "order_events")
public void onMessage(ConsumerRecord<String, String> record) {
Header traceHeader = record.headers().lastHeader("trace_id");
String traceId = traceHeader != null
? new String(traceHeader.value())
: UUID.randomUUID().toString(); // 新生成一个

MDC.put("trace_id", traceId); // 放入 MDC,后续日志自动带
log.info("消费 Kafka 消息: offset={}, key={}", record.offset(), record.key());

// 处理业务逻辑...

MDC.remove("trace_id");
}

OpenTelemetry 的 Kafka Instrumentation 可以自动做这件事。如果你用了 OTel SDK,Header 的注入和提取是自动的。

Doris 留痕

Doris 不支持在 SQL 查询里传 Trace ID。但可以在 Routine Load 的导入标签里编码 Trace ID:

1
2
3
4
// Doris Routine Load 的 label 可以自定义
// 把 trace_id 编码进 label
String label = "load_" + orderId + "_" + traceId;
// 这样在 Doris 的 SHOW ROUTINE LOAD 里能看到 trace_id

或者用 Doris 的 audit log:

Doris 4.0+ 支持 audit log 插件,记录所有 SQL 查询。在应用层执行 Doris 查询前,先在应用日志里记录 Trace ID + SQL,排查时用时间戳对齐 Doris audit log。

ES 留痕

ES 的写入可以带 routing key 或 metadata:

1
2
3
4
5
6
7
8
9
10
11
12
// ES 写入时在 doc 里加 trace_id 字段
Map<String, Object> doc = new HashMap<>();
doc.put("order_id", orderId);
doc.put("status", "PAID");
doc.put("_trace_id", traceId); // 内部追踪字段,不暴露给用户

IndexRequest request = IndexRequest.of(i -> i
.index("orders")
.id(orderId)
.document(doc)
);
esClient.index(request);

排查时直接在 ES 里搜 _trace_id: a1b2c3d4e5f6 就能看到这条请求在 ES 里的全部操作。

第三层:AI 调用 Trace(新需求)

AI 后端比传统后端多了 LLM 调用、RAG 检索、Tool 调用,这些也需要追踪。

2026 年 3 月,OpenTelemetry 发布了 GenAI Semantic Conventions,定义了 AI 调用的标准追踪属性:

属性 含义
gen_ai.request.model 调用的模型名(gpt-4, claude-3)
gen_ai.usage.input_tokens 输入 token 数
gen_ai.usage.output_tokens 输出 token 数
gen_ai.operation.name 操作类型(chat, embed, tool_call)

在 Spring AI 中,这些可以通过 Advisor 自动记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class TracingAdvisor implements BaseAdvisor {

@Override
public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) {
String traceId = MDC.get("trace_id");
long start = System.currentTimeMillis();

AdvisedResponse response = chain.nextAroundCall(request);

long duration = System.currentTimeMillis() - start;
String model = response.response().getMetadata().getModel();
int inputTokens = response.response().getMetadata().getUsage().getPromptTokens();
int outputTokens = response.response().getMetadata().getUsage().getCompletionTokens();

log.info("[{}] LLM 调用: model={}, input_tokens={}, output_tokens={}, latency={}ms, cost≈${}",
traceId, model, inputTokens, outputTokens, duration,
calculateCost(model, inputTokens, outputTokens));

return response;
}
}

AI 调用追踪的关键信息:

  1. Token 成本:每次 LLM 调用消耗多少 token,花了多少钱
  2. RAG 检索质量:检索到几条文档,相关性分数多少,是否命中预期
  3. Tool 调用决策:Agent 选择了哪个工具,为什么选,执行结果
  4. 端到端延迟:从用户请求到响应返回,各环节耗时分布

完整的 Trace 链路设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户请求 [trace_id=a1b2c3]

├─ Spring AI 应用 [a1b2c3]
│ ├─ MySQL 查询 [a1b2c3] → SET @trace_id='a1b2c3'; SELECT...
│ ├─ RAG 检索 [a1b2c3] → ES kNN search, top_k=5, score=0.87
│ ├─ LLM 调用 [a1b2c3] → gpt-4, input=500tok, output=200tok, $0.012
│ └─ Tool 调用 [a1b2c3] → queryOrder(123), result=PAID

├─ Canal [a1b2c3] → binlog pos=mysql-bin.000123:4567, op=INSERT

├─ Kafka [a1b2c3] → topic=order_events, partition=2, offset=789

├─ Doris [a1b2c3] → label=load_123_a1b2c3, status=SUCCESS

└─ ES [a1b2c3] → index=orders, id=123, _trace_id=a1b2c3

排查时搜 a1b2c3 → 完整链路一目了然。

成本考量

可观测性不是免费的。完整的 Trace 链路有成本:

维度 成本 建议
存储成本 Trace 数据量大,每条请求生成 10+ 条 span 采样:只存 1% 的成功请求,100% 的错误请求
性能开销 每个系统都要记录 Trace ID 用 MDC + 异步日志,不阻塞主流程
维护成本 Trace 基础设施需要运维 小团队用 SaaS(如 Jaeger + Grafana Cloud),大团队自建
AI 调用成本 每次 LLM 调用的 token 和成本都要记录 必须做——这是 AI 后端独有的成本可观测性需求

采样策略

不是每条请求都需要完整 Trace。推荐的采样策略:

1
2
3
4
错误请求 → 100% 采样(必须看到完整链路)
慢请求(P99) → 100% 采样
正常请求 → 1% 采样(统计用)
AI 调用 → 100% 采样(token 成本必须追踪)

对正在学 Spring AI + MySQL + Doris + ES 的人意味着什么

1. 可观测性不是”最后加”,是”一开始就设计”

很多人在系统跑起来后才发现”出了问题不知道哪一环的问题”。Trace ID 的传播需要从第一个 commit 就设计好——MySQL 查询加 @trace_id session 变量、Kafka 消息带 Header、ES 文档加 _trace_id 字段——这些都需要在写代码时就做,不是事后补。

2. Doris 本身可以作为可观测性后端

Doris 4.1 官方定位了”AI Agent 可观测性”场景:高吞吐写入日志/Trace/Metrics,VARIANT 类型处理动态 JSON,倒排索引做全文搜索,向量化执行做聚合分析。

如果你的 Trace 数据量大到传统 Trace 后端(如 Jaeger / Zipkin)扛不住,Doris 是一个可以考虑的替代——同一个数据库同时做 OLAP 和 Trace 存储。

3. AI 后端的可观测性多了”成本”维度

传统后端的可观测性关注”延迟”和”错误率”。AI 后端多了”token 成本”——每次 LLM 调用花多少钱、RAG 检索有没有浪费 token、Agent 重试了几次。

Token 成本的追踪是 AI 后端可观测性的独特需求,必须在 Trace 链路中记录。

参考链接