季度会上有人放了一张图:缺陷密度比去年同期下降 30%。没人反驳数字,但也没人相信结论。理由随手就能凑齐——重构砍掉了三分之一代码,需求拆得比去年细,还有一批问题被登记成"体验优化"。三个说法都成立,说明这个比值已经不能单独承载"质量变好"的判断。
缺陷密度不是测量,是除法。分子分母都能被口径搬动,搬动之后趋势就从观测量变成叙事工具。这篇只谈一件事:把密度用于跨年比较时,先固定什么、放弃什么、什么时候干脆不用。
1. 分母:四种算法,四个偏差方向
同一批缺陷,换分母能得到三种相反的结论。
| 分母 | 算法 | 偏差方向 | 什么时候"自动变好" |
|---|---|---|---|
| 代码行(KLOC) | 缺陷数 / 千行存量 | 分母是存量,新增代码的问题被摊薄 | 删代码、抽公共库、生成代码与注释被计入 |
| 功能点 / 需求数 | 缺陷数 / 已交付条目 | 粒度由拆单习惯决定 | 把一个大需求拆成十个条目 |
| 变更量(churn) | 缺陷数 / 变更行数 | 分母随开发强度波动 | 一次大重构把分母顶起来 |
| 时间(人月 / 迭代) | 缺陷数 / 单位时间 | 分母受产能与休假影响 | 扩编、并行迭代变多 |
代码行口径最危险的一点:删代码会让密度变差。一次成功的收敛(删掉重复实现)在报表上表现为质量下降,这会直接压制重构意愿。反过来,把注释、生成代码、迁移脚本都计入分母,等于给自己发了一张免费的改善券。
密度 = 缺陷数 / 分母
// 分母的选择不看精度,看"能不能被同一批人搬动"
// 存量口径:适合看长期技术债,不适合看版本质量
// 变更口径:适合看"这段改动有多脏",不适合跨团队比较
// 时间口径:适合看趋势斜率,绝对值没有意义
选口径的判据只有一条:分母要尽量不受被考核方的行为影响。存量代码行受重构影响,需求条目受拆单影响,变更行受提交粒度影响,时间受排期影响——四个都不干净,只能选最难操纵的那个,并把它写进文档。
实务上更稳的做法是双分母并行:变更口径看版本质量,时间口径看长期斜率。两条线同向,结论可以采信;一条改善一条恶化,说明改善发生在口径而不是发生在代码里。单条曲线永远可以讲出故事,两条互相牵制的曲线很难同时撒谎。
2. "下降"的三种解释:先证伪,再采信
密度下降有三个都成立的解释,只有第一个是好消息。
| 解释 | 伴随证据 | 证伪方式 |
|---|---|---|
| 质量真的变好 | 逃逸率同步下降、复发率下降、修复时长缩短 | 三个伴随指标至少两个同向 |
| 缺陷被发现得晚了 | 生产缺陷占比上升、单次修复时长上升、事故数上升 | 按发现阶段拆分密度曲线 |
| 统计口径变了 | 分母定义、归因规则、登记入口之一被改过 | 口径变更必须留版本与生效日期 |
第二种解释最容易被误读成好消息:编码期缺陷少、生产缺陷多,总密度可能持平甚至下降,因为难发现的问题本来就更少。判断方法只有按发现阶段拆开——编码期、测试期、预发、生产四段,任何一段的占比突变都说明密度曲线换了含义。
─── 口径快照,随报表一起归档
metric: defect_density
version: 3
effective_from: 2026-01-01
numerator: defects_filed // 按单计数,不按严重度加权
denominator: churn_lines // 变更行数,含删除、排除生成代码
scope: [repo_a, repo_b]
attribution: fix_location // 归修复位置;引入位置另存一列
长期统计要固定的不是数字,而是口径。口径变一次,历史曲线就断一次;断点必须标注,不能把两段拼成一条"持续改善"的线。实操做法:口径版本进报表页脚,口径变更次数本身做成一个指标——一年改三次口径的团队,密度趋势的可信度接近零。
─── 拼接检查:一条曲线能不能跨过断点读
断点 2025-07:分母从 KLOC 改为 churn_lines
断点 2026-01:归因从 fix_location 改为 dual_field
// 允许:分段比较(断点前后各算各的斜率)
// 禁止:把断点前后的点连成一条线并标注"同比"
// 需要:断点当季单独出图,标注受影响幅度
断点处理有个便宜的办法:在断点季度同时报告两套口径,用一个季度的重叠样本估算口径差。重叠缺失时,只能承认两段不可比,这比假装连续更省事。
3. 归因:分子是怎么被造出来的
分母决定尺度,分子决定方向。缺陷单的归因规则比统计工具更能左右结论。
| 陷阱 | 表现 | 对趋势的影响 | 处理 |
|---|---|---|---|
| 一次改动报多个单 | 同一根因拆成三个现象单 | 分子虚高,修完"大幅改善" | 按根因合并,另记根因单 |
| 跨模块缺陷归给谁 | 引入在 A 模块、暴露在 B 模块 | 热点模块背锅,源头隐形 | 双字段:引入位置 + 暴露位置 |
| 环境问题算不算 | 配置、依赖、数据问题计入或不计 | 计入则基础设施差被记成代码差 | 单列"环境类",不并进代码密度 |
| 严重度主观 | 定级靠开会争 | 等级结构变化伪装成质量变化 | 趋势只看单数,等级只用于排优先级 |
严重度的污染是隐性的:定级标准一旦收紧(把所有 P2 降成 P3),加权密度立刻下降,而缺陷一个没少。趋势统计用计数,不用加权分;加权分留给资源分配。
─── 归因双字段:让"引入位置"和"暴露位置"同时可查
SELECT introduced_module, exposed_module, COUNT(*) AS n
FROM defect
WHERE found_stage = 'prod'
GROUP BY introduced_module, exposed_module
ORDER BY n DESC;
// 两个位置差异大的组合 = 耦合与测试缺口同时存在的地方
把密度当考核指标,反噬是可预测的:隐匿不报、拆单、降级、把缺陷改称"需求变更"。这四种行为都不违反流程,只让数据失去解释力。所以密度的正确位置是观测,不是目标。真要考核质量,换成一组更贴近后果的量:
─── 密度按发现阶段分解,判断"下降"发生在哪一段
stage code test staging prod
defects 83 61 12 9
density 0.41 0.30 0.06 0.04
// code 段下降而 prod 段上升 = 发现点后移,不是质量改善
// 四段同时下降才算质量真的变好
| 替代度量 | 定义 | 回答的问题 |
|---|---|---|
| 逃逸率 | 生产发现缺陷 / 全部缺陷 | 门禁是否有效 |
| 修复时长 | 从登记到关闭的中位数 | 响应能力,不随单数波动 |
| 复发率 | 同源缺陷再犯 / 已归纳根因的缺陷 | 改进是否落地 |
| 同源聚集 | 单一模块缺陷占比 | 风险是否集中 |
4. 排错:读数反直觉时的五种情况
| 现象 | 更可能的原因 | 处理 |
|---|---|---|
| 密度骤降但事故没少 | 分母被重构顶起,或归因改了口径 | 拉分母曲线与口径版本对照 |
| 某模块密度极高 | 它承接了别的模块的问题 | 看引入位置分布,别只看修复位置 |
| 人均缺陷数下降 | 扩编,或缺陷进了暗账 | 用绝对单数与逃逸率交叉验证 |
| 密度平稳但交付变慢 | 缺陷变重、修复变长,单数掩盖成本 | 引入修复时长与返工率 |
| 阈值沿用三年没人质疑 | 度量老化 | 每年按当年分布重算分位 |
最后一条值得单独说:阈值会老化。三年前"密度低于 0.8 算健康"是从当时的分布切出来的;语言、结构、测试手段全变之后,这个数只剩仪式意义。阈值必须每年从当年分布重算,并接受它会上移——上移不是退步,是分母与工具一起变了。
5. 边界:什么时候这套统计不值钱
| 场景 | 密度统计的解释力 | 该换成什么 |
|---|---|---|
| 团队少于三人 | 接近零,单数波动主导 | 直接看事故与修复时长 |
| 缺陷单本身不规范 | 低,归因字段常年空着 | 先修登记流程,再谈统计 |
| 系统处于重写期 | 负值,旧代码与新代码混算 | 新旧两套代码分别记 |
| 变更量年际差异超一倍 | 低,分母主导趋势 | 用时间口径看斜率 |
| 只有半年数据 | 低,跨不过一个版本节奏 | 至少覆盖两个完整发布周期 |
统计周期要长到能跨过版本节奏:单个版本的缺陷分布受排期、假期、大客户上线影响,单版本比较几乎必然误判。两个完整周期是下限;跨年比较才有决策价值。还有一条容易被跳过——缺陷与变更量是乘法关系,密度下降常常只是因为变更量涨了。看到改善,先把分母画出来。
─── 用密度前先过的四道判据
usable: false
reasons:
- 缺陷登记字段完整率 < 80% // 归因字段常年空着
- 数据跨越的发布周期 < 2 // 跨不过一个节奏
- 期间口径变更次数 >= 2 // 曲线已断
- 团队规模 < 3 // 单数波动主导
// 任一条命中:密度只作为叙事,不作为判据
四道判据里最容易忽略的是第一条:缺陷单本身不规范时,所有统计动作只是在给噪声加权。先修登记流程,再谈密度。
6. 自测
- 用代码行做分母,一次成功的重构会让密度朝哪个方向走?为什么这会压制重构意愿?
- 密度下降而事故数上升,最可能对应三种解释里的哪一种?用什么拆分方式确认?
- 为什么说"口径变更次数"本身应该是一个指标?
- 跨模块缺陷只记修复位置,会系统性地高估哪个模块的风险?
- 严重度加权分为什么不适合用来做趋势判断?
- 团队只有三人时,密度统计该被替换成哪两个量?