使用 Vercel Functions 与 ClickHouse 构建低延迟的研发效能度量管道


我们团队的 Scrum 回顾会议陷入了一种困境。讨论总是围绕着“感觉这个 Sprint 交付慢了”或者“感觉最近线上问题变多了”这类模糊的主观感受。作为工程师,我们都清楚,没有数据的支撑,任何改进措施都可能是凭空臆想,甚至南辕北辙。我们需要客观、量化的指标来驱动决策,而 DORA (DevOps Research and Assessment) 指标正是为此而生的黄金标准:部署频率、变更前置时间、变更失败率和平均恢复时间。

问题在于,如何构建一个轻量、低成本且实时的系统来收集和分析这些指标?我们不想为此专门维护一台服务器,也不想引入复杂的流处理框架。我们的研发流程高度依赖 GitHub Actions,所有关键事件——代码提交、PR 合并、部署成功与失败——都在这里留有痕跡。这自然地引出了一个构想:利用 GitHub Actions 的 Webhook 作为事件源,搭建一个 Serverless 的接收端,并将数据沉淀到一个高性能的分析型数据库中。

技术选型决策:Vercel 与 ClickHouse 的非典型组合

在选择技术栈时,我们评估了几个方案,最终的决定看起来有些非主流,但背后有清晰的工程考量。

数据接收端:为什么是 Vercel Functions?

Webhook 的流量特征是突发性的、非连续的。在一天中的大部分时间里,可能没有任何流量,但在发布窗口期,可能会有密集的事件推送。

  • 备选方案1:专用服务器 (EC2/ECS)。 这是最直接的方案,但也是最浪费的。为了应对峰值流量,我们需要预留资源,导致在 95% 的时间里服务器都是空闲的,这不符合成本效益原则。同时,还需要我们自己处理服务器运维、安全补丁等琐事。
  • 备选方案2:AWS Lambda + API Gateway。 这是一个标准的 Serverless 方案,功能强大。但在我们的场景下,它显得有些“重”。API Gateway 的配置相对繁琐,并且我们团队的前端项目已经托管在 Vercel 平台,开发者对 Vercel 的生态更为熟悉。
  • 最终选择:Vercel Functions。 Vercel Functions(尤其是 Edge Functions)提供了极致的简洁性。我们只需要在现有的前端项目中增加一个 api 目录,部署过程无缝集成。它的冷启动速度极快,按需付费模型完美契合 Webhook 流量模式。最关键的是,它将基础设施的复杂性降到了最低,让我们可以专注于业务逻辑本身。

数据分析端:为什么是 ClickHouse?

DORA 指标的计算本质上是时序数据的聚合分析。我们需要对数月甚至数年的研发事件数据进行快速的切片、钻取和统计。

  • 备选方案1:PostgreSQL / MySQL。 这些关系型数据库是通用好手,但在处理海量事件数据的分析查询时,性能会成为瓶颈。当事件表达到千万甚至上亿级别时,计算中位数、P95 分位数这类查询会变得非常缓慢,除非进行复杂的手动优化和表分区。
  • 备-选方案2:Elasticsearch。 ES 在日志分析领域非常出色,但我们的需求并非全文搜索,而是结构化数据的聚合。使用 ES 来做这件事,有点大材小用,而且其资源消耗和运维成本相对较高。
  • 最终选择:ClickHouse。 ClickHouse 是为 OLAP (在线分析处理) 而生的列式存储数据库。它为大规模数据的快速聚合查询进行了极致优化。计算数亿行数据的中位数或 P95 分位数,通常能在亚秒级完成。其 SQL 方言非常强大,内置了大量用于数据分析的函数。我们选择了 ClickHouse Cloud,这让我们无需关心集群运维,可以像使用普通数据库一样使用它。

这个 Vercel Functions + ClickHouse 的组合,形成了一个清晰、高效的架构:由 Vercel 负责无状态、高弹性的数据接入,ClickHouse 负责有状态、高性能的数据分析。

架构与数据流设计

我们的目标是捕获从代码提交到生产部署的整个生命周期事件。数据流的核心逻辑如下:

sequenceDiagram
    participant GH as GitHub Actions
    participant VF as Vercel Edge Function
    participant CH as ClickHouse Cloud

    GH->>+VF: POST /api/ingest (Webhook)
    Note over VF: 1. 验证 Bearer Token
    Note over VF: 2. Zod 解析与验证 Payload
    VF-->>GH: HTTP 202 Accepted (立即响应)
    VF->>+CH: INSERT INTO dora_events
    Note over CH: 数据异步写入
    CH-->>-VF: 写入成功

这个流程的关键在于解耦。Vercel Function 在接收到请求后,会进行快速的验证和解析,然后立即返回 202 Accepted 响应给 GitHub Actions,避免阻塞 CI/CD 流水线。实际的数据写入操作是异步进行的,即使 ClickHouse 有短暂的延迟,也不会影响到上游系统。

ClickHouse 表结构设计

数据建模是第一步。我们需要一张表来存储所有与研发流程相关的原子事件。在 ClickHouse 中,一个设计良好的表结构对查询性能至关重要。

-- DORA 指标事件表
-- 使用 MergeTree 引擎,这是 ClickHouse 的核心引擎,适用于绝大多数场景
CREATE TABLE IF NOT EXISTS default.dora_events
(
    -- 事件唯一ID,由客户端生成,用于去重
    event_id UUID,

    -- 事件发生的时间戳,使用 DateTime64(3) 以支持毫秒精度
    event_timestamp DateTime64(3, 'UTC'),

    -- 事件类型,例如 'COMMIT_PUSHED', 'DEPLOYMENT_STARTED', 'DEPLOYMENT_SUCCESS', 'DEPLOYMENT_FAILURE'
    -- 使用 LowCardinality 进行优化,因为类型数量有限,可以大幅减少存储空间和提升查询性能
    event_type LowCardinality(String),

    -- 数据来源,例如 'github_actions'
    source LowCardinality(String),

    -- 核心关联ID
    -- 提交的 SHA 值,用于关联代码变更
    commit_sha String,
    -- 团队/服务标识,用于多团队/多项目数据隔离
    team_id LowCardinality(String),
    -- 环境标识,例如 'production', 'staging'
    environment LowCardinality(String),

    -- 附加元数据,以JSON格式存储,提供灵活性
    -- 例如,可以存储 PR ID, 部署日志URL, 触发者等信息
    -- ClickHouse 对 JSON 的支持并不完善,这里使用 String 存储,查询时按需解析
    metadata String
)
ENGINE = MergeTree()
-- 分区键:按月分区,便于数据管理和冷热分离
PARTITION BY toYYYYMM(event_timestamp)
-- 排序键/主键:查询时最常用的过滤和聚合字段
-- 将 team_id 放在前面,可以有效过滤数据,event_type 和 timestamp 紧随其后
ORDER BY (team_id, environment, event_type, event_timestamp)
-- TTL: 数据保留18个月后自动删除,避免数据无限增长
TTL event_timestamp + INTERVAL 18 MONTH;

这里的几个关键设计点:

  1. LowCardinality(String): 对于基数(唯一值数量)较低的字符串列,如 event_type, team_id 等,使用 LowCardinality 是一种重要的性能优化手段。ClickHouse 会将这些字符串字典化编码,显著降低存储占用并加速过滤和聚合。
  2. ENGINE = MergeTree(): 这是 ClickHouse 的核心表引擎,专为高性能写入和查询设计。
  3. ORDER BY: 排序键是 MergeTree 性能的灵魂。查询时如果 WHERE 条件能够利用排序键的前缀,ClickHouse 可以只读取必要的数据块(granules),实现性能的巨大飞跃。我们的查询通常会先按 team_idenvironment 进行过滤,所以这个排序键设计是合理的。
  4. PARTITION BY: 按月分区使得删除旧数据(如使用 TTL)或进行备份等维护操作非常高效,因为它只需要直接删除整个分区目录,而不是逐行扫描。

Vercel Function 实现数据接入

现在我们来实现负责接收 Webhook 的 Vercel Function。我们使用 TypeScript 和 Zod 库来保证类型安全和数据验证。

项目结构如下:

/pages
  /api
    /metrics
      ingest.ts

ingest.ts 的完整代码如下:

// /pages/api/metrics/ingest.ts

import { NextApiRequest, NextApiResponse } from 'next';
import { createClient } from '@clickhouse/client-web';
import { z, ZodError } from 'zod';
import { v4 as uuidv4 } from 'uuid';

// 使用 Zod 定义输入数据的 schema,确保数据质量
const IngestSchema = z.object({
  eventType: z.enum([
    'COMMIT_PUSHED',
    'DEPLOYMENT_STARTED',
    'DEPLOYMENT_SUCCESS',
    'DEPLOYMENT_FAILURE',
  ]),
  source: z.string().min(1),
  commitSha: z.string().regex(/^[a-f0-9]{40}$/), // 校验 commit sha 格式
  teamId: z.string().min(1),
  environment: z.enum(['production', 'staging', 'development']),
  metadata: z.record(z.any()).optional().default({}),
});

type IngestPayload = z.infer<typeof IngestSchema>;

// ClickHouse client 初始化
// 凭证从环境变量中读取,这是最佳实践
const clickhouseClient = createClient({
  host: process.env.CLICKHOUSE_HOST,
  username: process.env.CLICKHOUSE_USER || 'default',
  password: process.env.CLICKHOUSE_PASSWORD,
  database: process.env.CLICKHOUSE_DATABASE || 'default',
});

// Vercel 的 API Route handler
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  // 1. 仅接受 POST 请求
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method Not Allowed' });
  }

  // 2. 安全校验:验证 Bearer Token
  const authHeader = req.headers.authorization;
  const expectedToken = `Bearer ${process.env.INGEST_API_KEY}`;
  if (!authHeader || authHeader !== expectedToken) {
    // 故意返回模糊的 404,避免攻击者探测到端点的存在
    return res.status(404).json({ message: 'Not Found' });
  }

  try {
    // 3. 数据验证
    const payload: IngestPayload = IngestSchema.parse(req.body);

    // 4. 构造写入 ClickHouse 的数据
    const event = {
      event_id: uuidv4(),
      event_timestamp: new Date().toISOString().slice(0, 19).replace('T', ' '),
      event_type: payload.eventType,
      source: payload.source,
      commit_sha: payload.commitSha,
      team_id: payload.teamId,
      environment: payload.environment,
      metadata: JSON.stringify(payload.metadata),
    };

    // 5. 异步写入 ClickHouse
    // 注意:这里没有 'await',但我们将 insert 操作包装在 Promise 中。
    // Vercel 会在 handler 函数返回后保持进程一段时间,允许后台任务完成。
    // 这是一个权衡,在大多数情况下是可靠的。
    // 若要追求极致的可靠性,应引入消息队列(如 Upstash QStash)。
    clickhouseClient.insert({
      table: 'dora_events',
      values: [event],
      format: 'JSONEachRow',
    }).catch(error => {
      // 生产环境中应该有更完善的错误上报机制
      console.error('ClickHouse insert failed:', error);
    });

    // 6. 立即返回 202 Accepted
    // 告知客户端请求已接收,正在处理中,不阻塞上游
    return res.status(202).json({ message: 'Event accepted' });

  } catch (error) {
    if (error instanceof ZodError) {
      // 如果数据验证失败,返回详细的错误信息
      return res.status(400).json({ message: 'Invalid payload', errors: error.errors });
    }
    // 其他未知错误
    console.error('Unhandled error in ingest endpoint:', error);
    return res.status(500).json({ message: 'Internal Server Error' });
  }
}

这段代码包含了生产级应用的关键要素:

  • 安全: 通过环境变量中的 INGEST_API_KEY 进行简单的 Token 认证,防止端点被滥用。
  • 验证: Zod 强制对输入数据进行结构和类型校验,避免脏数据进入数据库。这是一个常见的错误,即过于信任上游系统的数据格式。
  • 解耦: 无论 ClickHouse 写入成功与否或耗时多久,API 都会尽快响应,不拖慢 CI/CD 流程。
  • 日志: 关键错误被打印到控制台,Vercel 会自动收集这些日志,便于排查问题。

GitHub Actions 集成

最后一步是将我们的 CI/CD 流水线与这个新的端点连接起来。我们只需要在 GitHub Actions 的 workflow 文件中添加一个步骤,使用 curl 发送事件即可。

# .github/workflows/deploy.yml

name: Deploy to Production

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      # ... 其他构建和测试步骤 ...

      - name: Notify Deployment Started
        if: always()
        run: |
          curl -X POST "${{ secrets.DORA_METRICS_ENDPOINT }}" \
               -H "Authorization: Bearer ${{ secrets.DORA_METRICS_API_KEY }}" \
               -H "Content-Type: application/json" \
               -d '{
                     "eventType": "DEPLOYMENT_STARTED",
                     "source": "github_actions",
                     "commitSha": "${{ github.sha }}",
                     "teamId": "alpha-team",
                     "environment": "production"
                   }'

      - name: Deploy to Vercel
        id: deploy_step
        # 假设这是部署步骤,成功或失败
        run: |
          echo "Simulating deployment..."
          # 在真实场景中,这里是你的 vercel deploy 命令
          # exit 0 表示成功, exit 1 表示失败
          exit 0 
        continue-on-error: true

      - name: Notify Deployment Success
        if: steps.deploy_step.outcome == 'success'
        run: |
          curl -X POST "${{ secrets.DORA_METRICS_ENDPOINT }}" \
               -H "Authorization: Bearer ${{ secrets.DORA_METRICS_API_KEY }}" \
               -H "Content-Type: application/json" \
               -d '{
                     "eventType": "DEPLOYMENT_SUCCESS",
                     "source": "github_actions",
                     "commitSha": "${{ github.sha }}",
                     "teamId": "alpha-team",
                     "environment": "production",
                     "metadata": { "deploy_url": "https://your-app.vercel.app" }
                   }'

      - name: Notify Deployment Failure
        if: steps.deploy_step.outcome == 'failure'
        run: |
          curl -X POST "${{ secrets.DORA_METRICS_ENDPOINT }}" \
               -H "Authorization: Bearer ${{ secrets.DORA_METRICS_API_KEY }}" \
               -H "Content-Type: application/json" \
               -d '{
                     "eventType": "DEPLOYMENT_FAILURE",
                     "source": "github_actions",
                     "commitSha": "${{ github.sha }}",
                     "teamId": "alpha-team",
                     "environment": "production",
                     "metadata": { "run_id": "${{ github.run_id }}" }
                   }'

这里我们使用了 GitHub Actions 的 secrets 来安全地存储 API 端点 URL 和密钥。通过 if 条件和 steps.deploy_step.outcome,我们可以在部署成功或失败时发送不同的事件。

用 ClickHouse SQL 计算 DORA 指标

数据管道已经建立,现在是见证 ClickHouse 威力的时候。我们可以编写 SQL 查询来计算关键指标。

1. 部署频率 (Deployment Frequency)

计算指定团队在生产环境,过去30天内每天的成功部署次数。

SELECT
    toDate(event_timestamp) AS deployment_date,
    count() AS successful_deployments
FROM dora_events
WHERE
    team_id = 'alpha-team' AND
    environment = 'production' AND
    event_type = 'DEPLOYMENT_SUCCESS' AND
    event_timestamp >= now() - INTERVAL 30 DAY
GROUP BY deployment_date
ORDER BY deployment_date ASC;

这个查询非常直接,并且由于 ORDER BY 键的优化,执行速度极快。

2. 变更前置时间 (Lead Time for Change)

这是最复杂的指标之一,衡量从代码提交到成功部署到生产环境所需的时间。要精确计算,我们需要 COMMIT_PUSHED 事件。假设我们已经通过另一个 Webhook 将其推送到表中。

查询的逻辑是:对于每一次成功的部署,找到其对应的 COMMIT_PUSHED 事件,然后计算时间差。

-- 计算过去90天内,生产环境变更前置时间的中位数(Median Lead Time)
-- 中位数比平均值更能抵抗极端异常值的影响
SELECT
    -- quantile(0.5) 用于计算中位数
    -- toIntervalSecond 将时间差转换为秒
    quantile(0.5)(
        toIntervalSecond(deploy_time - commit_time)
    ) AS median_lead_time_seconds
FROM
(
    -- 子查询:找到每次成功部署及其对应的首次提交时间
    SELECT
        commit_sha,
        -- 使用 argMin 函数获取每个 commit 第一次被推送的时间
        argMin(event_timestamp, event_timestamp) AS commit_time,
        -- 使用 argMax 函数获取每个 commit 最后一次成功部署的时间
        argMax(event_timestamp, event_timestamp) AS deploy_time
    FROM dora_events
    WHERE
        team_id = 'alpha-team' AND
        environment = 'production' AND
        event_timestamp >= now() - INTERVAL 90 DAY AND
        -- 我们只关心这两种事件类型
        event_type IN ('COMMIT_PUSHED', 'DEPLOYMENT_SUCCESS')
    GROUP BY commit_sha
    -- having 子句确保我们只处理那些既有提交又有成功部署的 commit
    HAVING
        has(groupArray(event_type), 'COMMIT_PUSHED') AND
        has(groupArray(event_type), 'DEPLOYMENT_SUCCESS')
)

这个查询展示了 ClickHouse 的强大分析能力:

  • argMin/argMax: 这两个函数非常高效,可以根据一个字段的最小值/最大值,来查找同一行另一个字段的值。这里我们用它来精确地找到每个 commit_sha 的首次提交时间和最后部署时间。
  • groupArrayhas: 这是一种在聚合后进行条件过滤的常用技巧,确保我们匹配的 commit_sha 同时包含了两种必要的事件类型。
  • quantile: ClickHouse 内置了计算分位数的函数,这对于性能评估至关重要。

局限性与未来迭代方向

这个系统虽然轻量且强大,但并非完美。在真实项目中,还有一些需要考虑的边界情况和可以改进的地方。

首先,Vercel Function 的“即发即忘”式写入存在数据丢失的风险。如果 ClickHouse Cloud 短暂不可用,或者网络抖动导致写入失败,这次的事件就永久丢失了。一个更健壮的架构是在 Vercel Function 和 ClickHouse 之间引入一个轻量级的消息队列,例如 Upstash QStash 或 AWS SQS。Vercel Function 的任务仅仅是把事件快速投递到队列,然后由一个专门的消费者(可以是另一个长时间运行的 Serverless Function 或一个小型服务)负责从队列中取出并保证数据可靠地写入 ClickHouse。

其次,当前的 schema 相对简单。它没有很好地处理“一次部署包含多个 commits”的场景。要计算更精确的变更前置时间,我们可能需要从 Git 或 GitHub API 中拉取更详细的信息,例如一个部署关联的所有 commits,然后计算这些 commits 中最早的提交时间。

最后,我们只实现了数据管道,数据的呈现还需要一个 BI 工具。将 ClickHouse 连接到 Grafana、Metabase 或 Superset,可以快速创建交互式的仪表盘,让 Scrum 团队在回顾会议上能够直观地看到他们的效能趋势,从而真正实现数据驱动的持续改进。


  目录