问题:一条请求穿过了 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
|
@GetMapping("/order/{id}") public OrderStatus getOrder(@PathVariable Long id) { log.info("查询订单: id={}", id); Order order = mysqlRepo.findById(id); esClient.update("orders", id, order); 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
| @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
| jdbcTemplate.execute("SET @trace_id = '" + traceId + "'");
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
| @KafkaListener(topics = "canal_order_events") public void onCanalEvent(CanalMessage msg) { String orderId = msg.getData().get("id"); String traceId = traceIdCache.get(orderId); 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
| ProducerRecord<String, String> record = new ProducerRecord<>( "order_events", orderId, orderJson ); record.headers().add("trace_id", traceId.getBytes(StandardCharsets.UTF_8)); kafkaTemplate.send(record);
@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); 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
|
String label = "load_" + orderId + "_" + traceId;
|
或者用 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
| 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 调用追踪的关键信息:
- Token 成本:每次 LLM 调用消耗多少 token,花了多少钱
- RAG 检索质量:检索到几条文档,相关性分数多少,是否命中预期
- Tool 调用决策:Agent 选择了哪个工具,为什么选,执行结果
- 端到端延迟:从用户请求到响应返回,各环节耗时分布
完整的 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 链路中记录。
参考链接