构建基于 ClickHouse 与 Consul Connect 的微前端统一可观测性数据管道


微前端架构解决了前端应用的规模化问题,但其分布式特性也急剧放大了可观测性挑战。一个用户操作可能横跨多个独立部署的前端应用和后端服务,当问题出现时,要完整追踪其调用链、关联相关日志和指标,就成了一场噩梦。传统的日志、追踪、指标三系统分立的方案(如 ELK + Jaeger + Prometheus)不仅运维成本高昂,数据间的关联分析也异常困难,查询性能往往无法满足复杂问题的排查需求。

架构决策:为何选择统一数据存储模型

在评估可观测性方案时,我们面临两个主要选择。

方案 A:业界标准组合(ELK + Jaeger + Prometheus)

  • 优势:
    • 生态成熟,每个组件都是其领域的佼佼者。
    • 功能完备,拥有开箱即用的 UI 和丰富的集成插件。
    • 社区庞大,遇到问题容易找到解决方案。
  • 劣势:
    • 运维复杂性: 维护三个独立的、高可用的分布式系统是一项巨大的挑战。
    • 资源消耗: Elasticsearch 的索引机制和 Jaeger 的存储后端(通常也是 ES 或 Cassandra)对内存和磁盘 IO 要求极高,成本不菲。
    • 数据孤岛: 日志、追踪、指标数据存储在不同系统中。虽然可以通过 TraceID 进行手动关联,但在一个查询中进行深度、高性能的聚合分析几乎不可能。例如,要“查询过去24小时内,所有支付失败追踪中,关联日志包含‘余额不足’关键字,且用户等级为 VIP3 的操作次数”,这种查询在分离的系统中实现起来极为低效和复杂。

方案 B:基于 ClickHouse 的统一管道模型

  • 优势:
    • 单一数据存储: 日志、追踪(Spans)、指标均以流式数据形式写入同一个 ClickHouse 集群。从根本上消除了数据孤島。
    • 极致查询性能: ClickHouse 的列式存储和向量化执行引擎为大规模数据的即时聚合分析(OLAP)而生。上述复杂查询可以由一条 SQL 在秒级完成。
    • 成本效益: ClickHouse 惊人的数据压缩比(通常可达10:1)和较低的硬件要求能显著降低存储和计算成本。
    • 架构简化: 运维对象从三个复杂系统简化为一个 ClickHouse 集群,以及一个轻量级的数据采集和转发层。
  • 劣势:
    • 生态相对不成熟: 缺乏像 Jaeger UI 那样开箱即用的追踪可视化前端,需要依赖 Grafana 等工具进行定制化开发。
    • 需要定制化开发: 数据采集、格式化、写入 ClickHouse 的逻辑需要自行实现,对团队的工程能力有一定要求。

最终决策:

考虑到团队对性能、成本和深度分析能力的极致追求,我们最终选择了方案 B。我们认为,牺牲部分开箱即用的便利性,换取架构的简化、性能的飞跃和数据洞察能力的解放,是值得的。这里的核心思想是,将可观测性数据视为一种特殊的时序事件流,并用最擅长处理这种数据的工具来解决它。

核心实现概览

整个系统的核心在于将用户在微前端的每一次操作,通过服务网格(Consul Connect)进行透明的追踪上下文注入,传递到 Laravel 后端服务,最终由 Laravel 将结构化的日志和业务事件统一写入 ClickHouse。

graph TD
    subgraph Browser
        A[User Action on Micro-frontend App1] --> B{fetch API};
    end

    subgraph Consul Service Mesh
        B --> C[Envoy Sidecar for App1];
        C -- Add/Forward Trace Headers --> D[Envoy Sidecar for Laravel];
    end

    subgraph Backend
        D --> E[Laravel Service];
    end

    subgraph Observability Pipeline
        E -- Batch Write --> F[(ClickHouse)];
    end

    subgraph Analysis
        F -- SQL Queries --> G[Grafana Dashboard];
    end

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#f9f,stroke:#333,stroke-width:2px

1. ClickHouse 的统一数据模型设计

这是整个方案的基石。我们需要设计一个能够同时容纳日志、追踪 Span 和关键业务指标的表结构。在真实项目中,一个常见的错误是为不同类型的数据创建不同的表,这又回到了数据孤岛的老路。我们的目标是创建一个“宽表”。

-- a_unified_observability.events on cluster '{cluster}'
CREATE TABLE a_unified_observability.events (
    -- 核心时间与标识
    `timestamp` DateTime64(6, 'Asia/Shanghai') CODEC(Delta(8), ZSTD(1)),
    `trace_id` UUID CODEC(ZSTD(1)),
    `span_id` UInt64 CODEC(ZSTD(1)),
    `parent_span_id` UInt64 CODEC(ZSTD(1)),

    -- 服务与环境标识 (使用 LowCardinality 优化存储和查询)
    `service_name` LowCardinality(String) CODEC(ZSTD(1)),
    `service_version` LowCardinality(String) CODEC(ZSTD(1)),
    `environment` LowCardinality(String) CODEC(ZSTD(1)),
    `hostname` LowCardinality(String) CODEC(ZSTD(1)),

    -- 事件类型与级别
    `event_type` LowCardinality(String) COMMENT 'log, span.start, span.end, metric, business_event',
    `log_level` LowCardinality(String) COMMENT 'debug, info, warning, error',

    -- 核心内容
    `message` String CODEC(ZSTD(3)),
    `duration_ms` Float64 CODEC(T64, ZSTD(1)), -- span 持续时间或操作耗时

    -- 动态属性与标签 (灵活存储不同事件的元数据)
    `attributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `numeric_attributes` Map(LowCardinality(String), Float64) CODEC(ZSTD(1)),

    -- HTTP 请求上下文 (可选)
    `http_method` LowCardinality(String) CODEC(ZSTD(1)),
    `http_url` String CODEC(ZSTD(1)),
    `http_status_code` UInt16 CODEC(ZSTD(1))

) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (service_name, timestamp, trace_id)
TTL toDateTime(timestamp) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;

设计决策剖析:

  • DateTime64(6): 微秒级精度对于追踪和性能分析至关重要。
  • UUID for trace_id: 保证全局唯一性,是关联所有事件的纽带。
  • LowCardinality(String): 这是 ClickHouse 的一个性能杀手锏。对于基数(唯一值数量)有限的列,如服务名、环境、日志级别,它能将字符串存储为字典编码,大幅减少存储空间并加速 GROUP BYWHERE 查询。
  • Map 类型: attributesnumeric_attributes 列使用了 Map 类型,这提供了极大的灵活性。日志可以放入自定义上下文,业务事件可以记录订单ID和金额,而无需频繁修改表结构。这是一个反范式设计,但在 OLAP 场景中非常实用。
  • PARTITION BY toYYYYMM(timestamp): 按月分区是常规操作,便于管理数据生命周期和提高查询效率,因为查询通常会带有时间范围。
  • ORDER BY (service_name, timestamp, trace_id): 排序键的选择至关重要。将 service_name 放在第一位,可以有效压缩数据,并加速按服务筛选的查询。timestamp 保证了时间局部性。
  • TTL: 自动删除过期数据,避免手动维护。可观测性数据通常只需要保留一段时间。

2. Consul Connect 的透明追踪集成

我们不需要在 Laravel 应用中引入复杂的 Jaeger 或 OpenTelemetry SDK 来处理追踪上下文的传播。Consul Connect 通过其注入的 Envoy sidecar 代理可以自动完成这项工作。

当一个请求从上游服务(比如另一个微服务或微前端的 BFF)进入 Laravel 服务的 Envoy sidecar 时,Envoy 会检查是否存在追踪头(如 x-b3-traceid)。如果不存在,它会生成一个新的 Trace ID。然后,它会将这些头注入到转发给 Laravel 应用的请求中。

Consul 服务定义示例 (laravel-service.hcl):

service {
  name = "laravel-api"
  port = 8000
  id   = "laravel-api-v1-instance1"

  tags = ["v1", "production"]

  connect {
    sidecar_service {}
  }

  // 关键配置:开启追踪上下文注入
  meta {
    envoy_tracing_json = <<EOF
{
  "http": {
    "name": "envoy.tracers.zipkin",
    "typed_config": {
      "@type": "type.googleapis.com/envoy.config.trace.v3.ZipkinConfig",
      "collector_cluster": "zipkin_collector", // 这是一个虚拟集群,我们实际上不发送数据,只利用其传播能力
      "collector_endpoint": "/api/v2/spans",
      "collector_endpoint_version": "HTTP_JSON",
      "shared_span_context": false
    }
  }
}
EOF
  }

  check {
    id       = "http-health"
    name     = "HTTP API on port 8000"
    http     = "http://localhost:8000/api/health"
    method   = "GET"
    interval = "10s"
    timeout  = "1s"
  }
}

这里的坑在于,我们并不真的需要一个 Zipkin/Jaeger Collector。我们只需要 Envoy 的追踪传播功能。通过配置一个虚拟的 collector_cluster,我们“欺骗” Envoy 激活其追踪逻辑,它就会自动处理 x-request-id, x-b3-traceid, x-b3-spanid 等 HTTP 头的生成和转发。应用代码只需从请求头中读取它们即可。

3. Laravel 中的高效日志管道实现

现在,我们需要在 Laravel 中创建一个自定义的 Monolog Handler,它能将日志高效地、结构化地写入 ClickHouse。直接在每次 Log::info() 调用时都进行一次 HTTP 请求是生产环境的灾难。必须实现批量写入和异步处理。

app/Logging/ClickHouseHandler.php

<?php

namespace App\Logging;

use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Logger;
use Monolog\LogRecord;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Config;
use Ramsey\Uuid\Uuid;
use Throwable;

class ClickHouseHandler extends AbstractProcessingHandler
{
    private array $buffer = [];
    private int $bufferLimit;
    private string $clickhouseHost;
    private string $clickhouseUser;
    private string $clickhousePassword;
    private string $tableName;

    // 由 Laravel 的 TraceableMiddleware 注入
    public static ?string $currentTraceId = null; 

    public function __construct($level = Logger::DEBUG, bool $bubble = true)
    {
        parent::__construct($level, $bubble);

        $this->bufferLimit = Config::get('logging.channels.clickhouse.buffer_limit', 100);
        $this->clickhouseHost = Config::get('logging.channels.clickhouse.host');
        $this->clickhouseUser = Config::get('logging.channels.clickhouse.user');
        $this->clickhousePassword = Config::get('logging.channels.clickhouse.password');
        $this->tableName = Config::get('logging.channels.clickhouse.table');

        // 注册一个在应用终止时调用的函数,以刷新任何剩余的日志
        register_shutdown_function([$this, 'flushBuffer']);
    }

    protected function write(LogRecord $record): void
    {
        $this->buffer[] = $this->formatRecord($record);

        if (count($this->buffer) >= $this->bufferLimit) {
            $this->flushBuffer();
        }
    }

    private function formatRecord(LogRecord $record): array
    {
        // 从请求上下文或当前静态变量获取 trace_id
        $traceId = self::$currentTraceId ?: (request()->header('x-b3-traceid') ?: Uuid::uuid4()->toString());
        self::$currentTraceId = $traceId; // 缓存以备后续日志使用

        $spanId = hexdec(substr(request()->header('x-b3-spanid', bin2hex(random_bytes(8))), 0, 16));
        $parentSpanId = hexdec(substr(request()->header('x-b3-parentspanid', '0'), 0, 16));

        $attributes = array_merge($record->context, [
            'channel' => $record->channel,
            'php_version' => PHP_VERSION,
        ]);

        return [
            'timestamp' => $record->datetime->format('Y-m-d H:i:s.u'),
            'trace_id' => $traceId,
            'span_id' => $spanId,
            'parent_span_id' => $parentSpanId,
            'service_name' => Config::get('app.name', 'laravel-app'),
            'service_version' => '1.0.0', // 最好从配置或环境变量读取
            'environment' => Config::get('app.env', 'production'),
            'hostname' => gethostname(),
            'event_type' => 'log',
            'log_level' => $record->level->getName(),
            'message' => $record->message,
            'duration_ms' => 0.0, // 日志事件没有持续时间
            'attributes' => $attributes,
            'numeric_attributes' => [], // 可以从 context 中提取数值型属性
            'http_method' => request()->method(),
            'http_url' => request()->fullUrl(),
            'http_status_code' => http_response_code() ?: 0,
        ];
    }

    public function flushBuffer(): void
    {
        if (empty($this->buffer)) {
            return;
        }

        try {
            // 使用 NDJSON (Newline Delimited JSON) 格式进行高效插入
            $data = implode("\n", array_map('json_encode', $this->buffer));
            $this->buffer = []; // 清空缓冲区,即使发送失败,避免重复发送

            Http::withHeaders([
                'X-ClickHouse-User' => $this->clickhouseUser,
                'X-ClickHouse-Key' => $this->clickhousePassword,
            ])
            ->withBody($data, 'application/json')
            ->post("{$this->clickhouseHost}/?database=a_unified_observability", [
                'query' => "INSERT INTO {$this->tableName} FORMAT JSONEachRow"
            ]);

        } catch (Throwable $e) {
            // 生产环境中,这里应该记录到一个备用日志文件(如 file or stderr)
            // 以避免日志系统故障导致应用崩溃或无限循环
            error_log("Failed to send logs to ClickHouse: " . $e->getMessage());
        }
    }

    public function close(): void
    {
        $this->flushBuffer();
    }
}

配置 (config/logging.php):

'clickhouse' => [
    'driver' => 'custom',
    'via' => App\Logging\ClickHouseHandler::class,
    'level' => env('LOG_LEVEL', 'debug'),
    'host' => env('CLICKHOUSE_HOST', 'http://localhost:8123'),
    'user' => env('CLICKHOUSE_USER', 'default'),
    'password' => env('CLICKHOUSE_PASSWORD', ''),
    'table' => 'a_unified_observability.events',
    'buffer_limit' => 100,
],

中间件 (app/Http/Middleware/TraceableMiddleware.php):

<?php

namespace App\Http\Middleware;

use App\Logging\ClickHouseHandler;
use Closure;
use Illuminate\Http\Request;
use Ramsey\Uuid\Uuid;

class TraceableMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // 从 Consul Envoy 注入的头中获取或生成一个新的 trace_id
        $traceId = $request->header('x-b3-traceid', Uuid::uuid4()->toString());
        
        // 将 trace_id 设置到静态属性,供整个请求生命周期中的日志处理器使用
        ClickHouseHandler::$currentTraceId = $traceId;

        $response = $next($request);

        // 将 trace_id 注入到响应头中,以便微前端或调用方可以获取
        $response->headers->set('X-Trace-Id', $traceId);

        return $response;
    }
}

这个实现的关键点:

  • 批量写入: 日志先暂存在内存缓冲区 $this->buffer 中,达到一定数量 (buffer_limit) 或在请求结束时 (register_shutdown_function) 才通过一次 HTTP 请求批量发送到 ClickHouse。
  • JSONEachRow 格式: 这是向 ClickHouse 插入数据的最高效格式之一。
  • 无缝 Trace ID 传递: 通过中间件在请求开始时捕获 Trace ID,并存储在 ClickHouseHandler 的静态属性中,确保了在请求处理过程中任何地方调用 Log::info() 都能自动关联上正确的 trace_id
  • 容错: try-catch 块确保了即使 ClickHouse 连接失败,也不会影响主应用的正常运行。日志丢失是次要问题,主业务稳定是首要原则。

4. 在 ClickHouse 中进行关联查询

现在,最激动人心的部分来了。我们可以用简单的 SQL 来回答之前那些难以回答的问题。

查询 1: 重建一个完整的调用链

SELECT
    timestamp,
    service_name,
    message,
    duration_ms,
    attributes
FROM a_unified_observability.events
WHERE trace_id = '0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d'
ORDER BY timestamp ASC;

查询 2: 查找过去1小时内,所有 HTTP 500 错误,并展示与之相关的最近10条日志

SELECT
    trace_id,
    timestamp,
    service_name,
    http_url,
    message
FROM a_unified_observability.events
WHERE trace_id IN (
    SELECT trace_id
    FROM a_unified_observability.events
    WHERE timestamp >= now() - INTERVAL 1 HOUR
      AND http_status_code = 500
)
ORDER BY timestamp DESC
LIMIT 10 BY trace_id; -- ClickHouse 特有语法,获取每个 trace_id 的前10条

查询 3: 分析不同用户等级的支付成功率和平均耗时

SELECT
    attributes['user_level'] AS user_level,
    countIf(attributes['event_name'] = 'payment_success') / countIf(attributes['event_name'] = 'payment_start') AS success_rate,
    avgIf(duration_ms, attributes['event_name'] = 'payment_process') AS avg_duration_ms
FROM a_unified_observability.events
WHERE event_type = 'business_event'
  AND timestamp >= now() - INTERVAL 1 DAY
  AND attributes['event_name'] IN ('payment_start', 'payment_success', 'payment_process')
GROUP BY user_level;

这种查询的性能和灵活性,是分离式系统无法比拟的。

架构的局限性与未来展望

这个方案并非银弹。最显著的局限是缺乏开箱即用的可视化界面。团队需要投入资源,使用 Grafana 等工具构建定制化的仪表盘来可视化调用链、分析日志和监控指标。这需要对 ClickHouse 的查询能力和 Grafana 的插件有深入的理解。

此外,当前的数据写入管道是直接从 Laravel 应用写入的,在超高并发场景下,这可能会对应用本身造成压力。一个可行的优化路径是,将日志发送到 Kafka 或其他消息队列中,然后通过一个独立的消费者服务(如 Vector 或一个专用的 Go 服务)将数据异步、批量地导入 ClickHouse,从而实现应用与可观测性管道的彻底解耦,进一步提升系统的韧性。

最后,随着 OpenTelemetry 标准的日益成熟,未来可以考虑将 Laravel 端的日志处理器替换为一个标准的 OTLP (OpenTelemetry Protocol) 导出器,并将数据发送到 OpenTelemetry Collector。由 Collector 负责将数据转换为 ClickHouse 的格式并写入。这将使我们的架构更具通用性,能够轻松接入任何支持 OTLP 的组件,而不仅仅是 Laravel。


  目录