59.1 DORA 四指标

一句话

DORA 四指标是把“交付快”和“交付稳”拆成两组、四个能从流水线里采出来的数——吞吐看部署频率与变更前置时间,稳定看变更失败率与恢复时长;单看任何一个都会被优化成假象,而官方口径在 2024 年已经扩成五个(多了返工率)。

为什么是这四个

2014 年 DORA 想把“IT 性能”量化,试了一堆变量;2015 年的研究把它收敛成两个统计上独立的因子:

因子 指标 回答的问题 单位
吞吐 部署频率 多久能上一次线 次/周
吞吐 变更前置时间 一个提交到生产可用要多久 小时
稳定性 变更失败率(CFR) 多少次上线需要救火 %
稳定性 失败部署恢复时长(FDRT) 救火要多久 分钟
稳定性(2024 新增) 部署返工率 多少上线是计划外返工 %

🔑 两个因子独立,是这四个指标能成立的前提:“快”与“稳”在数据上不是一根轴的两端,所以不能用一个综合分把它们压成一个数。压成一个数,等于放弃了“哪一维在恶化”的诊断能力。

口径:四个指标各自的起止点

指标 起点 终点 从哪采 常见错法
部署频率 — 每次成功部署到生产 部署系统 / CD 流水线(发布方式见 55.1 发布策略) 同一 sha 重复部署算多次;把测试环境发布算进来
变更前置时间 提交进版本库 该提交在生产可用 git log + 部署记录 起点写成“需求提出”(那是需求前置时间,见 59.2 交付周期与吞吐)
变更失败率 — 部署后需立即介入(回滚/热修) 部署记录 + 回滚标记 分母只算计划内,或把热修也算成“正常发布”
恢复时长 失败部署被发现 服务恢复可用 事故记录 / 值班日志(流程见 56.5 事故响应) 把机房断电、上游故障也算进来
返工率 — 计划外部署 部署记录的 planned 字段 把热修标成计划内,返工率恒为 0

三条口径细节,差一个就会让数字前后不可比:

  1. 分母要写死。CFR = 失败部署 / 全部部署;若只算计划内,本节样例数据是 10%,含计划外是 8.7%——差 1.3 个百分点,跨季度对比就会失真。
  2. 前置时间算“批内第一个提交”。一次部署含 3 个提交时,从最早的提交算起,否则批量越大数字越好看。
  3. 恢复时长的终点是“服务恢复”,不是“根因查清”。根因分析可能拖一周,那属于复盘,不进这个数。

四指标已经变成五指标:口径在演进

把“DORA 四指标”当固定不变的教科书,是常见误解。官方口径改过两次,每次都有明确的统计理由:

时间 变化 为什么改
2014–2015 从一堆变量收敛为四指标、两因子 统计分析显示吞吐与稳定是两个独立因子;顺带证伪了“快就不稳”
2023 MTTR → 失败部署恢复时长 旧的 MTTR 把机房断电、上游故障也算进去,测的不再是“交付能力”;新口径只算由变更引起、需立即介入的失败
2024 新增 部署返工率 CFR 只是“返工”的代理指标;返工率直接测“计划外工作占比”,补上“通过了但随后返工”的盲区

💡 出处:DORA 指标指南 (opens new window)、DORA 指标演进史 (opens new window)。报告本身也在改分档,别把某一年的分档当永久分数线。

行业里流传的分档只能当量级判断用:

指标 领先(约) 中位(约) 落后(约)
部署频率 每天多次 每周 1 次 ~ 每月 1 次 少于每月 1 次
变更前置时间 小于 1 天 1 天 ~ 1 周 大于 1 个月
变更失败率 约 5% 约 15% 大于 30%
恢复时长 小于 1 小时 小于 1 天 大于 1 周

用这张表的正确方式是回答“我在哪个数量级”,不是回答“我离 5% 还差几个点”。

速度与稳定性不是跷跷板

DORA 反复验证的结论是:高绩效团队四个指标同时好,低绩效团队同时差。机制不神秘:

机制 对吞吐的作用 对稳定的作用
小批量 前置时间短、部署频率高 爆炸半径小,失败代价低
自动化测试与流水线 等待少、反馈快 失败在合并前被发现
可回滚设计(开关、灰度) 敢发,频率高 恢复快(FDRT 低)
主干开发、短分支 合并冲突少 集成问题早暴露

反过来,“慢工出细活”在数据上不成立:批量越大,一次失败要回滚的东西越多,恢复越慢,失败率也不会因为“多检查几天”而下降——多出来的往往只是等待(推导见 40.5 变更成本)。

从 git 与部署记录里把四个数算出来

采集器的输入只有两样:git log 和部署流水线写出的 deploy.jsonl。后者每行一次部署:

{"id": "D000", "ts": "2026-09-02T09:52:04+08:00", "sha": "17f2a96b...", "status": "ok", "planned": true, "recovered_at": null}
{"id": "D007", "ts": "2026-09-09T15:10:00+08:00", "sha": "0e3c9a11...", "status": "failed", "planned": true, "recovered_at": "2026-09-09T15:35:00+08:00"}
{"id": "D007H", "ts": "2026-09-09T17:10:00+08:00", "sha": "0e3c9a11...", "status": "ok", "planned": false, "recovered_at": null}
#!/usr/bin/env python3
"""dora_collect.py —— 从 git 与部署流水线数据算 DORA 指标(无外部依赖)。

用法:python3 dora_collect.py <repo> <deploy.jsonl>
"""
import json
import subprocess
import sys
from datetime import datetime, timezone
from statistics import median


def ts(s):
    return datetime.fromisoformat(s).astimezone(timezone.utc)


def pct(xs, p):
    xs = sorted(xs)
    k = (len(xs) - 1) * p
    lo, hi = int(k), min(int(k) + 1, len(xs) - 1)
    return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)


def main(repo, deploy_file):
    # ① 提交序列(sha -> 提交时间),用于把一次部署还原成"一批提交"
    out = subprocess.run(
        ["git", "-C", repo, "log", "--reverse", "--format=%H %ct"],
        check=True, capture_output=True, text=True,
    ).stdout.split()
    commits = [(out[i], datetime.fromtimestamp(int(out[i + 1]), timezone.utc))
               for i in range(0, len(out), 2)]
    order = {sha: i for i, (sha, _) in enumerate(commits)}

    deploys = [json.loads(line) for line in open(deploy_file, encoding="utf-8") if line.strip()]
    deploys.sort(key=lambda d: d["ts"])
    start, end = ts(deploys[0]["ts"]), ts(deploys[-1]["ts"])
    weeks = (end - start).total_seconds() / 86400 / 7

    # ② 按计划内部署切批:一批 = 上次部署之后新增的提交
    lead_times, prev = [], -1
    for d in deploys:
        if not d["planned"]:
            continue
        idx = order[d["sha"]]
        if prev + 1 <= idx:                  # 本批首个提交 → 部署完成
            lead_times.append((ts(d["ts"]) - commits[prev + 1][1]).total_seconds() / 3600)
        prev = max(prev, idx)

    planned = [d for d in deploys if d["planned"]]
    failed = [d for d in deploys if d["status"] == "failed"]
    rework = [d for d in deploys if not d["planned"]]
    recovered = [d for d in failed if d.get("recovered_at")]

    df = len(planned) / weeks
    cfr = len(failed) / len(deploys)
    fdrt = median((ts(d["recovered_at"]) - ts(d["ts"])).total_seconds() / 60 for d in recovered)
    rwr = len(rework) / len(deploys)

    print(f"窗口 {start:%Y-%m-%d} ~ {end:%Y-%m-%d}({weeks:.1f} 周);"
          f"提交 {len(commits)},部署 {len(deploys)}(计划内 {len(planned)},"
          f"计划外 {len(rework)},失败 {len(failed)})")
    print(f"部署频率 {df:.1f}/周 | 前置时间 中位 {median(lead_times):.1f}h "
          f"P85 {pct(lead_times, .85):.1f}h | CFR {cfr:.0%} | "
          f"恢复 {fdrt:.0f}m | 返工率 {rwr:.0%}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

样例仓库是 120 个提交、46 次部署(4 次失败、6 次计划外)的三周数据。本机实测输出(macOS 26 / Python 3.14.8 / git 2.56.0):

窗口 2026-09-02 ~ 2026-09-24(3.2 周);提交 120,部署 46(计划内 40,计划外 6,失败 4)
部署频率 12.6/周 | 前置时间 中位 23.5h P85 64.0h | CFR 9% | 恢复 65m | 返工率 13%

逐条读:

  1. 12.6 次/周属于“每天 1–2 次”的中间档,不是领先档;前置时间中位 23.5 小时(约 1 天)也正好卡在中间档的边界上。
  2. P85 = 64 小时,是中位数的 2.7 倍。承诺“这个需求多久上线”时,用中位数会有一半概率打脸;对业务承诺应该用 P85(分布为什么长这样,见 59.2 交付周期与吞吐)。
  3. CFR 9%(4/46)与返工率 13%(6/46)不一致,正好暴露口径问题:4 次失败之外还有 2 次“计划外的功能返工”,它们没进 CFR。这就是 2024 年要加返工率的现实原因——只盯 CFR 会漏掉“通过了但随后返工”的那部分工作。

怎么读:四指标组合起来才是诊断

单点数字只能定档,组合才能定位:

观察到的组合 症状 先查什么
部署频率骤降 + 恢复时长上升 进入冻结期:批量变大、回滚变难 回滚成本与审批环节(55.3 回滚设计)
部署频率上升 + CFR 上升 带病快跑:门禁被绕过 测试门禁与灰度放量
前置时间上升 + CFR 下降 批量变大或评审变长 等待时间、批大小
前置时间长 + CFR 高 + 恢复慢 系统性交付能力差 价值流与约束(59.4 效能改进方法)
CFR 低 + 返工率高 失败被重新分类成“正常修复” 部署的 planned 标记是否可信

🔑 四指标是团队自省的仪表盘,不是公司排名的计分板。一旦用于排名,被度量者会去优化口径而不是优化交付——机制见 59.3 度量的陷阱。

边界:什么时候别用这套口径

  • 同一 sha 被反复部署(配置漂移修复、重启):每次重部署都算部署,频率会被虚高,需要按“变更集”去重。
  • 移动端 / 客户端:商店审核 1–3 天,前置时间里混入外部等待,应该单独度量“提交→提审”和“提审→放量”。
  • 库 / SDK:没有“生产部署”这个动作,只能度量发布节奏与下游采用率。
  • 跨团队求平均:不同应用、不同约束,平均之后既不能定位也不能行动,DORA 官方明确建议按应用/服务分别度量。

反模式

反模式 后果
把四指标做成团队排行榜 被度量者刷部署、藏失败、改口径,数字变好交付没变
只报部署频率 拆发布、重复部署即可翻倍,且掩盖了批量变小还是真变快
用平均恢复时长 长尾被平均吃掉;一次 8 小时的故障能被 20 次 20 分钟抵消
前置时间起点定在“需求提出” 与 DORA 口径混用,跨季度不可比,改进 CI 看不出效果
CFR 分母随手改(含/不含热修) 数字漂移 1–3 个百分点,趋势线变成锯齿
跨团队/跨产品求平均 差异被抹平,既不能定位瓶颈也不能分配改进
只采集不行动 采集成本照付,团队只学会“填数”,三个月后数据失真
把部署频率写进 OKR 目标化的指标必然被优化,频率涨、价值不变(见 59.3)

关联

自检

Q1 团队把部署频率从每周 1 次提到每天 5 次,CFR 从 6% 升到 14%,恢复时长从 30 分钟降到 20 分钟。这是变好还是变坏?

▸ 答案:先算单位时间的失败次数:1×6% = 0.06 次/周,5×14% = 0.7 次/周,失败次数涨了约 11 倍;但每次失败的影响面更小、恢复更快。判定要看单次失败的后果:如果失败都是小问题、灰度能拦住、用户无感,这是“用更快的反馈换更高的失败概率”,整体是变好;如果有资损或数据错乱,先降 CFR。关键不是百分比,而是“失败次数 × 单次影响”。

Q2 为什么 DORA 的变更前置时间从 commit 算起,而不是从需求提出算起?

▸ 答案:它要测的是交付管道的效率。需求提出到 commit 之间由优先级、澄清、排期决定,属于需求侧;混进来之后,“改 CI/CD”的效果会被需求等待淹没,指标失去诊断力。需求侧的时间要单独度量(见 59.2 交付周期与吞吐)。

Q3 为什么“恢复时长”不能继续叫 MTTR?

▸ 答案:传统 MTTR 是“平均修复时间”,把所有故障都算进去,包括机房断电、上游依赖故障——那些与“变更交付能力”无关。2023 年 DORA 改成“失败部署恢复时长”:只算由变更引起、需要立即介入的失败,起点是被发现,终点是服务恢复。口径变了,历史 MTTR 数据不能直接接着比。

Q4 五个指标里,哪个最容易被“做”成好看的数字?防线是什么?

▸ 答案:部署频率。拆发布、同一 sha 重复部署、加一次空跑都能翻倍,成本最低。防线是配对看:每次部署的变更数、返工率、CFR;频率涨而每次部署变更数骤降,多半是在拆发布(推演见 59.3)。

Q5 团队 CFR 从 12% 降到 5%,但返工率从 4% 升到 18%,发生了什么?

▸ 答案:失败被重新分类,不是消失。回滚少了,但“计划外返工部署”多了:问题在部署后暴露,团队用“修复发布”而不是“回滚”处理,于是不计入 CFR。这正是 2024 年增加返工率的原因——CFR 只是返工的代理指标,两个一起看才能发现“数字搬家”。

Q6 本节的样例数据里,前置时间中位 23.5 小时、P85 64 小时。对业务承诺“一周内上线”够不够?

▸ 答案:一周 = 168 小时,P85 是 64 小时,看起来够;但要看承诺的是哪一批变更。如果是含周末、含审批的长尾批次,P95 可能已经接近 75 小时(样例数据 P95 = 74.6 小时),再叠加故障与返工就会破 168。承诺应该用 P85/P95 加缓冲,并且明确“从 commit 起算”还是“从需求受理起算”——两者的差可能是几天。

Q7 一家 iOS 应用为主的公司,DORA 指标还适用吗?怎么改口径?

▸ 答案:适用,但要把“部署”拆成两段:提交 → 提审(团队可控,用 DORA 口径)与提审 → 商店放量(外部约束,单独看时长与拒绝率)。服务端配置、灰度放量仍可按标准口径度量。直接把商店审核时间算进前置时间,会让团队为“审核慢”背锅,也让改进方向失真。

Q8 为什么四指标不能压成一个“研发效能分”?

▸ 答案:因为统计上吞吐与稳定是两个独立因子,压成一个加权分,等于假设它们可以互相补偿——而现实里“高频发布 + 高失败率”和“低频发布 + 低失败率”可以是同一个分数的两种完全不同的团队,改进方向相反。分数适合对外汇报,不适合对内诊断。