我们团队的 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;
这里的几个关键设计点:
-
LowCardinality(String): 对于基数(唯一值数量)较低的字符串列,如event_type,team_id等,使用LowCardinality是一种重要的性能优化手段。ClickHouse 会将这些字符串字典化编码,显著降低存储占用并加速过滤和聚合。 -
ENGINE = MergeTree(): 这是 ClickHouse 的核心表引擎,专为高性能写入和查询设计。 -
ORDER BY: 排序键是 MergeTree 性能的灵魂。查询时如果WHERE条件能够利用排序键的前缀,ClickHouse 可以只读取必要的数据块(granules),实现性能的巨大飞跃。我们的查询通常会先按team_id和environment进行过滤,所以这个排序键设计是合理的。 -
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的首次提交时间和最后部署时间。 -
groupArray和has: 这是一种在聚合后进行条件过滤的常用技巧,确保我们匹配的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 团队在回顾会议上能够直观地看到他们的效能趋势,从而真正实现数据驱动的持续改进。