技术痛点:向量检索的性能黑盒
我们的一个核心业务依赖于向量检索,用户在前端发起查询,后端服务进行向量化,然后请求 Weaviate 获取相似结果。最近,这个检索接口的 P99 延迟频繁告警,但排查过程异常痛苦。问题出在哪?是 Angular 应用本身的渲染卡顿,是客户端到服务端的网络延迟,是 Fastify 服务的内部逻辑处理,还是 Weaviate 的 HNSW 索引性能瓶颈?每个环节都有日志,但它们是孤立的。我们无法将前端的一次用户点击,与后端的一条慢查询日志,以及 Weaviate 的一次具体查询操作关联起来。整个请求链路就像一个性能黑盒。
初步的构想是为每个环节加上更详细的日志,但这治标不治本。我们需要的是一个统一的视图,一条贯穿始终的线索,能将用户在浏览器中的操作与后端深处的数据库查询串联起来。这个线索就是分布式追踪中的 Trace ID。目标变得清晰:构建一个从 Angular 前端到 Fastify 后端,再到 Weaviate 客户端的全链路可观测性体系,并将关键性能指标暴露给现有的 Prometheus 监控系统。
技术选型决策:为什么是 OpenTelemetry?
在选型上,我们评估了几种方案。最初考虑过各个云厂商自带的 APM 工具,但为了避免厂商锁定,并保持架构的灵活性,我们倾向于开源标准。手动在每一层传递 Trace ID 也是一个选项,但这需要大量侵入式代码,维护成本极高,而且容易出错。
最终,我们选择了 OpenTelemetry (OTel)。原因有三:
- 标准化与中立性: OTel 是 CNCF 的项目,提供了一套统一的 API 和 SDK,用于生成、收集和导出遥测数据(Metrics, Traces, Logs)。它不与任何后端绑定,我们可以今天将数据发送到 Jaeger 和 Prometheus,明天无缝切换到其他商业或开源方案。
- 自动与手动埋点的结合: OTel 提供了强大的自动埋点能力,能零侵入式地为常见的库(如 HTTP、gRPC、Fastify)添加追踪。同时,它也允许我们为核心业务逻辑(如调用 Weaviate 的部分)创建自定义的 Span,实现粗粒度与细粒度的完美结合。
- 生态覆盖广: OTel 的生态系统覆盖了从前端 JavaScript 到后端 Node.js 的所有技术栈,这对于我们这种全栈应用至关重要。特别是其
ZoneContextManager能够很好地兼容 Angular 的zone.js,解决了前端异步追踪上下文传递的难题。
我们的架构决策因此确定:使用 OpenTelemetry SDK 分别在 Angular 和 Fastify 应用中进行埋点,通过 HTTP traceparent Header 自动传递上下文。Fastify 服务将 Traces 导出到 Jaeger/Tempo,同时将自定义的业务性能指标(Metrics)通过 /metrics 端点暴露给 Prometheus。
sequenceDiagram
participant Angular App as Angular App (Browser)
participant Fastify API as Fastify API (Node.js)
participant Weaviate as Weaviate
participant Prometheus as Prometheus
participant Jaeger as Jaeger/Tempo
Note over Angular App, Fastify API: Both instrumented with OpenTelemetry SDK
Angular App->>Fastify API: HTTP POST /search (with traceparent header)
Fastify API->>Fastify API: OTel auto-instrumentation creates root server span
Fastify API->>Fastify API: Manually create "vector-search" span
Fastify API->>Weaviate: gRPC/HTTP query
Weaviate-->>Fastify API: Query results
Fastify API->>Fastify API: End "vector-search" span, record latency metric
Fastify API-->>Angular App: HTTP 200 OK
Note over Fastify API: OTel SDK exports data asynchronously
Fastify API->>Jaeger: Export Trace Spans
Prometheus->>Fastify API: Scrape /metrics endpoint
Note left of Prometheus: Records Weaviate query latency histogram
步骤化实现:打通全链路遥测数据
1. 后端服务层:为 Fastify 和 Weaviate 植入探针
首先改造 Fastify 服务。我们需要安装 OTel 相关的 Node.js 库。
npm install @opentelemetry/sdk-node @opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-prometheus \
@opentelemetry/exporter-trace-otlp-http \
weaviate-ts-client
接下来,创建一个中心化的遥测服务初始化文件 telemetry.ts。这是整个后端可观测性的心脏。在真实项目中,这个文件会比示例复杂得多,需要处理优雅停机、动态配置等。
src/telemetry.ts:
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import {
MeterProvider,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';
// 用于调试OTel本身
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
// 1. 定义服务资源属性,这些属性会附加到所有的遥测数据上
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'vector-search-api',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});
// 2. 配置 Prometheus Exporter
// 它会在指定的端口上暴露一个 /metrics 端点
const prometheusExporter = new PrometheusExporter({
port: 9464, // Prometheus scrape port
preventServerStart: true, // 我们希望手动控制何时启动服务
});
// 3. 配置 MeterProvider 用于处理 Metrics
// 使用 PeriodicExportingMetricReader 将指标定期推送到 Exporter
const meterProvider = new MeterProvider({ resource });
meterProvider.addMetricReader(prometheusExporter);
// 4. 配置 Trace Exporter
// 这里我们使用 OTLP 协议将追踪数据发送到类似 Jaeger/Tempo 的后端
const traceExporter = new OTLPTraceExporter({
// URL of the OTel Collector or Jaeger/Tempo
// 在生产环境中,这应该来自环境变量
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http://localhost:4318/v1/traces',
});
// 5. 初始化 NodeSDK
const sdk = new NodeSDK({
resource,
traceExporter,
// Node.js 中必须使用 AsyncLocalStorageContextManager
contextManager: new AsyncLocalStorageContextManager(),
// 这里是魔法发生的地方:自动为所有支持的库打点
instrumentations: [getNodeAutoInstrumentations({
// 禁用我们不需要的模块,减少性能开销
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
})],
// 将我们自定义的 MeterProvider 关联上
metricReader: prometheusExporter,
});
// 优雅停机处理
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Telemetry SDK shut down successfully.'))
.catch((error) => console.error('Error shutting down Telemetry SDK:', error))
.finally(() => process.exit(0));
});
export const startTelemetry = async () => {
try {
await sdk.start();
await prometheusExporter.startServer();
console.log('OpenTelemetry SDK started successfully. Prometheus exporter running on port 9464.');
} catch (error) {
console.error('Error starting OpenTelemetry SDK:', error);
process.exit(1);
}
};
// 导出一个全局的 Meter,用于在业务代码中创建自定义指标
export const meter = meterProvider.getMeter('weaviate-instrumentation');
这个文件做了几件关键的事:
- Resource定义: 标识了所有遥测数据的来源服务。
- Prometheus Exporter: 启动了一个HTTP服务器,暴露
/metrics接口。 - OTLP Trace Exporter: 配置了将追踪数据发送到收集器(如 Jaeger)的端点。
- NodeSDK: 核心,集成了自动埋点、追踪导出和上下文管理器。
现在,在应用主入口 server.ts 中启动它。
src/server.ts:
import Fastify from 'fastify';
import weaviate, { WeaviateClient } from 'weaviate-ts-client';
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { startTelemetry, meter } from './telemetry';
import { Histogram } from '@opentelemetry/api-metrics';
// --- 启动遥测服务 ---
startTelemetry();
// --- 自定义指标 ---
// 创建一个 Histogram 来记录 Weaviate 查询延迟
// Bucket boundaries (in seconds) for latency histogram.
// [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
const weaviateQueryLatencyHistogram: Histogram = meter.createHistogram(
'weaviate_query_latency_seconds',
{
description: 'Latency of Weaviate queries',
unit: 's',
}
);
const fastify = Fastify({ logger: true });
// --- Weaviate 客户端配置 ---
const weaviateClient: WeaviateClient = weaviate.client({
scheme: 'http',
host: process.env.WEAVIATE_HOST || 'localhost:8080',
});
// --- 核心业务路由 ---
fastify.post('/search', async (request, reply) => {
// OTel 的自动埋点已经为这个路由创建了一个父 Span
// 我们要为关键的内部操作创建子 Span
const tracer = trace.getTracer('vector-search-tracer');
const parentSpan = trace.getSpan(context.active());
// 1. 创建手动 Span,用于追踪 Weaviate 查询
return tracer.startActiveSpan('weaviate-vector-search', async (span) => {
try {
// 假设我们从请求体中获取了查询向量
const { vector } = request.body as { vector: number[] };
if (!vector || !Array.isArray(vector)) {
span.setStatus({ code: SpanStatusCode.ERROR, message: 'Invalid vector in request body' });
span.end();
return reply.status(400).send({ error: 'Invalid vector provided' });
}
// 附加有意义的属性到 Span 上,便于后续排查
span.setAttribute('weaviate.collection', 'MyDocuments');
span.setAttribute('weaviate.query.vector_dimensions', vector.length);
const startTime = performance.now();
const response = await weaviateClient.graphql
.get()
.withClassName('MyDocuments')
.withNearVector({ vector })
.withLimit(5)
.withFields('_additional { id certainty distance }')
.do();
const endTime = performance.now();
const latencySeconds = (endTime - startTime) / 1000;
// 2. 记录自定义指标
weaviateQueryLatencyHistogram.record(latencySeconds, {
collection: 'MyDocuments',
});
// 再次附加结果信息到 Span
const resultsCount = response.data.Get.MyDocuments.length;
span.setAttribute('weaviate.results.count', resultsCount);
span.setStatus({ code: SpanStatusCode.OK });
reply.send(response.data);
} catch (error: any) {
fastify.log.error(error, 'Weaviate search failed');
// 3. 在 Span 中记录错误
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.recordException(error);
reply.status(500).send({ error: 'Internal Server Error' });
} finally {
// 确保 Span 总是被关闭
span.end();
}
});
});
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
这里的关键点:
- 手动创建 Span: 我们使用
tracer.startActiveSpan包裹了对 Weaviate 的调用。这使得在追踪视图中,我们可以清晰地看到weaviate-vector-search这个操作占用了多少时间。 - 丰富的属性: 我们在 Span 上附加了集合名称、向量维度、返回结果数等上下文信息。当问题发生时,这些信息是定位问题的金钥匙。
- 错误记录: 当查询失败时,使用
span.setStatus和span.recordException记录详细的错误信息,而不是让它无声地失败。 - 自定义指标:
weaviate_query_latency_seconds这个 Histogram 指标是为 Prometheus 准备的。它让我们能够计算 Weaviate 查询的平均延迟、P95、P99 等关键 SLI,并设置告警。
2. 前端应用层:将 Angular 用户操作与后端请求关联
现在轮到前端。在 Angular 中集成 OTel 稍微复杂一些,因为它需要处理 zone.js 的上下文。
npm install @opentelemetry/sdk-trace-web @opentelemetry/context-zone \
@opentelemetry/instrumentation-fetch @opentelemetry/exporter-otlp-http \
@opentelemetry/resource-web
我们在 Angular 项目中创建一个 tracing.service.ts 来封装所有 OTel 的逻辑。
src/app/tracing.service.ts:
import { Injectable, NgZone } from '@angular/core';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-http';
import { Resource } from '@opentelemetry/resource-web';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
@Injectable({
providedIn: 'root',
})
export class TracingService {
private tracerProvider: WebTracerProvider | null = null;
constructor(private ngZone: NgZone) {}
public initialize(): void {
// 防止在服务端渲染(SSR)或多次初始化时重复执行
if (this.tracerProvider) {
return;
}
// 1. 定义前端应用的服务资源
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'vector-search-frontend',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});
// 2. 创建 WebTracerProvider
const provider = new WebTracerProvider({
resource,
});
// 3. 配置 OTLP Exporter
const exporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces', // 指向 OTel Collector
});
// 4. 使用 SimpleSpanProcessor,因为在浏览器中我们希望尽快发送追踪数据
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
// 5. 关键:使用 ZoneContextManager 来兼容 Angular 的 zone.js
// 它确保了在异步操作(如 setTimeout, Promise, HTTP请求)中追踪上下文不会丢失
provider.register({
contextManager: new ZoneContextManager(this.ngZone),
});
// 6. 注册自动埋点工具
// FetchInstrumentation 会自动拦截 fetch 请求
// 并在请求头中注入 'traceparent',将前端 Span 和后端 Span 关联起来
registerInstrumentations({
tracerProvider: provider,
instrumentations: [
new FetchInstrumentation({
// 可以配置忽略一些不关心的请求,比如对静态资源的请求
ignoreUrls: [/.*\.ico/],
// 可以在请求发送前添加额外属性
propagateTraceHeaderCorsUrls: [
/http:\/\/localhost:3000\/.*/, // 必须包含后端API的URL,以允许跨域传递traceparent头
],
}),
],
});
this.tracerProvider = provider;
console.log('OpenTelemetry Web SDK initialized.');
}
}
然后在 app.module.ts 或主组件 app.component.ts 中尽早初始化这个服务。
src/app/app.component.ts:
import { Component, OnInit } from '@angular/core';
import { TracingService } from './tracing.service';
import { HttpClient } from '@angular/common/http';
import { trace } from '@opentelemetry/api';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
constructor(
private tracingService: TracingService,
private http: HttpClient
) {
// 尽早初始化
this.tracingService.initialize();
}
ngOnInit() { }
performSearch() {
// 1. 获取一个 tracer 实例
const tracer = trace.getTracer('angular-app-tracer');
// 2. 创建一个代表用户意图的父 Span
tracer.startActiveSpan('user-clicks-search-button', (span) => {
console.log('Starting search process from UI...');
span.setAttribute('component', 'AppComponent');
span.setAttribute('user.action', 'click');
// 模拟生成一个查询向量
const randomVector = Array.from({ length: 1536 }, () => Math.random());
// HttpClient 的请求会被 FetchInstrumentation 自动拦截
// 这次请求会自动携带 `traceparent` 头
this.http.post('http://localhost:3000/search', { vector: randomVector })
.subscribe({
next: (response) => {
console.log('Search successful', response);
span.addEvent('search_success');
},
error: (error) => {
console.error('Search failed', error);
span.recordException(error);
span.setStatus({ code: 2, message: error.message }); // 2 is ERROR code
},
complete: () => {
// 3. 当整个流程结束时,关闭 Span
span.end();
console.log('Search process span ended.');
}
});
});
}
}
前端集成的核心在于 ZoneContextManager 和 FetchInstrumentation。前者保证了追踪上下文在 Angular 的异步世界里正确传递,后者则像一座桥梁,自动将前端的 Trace ID 注入到对后端的 API 请求中,实现了链路的自动串联。
3. 验证与观测:在 Prometheus 和 Jaeger 中看到成果
万事俱备,现在是验证成果的时候。
- 启动所有服务: 启动 Weaviate, Jaeger/Tempo, Prometheus, Fastify API, 以及 Angular 应用。
- 配置 Prometheus: 确保 Prometheus 的配置文件
prometheus.yml包含了对 Fastify 服务/metrics端点的抓取配置。
# prometheus.yml
scrape_configs:
- job_name: 'vector-search-api'
static_configs:
- targets: ['host.docker.internal:9464'] # 或者你的 Fastify 服务实际地址
- 触发操作: 在 Angular 界面点击搜索按钮。
- 观察结果:
- 在 Jaeger/Tempo UI 中: 你会看到一条完整的链路。根 Span 是
user-clicks-search-button,它有一个子 Span 是 Fastify 的/search路由处理,而这个子 Span 又有一个更深的子 Spanweaviate-vector-search。每个 Span 的耗时、属性、错误都一目了然。现在,如果查询慢,我们可以精确地知道是慢在了weaviate-vector-search还是 Fastify 的其他逻辑上。 - 在 Prometheus/Grafana 中: 我们可以使用 PromQL 查询我们自定义的指标:
基于这些查询,我们可以构建仪表盘,设置告警,真正做到基于数据的性能监控和容量规划。# 计算过去5分钟内 Weaviate 查询的 P99 延迟 histogram_quantile(0.99, sum(rate(weaviate_query_latency_seconds_bucket[5m])) by (le, collection)) # 计算查询的 QPS sum(rate(weaviate_query_latency_seconds_count[5m])) by (collection)
- 在 Jaeger/Tempo UI 中: 你会看到一条完整的链路。根 Span 是
当前方案的局限性与未来迭代
这套基于 OpenTelemetry 的体系极大地提升了我们对向量检索链路的可见性,但它并非银弹。当前的实现依然存在一些局限和值得优化的地方。
首先,我们的埋点仅限于应用层。对于 Weaviate 自身的性能,比如它内部的索引查询、对象获取、压缩等阶段的耗时,我们仍然是黑盒。一个更理想的方案是 Weaviate 自身也能支持 OTel,导出其内部操作的 Span。在此之前,我们只能依赖其暴露的 Prometheus 指标,并通过 Trace ID 与应用层 Span 进行手动关联分析。
其次,对于前端而言,我们目前只追踪了 API 请求。用户的真实体验还包括页面加载时间、资源渲染耗时、交互卡顿等。后续可以集成 OpenTelemetry 的 Real User Monitoring (RUM) 相关工具,捕获 Core Web Vitals 等指标,从而获得更完整的用户体验画像。
最后,随着系统复杂度的增加,全量采集 Trace 可能会带来巨大的存储成本和性能开销。下一步需要研究和实施更智能的采样策略,例如基于尾部的采样,只保留那些包含错误或者延迟异常的链路,在保证问题可追溯性的前提下,有效控制数据量。