59.2 交付周期与吞吐

一句话

交付周期是一条分布,不是一个平均数;吞吐由瓶颈决定,不由总人数决定;两者被利特尔法则锁死(W = L / λ),所以想同时要短周期和高吞吐,杠杆只有两个:降在制品或提瓶颈产能。

三种“周期”别混着用

名称 起点 终点 回答什么问题 谁在用
需求前置时间 需求受理 生产可用 用户从提需求到能用要多久 业务、产品
变更前置时间(DORA,口径见 59.1 DORA 四指标) 提交进版本库 生产可用 交付管道本身有多快 工程团队
开发周期(cycle time) 开始动工 完成 看板上的流动是否顺畅 团队日常

三者的差值就是等待:需求前置时间 − 变更前置时间 ≈ 排队等排期、等澄清、等评审;变更前置时间 − 开发周期 ≈ 等合并、等流水线、等发布窗口。改进要先看差在哪一段,否则会把“等排期”的锅算到 CI 头上。

🔑 统一口径比精确数字重要:同一张图上的两条趋势线,若起点定义变过,趋势就是假的。指标定义写进看板说明,改口径要像改 API 一样公告。

为什么必须看分布

本节的样例数据(12 周、174 件完成,仿真实测见 59.3 度量的陷阱):

分位 小时 天 含义
P50 80.7 3.4 一半的工单在此时间内完成
P85 160.2 6.7 85% 的工单在此时间内完成
P95 193.0 8.0 95% 的工单在此时间内完成
均值 96.3 4.0 被长尾拉高,不代表“典型体验”
最长 299.9 12.5 一条工单拖了三周半

均值和 P50 差 20%,P85 是 P50 的 2 倍。对业务承诺用均值,等于承诺了一个 40% 的工单都达不到的时间;用 P85 才是“十次有八次能兑现”。

利特尔法则:三个数被一个等式锁住

在稳态下,排队系统里长期平均:

L = λ × W
L:系统内的平均工作项数(在制品,WIP)
λ:平均吞吐率(件/周)
W:平均交付周期(周)

推导只需一步:把时间切成小段,Σ 工单耗时 = ∫ 在制品数 dt。两边同除以窗口长度 T,左边是“平均工单耗时 × 吞吐”,右边是“时间平均在制品数”,于是 W = L / λ(公式前提与排队论推导见 34.2 性能建模)。

它给的是算术约束,不是建议:

想达到 必须满足 在本节样例(λ = 14.5 件/周)
平均周期 ≤ 2 天 WIP ≤ 14.5 × 2/7 ≈ 4.1 件 当前 7.6 件,要砍掉近一半
平均周期 ≤ 3 天 WIP ≤ 6.2 件 当前偏高超 20%
吞吐翻倍(14.5 → 29 件/周) 瓶颈产能翻倍,或 WIP 翻倍(周期也翻倍) 加人若不动瓶颈,只是把队列变长

它什么时候不成立

失效条件 表现 处理
非稳态 刚加人、刚改流程、需求突增,WIP 在剧烈变化 只用长期平均;看趋势不看单周
窗口不一致 用 12 周算吞吐、用排空后的窗口算周期 三个量取同一时间窗(本节实测正是这个坑)
工作项类型混装 大需求和小 bug 混在一起算平均 按类型分层,或统一折算后声明折算规则
只统计已完成 卡住不动的工单被排除,周期看起来很好 用“截至今天的存活时间”纳入未完成项
返工重开 同一需求做两遍,只算一次 每段开工到完成各计一次,或单列返工率

吞吐由瓶颈决定,不由总人数决定

约束理论(TOC)的一句话版本:系统的产出等于瓶颈的产出,非瓶颈上的改善不增加产出,只增加库存。

瓶颈位置 加开发的后果 正确动作
需求澄清 开发等需求,WIP 堆在“待开发” 加产品/分析,或先做需求就绪定义
代码评审 提交排队等评审,周期长尾 缩小 PR、评审 SLA、配对评审
测试环境 环境排队,集成等待 环境即服务、按需隔离
发布审批 批量变大,前置时间上升 风险分级、自动化放行(见 55.1 发布策略)

🔑 判断瓶颈的方法不是猜,是看队列堆在哪:看板上某一列长期堆积、且下游经常空闲,那一列就是约束。加人加到非瓶颈上,只会让在制品更多、周期更长。

利用率陷阱:把人排满,周期爆炸

单队列、单服务台的平均排队时间(以服务时间为单位):

Wq / S = ρ / (1 − ρ)      ρ = 利用率
利用率 ρ 平均排队时间(× 服务时间) 直观解释
50% 1.0 来一件做一件,几乎不排
70% 2.3 开始有感
80% 4.0 排队时间是干活的 4 倍
90% 9.0 周期是服务时间的 10 倍
95% 19.0 一点抖动就雪崩
98% 49.0 实际上已经不可用

多服务台(c 个并行开发位)会把这个曲线压平一些,但不会改变形状:接近饱和时,等待时间仍然发散。所以“提升资源利用率”这个财务指标,和“缩短交付周期”在 80% 以上是直接冲突的。

仿真实测:周期、WIP 与利特尔法则

生成 12 周的工单流:6 个并行开发位,服务时长对数正态(中位 2.2 天),前 6 周到达 1.6 件/天、后 6 周 2.3 件/天。本机实测输出(Python 3.14.8):

12 周完成 174 件;吞吐 λ = 14.50 件/周
时间加权平均在制品 L = 7.61 件
交付周期(小时):
  P50    80.7   约  3.4 天
  P85   160.2   约  6.7 天
  P95   193.0   约  8.0 天
  均值   96.3   约  4.0 天;最长 299.9 小时
  排队等待占周期 39%;中位等待 14.1 小时
窗口:首件到达到最后一件完成 = 91.7 天(比 84 天多出排空时间)
利特尔法则(同窗口):L/λ_T = 7.61/13.28 = 96.3 小时,实测均值 96.3 小时
若错用 84 天窗口:88.2 小时 —— 与实测差 -8%,是窗口不一致,不是法则失效
若吞吐不变、在制品减半:W ≈ 48.2 小时(2.0 天)

读法:

  1. 利特尔法则精确成立(96.3 vs 96.3),但用错窗口就出现 8% 的“偏差”。现实里团队经常拿“本季度吞吐”配“上季度周期”,误差不是法则的错,是口径的错。
  2. 等待占 39%:即使没有大排队,仍有近四成时间在等。VSM 的典型结论是等待占 70%–95%,这个仿真已经算“流畅”的了;改进先砍等待。
  3. 在制品减半 → 周期减半(96.3 → 48.2 小时),前提是吞吐不变。如果砍 WIP 时把开发位也砍了,吞吐会掉,法则照样成立,只是两边一起变差。

生成与统计的完整脚本:

#!/usr/bin/env python3
"""flow_stats.py —— 事件仿真生成工单流,再算吞吐 / 时间加权 WIP / 周期分布。"""
import heapq
import math
import random
from statistics import median

random.seed(59)
C, DAYS, BASE, BUMP = 6, 84.0, 1.6, 2.3     # 6 个开发位;12 周;前后两段到达率
MEDIAN_SERVICE = 2.2                        # 单件服务时长中位数(天)


def service_days():
    return math.exp(math.log(MEDIAN_SERVICE) + random.gauss(0, 0.55))


def simulate():
    t, items, events = 0.0, [], []
    while t < DAYS:
        t += random.expovariate(BASE if t < 42 else BUMP)
        if t >= DAYS:
            break
        items.append({"id": len(items), "arrive": t, "start": None, "done": None})
        events.append((t, "arrive", items[-1]["id"]))
    heapq.heapify(events)
    by_id = {it["id"]: it for it in items}
    queue, free = [], C
    while events:
        t, kind, i = heapq.heappop(events)
        if kind == "arrive":
            if free:
                free -= 1
                by_id[i]["start"] = t
                heapq.heappush(events, (t + service_days(), "done", i))
            else:
                queue.append(i)
        else:
            by_id[i]["done"] = t
            if queue:
                j = queue.pop(0)
                by_id[j]["start"] = t
                heapq.heappush(events, (t + service_days(), "done", j))
            else:
                free += 1
    return [it for it in items if it["done"] is not None]


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)


items = simulate()
weeks, n = DAYS / 7, len(items)
throughput = n / weeks

# 时间加权平均在制品:每条工单在系统内的区间对时间积分
points = sorted({p for it in items for p in (it["arrive"], it["done"])})
area = sum(sum(1 for it in items if it["arrive"] <= (a + b) / 2 < it["done"]) * (b - a)
           for a, b in zip(points, points[1:]))
avg_wip = area / (points[-1] - points[0])

cycle = [(it["done"] - it["arrive"]) * 24 for it in items]
wait = [(it["start"] - it["arrive"]) * 24 for it in items]

print(f"12 周完成 {n} 件;吞吐 λ = {throughput:.2f} 件/周")
print(f"时间加权平均在制品 L = {avg_wip:.2f} 件")
print("交付周期(小时):")
for p in (0.5, 0.85, 0.95):
    print(f"  P{int(p*100):<3} {pct(cycle, p):>6.1f}   约 {pct(cycle, p)/24:>4.1f} 天")
print(f"  均值 {sum(cycle)/n:>6.1f}   约 {sum(cycle)/n/24:>4.1f} 天;最长 {max(cycle):.1f} 小时")
print(f"  排队等待占周期 {sum(wait)/sum(cycle):.0%};中位等待 {median(wait):.1f} 小时")

t0, t1 = min(it["arrive"] for it in items), max(it["done"] for it in items)
lam_T = n / ((t1 - t0) / 7)
correct = (avg_wip / lam_T) * 7 * 24
wrong = (avg_wip / throughput) * 7 * 24
print(f"窗口:首件到达到最后一件完成 = {t1-t0:.1f} 天(比 84 天多出排空时间)")
print(f"利特尔法则(同窗口):L/λ_T = {avg_wip:.2f}/{lam_T:.2f} = {correct:.1f} 小时,"
      f"实测均值 {sum(cycle)/n:.1f} 小时")
print(f"若错用 84 天窗口:{wrong:.1f} 小时 —— 与实测差 {(wrong/(sum(cycle)/n)-1)*100:.0f}%,"
      f"是窗口不一致,不是法则失效")
print(f"若吞吐不变、在制品减半:W ≈ {correct/2:.1f} 小时({correct/2/24:.1f} 天)")

怎么用:三个数、两张图、一条公式

要回答的问题 看什么 判据
该承诺多久 P85 / P95 承诺值 = P85 + 缓冲,不用均值
现在堵在哪 累积流图(CFD)中变宽的列 最宽且持续增长的那列是约束
该降到多少 WIP WIP = λ × W目标 例:λ = 14.5/周、目标 3 天 → WIP ≈ 6.2
改进有没有效 周期分布 + 吞吐一起看 周期降但吞吐崩了 = 砍了产能,不是改进(改进顺序见 59.4 效能改进方法)

WIP 上限不是“不超过人数”,而是由目标周期反算:WIP = 吞吐 × 目标周期(排期上的用法见 1.3 需求优先级)。上限设好之后,只允许“做完一件、拉入一件”,不允许“有空位就塞”。

反模式

反模式 后果
用平均交付周期对业务承诺 一半以上的工单超时;长尾被平均掩盖
把利用率提到 90% 以上当目标 排队时间放大 9–19 倍,周期爆炸、故障恢复也更慢
瓶颈在评审时加开发 WIP 上升、周期变长、吞吐不变(TOC:非瓶颈改善不增产)
WIP 上限按人数拍 上限与目标周期脱钩,等于没有约束
只统计“开发动工→完成” 漏掉排队与等待,通常是总周期的 60% 以上
大需求和小 bug 混算周期 均值随需求构成波动,趋势线无意义
用故事点/人天当吞吐 单位不可比,跨迭代“提速”是估算口径变了
三个数取自不同窗口 利特尔法则“失效”,得出“降 WIP 没用”的错误结论

关联

自检

Q1 团队平均交付周期 6 天,P85 是 14 天。业务要求“两周内必须上线”,现在能满足吗?

▸ 答案:不能按“平均 6 天”回答。P85 = 14 天意味着约 15% 的工单会超过两周;如果业务要求是“绝大多数按时”,至少要看到 P95。要么把承诺改成 P85 口径并补缓冲,要么降 WIP 把整条分布左移——只压均值不改分布,尾部照样违约。

Q2 经理想“提高吞吐”,给团队加了两名开发。按本节模型,什么情况下吞吐不变、什么情况下反而下降?

▸ 答案:瓶颈不在编码时吞吐不变——新增的人只会让“待评审/待测试”队列更长,WIP 上升、周期变长。若新人还需要老成员带,短期内瓶颈产能反而下降(指导占用),吞吐会先跌后升。正确顺序:先找队列堆在哪,再决定加谁。

Q3 为什么把利用率从 70% 提到 90%,交付周期会涨 4 倍以上?

▸ 答案:按 Wq/S = ρ/(1−ρ),70% 时排队是服务时间的 2.3 倍,90% 时是 9 倍;周期 = 服务 + 排队,所以从 3.3 倍涨到 10 倍,约 3 倍。再叠加需求波动和服务时间波动,实际更差。利用率越高,系统对波动的吸收能力越弱——这也是故障恢复变慢的原因。

Q4 本节实测里,用 84 天窗口算出的 L/λ 比实测均值低 8%。这说明利特尔法则错了吗?

▸ 答案:没有错,是窗口不一致:λ 用了 84 天(含尚未排空的尾部),而平均周期只统计了已完成工单,这些工单的完成时间拖到了 91.7 天。三个量必须取同一窗口。修正后 7.61/13.28×168 = 96.3 小时,与实测完全一致。

Q5 团队把 WIP 从 12 降到 6,周期从 8 天降到 5 天,没有降到 4 天,为什么?

▸ 答案:利特尔法则给的是稳态下的比例关系,前提是吞吐不变。降 WIP 的过程中吞吐通常会先掉一点(开发位短暂空闲、批次变小带来额外切换),稳态要几周才到;另外 8 天里若有 30% 是外部等待(等审批、等环境),这部分不随 WIP 变化。先把不变的等待剔掉,再看比例是否成立。

Q6 看板上“待评审”一列长期堆 30 多个 PR,“开发中”只有 5 个。吞吐的约束在哪?该动什么?

▸ 答案:约束在评审。开发不是瓶颈,加开发只会让待评审队列更长。动作顺序:① 限制同时在评审的 PR 数(WIP 上限);② 缩小 PR 体积、设评审 SLA;③ 增加评审能力(轮值、配对评审);④ 若评审是必要的质量门禁,考虑自动化替代部分人工检查(静态检查、契约测试)。

Q7 为什么不能拿“故事点/周”当吞吐,再和别的团队比?

▸ 答案:故事点是团队内部的相对估算,锚点、拆分粒度、包含的隐性工作都不同;两个团队的“20 点/周”不可比。吞吐要用可数的完成项(需求数、PR 数、部署数),并注明工作项类型的构成;跨团队比较只能用 DORA 这类有统一采集口径的指标,且仍要按应用分别看。

Q8 需求前置时间 30 天、变更前置时间 2 天,改进应该先做哪段?

▸ 答案:先做 30 天那段。它占总周期的 94%,即使把交付管道从 2 天压到 1 天,总周期也只从 30 降到 29。要在 30 天里找等待:排期队列、澄清往返、跨团队依赖。判断依据是哪一段的等待最长,不是哪一段最容易改。