基于列式NoSQL与GitOps构建跨平台构建分析系统的架构决策


我们的技术栈正变得越来越分裂:核心业务逻辑由Swift团队负责,产出高质量的iOS/macOS原生应用;而配套的运营后台、数据看板则由前端团队使用TypeScript和Rollup构建。这种分离本身是健康的,但它带来了一个被长期忽视的痛点:构建遥测数据的孤岛化。Swift的构建日志、测试结果、编译时长等数据散落在CI/CD节点的控制台输出里,而Rollup的打包分析、模块大小、依赖图信息则以JSON文件的形式存储在构建产物中。当需要对研发效能进行全局分析时,比如回答“哪个模块在两个平台上的编译耗时增长最快?”,我们束手无策。

问题定义很清晰:我们需要一个统一的、可查询的平台,用于收集、存储和分析来自异构构建系统(Xcode build, Rollup)的遥测数据。这个平台必须能够处理高写入吞吐量,并提供灵活、快速的聚合查询能力。

方案A:通用型列式存储 Cassandra/ScyllaDB

第一种思路是使用成熟的、以写入性能著称的宽列存储数据库,如Apache Cassandra或其高性能变种ScyllaDB。

优势分析:

  1. 极高的写入吞吞吐: 这是Cassandra的核心优势。其LSM-Tree架构为追加写入操作提供了极佳的性能,非常适合CI/CD场景下大量构建日志的持续涌入。
  2. 去中心化与高可用: 无主节点架构确保了系统没有单点故障,可以轻松地进行水平扩展,满足未来数据量的增长。
  3. 灵活的Schema: 宽列模型允许每行有不同的列,这对于处理来自不同构建系统、字段可能不完全一致的遥测数据有一定吸引力。

劣势与现实考量:

在真实项目中,选择一个数据库远不止看它的优点。这里的坑在于,我们的核心需求是分析,而非简单的键值查询。

  1. 复杂的聚合查询性能不佳: Cassandra的查询语言CQL在GROUP BYJOIN等聚合操作上能力有限且性能较差。执行一次全表扫描以计算某个指标的P95延迟,对于Cassandra来说是一场灾难。这要求我们在数据建模时进行大量的反范式设计,为每一种查询模式创建一张新表,维护成本极高。
  2. 时间序列数据处理的短板: 构建遥测本质上是时间序列数据。虽然可以使用时间桶(Time Bucketing)等技巧在Cassandra中建模,但这并非其原生强项。复杂的窗口函数、趋势分析等查询实现起来非常笨拙。
  3. 运维复杂度: 维护一个生产级的Cassandra集群需要专门的知识,尤其是在修复、扩容和性能调优方面。

结论是,使用Cassandra就像用一把锤子去拧螺丝。虽然它能勉强完成任务,但工具和问题本质上不匹配。我们会花费大量精力在数据建模和查询优化上,以弥补数据库本身的不足。

方案B:分析型列式数据库 ClickHouse

第二种方案是采用一个专为在线分析处理(OLAP)设计的数据库。ClickHouse是这个领域的佼佼者。

优势分析:

  1. 极致的查询性能: ClickHouse为分析而生。它按列存储数据,能够利用向量化计算引擎,在聚合查询上达到惊人的速度。查询“过去一个月内,所有项目中Swift编译时间超过5分钟的构建次数”这类问题,响应时间通常在毫秒级。
  2. 卓越的数据压缩: 列式存储带来了极高的压缩比。相似的数据类型连续存储在一起,可以应用更高效的压缩算法。对于高度重复的构建日志文本,这意味着巨大的存储成本节省。
  3. 强大的SQL方言: 它支持丰富的SQL语法,包括复杂的JOIN、窗口函数、数组函数等,使得数据分析师和工程师可以轻松地对数据进行深度挖掘,无需学习新的查询语言。

劣势与现实考量:

  1. 不擅长高频单点更新/删除: ClickHouse的MergeTree引擎族专为批量写入和不可变数据设计。UPDATEDELETE是异步的、重量级的操作,不适用于事务性场景。但这恰好符合我们的用例:构建日志一旦生成,就不会被修改。
  2. 写入延迟相对较高: 相比于Cassandra的毫秒级写入,ClickHouse为了优化存储和后续查询,通常会进行小批量写入。数据在被查询到之前可能会有秒级的延迟。在我们的场景下,构建分析并非实时交易,秒级延迟完全可以接受。

最终决策与理由:

我们选择了ClickHouse。核心理由是,它完美匹配了我们问题的本质——对海量、半结构化的时间序列数据进行快速、灵活的聚合分析。它让我们能够专注于数据分析本身,而不是把时间浪费在与数据库的底层机制搏斗上。它将查询的复杂性从应用层转移到了它最擅长的数据库层。

核心实现概览

我们的系统由三部分构成:数据采集端(CI脚本集成)、数据存储与API(ClickHouse集群与一个简单的Ingestion Service)、以及GitOps化的部署流程。

1. ClickHouse 数据表结构设计

一个好的表结构是性能的基石。我们设计的核心事件表build_events如下,这里的ORDER BY键的选择至关重要,它决定了数据在磁盘上的物理排序,直接影响查询性能。

-- file: schema/001_create_build_events.sql

CREATE TABLE default.build_events (
    `timestamp` DateTime64(3, 'UTC'),
    `project_name` LowCardinality(String), -- 项目名,使用LowCardinality优化存储和查询
    `build_id` UUID, -- CI/CD系统提供的唯一构建ID
    `platform` Enum8('iOS' = 1, 'macOS' = 2, 'Web' = 3), -- 构建平台
    `event_type` String, -- 事件类型,如 'compile_task', 'link_task', 'rollup_chunk'
    `event_source` String, -- 事件来源,如 'Xcode', 'Rollup'
    `duration_ms` UInt64, -- 事件耗时(毫秒)
    
    -- 核心遥测数据,JSON格式,允许异构数据共存
    `payload` String, 
    
    -- 用于查询和过滤的维度信息
    `git_branch` LowCardinality(String),
    `git_commit_hash` String,
    `ci_agent_name` LowCardinality(String),
    `status` Enum8('success' = 1, 'failure' = 2, 'warning' = 3)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp) -- 按月分区,便于数据管理和归档
ORDER BY (project_name, platform, timestamp) -- 关键!将最常用于过滤的列放在ORDER BY前缀
SETTINGS index_granularity = 8192;

设计考量:

  • LowCardinality(String): 对于project_namegit_branch这类值重复度高的列,使用LowCardinality可以极大地减少存储空间并加速过滤。
  • PARTITION BY toYYYYMM(timestamp): 按月分区是常见操作,这使得删除旧数据(如ALTER TABLE ... DROP PARTITION ...)成为一个瞬时完成的元数据操作,而非昂贵的DELETE
  • ORDER BY (project_name, platform, timestamp): 这是最重要的优化。ClickHouse会根据这个键对数据进行排序和稀疏索引。这意味着当我们查询WHERE project_name = 'AppA' AND platform = 'iOS'时,ClickHouse可以快速跳过不相关的数据块,极大地提升查询效率。
  • payload String: 我们选择用一个字符串字段存储JSON负载。虽然ClickHouse支持JSON对象,但将其作为字符串存储,在写入时更简单,查询时使用JSONExtract*系列函数进行解析,性能依然出色。这给了我们处理来自Swift和Rollup异构数据的灵活性。

2. Swift 原生构建数据采集器

为了从Xcode构建日志中提取有价值的信息,我们开发了一个轻量级的Swift命令行工具 BuildObserver。它在CI流程中被调用,解析xcodebuild的输出,并将其转换为我们定义的JSON结构。

// file: BuildObserver/Sources/main.swift
import Foundation
import ArgumentParser

// 配置项,通过环境变量或命令行参数传入
struct Config {
    static let endpoint = URL(string: ProcessInfo.processInfo.environment["TELEMETRY_ENDPOINT"]!)!
    static let projectName = ProcessInfo.processInfo.environment["PROJECT_NAME"]!
    static let buildID = UUID(uuidString: ProcessInfo.processInfo.environment["BUILD_ID"]!)!
    // ... 其他 CI 环境变量
}

// 定义与ClickHouse表结构对应的事件模型
struct BuildEvent: Codable {
    let timestamp: String
    let projectName: String
    let buildId: UUID
    let platform: String
    let eventType: String
    let eventSource: String = "Xcode"
    let durationMs: UInt64
    let payload: String // A JSON string containing detailed info
    let gitBranch: String
    let gitCommitHash: String
    let ciAgentName: String
    let status: String
}

// 简单的HTTP客户端,用于批量发送事件
class TelemetryClient {
    private var buffer: [BuildEvent] = []
    private let bufferLock = NSLock()
    private let bufferSize = 50 // 每50个事件发送一次

    func track(_ event: BuildEvent) {
        bufferLock.lock()
        defer { bufferLock.unlock() }

        buffer.append(event)
        if buffer.count >= bufferSize {
            flush()
        }
    }

    func flush() {
        guard !buffer.isEmpty else { return }
        
        let eventsToSend = buffer
        buffer.removeAll()

        var request = URLRequest(url: Config.endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        do {
            let data = try JSONEncoder().encode(eventsToSend)
            request.httpBody = data
            
            // 在真实项目中,这里应该是异步的,并有重试逻辑
            // 为简化示例,使用同步调用
            let task = URLSession.shared.dataTask(with: request) { data, response, error in
                if let error = error {
                    print("Error sending telemetry: \(error)")
                    // 实际应有错误处理,如将失败的批次写回本地文件
                    return
                }
                guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                    print("Telemetry endpoint returned non-200 status")
                    return
                }
            }
            task.resume()
        } catch {
            print("Failed to encode telemetry data: \(error)")
        }
    }
}


// 主逻辑,解析xcodebuild日志流
// 这是一个简化的解析器,实际生产中需要更复杂的正则表达式
func parseXcodeBuildLog() {
    let client = TelemetryClient()
    defer { client.flush() } // 确保程序退出时发送剩余的事件

    while let line = readLine() {
        // 示例:解析编译单个文件的日志
        // "CompileSwift normal arm64 /path/to/MyFile.swift (in target 'MyApp' from project 'MyApp')"
        if line.contains("CompileSwift ") {
            let startTime = Date()
            // 在CI中,我们需要更精确地从日志中捕获开始和结束时间戳
            // 这里为了简化,我们假设任务立即开始并需要测量其执行
            
            // 模拟任务耗时
            Thread.sleep(forTimeInterval: Double.random(in: 0.05...0.5))
            let duration = UInt64(Date().timeIntervalSince(startTime) * 1000)

            let components = line.components(separatedBy: " ")
            guard components.count > 3 else { continue }
            let filePath = components.last ?? "unknown"

            let payloadDict = ["file": filePath, "target": "MyApp"]
            guard let payloadData = try? JSONSerialization.data(withJSONObject: payloadDict),
                  let payloadString = String(data: payloadData, encoding: .utf8) else {
                continue
            }

            let event = BuildEvent(
                timestamp: ISO8601DateFormatter().string(from: Date()),
                projectName: Config.projectName,
                buildId: Config.buildID,
                platform: "iOS",
                eventType: "compile_task",
                durationMs: duration,
                payload: payloadString,
                gitBranch: ProcessInfo.processInfo.environment["GIT_BRANCH"]!,
                gitCommitHash: ProcessInfo.processInfo.environment["GIT_COMMIT_HASH"]!,
                ciAgentName: ProcessInfo.processInfo.environment["CI_AGENT_NAME"]!,
                status: "success"
            )
            client.track(event)
        }
        // ... 添加更多解析规则,例如链接、测试等
    }
}

// 在CI脚本中这样调用:
// xcodebuild ... | swift run BuildObserver
parseXcodeBuildLog()

3. Rollup 插件与数据统一

对于前端项目,我们编写一个自定义的Rollup插件。这个插件利用Rollup的钩子(hooks)如 buildStart, buildEnd, renderChunk 来收集信息,并生成与Swift采集器schema一致的JSON对象。

// file: rollup-telemetry-plugin.js

import fetch from 'node-fetch';

function telemetryPlugin(options) {
  const events = [];
  const { endpoint, projectName, buildId, gitBranch, gitCommitHash, ciAgentName } = options;

  return {
    name: 'telemetry-plugin',

    buildStart() {
      const event = {
        timestamp: new Date().toISOString(),
        projectName,
        buildId,
        platform: 'Web',
        eventType: 'build_start',
        eventSource: 'Rollup',
        durationMs: 0, // Will be calculated at buildEnd
        payload: JSON.stringify({}),
        gitBranch,
        gitCommitHash,
        ciAgentName,
        status: 'success'
      };
      this.buildStartTime = Date.now();
      events.push(event);
    },

    renderChunk(code, chunk) {
      const event = {
        timestamp: new Date().toISOString(),
        projectName,
        buildId,
        platform: 'Web',
        eventType: 'render_chunk',
        eventSource: 'Rollup',
        durationMs: 0, // Rollup doesn't provide per-chunk duration easily, could be added with more effort
        payload: JSON.stringify({
          chunkName: chunk.name,
          fileName: chunk.fileName,
          size_kb: (chunk.code.length / 1024).toFixed(2),
          modules: Object.keys(chunk.modules),
        }),
        gitBranch,
        gitCommitHash,
        ciAgentName,
        status: 'success'
      };
      events.push(event);
    },

    async buildEnd(error) {
      const buildDuration = Date.now() - this.buildStartTime;
      events[0].durationMs = buildDuration; // Update build_start event duration
      
      if (error) {
          // Add a failure event
      }
      
      try {
        await fetch(endpoint, {
          method: 'POST',
          body: JSON.stringify(events),
          headers: { 'Content-Type': 'application/json' }
        });
      } catch (e) {
        console.error('Failed to send telemetry data:', e);
        // In a real scenario, write to a local file for later retry.
      }
    }
  };
}

export default telemetryPlugin;

4. CI/CD 与 GitOps 部署

整个系统的部署和运维遵循GitOps原则,使用ArgoCD进行管理。所有配置——包括ClickHouse的部署、Ingestion Service的Kubernetes Deployment,以及CI流水线的定义——都存储在Git仓库中。

graph TD
    A[Developer Pushes Code] --> B{GitHub Actions CI};
    B -- Triggers --> C[Swift Build & Test];
    C -- Pipes Log to --> D[BuildObserver CLI];
    D -- Sends JSON Batch --> E[Ingestion API];
    
    B -- Also Triggers --> F[Rollup Build];
    F -- Uses --> G[telemetryPlugin];
    G -- Sends JSON Batch --> E;

    E -- Writes to --> H[ClickHouse Cluster];

    subgraph GitOps Managed by ArgoCD
        I[Git Repo: Infra Config] -- Defines --> H;
        I -- Defines --> E;
    end

    J[ArgoCD Controller] -- Watches --> I;
    J -- Syncs State --> K[Kubernetes Cluster];
    K -- Runs --> H;
    K -- Runs --> E;

一个典型的ArgoCD应用定义,用于部署我们的ClickHouse实例(这里使用社区的Helm Chart):

# file: argocd-apps/clickhouse-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: clickhouse
  namespace: argocd
spec:
  project: default
  source:
    repoURL: 'https://github.com/ClickHouse/clickhouse-helm-chart.git'
    targetRevision: '2.4.6'
    path: '.'
    helm:
      values: |
        # Production-grade values should be more complex
        # This is a simplified example
        cluster:
          name: telemetry-cluster
        replicas:
          count: 1
        persistence:
          enabled: true
          size: 100Gi
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: data-platform
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

当我们需要更新ClickHouse配置或Ingestion Service时,我们只需修改Git仓库中的YAML文件。ArgoCD会自动检测到变更并将其应用到Kubernetes集群中,实现了基础设施的声明式管理。

架构的局限性与未来迭代

当前架构虽然解决了核心问题,但仍有其局限性。首先,我们自研的Ingestion API是一个潜在的瓶颈和单点故障。虽然可以部署多个实例实现高可用,但它缺乏削峰填谷和背压处理能力。在CI/CD高峰期,突发的构建任务可能会压垮这个服务。一个明确的优化路径是引入一个消息队列(如Kafka或Pulsar)作为缓冲层,Ingestion API将数据写入队列,再由专门的消费者服务批量写入ClickHouse,从而实现系统的解耦和弹性。

其次,当前的遥测数据粒度还比较粗。例如,我们只是记录了单个Swift文件的编译,但没有深入到函数级别的编译耗时分析。这需要更深度的工具链集成,比如利用Swift编译器的 -driver-time-compilation 等标志,这会显著增加数据量,对ClickHouse的性能和存储成本提出更高要求。

最后,虽然我们统一了数据模型,但数据的价值最终体现在其可视化和告警上。下一步的工作是基于这套系统构建Grafana仪表盘,设立关键效能指标(KPI)的SLO,并配置自动化告警,例如当某个模块的编译时间P99值连续三个版本持续上涨时,自动通知相关团队。


  目录