构建一套贯穿CV微前端与SQL Server的统一可观测性架构


当一个用户操作的响应时间从 200ms 劣化到 2s,问题可能出在哪里?在单体应用中,答案或许不难寻觅。但在一个由多个独立部署的微前端构成、由不同团队维护、后端依赖 SQL Server 的复杂系统中,这成了一个棘手的难题。问题可能在用户的浏览器、网络、某个微前端的 JavaScript 执行、一个 WebAssembly 计算机视觉(CV)模型推理、API 网关、某个后端服务,或是数据库的一个慢查询。没有一个统一的视图,定位问题就像在黑暗中寻找一只黑猫。

我们的挑战正是如此:一个工业质检平台,其前端采用微前端架构。其中,“瑕疵分析”模块是一个独立的微前端,它加载一个在浏览器中运行的 CV 模型(通过 WASM)对产线图像进行实时分析;分析结果的元数据,连同其他业务数据,全部存储在后端的 SQL Server 中。当用户报告“瑕疵标记在图像上显示得非常慢”时,我们需要一套机制能精确追踪这一请求的全生命周期。

方案权衡:隔离工具链 vs. 统一上下文

方案 A:传统的隔离式监控

这是最直接的方案。前端团队使用 Sentry 或类似的前端监控工具来捕获 JavaScript 错误和性能指标。后端团队使用标准的 APM 工具(如 SkyWalking 或 OpenTelemetry .NET SDK)来追踪服务间的调用。DBA 则依赖 SQL Server Profiler 或 Extended Events 来捕取慢查询。

  • 优势:

    • 实现简单,各团队可以沿用自己熟悉的技术栈和工具。
    • 无需跨团队的强协议或定制开发。
  • 劣势:

    • 数据孤岛。前端监控平台中的一次“慢交互”与后端 APM 系统中的一条慢链路,以及 SQL Server 中的一条慢查询日志之间,没有任何关联。
    • 诊断效率极低。排查问题需要多方团队拉通,人工对比时间戳,过程繁琐且极易出错。我们无法回答“是哪个前端操作,由哪个用户触发,最终导致了这条慢SQL?”这样的关键问题。
    • 无法建立端到端的服务等级目标(SLO)。因为我们根本无法度量一次完整用户操作的耗时。

在真实项目中,这种隔离的方案很快就会达到瓶颈。当系统复杂性增加时,它带来的沟通成本和问题定位的延迟是不可接受的。

方案 B:基于统一上下文传播的端到端可观测性架构

该方案的核心思想是:为每一次用户交互生成一个全局唯一的 Trace ID,并确保这个 ID 随着请求链路,从浏览器端的用户点击开始,贯穿微前端、后端服务,最终“注入”到对 SQL Server 的查询中。所有层级的日志、指标和追踪数据都必须附带上这个 ID。

  • 优势:

    • 端到端可见性。在可观测性平台(如 Jaeger 或 Grafana Tempo)中输入一个 Trace ID,即可看到从前端渲染、WASM 计算到数据库查询的完整火焰图。
    • 快速根因定位。瓶颈所在一目了然,责任清晰。
    • 数据驱动的优化。可以精确分析哪些类型的用户操作对后端或数据库造成了最大压力。
  • 劣势:

    • 实现复杂。需要在整个技术栈中进行侵入式或非侵入式的代码插桩。
    • 需要建立跨团队的技术规范,所有微前端和后端服务都必须遵循上下文传播协议(例如 W3C Trace Context)。
    • 存在技术盲点,比如从应用代码到 SQL Server 内部执行的上下文传递,通常没有现成的解决方案。

我们最终选择了方案 B。对于一个追求长期稳定性和可维护性的复杂系统而言,前期在可观测性基础设施上的投入,将在未来的无数次故障排查中得到回报。

核心实现概览:构建跨边界的追踪链路

我们的目标是实现下面这幅图所描述的追踪流。

sequenceDiagram
    participant User as 用户
    participant Shell as 主应用(Shell)
    participant MFE_CV as CV微前端
    participant WASM as CV模型(WASM)
    participant Gateway as API网关
    participant Backend as 后端服务
    participant SQL as SQL Server

    User->>Shell: 点击分析按钮
    activate Shell
    Shell->>Shell: 生成TraceContext (traceId, parentId)
    Shell->>MFE_CV: postMessage 传递 TraceContext
    deactivate Shell
    activate MFE_CV
    MFE_CV->>MFE_CV: 开始Span: "image-analysis"
    MFE_CV->>WASM: 调用模型进行推理
    activate WASM
    WASM-->>MFE_CV: 返回推理结果
    deactivate WASM
    MFE_CV->>Gateway: fetch('/api/results', {headers: {traceparent: '...'}})
    deactivate MFE_CV
    activate Gateway
    Gateway->>Backend: 转发请求 (含traceparent)
    deactivate Gateway
    activate Backend
    Backend->>Backend: 解析traceparent, 继续Trace
    Backend->>SQL: 执行查询 (带Trace信息的SQL注释)
    activate SQL
    SQL-->>Backend: 返回查询结果
    deactivate SQL
    Backend-->>User: 返回最终结果
    deactivate Backend

1. Trace 上下文的生成与跨微前端传播

挑战在于,微前端通常运行在隔离的 iframeWeb Component 中,它们与主应用(Shell)之间不能直接共享内存。我们采用**中介者模式 (Mediator Pattern)**,由主应用统一负责 Trace 上下文的生命周期管理,并通过标准浏览器 API (postMessage) 与微前端通信。

主应用 (Shell) 的实现:

这里我们使用 OpenTelemetry JS SDK。

// file: shell/tracing/tracer.js

import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-proto-http';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { ZoneContextManager } from '@opentelemetry/context-zone';

// 配置导出器,指向你的 OTel Collector
const exporter = new OTLPTraceExporter({
    url: 'http://your-otel-collector:4318/v1/traces',
});

const provider = new WebTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
provider.register({
    contextManager: new ZoneContextManager(),
    propagator: new W3CTraceContextPropagator(),
});

export const tracer = provider.getTracer('industrial-inspection-shell');

/**
 * 监听所有微前端 iframe 的加载
 * @param {HTMLIFrameElement} iframeElement
 */
export function registerMfeForTracing(iframeElement) {
    iframeElement.addEventListener('load', () => {
        // 当需要启动一个新追踪时,例如用户点击
        // 我们在这里模拟一个点击事件
        const rootSpan = tracer.startSpan('user-click-analyze-button');
        
        tracer.getContextManager().with(
            api.trace.setSpan(api.context.active(), rootSpan),
            () => {
                const context = {};
                // 注入 W3C trace context 到一个普通对象中
                api.propagation.inject(api.context.active(), context);
                
                // 将上下文发送给微前端
                iframeElement.contentWindow.postMessage({
                    type: 'TRACING_CONTEXT_PROPAGATION',
                    payload: context,
                }, '*'); // 在生产环境中,请指定确切的 origin
                
                // 模拟操作耗时
                setTimeout(() => {
                    rootSpan.end();
                }, 50);
            }
        );
    });
}

CV 微前端的实现:

微前端需要监听来自 Shell 的消息,并用接收到的上下文继续追踪。

// file: cv-mfe/tracing/context-receiver.js

import { api } from '@opentelemetry/api';

// 假设微前端也配置了类似的 TracerProvider
import { tracer } from './tracer'; 

window.addEventListener('message', (event) => {
    // 同样,生产环境需要校验 event.origin
    if (event.data && event.data.type === 'TRACING_CONTEXT_PROPAGATION') {
        const propagatedContext = event.data.payload;
        
        // 从接收到的对象中提取上下文
        const parentContext = api.propagation.extract(api.context.active(), propagatedContext);
        
        // 使用提取的上下文作为父级,创建新的 Span
        const cvSpan = tracer.startSpan('image-analysis', undefined, parentContext);
        
        // 将此 Span 设置为当前活动 Span
        api.context.with(api.trace.setSpan(api.context.active(), cvSpan), () => {
            console.log('CV MFE received trace context and started a new span.');
            performCvAnalysis(); // 执行核心的 CV 任务
            cvSpan.end();
        });
    }
});

function performCvAnalysis() {
    // 这是一个关键部分,我们需要进一步追踪 WASM 的执行
    const wasmSpan = tracer.startSpan('wasm-model-inference');
    
    try {
        // 假设 `runCvModel` 是调用 WASM 模块的函数
        const result = runCvModel(getCanvasData()); 
        wasmSpan.setAttribute('inference.success', true);
        wasmSpan.setAttribute('defects.count', result.length);
    } catch (error) {
        wasmSpan.setStatus({ code: api.SpanStatusCode.ERROR, message: error.message });
        throw error; // 重新抛出异常
    } finally {
        wasmSpan.end();
    }
    
    // ... 后续将结果发送到后端
    sendResultsToBackend();
}

2. 将上下文从前端注入到后端

这一步相对标准。我们使用 fetch API,并让 OpenTelemetry 的插桩自动处理 traceparent 请求头的附加。

// file: cv-mfe/api/client.js

import { api }from '@opentelemetry/api';

function sendResultsToBackend(results) {
    const activeContext = api.context.active();
    
    // OpenTelemetry Fetch Instrumentation 会自动完成以下操作,
    // 但为了清晰,这里展示手动注入的逻辑
    const headers = { 'Content-Type': 'application/json' };
    api.propagation.inject(activeContext, headers);
    // headers 对象现在会包含 'traceparent' 键

    fetch('/api/results', {
        method: 'POST',
        headers: headers,
        body: JSON.stringify(results),
    }).catch(err => console.error("API call failed", err));
}

3. 从后端服务到 SQL Server 的上下文“最后一公里”

这是最棘手的部分。后端服务(例如一个 ASP.NET Core 应用)可以通过中间件轻松地从请求头中提取 traceparent 并继续追踪。但如何将这个上下文传递给 SQL Server 呢?数据库驱动(如 Microsoft.Data.SqlClient)本身并不支持 W3C Trace Context。

这里的关键在于,利用 SQL Server 可以执行带注释的查询。我们将 traceIdspanId 作为注释附加到每一条即将执行的 SQL 语句上。这需要通过装饰器模式 (Decorator Pattern) 来实现,对现有的数据访问逻辑进行无侵入的增强。

假设我们有一个 Repository 类负责数据库操作。

原始的 Repository 接口和实现:

// file: Infrastructure/ISqlExecutor.cs
public interface ISqlExecutor
{
    Task<T> QuerySingleAsync<T>(string sql, object param);
    Task<int> ExecuteAsync(string sql, object param);
}

// file: Infrastructure/SqlServerExecutor.cs
public class SqlServerExecutor : ISqlExecutor
{
    private readonly string _connectionString;

    public SqlServerExecutor(IConfiguration configuration)
    {
        _connectionString = configuration.GetConnectionString("DefaultConnection");
    }

    public async Task<T> QuerySingleAsync<T>(string sql, object param)
    {
        using var connection = new SqlConnection(_connectionString);
        return await connection.QuerySingleOrDefaultAsync<T>(sql, param);
    }

    public async Task<int> ExecuteAsync(string sql, object param)
    {
        using var connection = new SqlConnection(_connectionString);
        return await connection.ExecuteAsync(sql, param);
    }
}

带有追踪功能的装饰器:

// file: Infrastructure/TracingSqlExecutorDecorator.cs
using System.Diagnostics;

public class TracingSqlExecutorDecorator : ISqlExecutor
{
    private readonly ISqlExecutor _decorated;
    private readonly ILogger<TracingSqlExecutorDecorator> _logger;
    private static readonly ActivitySource ActivitySource = new("SqlServer.Client");

    public TracingSqlExecutorDecorator(ISqlExecutor decorated, ILogger<TracingSqlExecutorDecorator> logger)
    {
        _decorated = decorated;
        _logger = logger;
    }

    public async Task<T> QuerySingleAsync<T>(string sql, object param)
    {
        // 使用 .NET 的 ActivitySource 创建一个新的 Span
        using var activity = ActivitySource.StartActivity("SQL Query", ActivityKind.Client);
        
        // 关键步骤:注入追踪上下文到 SQL 注释
        var sqlWithTrace = InjectTraceContextIntoSql(sql, activity);
        
        activity?.SetTag("db.system", "sqlserver");
        activity?.SetTag("db.statement", sql);

        try
        {
            return await _decorated.QuerySingleAsync<T>(sqlWithTrace, param);
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
    }

    public async Task<int> ExecuteAsync(string sql, object param)
    {
        using var activity = ActivitySource.StartActivity("SQL Execute", ActivityKind.Client);
        var sqlWithTrace = InjectTraceContextIntoSql(sql, activity);
        
        activity?.SetTag("db.system", "sqlserver");
        activity?.SetTag("db.statement", sql);

        try
        {
            return await _decorated.ExecuteAsync(sqlWithTrace, param);
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
    }

    private string InjectTraceContextIntoSql(string sql, Activity activity)
    {
        if (activity == null || string.IsNullOrEmpty(activity.Id))
        {
            return sql;
        }

        // W3C TraceContext 格式: version-traceid-spanid-flags
        // 例如: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
        // 我们只需要 traceparent
        var traceParent = activity.Id;
        
        // 一个常见的错误是直接拼接,这可能破坏原有的 SQL 注释或结构。
        // 一个更健壮的方式是寻找 SQL 的第一个 token 并在此之前插入。
        // 为简化,我们这里直接加在最前面。
        var comment = $"-- traceparent='{traceParent}'\n";
        
        _logger.LogInformation("Executing SQL with trace context: {TraceParent}", traceParent);

        return comment + sql;
    }
}

在依赖注入容器中注册装饰器 (以 ASP.NET Core 为例):

// file: Program.cs
// ...
builder.Services.AddSingleton<ISqlExecutor, SqlServerExecutor>();

// 用装饰器包裹原始实现
builder.Services.Decorate<ISqlExecutor, TracingSqlExecutorDecorator>();

// 添加 OpenTelemetry
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSource("SqlServer.Client") // 订阅我们的自定义 ActivitySource
        .AddOtlpExporter());
// ...

现在,每一条通过 ISqlExecutor 执行的 SQL,都会自动在开头附加类似 -- traceparent='00-...' 的注释。

4. 关联慢查询与端到端 Trace

当 DBA 在 SQL Server 的慢查询日志(例如,通过 Extended Events 捕获)中发现一条耗时很长的查询时:

-- traceparent='00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'
SELECT 
    d.DefectId, 
    d.Location, 
    i.ImageUrl
FROM 
    dbo.Defects d
JOIN 
    dbo.Images i ON d.ImageId = i.ImageId
WHERE 
    i.InspectionTimestamp > '2023-10-26' AND d.Severity > 5;

他们不再需要猜测这条查询的业务背景。他们可以直接复制 traceparent 中的 Trace ID (0af7651916cd43dd8448eb211c80319c),并将其粘贴到 Jaeger 或 Grafana 的搜索框中。系统会立即展示出完整的分布式追踪链路:从某个用户在哪个浏览器上点击了分析按钮,到 CV 微前端处理图像耗时,再到后端服务调用,最终触发了这条慢查询。问题的根因、影响范围和业务场景瞬间清晰。

架构的局限性与未来展望

这套架构并非没有成本。它要求所有参与的开发团队都必须理解并遵循可观测性的规范。postMessage 的通信方式在微前端数量极多时会增加复杂性,可能需要一个更健壮的事件总线。SQL 注释注入法也存在一些风险,例如注释可能被某些数据库代理或防火墙剥离,或者当 SQL 语句本身非常复杂时,注入逻辑需要更加智能以避免语法错误。

此外,当前方案主要关注追踪(Tracing)。一个更完善的可观测性体系还需要将日志(Logging)和指标(Metrics)与 Trace ID 关联起来。例如,所有服务输出的结构化日志中都应包含 trace_idspan_id 字段,Prometheus 指标的标签中也可以附加相关上下文,从而实现 Trace、Metric、Log 三者之间的无缝下钻和关联分析。这构成了我们下一步优化的路径。


  目录