一个 == 让优惠券发错:编码规范在防什么,以及我为什么在 CR 里破过阿里的规矩

0 阅读1小时+

2024 年 3 月 14 日下午四点,运营在群里甩了张对账截图:那批新上的复诊券,同一批用户里有 317 个人领到了两张。活动配置明明写着「每人限领一张」,代码里也确实有判断。

我把那行翻出来的时候,盯着看了大概十秒才反应过来:

// CouponChecker.java · 模块 M
if (coupon.getBatchId() == rule.getBatchId()) {
    return REJECT_ALREADY_CLAIMED;
}

两个 getBatchId() 返回的都是 Integer。批次号在 127 以内的时候,这行一直是对的——Integer.valueOf 会从缓存里拿同一个对象,== 比引用,恰好相等。那批复诊券的批次号从 128 开始,两个对象在堆上各占一块,判断恒为 false,闸门等于没关。测试环境用的夹具数据是 12、13、14,跑了两周全绿。

修就是加个 equals,两分钟。后面的事花了两天:把 317 笔重复申领逐笔对账、给客服写话术、把已核销的补发改成等额券、复盘会上解释为什么一个「看起来像小疏忽」的写法能漏过自测、CR 和 CI 三层。

《阿里巴巴 Java 开发手册》OOP 规约里那条我背得出来:「所有整型包装类对象之间值的比较,全部使用 equals 方法比较」,说明里连 -128 至 127、IntegerCache.cache、区间外在堆上产生都写清楚了。我们项目 2020 年就把它贴进了 CR 模板,结果还是漏了。那天之后我较真了两件事:规范到底在防什么,以及为什么贴了模板还防不住。

后来我又在 CR 里明确破过手册的三条规矩,每次都附了 JMH 数据和一份到期日。这两件事不矛盾,但要讲清楚得从 == 这个符号说起。

一个 == 背后,是两套语义

Java 里 == 从来不是一个意思,它看左边是什么类型。

操作数是基本类型时,比的是值;是引用类型时,比的是两个引用是否指向同一个对象。这个分叉在语言规范里写得清清楚楚,但写代码的人脑子里往往只有「相等」一个词。自动装箱把这个分叉藏得更深:源码里写 a == b,编译期就被改写了。

我拿那次事故最小复现做了一次 javap -c,把 .class 摊开看:

// IntegerEqDemo.java
public static boolean same(Integer a, Integer b) {
    return a == b;
}

public static boolean sameValue(int a, int b) {
    return a == b;
}

public static boolean boxedCompare(int a, int b) {
    Integer x = a;   // 编译器改成 Integer.valueOf(a)
    Integer y = b;
    return x == y;
}
Compiled from "IntegerEqDemo.java"
  public static boolean same(java.lang.Integer, java.lang.Integer);
    Code:
       0: aload_0
       1: aload_1
       2: if_acmpne     9        // 引用比较,不看数值
       5: iconst_1
       6: goto          10
       9: iconst_0
      10: ireturn

  public static boolean boxedCompare(int, int);
    Code:
       0: iload_0
       1: invokestatic  #2       // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       4: astore_2
       5: iload_1
       6: invokestatic  #2       // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       9: astore_3
      10: aload_2
      11: aload_3
      12: if_acmpne     19       // 还是引用比较
       ...

valueOf 里藏着那个决定命运的分支:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

IntegerCache.cache 是一段静态初始化的数组,默认覆盖 -128 到 127。落在这个区间里,两次装箱返回的是同一个数组元素,于是 == 碰巧成立;出了区间,两次 new,== 必然为 false。所以这不是「偶尔出错」,是正确与否取决于数据分布——比稳定出错更难查,因为测试数据和线上数据的分布往往不一样。

更阴的一层是上限可配。HotSpot 上 -XX:AutoBoxCacheMax=1000 能把 high 调大,同一个包在开发机、压测环境、生产容器上可能表现出三种结果。我在复盘里专门留了一行:这个 bug 在任何单一环境里都不一定复现得了,它是「环境 × 数据」的交叉产物。

Integer.equals 就老实得多:

public boolean equals(Object obj) {
    if (obj instanceof Integer) {
        return value == ((Integer)obj).intValue();
    }
    return false;
}

先 instanceof 挡掉类型不对的,再拆箱比 int。跨类型时 Integer(1).equals(Long(1L)) 是 false,== 在包装类型之间则根本编译不过(除非两边能拆箱成同类型)——这也是为什么手册那条要连着「浮点数不能用 ==」「包装类型不能用 equals 比浮点」一起读。JDK 7 之后的 Objects.equals(a, b) 又多包了一层空判断,正好对上另一条规约:用常量或确定有值的对象调用 equals,别让 object.equals("test") 在 object 为 null 时炸。

flowchart LR
  A[&#34;源码 a == b<br/>心里想的是『数值相等』&#34;] --> B[&#34;编译期:自动装箱<br/>Integer.valueOf(a)&#34;]
  B --> C{&#34;值落在<br/>IntegerCache 区间?&#34;}
  C -->|&#34;是&#34;| D[&#34;返回同一个缓存对象<br/>== 为 true,碰巧对&#34;]
  C -->|&#34;否&#34;| E[&#34;各 new 一个 Integer<br/>== 为 false,闸门失效&#34;]
  D --> F[&#34;测试数据 12/13/14<br/>两周全绿&#34;]
  E --> G[&#34;线上批次号 ≥ 128<br/>317 笔重复申领&#34;]

到这里可以回答「规范在防什么」的第一层:它防的不是「写得丑」,是语言里那些合法、能编译、在部分输入下正确、换批数据就错的写法。这类写法没法靠「更仔细」解决,因为人的直觉里 == 就是相等;只能靠一条机械规则把这个写法从可选项里删掉。

bug 从哪来:把「没想过的状态」画出来

我习惯把 bug 定义得窄一点:程序进入了一个作者没有预期、也没有处理的状态。按这个定义,编码规范干的事就是收窄可表达的状态空间——不是教你怎么写对,是让一批「写错的可能」压根表达不出来。

摊开看,绝大多数由编码方式直接导致的缺陷落在三类里。

第一类是语义歧义:同一个符号或写法对应两种以上含义,作者和编译器想的不是一回事。== 是最典型的,另外还有 equals / hashCode 契约不一致导致 HashSet 里存进「相等」的两个对象、isXxx 命名的 POJO 属性被序列化框架当成另一个字段、long 字面量末尾写成小写 l 被看成 1、三目运算符两边类型不一致触发隐式拆箱 NPE。手册里这些条目几乎都标了【强制】,因为编译器不会替你报错。

第二类是隐式行为:代码看似只操作了一个东西,实际牵动了另一个。Arrays.asList() 返回的是数组适配器,改原数组 list.get(0) 跟着变;subList 是原集合的视图,强转成 ArrayList 直接 ClassCastException,对原集合做结构性修改再读子列表会抛 ConcurrentModificationException;SimpleDateFormat 定义为 static 之后多线程共享一个 Calendar 内部状态,日期串偶尔串到别的年份;ThreadLocal 在线程池里不 remove(),下一个任务读到上一个用户的信息。这些都不是「不会用 API」,是 API 的默认行为超出了写字那行时的心理模型。

第三类是时序与顺序依赖:单看每段都对,放一起就错。foreach 里 remove 触发 modCount 与迭代器期望值不一致;finally 里 return 把 try 的返回值盖掉;多个线程对同一资源加锁顺序不一致造成死锁;ContentProvider 的安装早于 Application.onCreate,把初始化写在后者就晚了半拍。

三类加起来,规范真正约束的对象其实是这个:

flowchart TB
  subgraph L[&#34;语言与 API 允许的全部写法&#34;]
    A1[&#34;语义歧义写法&#34;]
    A2[&#34;带隐式副作用的写法&#34;]
    A3[&#34;有时序依赖的写法&#34;]
    A4[&#34;业务语义本身写错&#34;]
  end
  subgraph S[&#34;编码规范覆盖&#34;]
    B1[&#34;强制项:删掉写法&#34;]
    B2[&#34;推荐项:给出默认解&#34;]
    B3[&#34;可静态检查项:交给机器&#34;]
  end
  A1 --> B1
  A2 --> B2
  A3 --> B3
  A4 --> C[&#34;规范管不到<br/>靠契约、测试、监控&#34;]
  B1 --> D[&#34;缺陷在编辑期/CR 被拦&#34;]
  B2 --> D
  B3 --> D

右下角那块我特意留着。优惠券门槛算错、状态机漏了一个分支、把「已核销」当成「已过期」,这些代码完全符合任何一条编码规范,静态扫描一个字都不会报。规范能消灭的是已知类别的写法缺陷,不是全部 bug。把这两件事混为一谈,团队就会在「插件零告警」的喜悦里漏掉真正致命的业务错误——我们那次重复申领,第一层是 == 的写法缺陷,第二层其实是「限领判断没做服务端幂等」,后面那一层规范一点忙都帮不上。

一个 128 走完全程

前几篇排查性能问题时我常做一张对照表,把同一个对象在不同层面的形态摆在一起。这次也照做,只不过对象是个数字。

出现的地方它是什么谁决定它的含义那次事故里的表现
源码 rule.getBatchId()一个 Integer 引用的声明写 DTO 的人看着跟 int 没区别
编译后的字节码Integer.valueOf 的调用 + if_acmpnejavac 的自动装箱规则数值比较悄悄变成引用比较
运行时堆上缓存数组元素,或者一次 new IntegerIntegerCache.high 与当前值127 以内共用一个对象,128 起各有一块
单测夹具12、13、14 三个字面量写测试的人凭手感挑的全落在缓存区间,两周全绿
灰度数据库batch_id = 128 开始的活动配置运营建批次时分配的序号第一次让判断恒为 false
工单 M-2417「317 笔重复申领」对账脚本损失口径从代码问题变成资金问题

一行没变的 ==,在六个地方是六种东西。规范那条「全部使用 equals」做的事,是把第二行和第三行的不确定性从链条里摘掉,让后面四行不再依赖数据分布。这也是我后来给团队讲规范时唯一反复强调的一句:别背结论,要能顺着这条链说出它在第几行起作用。

「严格」两个字,比「有规范」值钱得多

我们 2020 年就把手册贴进 CR 模板了,2024 年照样出事。差别在哪儿?贴进模板的是一段给人看的建议,出事那天没有任何一道闸门会因为它而停下来。

能机械判定的规则和只能靠人记的规则,效果差着数量级。举个手册里最便宜的例子:@Override。父类方法签名一改,加了注解的实现类当场编译报错;没加注解的同名方法会静默变成一个全新方法,调用方仍然走父类逻辑,直到某天线上发现功能没生效。P3C 那批人写插件开发复盘时提过一个细节:PMD 原生不支持跨文件扫描,所以「所有覆写方法必须加 @Override」这条没法在 PMD 里实现,最后挪到 IDE 和 Sonar 各自的平台能力里做了。同一条【强制】,落在编译器、IDE、CI 三种位置,拦截强度完全不一样。

第二个差别是拦截位置。我按那次事故的真实工时算过一笔账:

写的时候改成 Objects.equals       ≈ 2 分钟
CR 里被同事圈出来               ≈ 10 分钟 + 一次往返
CI 增量扫描拦住并阻止合并         ≈ 20 分钟 + 一次构建
灰度 2.3% 用户后发现             ≈ 2 小时 20 分定位
                                  + 317 笔对账 + 客服话术 + 补发方案
                                  + 两天复盘

这条曲线不用画图也知道是什么形状。规范的全部经济价值,就是把修复动作从右边往左挪。而「严格」的意思不是态度严肃,是让规则在尽可能靠左的位置变成不可绕过的动作:编辑器里红色波浪线、提交前钩子、CI 里的 exit code,比 CR 模板里那句「请注意包装类型比较」有用得多。

第三个差别最容易被忽略:统一的写法会把 CR 里的注意力省下来。同一段 diff,如果里面混着三种命名、两种缩进、一处魔法值,评审的人得先把这些噪音滤掉才能看业务逻辑;diff 越大、噪音越多,真正危险的那行越容易滑过去。我在组里做过一次不太严谨但挺说明问题的记录:把格式与命名类问题全部交给 IDE 自动格式化之后,连着 30 次 CR 里,评审意见中指向业务语义的比例从大约四成升到七成多,评审时长中位数反而降了。样本小,我不拿它当结论,只当「注意力是有限资源」的一个佐证。

不过这里得把话说回去。学界对「通用规则和缺陷到底有没有关系」这件事,结论比很多人以为的保守。Boogerd 等人在两个工业案例里逐条检验 MISRA C 规则与缺陷位置的关系,一个案例 88 条规则里只有 10 条是显著预测因子,另一个 72 条里只有 12 条,两个案例之间还只重叠了 1 条;Wagner 和 Couto 那边分析 FindBugs / PMD 的规则与真实缺陷修复,没能确认通用规则具备缺陷预测能力。反过来,Renggli 等人研究「由熟悉系统的人针对本系统写的规则」,在长期演化的案例里显著好过通用规则,16 位有经验的开发者里多数认为这类规则更能找出关键问题。

这几组结论拼起来,正好是规范真正的初衷,也是它的边界:

  • 规范的收益主要来自把某次真实事故凝固成一条可重复的检查,而不是来自「条目多、覆盖广」。
  • 通用规则里真正咬得住缺陷的,往往只是少数几条;剩下的价值在于协作一致性,别把它们的功劳算到「减少 bug」头上。
  • 最值钱的规则是你自己团队从工单里长出来的那些,别人的手册是起点,不是终点。

M-2417 之后我们加的第一条团队规则不是「包装类型用 equals」——手册早写了,贴了四年也没用。加的是「DTO 中任何 Integer / Long / Boolean 字段参与业务判断时,必须走 Objects.equals 或 Kotlin ==,增量扫描里出现 == 直接阻断合并」。它比手册那条窄得多,只管我们出过事的地方,但它能跑在 CI 里。

规范这东西,最早是拿来保命的

把「编码规范」理解成「统一缩进和命名」是后来的事。它最初出现的地方,代码写错是要出人命的。

1998 年,英国汽车工业软件可靠性协会 MISRA 发布了面向车载软件的 C 语言规范,一百多条规则里包括禁止动态内存分配、限制函数复杂度、要求显式布尔比较这类在通用软件开发里看着近乎苛刻的约束。理由很实在:车载 ECU 内存有限、没有虚拟内存、跑起来不能重启,一个未定义行为在实验室里可能一年不出现,装到十万台车上就是召回。学界后来做「规则到底能不能预测缺陷」的研究,用的就是 MISRA-C 1998 那 127 条规则。

同一时期 Java 这条线走的是另一条路。Sun 早期给出的 Code Conventions、2000 年前后出现的 Checkstyle 主要管命名、格式、import、Javadoc;PMD 往语法树里走一层,查未使用变量、空 catch、资源没关;FindBugs 干脆分析编译后的字节码,找空指针、equals/hashCode 不一致这类逻辑错误。三者分工到今天也没变,只是名字换成了 SpotBugs。2001 年《Effective Java》第一版把另一种写法带火了:不讲「应该怎么写」,一条一个条目,配反例和后果,讲清楚「这么写会在哪儿炸」。PEP 8 也是 2001 年,Google 后来那套 Style Guide 里有句话被引用得很多——一个被多个工程师改过的文件,看起来应该像一个人写的。

这些坐标摆在一起,能看出规范的初衷其实有三条,跟审美都没关系:

flowchart LR
  A[&#34;事故 / 未定义行为&#34;] --> B[&#34;凝固成一条规则&#34;]
  B --> C1[&#34;保命:禁止某类写法&#34;]
  B --> C2[&#34;协作:把风格之争从 CR 里拿掉&#34;]
  B --> C3[&#34;可预测:让工具、重构、AI 都能安全处理代码&#34;]
  C1 --> D[&#34;缺陷左移到编辑期&#34;]
  C2 --> D
  C3 --> D

第三条是近些年才被重新看见的。代码形状越统一,静态分析、自动重构、批量迁移、IDE 补全乃至让 AI 读代码的准确率越高;反之,一个项目里三种单例写法、五种空判断,工具只能保守地什么都不敢动。我们 2025 年做模块 M 的 Kotlin 迁移时,吃过这个亏:同一份 DTO 在不同包里有四种「判空再取值」的写法,脚本一个都不敢自动改,最后靠人肉过了一百多个文件。

阿里这本手册进场的时间点,正好卡在国内 Java 后端工程化的那几年。我把版本线整理成表,贴在我们的规范仓库首页:

时间版本具体变化
2016-12-071.0.0 公开版首次向业界开放,官方《前世今生》记为第 1 版
2017-02-271.1.0 正式版增加前言与专有名词说明,修正部分描述
2017-05-201.2.0 升级版对外征集意见后的修订
2017-09-191.3.0 终极版新增单元测试规约;同期发布英文版
2017-10-14P3C 插件杭州云栖大会首发并开源,官方写「历经 247 天研发」
2018-06-061.4.0 详尽版新增 16 条设计规约;配套《码出高效》出版
2019-06-131.5.0 华山版新增 21 条设计规约,修改描述 112 处;去掉「阿里巴巴」限定词
2020-04-221.6.0 泰山版新增日期时间、SQL 表别名、统一错误码等规约
2020-08-031.7.0 嵩山版首次新增前后端规约,修正 BigDecimal 比较、HashMap 扩容等描述
2022-02-031.7.1 黄山版新增 11 条,含浮点后缀大写、枚举字段不可变、配置密码加密等

这张表我返工过两回。第一回是年份:网上流传的版本表把「首次公开」记成 2017-02-09、1.3.0 记成 2017-09-25、1.4.0 记成 2018-05-20、1.5.0 记成 2019-06-19,跟官方《前世今生》那篇列的 2016-12-07 / 2017-09-19 / 2018-06-06 / 2019-06-13 对不上。插件首发那篇官方文章又写了「今年 2 月 9 日首次公布」。三个来源三个口径,我不敢挑一个当事实,最后按官方版本说明和 PDF 封面版本号定表,把 2017-02-09 那条作为「对外正式公布」的另一种口径留了备注。写技术文章时这种地方最容易被一句话带过去,可一旦有人按这张表去对版本,错一次就不信后面所有数字了。

第二回是「最新版」。搜的时候跳出来几篇 2025 年的博客,说华山版新增了 Serverless 与响应式编程规范、黄山版新增了 AIGC 编程规范。我差点抄进表里,翻到 P3C 仓库 README 发现它停在「下一个版本:黄山版」,官方专题页也停在嵩山版发布那次。那几篇看着像是拿旧版本名让模型补出来的内容,条目、年份、章节全都对不上。黄山版之后我没找到官方新版本的发布记录,表里就写到它为止,没往下编。

顺带记个不太要紧但挺有意思的细节:版本号后来改成五岳命名,是作者自己说的——华山最有武侠渊源,之后去泰山、嵩山;嵩山之后本想叫恒山,结果发现和「衡山」读音完全一样,表达上是大忌,干脆公开征集,最后选了「黄山」,取「五岳归来不看山,黄山归来不看岳」。一本规范文档能把命名这事较真到读音,跟它里面那些条目其实是同一种性格。

手册好在哪,得拆成三层看

先说结构。手册以 Java 开发者为中心,划成编程规约、异常日志、单元测试、安全规约、MySQL 数据库、工程结构、设计规约七个维度;按约束力强弱和故障敏感性,每条标【强制】【推荐】【参考】;条目下面再挂「说明」「正例」「反例」。三层叠起来,它就不只是一份可以查的清单了。

七个维度里最容易被低估的是后四个。一线开发写业务代码时最容易漏的从来不是命名,是「这张表没加索引」「这个接口没做鉴权」「这个异常被吞了」「这段日志把用户手机号打出去了」。手册前言里专门解释过为什么要收这些看似跟编码无关的章节:不是要人变成安全专家、运维专家,而是明确一位 Java 开发应该具备的基本技术素养。我们项目 2023 年那次合规清点,把日志脱敏、错误码统一、异常必须带现场信息与堆栈这三条直接从手册搬进团队清单,省了自己从零想的功夫。设计规约是 1.4.0 才加的,1.5.0 又补了 21 条,它把「这个需求要不要写设计文档」这种以往靠拍脑袋的事给出了可度量的判断依据,这是别家风格指南里少见的。

三级约束本身是个很克制的设计。真正「违反就可能出事」的才标强制,其余留给场景判断。可惜多数团队拿到手只看见第一级。

说明 / 正例 / 反例才是我认为它最值钱的部分。手册里的反例不是抽象原则,几乎全是事故报告的压缩版:

// 常量定义反例:开发者 A 定义缓存 key,开发者 B 用时少了下划线
String key = "Id#taobao_" + tradeId;
cache.put(key, value);

// 魔法值之外的另一条:两个类各自定义「是」
// 类 A:public static final String YES = "yes";
// 类 B:public static final String YES = "y";
// A.YES.equals(B.YES) 预期 true,实际 false

// POJO 不要设属性默认值
// private Date gmtCreate = new Date();
// 数据提取时没置入值,更新其它字段时顺带把它刷成当前时间

还有那个我每次带新人都拿出来讲的 getObject() 与 get0bject()——一个是字母 O,一个是数字 0,少了 @Override 编译器不会提醒你覆写其实没成功。以及 POJO 属性用包装类型那条的反例:交易报表显示涨跌 x%,x 是基本类型,RPC 调用失败返回默认值,页面就显示 0%,而正确表达应该是「没有数据」,画一条横线。这条我读第一遍时没什么感觉,直到自己在一个健康类 App 的报告页上把「本次未测」显示成 0,被医生用户投诉了才明白——null 在这里不是懒得赋值,是一种业务状态。

这类反例让新人不用亲自把坑踩一遍。一个人靠自己攒经验,一年能遇到几次?一本手册把几百人踩过的坑按类别排好,这是它最大的优势,跟「统一代码风格」根本不是一个量级的价值。

手册前言里那个交通法规的类比我挺喜欢:「制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全」,接着说规范和标准是限制过度个性化,以统一方式一起做事,提升协作效率、降低沟通成本。它没把规范包装成「写出优雅代码」,而是直说这是协作工具。这个定位很诚实,也解释了我后面为什么会破它的规矩——限制个性化是为了协作,不是为了把性能也一起限制住。

第三个优势是有配套的机器实现。2017-10-14 云栖大会首发的 P3C 项目分三块:PMD 规则实现、IntelliJ IDEA 插件、Eclipse 插件。扫描结果按 Blocker / Critical / Major 三级显示,IDEA 里基于 Inspection 机制还能实时提示。项目组那篇开发复盘写得很实在:一期定了并发、注释、命名、OOP、集合等方面的 41 条规则,选 PMD 是因为 Checkstyle 主要管风格、FindBugs 得先编译工程,而 PMD 已有不少通用规则可以改改就用;实现时始终守着一条原则——「对于错误,宁可少报也不误报」,因为误报会让开发同学产生阴影,从此不再信任工具。仓库 README 后来给的数字是基于 PMD 实现 49 条、另外 4 条放在 IDE 插件里实现。嵩山版前言提到配套插件下载量达到 162 万人次,云效也集成了扫描引擎。

文档能落成插件,这点比多写五十条规约更重要。我们团队真正开始少出同类 bug,不是从「把手册贴进 CR 模板」那天算起,是从「增量扫描阻断合并」那天算起。

把优势落到条目上,大概是这么张表:

手册条目防住哪类状态能机器查吗
包装类型值比较用 equals引用语义被当成值语义,正确与否依赖数据分布能,AST 直接识别
所有覆写方法加 @Override拼写或签名漂移导致覆写静默失效编译器能,PMD 跨不了文件
不允许魔法值直接出现在代码中同一常量多处漂移、少个字符能,但容易误报
不在 foreach 里 remove / addmodCount 与迭代器期望值不一致能
Arrays.asList 不调用修改方法适配器视图被当成独立集合能,需要类型推断
线程池不用 Executors 创建无界队列 / 无限线程导致资源耗尽能
必须回收自定义 ThreadLocal线程池复用造成跨请求数据串味难,需要过程间分析
异常信息含现场信息与堆栈线上只剩一句「操作失败」无法定位难,靠 CR
用户敏感数据脱敏后展示或输出合规风险难,靠团队自己补规则
表查询必须带索引条件慢 SQL 拖垮服务靠 SQL 审查与执行计划

右列那几个「难」和「靠 CR」,就是后面那几面墙的入口。

我在实际项目里撞上的那几面墙

优势说得够多了,下面这几条才是我这篇文章真正想写的。手册是 2016 年从阿里内部 Java 后端工程里长出来的,把它整本搬到 2026 年的 Android / Kotlin 项目上,会撞墙。

第一面:语言前提变了,条目还在

手册 OOP 规约里那条「POJO 类属性必须使用包装数据类型」,说明写得很清楚:数据库查询结果可能是 null,用基本类型接收有 NPE 风险;null 还能表达「远程调用失败」「异常退出」这类额外信息。这个理由在 Java 后端完全成立。

到了 Kotlin,Int 和 Int? 已经在类型系统里把这件事说死了:

// 我们项目现在的写法,编译期就区分「有没有值」
data class ExamReport(
    val systolic: Int?,        // 未测量,不是 0
    val measuredAt: Instant?,  // 未同步,不是 epoch
    val batchId: Int           // 必填,不可能是 null
)

一个字段能不能为 null,不用靠「统一用包装类型」这种约定来提醒,编译器会盯着。手册那条想解决的问题被语言本身接管了,剩下的只有它的副作用:装箱。在 Kotlin 里写 Array<Int> 每个元素都是对象,写 IntArray 才是连续的基本类型数组。报告页有一屏要画 720 个采样点,最早从 Java DTO 迁过来时用了 List<Int>,列表滑动时分配率明显不对,改成 IntArray 之后那一屏的 GC 次数才回到正常。手册里「局部变量推荐使用基本数据类型」那条【推荐】其实留了口子,可惜团队执行时通常只记住了前两句【强制】。

另一条是「用常量或确定有值的对象调用 equals」,正例 "test".equals(object)。Kotlin 里 == 编译成 equals 且空安全,=== 才是引用比较:

val a: String? = null
println(a == "test")   // false,不炸
println(a === "test")  // 引用比较,明确写着「我在比地址」

语义歧义被语言收走了,那种「把常量放左边」的技巧就没了存在意义。我把手册里这类条目列成一张「前提已消失」清单,在新人 onboarding 时单独讲一遍,免得有人照着写一手别扭的 Kotlin。类似的还有 SimpleDateFormat 那条——手册正例给 ThreadLocal<DateFormat>,说明里也提了 JDK 8 可以用 Instant / LocalDateTime / DateTimeFormatter 替代,但我们 minSdk 24 的 Android 项目要用 java.time 得开 core library desugaring,那是另一个工程决策,手册不会替你做。

还有「线程资源必须通过线程池提供,不允许在应用中自行显式创建线程」。它在服务端是对的,在移动端得翻译成「别在主线程做 I/O、别在列表滚动时起一堆短命线程」,具体落到协程调度器和主线程模型上。条目背后的意图可以照搬,字面照搬就会跟平台打架。

第二面:粒度不齐,一刀切就出形式主义

手册自己分了三级,可到了团队里往往只剩一级。我见过最常见的做法是把能找到的规则全塞进 CI 当阻断项,第一次全量扫描出来三千多条告警,然后大家花两周「清告警」。清法通常是这样:

// 为了消掉「不允许魔法值」,把所有数字抽进一个大常量类
public final class Consts {
    public static final int ZERO = 0;
    public static final int ONE = 1;
    public static final int TWO = 2;
    public static final int SEVEN = 7;
    public static final int MAX_RETRY = 3;
    // …… 后面还有两千行
}

问题在于手册同一节的第三条【推荐】白纸黑字写着「不要使用一个常量类维护所有常量,要按常量功能进行归类」,说明里还说大而全的常量类非得用查找功能才能定位,不利于理解与维护。一条【强制】和一条【推荐】被一刀切之后,执行出来的结果恰好是手册自己点名的反例。

我这边踩过更细的一次:为了过「不允许魔法值」,同事把每个 0、1 都抽成了常量,结果 for (int i = Consts.ZERO; i < list.size(); i++) 这种代码遍地开花,可读性比原来差,CR 里还得专门讨论 ZERO 和 ONE 该放哪个类。后来我们把规则改成:只拦出现在业务判断、缓存 key、状态码、时间阈值里的字面量;循环下标、数组索引、纯数学常量豁免,写进扫描白名单。告警数从三千多降到两百出头,剩下的才是真正值得看的。

第三面:能机械查的,只是目录里的一小部分

P3C 仓库给的是 49 条 PMD 规则加 4 条 IDE 规则,一期定的还是 41 条。手册正文的条目远不止这些。更要命的是实现边界:项目组自己写了,PMD 原生不支持跨文件扫描,所以 @Override 这类规则得挪到 IDE 和 Sonar 各自的平台能力上做。也就是说,「手册里写着」和「机器能拦住」之间,隔着一大片空地。

团队一旦把「插件零告警」当成质量指标,这片空地就会长出绕过写法:

@SuppressWarnings("PMD.AvoidDuplicateLiterals")
public class CouponRule {
    private static final String KEY_PREFIX = "Id#taobao_";  // 挪到另一个类,扫描器就看不见了
    // ...
}

我审过一段代码,为了消掉「魔法值」告警把字符串搬进一个 StringPool 类,两处使用各引用一次,语义一点没变,告警清零,评审通过。这类改动不产生 bug,也不减少 bug,纯粹是把 KPI 伺候好了。后来我们把度量从「告警数」换成「同类事故复发数」,这种改法立刻没了动力。

第四面:版本停了,语言还在往前走

黄山版是 2022 年初,之后我没找到官方新版的发布记录。中间 JDK 从 8 走到 21,record、sealed class、虚拟线程、模式匹配陆续落地;客户端这边 Kotlin 成了主流,Compose 的声明式 UI 跟手册里那套「视图模板不加复杂逻辑」的语境完全不同;构建工具链也换了几轮。手册里有些反例在新 JDK 上行为已经变了,有些条目讨论的框架今天基本没人用了。

这不是手册的错,是任何静态文档的宿命。可如果团队把 PDF 当永久标准,就会出一种很别扭的局面:新人拿着 2022 年的条目,评审 2026 年的 Kotlin 代码,争议还得靠谁资历深来收场。我们的处理是给每条团队规则加 valid_until 字段,半年复审一次,到期没复核就自动降级成【参考】,逼着人重新看一眼它还有没有意义。

第五面:「说明」不一定对,条目自己也在改

手册每条下面的「说明」是解释理由的,可理由写错了,结论就会被人用在错的场景。

泰山版日志规约里那条【强制】我印象很深:「在日志输出时,字符串变量之间的拼接使用占位符的方式」,说明写的是「String 字符串的拼接会使用 StringBuilder 的 append() 方式,有一定的性能损耗。使用占位符仅是替换动作,可以有效提升性能」。有人用 JMH 实测过,结论跟这句说明并不一致——日志级别关掉的时候占位符确实大赢,因为压根不用拼字符串;级别开着、真的要落盘的时候,占位符还得解析 {} 并装箱参数,未必比拼接快。我在自己机器上跑过一轮,两个数在开启级别时差得很近,误差范围内来回复调,跟「有效提升性能」这种笃定说法对不上。

「占位符仅是替换动作」这个描述本身也不准确,日志框架要做参数绑定、格式化、异常堆栈提取,不是字符串替换。结论(用占位符)我完全赞成,但理由得换成「避免无效拼接 + 结构化字段方便检索 + 参数与模板分离便于脱敏」,不是那句性能断言。

手册自己也在纠这类错。嵩山版的更新说明里就写着修正了 BigDecimal 比较、HashMap 扩容次数等描述;集合初始化那条 initialCapacity = (元素个数 / 负载因子) + 1 的公式,社区里专门有人写过文章,说照抄这个公式反而可能让性能变差。后面讲性能那章我会把这几处一条条量一遍。规范不是物理定律,它是某一批人在某个版本上的最佳判断,把它当定理背,迟早要吃亏。

第六面:只教避坑,不给调优的尺子

手册里跟性能有关的条目不少,但绝大多数是「别这么写会出事」:别用 Executors、别用 Apache BeanUtils、别在方法体里 Pattern.compile、集合初始化给个大小、用 entrySet 遍历 Map。这些都对,可它们是避坑清单,不是调优方法。整本手册没有给出一套可复用的测量口径:用什么工具量、预热几轮、看平均值还是 P90、机型和样本量怎么记、收益怎么跟回归一起看。

于是团队里最容易发生这样的对话:「手册说要指定 HashMap 初始容量」「那我全写 16」「为什么?」「手册说不确定就填 16」。前面那句是有条件的判断,被压缩成一句口号之后,性能反而可能变差——JDK 的 HashMap 会把入参向上取到 2 的幂,阈值是容量乘负载因子,公式里那个 + 1 在不同元素个数下有时刚好、有时白白多扩一次。

第七面:移动端特有的风险,得自己补

手册安全规约里有敏感数据脱敏的条目,方向是对的。但移动端还有一整片它没覆盖的地方:崩溃上报里夹带的就诊人姓名、导出的堆快照里的身份证号、debug 日志里的 token、第三方 SDK 在用户同意之前就初始化、权限申请前没说清用途。这些在我们那个健康类 App 里是要出合规事故的,手册不会替你写,只能团队自己往规则清单里补。这块我另外写过,这里不展开。

七面墙摞起来,结论不是「手册不好用」,是它的适用边界得由用的人补上。手册给的是一批经过大规模实战检验的默认解,默认解的价值在于省下从零想的功夫;代价是它不会替你判断「这条在我们项目里还成不成立」。

七个维度搬到移动端,我逐条改了一遍

2020 年刚把手册引进组里那阵,CR 里差不多一半的争论是「这条在我们这儿到底算不算」。有人拿 PDF 逐条对,有人说移动端不适用,谁也说服不了谁,最后往往按资历收场。后来我干脆坐下来把七个维度逐条过了一遍,每条只给四种结论:直接搬 / 改写 / 删掉 / 团队自补,并且把依据写在旁边。这张表现在还在 spec/local-adaptation.md 里,每季度跟着规则复审一起更新。

维度代表条目处理依据
编程规约 · 命名类名 UpperCamelCase、常量全大写、禁用拼音直接搬跨平台无差别,IDE 可格式化
编程规约 · 常量不允许魔法值改写数值计算里的 0/1/2 豁免(W-011),业务 key 与阈值仍拦
编程规约 · 常量不要用一个大常量类直接搬手册自己的【推荐】,正好补上上一条的漏洞
编程规约 · OOPPOJO 属性用包装类型改写Kotlin Int? 已表达「未测」;绘制层转 IntArray(W-009)
编程规约 · OOP用常量调用 equals删掉Kotlin == 空安全且走 equals,=== 才是比地址
编程规约 · OOP包装类型值比较用 equals直接搬M-2417,增量扫描 R-014
编程规约 · OOP覆写方法加 @Override直接搬Kotlin 用 override 关键字,编译器强制
编程规约 · 集合foreach 里不 remove / add直接搬补一条:删得多用 removeIf,不是破例
编程规约 · 集合Arrays.asList 不调用修改方法直接搬Kotlin 用 toMutableList() 显式表达意图
编程规约 · 集合subList 结果不可强转直接搬视图语义跨平台一致
编程规约 · 集合初始化指定容量改写按 capacityFor(n) 重推,公式与 JDK 实现绑定
编程规约 · 集合用 entrySet 遍历 Map改写Kotlin 直接 for ((k, v) in map),前提消失
编程规约 · 并发不用 Executors 创建线程池直接搬补:有界队列 + 背压 + 拒绝计数,见 UploadPools
编程规约 · 并发不允许显式创建线程改写落到「主线程不做 I/O」与协程调度器
编程规约 · 并发SimpleDateFormat 不 static改写DateTimeFormatter + desugaring;老链路 ThreadLocal 必须 remove
编程规约 · 并发必须回收 ThreadLocal直接搬线程池复用会串数据,扫描脚本单独覆盖
编程规约 · 其他正则要预编译改写编译期常量放伴生对象;运行时动态 pattern 单独缓存
编程规约 · 其他避免 Apache BeanUtils直接搬原则落地改成手写编译期映射,不引它给的替代库
前后端规约错误码、字段命名、精度部分搬前后端字段与错误码沿用;金额精度按 long 分统一
异常日志异常不许吞,带现场信息与堆栈直接搬线上排障全靠这两样
异常日志日志拼接用占位符改写无参固定字面量豁免(W-007);理由换成脱敏与避免无效拼接
异常日志统一错误码五段式删掉客户端本地错误不跨系统追溯,套五段式徒增负担
异常日志敏感数据脱敏团队自补崩溃上报、堆快照、debug 日志、SDK 初始化时机,手册没覆盖移动端
单元测试边界值、错误输入、设计边界团队自补夹具必须含 127 / 128,M-2417 就漏在这里
安全规约参数化查询、输入校验直接搬Room 的 @Query 参数绑定天然防注入
安全规约权限用途说明团队自补申请定位权限前先弹原生 AlertDialog 说清用途
MySQL 数据库建表必备三字段、索引、禁 SELECT *部分搬本地库用 Room 迁移与索引,字段裁剪按端上数据量重定
工程结构分层与依赖方向直接搬模块 M 的 data / domain / ui 三层
工程结构二方库、依赖管理改写零第三方依赖优先,引入要走依赖评审
设计规约UML 与设计文档的判断依据改写端上小需求不套服务端文档模板,改成状态机与边界条件表

四种结论里,「改写」最多,「删掉」只有三条。这个比例挺说明问题:手册的意图大多仍然成立,需要改的是字面规则与平台之间的那层翻译。真正该删的是那些语言已经替你做完的事,留着只会让人写出别扭的代码。

几条改写我顺手把落地形状记下来,省得下次又争一遍。

状态机用 Kotlin 的密封类型,比「POJO 里放个 int status + 魔法值」清晰得多,也正好把手册里「不要设默认值」「不允许魔法值」两条意图一次性接住:

// 报告同步状态:每个状态自带数据,不存在「默认 0」这种模糊表达
sealed interface SyncState {
    data object Idle : SyncState
    data class Running(val done: Int, val total: Int) : SyncState
    data class Done(val at: Instant) : SyncState
    data class Failed(val reason: String, val cause: Throwable?) : SyncState
    data object Unmeasured : SyncState          // 「本次未测」,不是 0
}
// 参数化查询:Room 帮我们挡住字符串拼接 SQL
@Dao
interface ReportDao {
    @Query("SELECT * FROM report WHERE user_id = :userId AND at >= :from")
    suspend fun listSince(userId: String, from: Long): List<ReportEntity>
}
// 日志出口统一脱敏,参数化日志恰好给了这一层位置
object SafeLog {
    private val SENSITIVE = setOf("idCard", "phone", "patientName", "token")

    fun debug(template: String, vararg args: Any?) {
        if (!BuildConfig.DEBUG) return
        val safe = args.map { if (it is Map<*, *>) mask(it) else it }.toTypedArray()
        Log.d("m", template.format(*safe))
    }

    private fun mask(m: Map<*, *>) =
        m.mapValues { (k, v) -> if (k in SENSITIVE) "***" else v }
}

裁剪记录本身也得留证据,不然半年后又变成口头约定:

<!-- spec/local-adaptation.md -->

## 裁剪口径(每条都要能回答四问)
1. 语言或平台是否已接管这件事?(Kotlin 可空类型接管 null 语义)
2. 平台约束是否不同?(minSdk、主线程、ART 分配、包体积)
3. 代价是否变化?(装箱在 720 点/帧的路径上不再是小钱)
4. 能不能机械检查?(能就写进扫描脚本,不能就只进 CR 模板)

## 本季变更
- 2026-Q3:删掉「用常量调用 equals」「用 entrySet 遍历 Map」两条,前提已消失
- 2026-Q3:新增「日志脱敏」「边界值夹具含 127/128」两条团队规则
- 2026-Q4 待复审:W-009 在 Compose 版本上收益缩小,需补重组计数口径

那四问我用了一年多,几乎能挡住所有「这条到底适不适用」的争论——争论之所以没完没了,多半是因为双方都在说结论,没人先说清前提。

规范落到业务流程上,是九道闸门

把手册 PDF 发给全组、贴进 Confluence、在周会上念一遍,这三件事我们都做过,M-2417 照样发生了。后来才想明白:规范不是一份文档,是一条带反馈的数据流,得在流程里有落点,每一处落点都要能拿出证据、能停下来。

我们现在这条链长这样:

flowchart LR
  R[&#34;需求评审&#34;] --> D[&#34;设计&#34;] --> C[&#34;编码&#34;] --> S[&#34;自测&#34;] --> V[&#34;CR&#34;] --> I[&#34;CI&#34;] --> G[&#34;灰度&#34;] --> O[&#34;线上复盘&#34;] --> U[&#34;规范更新&#34;]
  U -.->|&#34;回流成新规则 / 删掉过期规则&#34;| R
  U -.->|&#34;更新 lint 与 CR 模板&#34;| I
  V -.->|&#34;争议条目录档&#34;| U

九道闸门各自拦的东西不一样,混在一起就会互相推诿。我按我们项目现在的分工列一下:

闸门谁负责拿什么当证据主要拦哪类我们踩过的一次
需求评审产品 + 开发状态机与边界条件表业务语义本身写错限领规则没写「同一批次」还是「同一活动」
设计开发接口契约、幂等方案架构与并发边界限领判断只放在客户端
编码开发IDE 实时检查语义歧义与隐式行为== 那行
自测开发夹具覆盖边界值数据分布依赖夹具全是 12/13/14
CR同事只查机器查不了的契约、命名含义、异常语义模板里贴了手册,没人逐行对
CI流水线增量扫描 exit code、单测、JMH 门禁已知类别的写法缺陷当时只跑了编译和单测
灰度运维 + 开发分位数、异常率、业务对账环境 × 数据交叉产物2.3% 灰度暴露批次号 ≥128
线上复盘全员时间线、根因、影响面流程本身的漏洞两天复盘会
规范更新规范 owner新规则 + 检查脚本 + 到期日同一类事故再次发生加了那条能跑的窄规则

这里有个很实际的取舍:CR 里只留机器查不了的东西。以前我们的 CR 模板有二十来条,从缩进一直问到异常处理,评审的人扫一遍勾完,真正重要的那几行反而没人细看。现在模板只剩六条,全是语义相关的:状态机分支有没有漏、异常吞没吞、日志里有没有敏感字段、并发下共享状态怎么处理、DTO 字段的 null 表达什么业务含义、性能敏感路径有没有测量口径。格式、命名、@Override、魔法值一律交给 IDE 和 CI,谁在 CR 里提这些,反而会被我圈出来问「为什么机器没拦住」——那通常意味着流水线漏了配置。

工单 M-2417 走完整条链

把那次事故按九道闸门回放一遍,比讲十遍「规范很重要」管用。

闸门当时为什么没拦住后来加了什么
需求「每人限领一张」没定义跨批次是否算同一人状态机里补 batch_id + user_id 唯一键
设计幂等只在客户端做,服务端只管发服务端加唯一索引与幂等返回
编码IDE 检查没开包装类型比较那条Inspection 配置随仓库走,新人 clone 就有
自测夹具数据全在缓存区间边界值表强制包含 -1 / 0 / 127 / 128 / 129
CR模板贴了手册,但 200 行 diff 里没人盯那一行模板瘦到六条,机器能查的不写进去
CI只有编译 + 单测增量扫描,命中即阻断
灰度靠客服反馈才发现加业务对账:发券数与唯一键冲突数分钟级比对
复盘归因到「开发疏忽」就结束了归因到「规则没有落点」,必须产出可执行改动
更新以前只往模板里加一句话产出规则 + 扫描脚本 + 复测数据 + 到期日

最后一行是整条链的关键。复盘如果只是「以后注意」,等于什么都没改;产出一条能跑在 CI 里的规则,才叫闭环。我们后来把复盘模板改成这样,事故与规则一一对应:

<!-- docs/postmortem-to-rule.md -->

## M-2417 · 复诊券重复申领

- 现象:灰度 2.3%,317 笔重复申领
- 直接原因:Integer 批次号用 == 比较,128 起恒为 false
- 根本原因:限领判断没有服务端幂等;规范条目只贴在 CR 模板里
- 影响面:317 用户,补发等额券,客服话术 1 份
- 新增规则:R-014(见 spec/rules.yaml)
- 检查方式:增量 AST 扫描,命中 `==` 且两侧静态类型为包装类即阻断
- 复测:构造 batch_id = 127/128/129 三组,回归用例 6 个
- 度量:同类事故复发数、增量扫描阻断率
- 复审日期:2025-03-14(半年)

规则本身写在 spec/rules.yaml 里,每一条都得带上理由、反例、检查方式、豁免条件、到期日和度量口径。没有「理由」的规则不许进文件——因为半年后复审的人需要知道它当初在防什么,否则只能要么永远留着,要么凭感觉删掉。

# spec/rules.yaml
version: 7
rules:
  - id: R-014
    level: block            # block / warn / review
    title: 包装类型字段参与业务判断不得使用 ==
    rationale: >
      == 对引用比地址,IntegerCache 让 127 以内碰巧成立,
      正确与否取决于数据分布与 JVM 参数,测试用例容易全绿。
    case: M-2417(317 笔重复申领)
    check: tools/scan_spec.py --rule R-014 --diff
    scope: "**/*.java,**/*.kt"
    exclude:
      - "**/build/**"
      - "**/*Test.kt"      # 测试里故意构造边界值,不拦
    waiver: docs/spec-waiver.md#R-014
    valid_until: 2027-03-14
    metric: recurrence_count

  - id: R-021
    level: warn
    title: 热点路径禁止 Array<Int> 承载采样点
    rationale: 装箱分配在 720 点/帧的路径上会推高 GC 次数。
    case: 报告页滑动掉帧复盘
    check: tools/scan_spec.py --rule R-021 --diff
    waiver: ""
    valid_until: 2027-06-01
    metric: frame_p90_ms

扫描脚本只查增量,不做全量。全量扫描第一次会产生几千条历史告警,团队两周时间都花在「清干净」上,而历史代码里那些写法大多已经稳定运行多年,改它们反而是引入风险。脚本从 git diff 拿改动行,只在这些行上跑规则,输出 JSON 给 CI 判断:

#!/usr/bin/env python3
# tools/scan_spec.py —— 只扫增量,命中 block 级规则就返回非 0
import argparse, json, re, subprocess, sys, pathlib

ROOT = pathlib.Path(__file__).resolve().parents[1]

def changed_lines(base: str):
    out = subprocess.check_output(
        ["git", "diff", "--unified=0", base, "--", "*.java", "*.kt"],
        cwd=ROOT, text=True)
    cur, lineno, rows = None, 0, []
    for line in out.splitlines():
        if line.startswith("+++ b/"):
            cur = line[6:]
        elif line.startswith("@@"):
            m = re.search(r"\+(\d+)(?:,(\d+))?", line)
            lineno = int(m.group(1)) if m else 0
        elif line.startswith("+") and not line.startswith("+++"):
            rows.append((cur, lineno, line[1:]))
            lineno += 1
        elif not line.startswith("-"):
            lineno += 1
    return rows

BOXED = re.compile(
    r"(?<!\w)(?:\w+\(\))?\.?\w*\s*==\s*\w+"
)  # 粗匹配,精确判定交给下面的类型表

def check(rows, rule_id, waivers):
    hits = []
    for path, no, text in rows:
        if rule_id == "R-014" and BOXED.search(text):
            # 白名单:豁免文件里登记且在有效期内
            key = f"{path}:{no}"
            if key in waivers:
                continue
            hits.append({"file": path, "line": no, "code": text.strip(),
                         "rule": rule_id, "level": "block"})
        if rule_id == "R-021" and "Array<Int>" in text:
            hits.append({"file": path, "line": no, "code": text.strip(),
                         "rule": rule_id, "level": "warn"})
    return hits

def load_waivers():
    p = ROOT / "docs" / "spec-waiver.json"
    return {f"{w['file']}:{w['line']}" for w in json.loads(p.read_text())["items"]
            if not w.get("expired", False)} if p.exists() else set()

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--rule", required=True)
    ap.add_argument("--diff", default="HEAD~1")
    a = ap.parse_args()
    found = check(changed_lines(a.diff), a.rule, load_waivers())
    print(json.dumps({"rule": a.rule, "hits": found}, ensure_ascii=False, indent=2))
    sys.exit(1 if any(h["level"] == "block" for h in found) else 0)

这版脚本里的 BOXED 是粗匹配,会有误报;实际仓库里我接了 IDE 的类型信息导出来做精确判定,脚本只当兜底。写在这儿是想说清一件事:P3C 项目组那条「宁可少报也不误报」的原则,放到自研规则上一样成立。我们第一版扫得太宽,把 if (a == null) 也拦了,两天里被同事吐槽十几次,第三条规则上线时大家已经不信扫描结果了。宁可只拦最确定的那几种写法,也别让工具失去信任。

CI 里的接法很朴素,卡点只有两个:阻断级规则和豁免到期检查。

# .ci/spec-gate.yml
stages: [spec, test, report]

spec-incremental:
  stage: spec
  script:
    - python3 tools/scan_spec.py --rule R-014 --diff origin/main
    - python3 tools/scan_spec.py --rule R-021 --diff origin/main
    - python3 tools/check_waiver_expiry.py --today "$(date +%F)"
    - python3 tools/check_rule_expiry.py --rules spec/rules.yaml
  allow_failure: false

report:
  stage: report
  script:
    - python3 tools/collect_metrics.py --out spec/baseline.json
  artifacts:
    paths: [spec/baseline.json]

check_waiver_expiry.py 是后来补的。第一次做豁免机制时没写这个,结果一条「临时允许在报告页用包装类型」的豁免躺了十四个月,谁也想不起来当初为什么开。现在到期当天 CI 直接红,要么补复测数据续期,要么把代码改回来。

CR 模板瘦下来之后长这样,六条全是人该看的:

<!-- docs/cr-template.md -->
1. 状态机分支:有没有「未测 / 同步失败 / 已过期」之外的第四态
2. 异常:catch 之后是吞了、还是带现场信息与堆栈抛出
3. 日志:有没有手机号、就诊人姓名、token、完整请求体
4. 共享状态:多线程 / 协程下这个字段被谁改、什么时候改
5. DTO 里的 null 表达哪种业务含义,服务端和客户端理解是否一致
6. 性能敏感路径:有没有测量口径,破规矩的话豁免单在哪

第 6 条是我加的,因为它把「破规矩」正式放进了流程——不是偷偷破,也不是靠资历压人,是拿数据换一次例外,并且留下到期日。

顺带说,把事故复盘变成规则这件事,我偶尔会丢给 IDE 里的助手先出一版草稿,三段提示词就够,全选复制改改路径就能用:

角色:你只做「事故复盘 → 可检查规则」的转译,不改业务代码。
上下文:@docs/postmortem-to-rule.md 的 M-2417 段落、@spec/rules.yaml 现有规则、
        @tools/scan_spec.py 的检查接口。
任务:产出一条新规则的 yaml 草案,字段对齐现有文件。
约束:rationale 必须写清「在哪一层起作用」,不许写「提高代码质量」这类空话;
      check 必须指向一个真实存在的脚本,不许编造路径;
      不确定的数字留空并标注需要补哪项数据;
      不新增第三方依赖。
验收:把草案里的 check 命令实际跑一次,贴出 exit code 与命中行。
角色:你是对这条规则持怀疑态度的评审。
上下文:@spec/rules.yaml 的 R-014、@tools/scan_spec.py、最近 30 次 CI 的扫描输出。
任务:列出这条规则最可能出现的三类误报、两类漏报,各给一个最小代码示例。
约束:不许只说「可能有误报」,要指出具体语法形态;
      不许建议直接关掉规则,只能给收窄 scope 或精确化的方案。
验收:把你认为最该收窄的一处,写成一条可直接替换的 diff。
角色:半年复审人。
上下文:@spec/rules.yaml、@spec/baseline.json、@docs/spec-waiver.md。
任务:判断 R-014 该保留、降级还是删除,给出依据。
约束:依据只能来自度量数(同类事故复发数、阻断率、误报次数),
      不许用「大家都这么写」「手册上是强制」当理由;
      如果数据不足,直接写「数据不足,需补哪几项」,不要硬下结论。
验收:输出一版修改后的 yaml 与需要补采的度量项。

三段都不长,作用也有限——助手出的第一版规则常把 rationale 写成「避免潜在问题」,把 check 指向一个仓库里根本不存在的脚本,我返工过三次才把约束收紧到「必须指向真实存在的命令」。真正值钱的还是人自己把事故链讲清楚,工具只负责把它转成可执行的形状。

一次完整 CR,从 diff 到合并

九道闸门讲完还是偏抽象,我把 2025 年 9 月 8 日那次 CR 原样捋一遍。同事改的是报告页缓存,87 行 diff,里面恰好混了三种情况:一处明确的写法缺陷、一处需要判断的热点路径、一处合法豁免。三种凑在一次 PR 里,正好能看出机器和人各该管什么。

09:12  提 PR #1842「报告页缓存命中率优化」,87 行 diff
09:13  CI 增量扫描启动,三条规则依次跑
09:14  R-014 命中 block,流水线红
09:19  同事改掉 ==,重新推
09:22  扫描只剩一条 warn(R-021),流水线转绿
09:25  我在 CR 里问:这段是不是热点,有没有测量
09:31  同事贴了 JMH 与 gfxinfo,确认在 720 点/帧的路径上
09:34  按 W-009 追加豁免行,@客户端组另一人复核
09:35  合并,collect_metrics.py 上报本次数据
       全程 23 分钟,其中「规则争论」6 分钟

第一次扫描的输出长这样:

{
  "rule": "R-014",
  "hits": [
    {"file": "app/src/main/java/com/example/health/cache/ReportCache.kt",
     "line": 63,
     "code": "if (entry.batchId == cached.batchId) return cached",
     "level": "block"}
  ]
}
{
  "rule": "R-021",
  "hits": [
    {"file": "app/src/main/java/com/example/health/cache/ReportCache.kt",
     "line": 71,
     "code": "private val samples = arrayOf<Int>()",
     "level": "warn"}
  ]
}
CI 日志(节选)
$ python3 tools/scan_spec.py --rule R-014 --diff origin/main
  -> 1 hit, level=block, exit=1
$ python3 tools/check_waiver_expiry.py --today 2025-09-08
  -> 3 active waivers, 0 expired
spec-incremental: FAILED

第 63 行没什么可讨论的,跟 M-2417 是同一类写法,改完就完。有意思的是第 71 行:R-021 只报 warn,因为 Array<Int> 到底算不算问题,取决于这段代码在不在热点路径上——机器给不出这个判断。CR 里那几句是这么走的:

我:71 行这段是每帧都跑吗?如果是,按 W-009 走紧凑表示;
    如果只是初始化时建一次,Array<Int> 无所谓,别为了过规则改代码。
同事:每帧重建,720 个点。贴了 gfxinfo,Janky frames 12/120。
我:那申请 W-009,把 JMH 和帧数据贴进豁免单,@一下客户端组。
同事:加了,spec-waiver.json 多了这一行。
{"file": "app/src/main/java/com/example/health/cache/ReportCache.kt",
 "line": 71, "waiver": "W-009", "expired": false}
// spec/baseline.json 本次追加
{
  "date": "2025-09-08",
  "pr": 1842,
  "diff_lines": 87,
  "block_hits": 1,
  "warn_hits": 1,
  "waiver_applied": ["W-009"],
  "review_minutes": 23,
  "rule_debate_minutes": 6,
  "human_decisions": ["这段是否在热点路径"]
}

最后那个字段是我最喜欢的一格:human_decisions 只有一项。87 行 diff、23 分钟、两个人,人真正需要判断的只有「这段是不是热点」。剩下的交给编译器、扫描脚本和到期检查。以前那版二十条的 CR 模板,同样一次 PR 大概要勾二十个格子,勾完反而没人有精力问第 71 行到底跑多频繁。

顺带说清楚一个容易被误解的地方:这次 CI 先红了九分钟,不是流程效率低。如果没有那道红,第 63 行会跟着这次「缓存优化」一起进主干,然后在某个批次号 ≥128 的活动里,用一种跟 M-2417 完全不同、但根因一模一样的方式再炸一次。规范最实在的价值,就是让同一个坑不必被不同的人重新踩一遍。

想突破性能,先分清哪一条能破

「严格按照规范」和「把性能做上去」经常被摆成对立面,其实多数时候是误会:手册里相当一部分条目本身就是性能解,真正会打架的只有一小撮。我吃过亏之后给自己定了个判断框架,三条,按顺序过:

flowchart TB
  A[&#34;想破一条规矩&#34;] --> B{&#34;它约束的是<br/>语义正确性吗?&#34;}
  B -->|&#34;是&#34;| X[&#34;不破<br/>equals / @Override / ThreadLocal.remove&#34;]
  B -->|&#34;否&#34;| C{&#34;它约束的是<br/>资源上限或隐式行为吗?&#34;}
  C -->|&#34;是&#34;| D[&#34;可以换实现<br/>但代价必须显式、可观测&#34;]
  C -->|&#34;否&#34;| E{&#34;它只是风格 / 协作约定<br/>且这段是热点路径?&#34;}
  E -->|&#34;否&#34;| Y[&#34;按规矩写<br/>省下的注意力更值钱&#34;]
  E -->|&#34;是&#34;| F[&#34;申请豁免<br/>带测量数据、复核人、到期日&#34;]
  D --> G[&#34;把新实现写进团队规则<br/>更新扫描脚本&#34;]
  F --> H[&#34;写进 docs/spec-waiver.md<br/>CI 检查到期&#34;]

第一条:约束语义正确性的不能破。== 换 equals、@Override、ThreadLocal.remove()、异常不许吞、finally 里不 return——这些破一次省不下几纳秒,出一次事就是两天。

第二条:约束资源上限或隐式行为的,可以换实现,但代价必须显式。手册说别用 Executors,本意是别让无界队列和无限线程把资源吃光;你完全可以写一个显式参数的 ThreadPoolExecutor,甚至把队列压到很小,只要把拒绝、背压、监控都摊开写明。这类「破」其实是更深地遵守了初衷。

第三条:只是风格或协作约定的,在热点路径上可以豁免。前提是这段路径真的热、有测量、有到期日,而且破法不改变语义。

下面七个案例,都是我在 CR 里实际写过「这条我申请例外」或者「这条按字面写会亏」的。所有数字都是我在自己机器上跑出来的记录:JDK 17、JMH 1.36、5 轮预热 5 轮测量、每台机跑三遍取中位数,Score Error 一并留着。换 JDK、换日志实现、换数据规模都会变,这些数只用来判断量级和方向,别当通用结论。JMH 只在本地基准里用,不进 App 产物。

案例 A · 不用 Executors 之后,把线程池调得更狠

手册那条【强制】的原文我贴在规则文件里了:「线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险」,说明里点名 FixedThreadPool / SingleThreadPool 的请求队列长度是 Integer.MAX_VALUE,CachedThreadPool / ScheduledThreadPool 的线程数上限也是 Integer.MAX_VALUE。

我们模块 M 的报告批量上传,历史代码是 Executors.newFixedThreadPool(8):8 个线程配一条无界 LinkedBlockingQueue。平时没事,一次补传 2000 份报告时队列堆到 1992,内存抬了 90 多 MB,P99 到 1.8 秒,而且没有任何一处能看出「系统已经扛不住了」——不报错、不拒绝、只是越来越慢。

照手册改成显式构造只是第一步。真正把性能做上去的是后面几个参数:

// app/src/main/java/com/example/health/io/UploadPools.java
public final class UploadPools {
    private static final int CPU = Runtime.getRuntime().availableProcessors();

    // 等待/计算比实测约 4~6,先按 2*CPU 起,再压测找拐点
    private static final int CORE = 12;
    private static final int MAX  = 12;
    private static final int QUEUE_CAP = 256;

    private static final ThreadFactory NAMED = r -> {
        Thread t = new Thread(r, "m-upload-" + SEQ.incrementAndGet());
        t.setDaemon(false);
        return t;
    };

    private static final AtomicInteger SEQ = new AtomicInteger();
    private static final AtomicLong REJECTED = new AtomicLong();

    public static final ThreadPoolExecutor UPLOAD = new ThreadPoolExecutor(
            CORE, MAX, 30L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(QUEUE_CAP),      // 有界,堆积有上限
            NAMED,
            (r, executor) -> {                       // 背压,不静默丢
                REJECTED.incrementAndGet();
                if (!executor.isShutdown()) r.run();  // CallerRuns 语义
            });

    static {
        UPLOAD.allowCoreThreadTimeOut(true);          // 空闲后连核心线程一起回收
    }

    public static String snapshot() {
        return String.format(
            "active=%d queue=%d/%d rejected=%d completed=%d",
            UPLOAD.getActiveCount(), UPLOAD.getQueue().size(), QUEUE_CAP,
            REJECTED.get(), UPLOAD.getCompletedTaskCount());
    }
}

几个决定都不是拍的。CORE 先按 CPU * (1 + 等待/计算) 估,8 核机器上算出 24,压测下来 P99 反而比 12 差——后端连接池只有 16,多出来的线程全在等连接,还多了上下文切换。调到 12 才落在拐点上。队列定 256 是因为一次批量最多 200 份报告,留一点余量,超了就让提交方自己跑(CallerRuns 天然形成背压),比堆在内存里强。allowCoreThreadTimeOut(true) 是为了别让上传池在冷启动之后一直占着 12 条线程。

批量上传 2000 份报告 · Pixel 6 连接的测试服 · 每档跑 5 次取中位数
                        P99 耗时    队列峰值   拒绝次数   峰值内存增量
Executors 无界队列      1810ms       1992       0         +94MB
显式池 / 无界队列       1780ms       1988       0         +92MB   ← 只换构造器,等于没改
显式池 / 队列 256       742ms        256        37        +21MB

那 37 次拒绝不是故障,是第一次让「系统扛不住」变成了一个能看见、能告警、能重试的数。我在这次 CR 里破的不是手册那条【强制】——恰恰相反,是把它执行到底了。破的是团队里那种「手册说用 ThreadPoolExecutor,那 new 一个就算完事」的做法。

案例 B · 不在 foreach 里 remove,但 Iterator 不是最快的

手册集合处理那条【强制】给的正解是 Iterator,配的反例特别妙:把 "1".equals(temp) 换成 "2" 试试,结果「肯定会出乎大家的意料」。原因是 ArrayList 的迭代器记着期望的 modCount,循环里直接调 list.remove 改了 modCount,下一次 next() 就抛 ConcurrentModificationException;而删倒数第二个元素时恰好不进下一次 next(),风平浪静地漏过去。正确与否取决于「删的是不是倒数第二个」——又是数据分布。

我们报告页有个批量剔除无效采样点的逻辑,10 万点里常常要删三成。按手册写 Iterator.remove() 完全正确,跑起来却比预期慢:每删一个元素,ArrayList 都要把后面的所有元素 System.arraycopy 往前挪一次,删三成就是几万次数组拷贝。

ArrayList.removeIf 走的是另一条路。它先用 BitSet 把要删的下标标出来,再一趟遍历把存活元素压缩到前面,最后统一截断,只在末尾做一次清理。它不在 foreach 里改集合,完全符合那条规约,属于在规范内更快——这类突破风险最低,收益却往往最实在。

// 报告页采样点清洗,两种写法都合规
fun cleanWithIterator(points: MutableList<Int>): Int {
    var removed = 0
    val it = points.iterator()
    while (it.hasNext()) {
        if (isInvalid(it.next())) { it.remove(); removed++ }
    }
    return removed
}

fun cleanWithRemoveIf(points: MutableList<Int>): Int {
    val before = points.size
    points.removeIf { isInvalid(it) }   // BitSet 标记 + 一趟压缩
    return before - points.size
}
JMH · 10 万元素 ArrayList<Int> · JDK 17 · ms/op(越低越好)
删除比例     Iterator.remove        removeIf       倍数
30%          18.4 ± 0.6            2.1 ± 0.1      8.8x
5%            3.2 ± 0.2            1.9 ± 0.1      1.7x
0.1%          1.6 ± 0.1            2.0 ± 0.1      0.8x  ← 删得极少时反过来

最后一行我特意留着。删得极少时 removeIf 那块 BitSet 的分配和两趟遍历反而亏一点,我就把阈值写进了团队规则:预计删除比例低于 1% 用 Iterator,高于 5% 用 removeIf,中间按可读性选。一条规则能不能破、怎么破,最后都要落到这种带条件的判断上,而不是「A 比 B 快」这种结论。

案例 C · 包装类型是给边界用的,不是给热点循环用的

手册那条三条并列:POJO 属性【强制】用包装类型、RPC 参数返回值【强制】用包装类型、局部变量【推荐】用基本类型。第三条经常被忽略,可它正是留给性能的口子。

报告页画一条 720 点的趋势曲线,最早从 Java DTO 迁过来时用的是 List<Int>。Kotlin 里 List<Int> 每个元素都是装箱的 Integer,一帧要解 720 次、每帧产生一整批临时对象;IntArray 是连续的基本类型数组,没有装箱也没有对象头。

// 边界层:DTO 保留可空语义,null 表示「本次未测」
data class ReportDto(val samples: List<Int?>?)

// 绘制层:进热点之前一次性转成紧凑表示
class TrendRenderer(private val values: IntArray, private val valid: BooleanArray) {
    fun draw(canvas: Canvas, width: Int, height: Int) {
        // 循环里全是基本类型,不分配对象
    }
}

fun ReportDto.toRenderer(): TrendRenderer {
    val src = samples.orEmpty()
    val values = IntArray(src.size)
    val valid = BooleanArray(src.size)
    for (i in src.indices) {
        val v = src[i]
        if (v != null) { values[i] = v; valid[i] = true }
    }
    return TrendRenderer(values, valid)
}
报告页连续滑动 10 秒 · Pixel 6 · Android 13 · 每档 10 次取 P90
                        帧耗时 P90    分配率      GC 次数
List<Int?>  逐点判断    22.8ms       6.1MB/s     14
IntArray + BooleanArray 13.4ms       0.4MB/s      2

破的是「POJO 用包装类型」被无限外推到所有层的习惯,不是那条规约本身。边界层照样 Int?,只是数据一进热点就换成紧凑表示,中间那次转换是显式的、可测的。我还顺手量过 Sequence:短链路上它的 lambda 和迭代器分配常常比直接 List 还贵,别一听「惰性」就往上套。

案例 D · 手册里有些条目,本身就是性能最优解

不是所有规范都跟性能对着干。手册那条「任何货币金额,均以最小货币单位且整型类型来进行存储」,既防住了浮点误差,又比 BigDecimal 快一个量级。

// 反例:double 构造 BigDecimal,把二进制误差一起锁进去
new BigDecimal(0.1);                 // 0.1000000000000000055511151231257827...
BigDecimal.valueOf(0.1);             // 走 Double.toString,才得到 "0.1"
new BigDecimal("0.1");               // 手册要求的 String 构造

// 我们项目的做法:存储与比较全用 long 分
long amountFen = 1990L;              // ¥19.90
boolean enough = balanceFen >= amountFen;
JMH · 100 万次「金额相加 + 阈值比较」· ns/op
long 分                     3.8 ± 0.2
BigDecimal(String).add     142.6 ± 4.1

BigDecimal 不是不用,是要分场合:展示格式化、按比率分摊退款、涉及四舍五入和税率时还是得它,并且显式给 RoundingMode,别让默认行为替你决定钱怎么算。我们把它限制在结算这一个包里,其余全用 long,跨包边界靠一个 Money 值类挡住单位混用。

这一条让我改了对规范的看法:手册里最值钱的条目,往往同时解决正确性和性能,因为它当初就是从一次真实的性能/资金事故里长出来的。跟这种条目较劲,纯属浪费时间。

案例 E · 照抄初始化容量公式,可能反而更慢

手册集合处理那条【推荐】给过公式:initialCapacity = (需要存储的元素个数 / 负载因子) + 1,反例说 1024 个元素不设初始大小会被迫扩容若干次、反复重建哈希表。方向没错,但很多人只记住了「要给个数」,于是代码里到处是 new HashMap<>(1024)——以为够放 1024 个元素。

JDK 8 之后的 HashMap 不是这么算的:

// 构造时只是把入参向上取到 2 的幂,暂存进 threshold
public HashMap(int initialCapacity, float loadFactor) {
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

// 第一次 put 才真正分配 table,并把阈值改成 capacity * loadFactor

tableSizeFor(1024) 得 1024,阈值 1024 * 0.75 = 768。放到第 769 个元素照样扩容一次。想放 n 个不扩容,需要的不是「n」,是满足 capacity * 0.75 >= n 的最小 2 的幂:

// app/src/main/java/com/example/health/util/Maps.java
static int capacityFor(int expectedSize) {
    if (expectedSize < 3) return 4;
    double need = Math.ceil(expectedSize / 0.75d);
    int cap = 1;
    while (cap < need) cap <<= 1;      // 取 >= need 的最小 2 次幂
    return cap;                        // 1024 -> 2048
}
JMH · 连续插入 1024 个键值 · 反射读 table.length 统计扩容次数
构造方式                        扩容次数    每次插入耗时    首次分配
new HashMap<>()                  8         1.93ms/op     16
new HashMap<>(1024)             1         0.81ms/op     1024  ← 以为够,还是扩了一次
new HashMap<>(capacityFor(1024)) 0        0.62ms/op     2048

第三行是用内存换扩容:2048 个桶的数组比 1024 个多占一倍,但在「一次性灌满、之后只读」的场景里很划算。反过来,如果这个 Map 最后只放 20 个元素,那 2048 个桶就是纯浪费——手册说明里那句「暂时无法确定集合大小,指定默认值 16 即可」其实比公式更重要,只是没几个人读后半句。

顺带提一句,嵩山版的更新说明里专门写过修正 HashMap 扩容次数等描述。同一条规约,不同版本给的数字不一样,这本身就是「别把说明当定理」的最好注脚。社区里也有人写过整篇文章讲「给 HashMap 初始化了容量,性能反而更糟」,我看完的感受是:条目的结论可以照做,公式得自己按当前 JDK 的实现重推一遍。

案例 F · SimpleDateFormat 不要定义为 static,然后呢

手册并发处理那条【强制】:「SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为 static,必须加锁,或者使用 DateUtils 工具类。」正例给了 ThreadLocal<DateFormat>,说明里补了一句 JDK 8 可以用 Instant / LocalDateTime / DateTimeFormatter 代替,还引了官方那句「simple beautiful strong immutable thread-safe」。

按字面执行最省事的写法是每次用时 new 一个。线程安全是安全了,代价全在分配和解析 pattern 上:

JMH · 格式化 10 万条日期串 · ns/op
每次 new SimpleDateFormat("yyyy-MM-dd")     2680 ± 90
ThreadLocal<DateFormat>(同线程复用)         142 ± 6
静态 DateTimeFormatter.ISO_LOCAL_DATE        98 ± 3

DateTimeFormatter 不可变、天生线程安全,静态一个就够。但我们是 minSdk 24 的 Android 项目,java.time 在 API 26 以下需要开 core library desugaring,构建配置和包体积都得跟着动。这不是「手册写错了」,是手册默认你在一个能自由选 JDK 的服务端环境里。

我们最后分了两条路:新模块开 desugaring,日期一律 Instant + DateTimeFormatter;老链路留在 ThreadLocal<DateFormat>,但按手册另一条【强制】在 finally 里 remove()——线程池复用线程,不清理就会把上一个请求的格式、甚至上一个用户的上下文带下去。那条规则我加进了增量扫描,命中 ThreadLocal.set 而没有对应 remove 就阻断。

// 老链路 · 线程池任务里必须成对
DATE_HOLDER.set(formatter)
try {
    render(record.date)
} finally {
    DATE_HOLDER.remove()
}

这个案例里我破的不是「不要 static」,而是「条目只给安全默认解、没给性能最优解」这件事。真正做优化时,得把语言版本、平台能力、测量数据三样一起摆上桌。

案例 G · 日志占位符:结论照做,理由换掉

前面说过泰山版那条【强制】的「说明」把性能当理由,实测下来得分情况。我把自己那轮 JMH 贴全:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class LogBench {
    private static final Logger LOG = LoggerFactory.getLogger(LogBench.class);
    private final String id = "M-2417";
    private final String symbol = "coupon";

    @Benchmark
    public void concat() {
        LOG.debug("Processing trade with id: " + id + " and symbol: " + symbol);
    }

    @Benchmark
    public void placeholder() {
        LOG.debug("Processing trade with id: {} and symbol: {}", id, symbol);
    }
}
JDK 17 · logback 1.4 · 5 轮预热 5 轮测量 · ns/op
                        DEBUG 关闭        DEBUG 开启
字符串拼接              36.8 ± 1.4       112.0 ± 5.2
占位符                 8.9 ± 0.3        118.6 ± 4.7

关掉 DEBUG 时占位符赢四倍,因为参数只是传进去、压根不拼字符串;打开时两者几乎持平,占位符甚至略慢一点,多出来的活儿是解析 {}、绑定参数数组、格式化。所以「使用占位符仅是替换动作,可以有效提升性能」这句说明站不住,但结论依然该守,理由得换成三条实在的:

  • 避免无效拼接:生产环境 DEBUG 基本是关的,绝大多数日志调用根本不需要造那个字符串。
  • 参数与模板分离:结构化字段方便检索,也方便在出口统一做脱敏——我们那个健康类 App 里,就诊人姓名、手机号、token 全靠这一层白名单挡住。
  • 堆栈与现场信息带得全:logger.error("... {}", param, e) 一个调用就把现场和异常都留下,拼字符串时最容易漏掉最后的 e。

真正会踩的坑在参数位置:LOG.debug("result={}", expensiveDump()) 里 expensiveDump() 无论级别开没开都会执行,占位符省下的只有字符串拼接。Kotlin 里有人改用 LOG.debug { "result=${expensiveDump()}" } 做惰性,但每次调用都会分配一个 lambda 对象,在极热的循环里得量一下再决定。我们的规则写得很窄:可能高频的 debug 日志用占位符;error 一律带堆栈;固定字面量、无参数的日志允许直接写字符串(这就是我破的第一条规矩,见后面的豁免单)。

案例 H · 手册指出坑,但没给零依赖的填法

「避免用 Apache Beanutils 进行属性的 copy」这条【强制】我完全同意。它慢是有具体原因的:反射加内省拿属性描述符、逐个做类型转换、过程中输出大量调试日志并拼字符串、还重复做 instanceof 检查。但手册给的替代是 Spring BeanUtils 和 Cglib BeanCopier,两个都是第三方库——对我们这种「能不引就不引」的项目,等于把一个问题换成另一个问题。

// DTO -> 领域对象,编译期生成,没有反射也没有额外依赖
data class ReportDto(val id: Long, val systolic: Int?, val measuredAt: Long?)

data class Report(
    val id: Long,
    val systolic: Int?,
    val measuredAt: Instant?
)

fun ReportDto.toDomain(): Report = Report(
    id = id,
    systolic = systolic,
    measuredAt = measuredAt?.let(Instant::ofEpochMilli)
)

手写映射还有个手册没提的好处:类型不一致时编译期就报错。BeanUtils.copyProperties 那种「同名就拷、类型不同悄悄转换或不拷」的行为,在我们这里有 Long 时间戳和 Instant 混用,恰恰最容易出静默错误。

JMH · 10 万次对象映射 · ns/op
Apache BeanUtils.copyProperties       ~4100
Spring BeanUtils.copyProperties        ~520
手写映射(Kotlin 具名参数)             ~28

引不引库是团队自己的取舍,我没有普适建议。这里想说的是:手册负责告诉你哪里有坑,填坑的方案得结合自己项目的依赖策略来定,照着「正例」抄也可能抄出新的麻烦。

我破过的三条规矩,和留在仓库里的证据

把前面几个案例收拢,真正需要申请豁免的其实只有三条,其余都是「在规范内做得更好」。豁免单统一放 docs/spec-waiver.md,CI 每天检查到期日:

<!-- docs/spec-waiver.md -->

## W-007 · 无参数固定字面量日志允许字符串直写
- 破的条目:日志输出时字符串变量之间的拼接使用占位符(泰山版 2-3-4【强制】)
- 范围:app/src/main/java/com/example/health/** 中不含任何变量的 LOG.debug/warn 调用
- 理由:无参可绑定,占位符解析是纯开销;JMH 显示开启级别时拼接略快。
        占位符的其余收益(脱敏、结构化)在无变量日志上不成立。
- 证据:captures/20250321/log-bench.txt,DEBUG 开启 112.0 vs 118.6 ns/op
- 复核:@后端组 2 人,2025-03-21
- 到期:2026-03-21,到期未补测自动失效
- 回归:改动后 error 级别日志仍强制带堆栈,扫描脚本单独覆盖

## W-009 · 绘制层使用紧凑基本类型数组
- 破的条目:POJO 类属性必须使用包装数据类型(被外推到绘制层的团队习惯)
- 范围:TrendRenderer 及其输入转换,DTO 边界层不变
- 理由:720 点/帧路径上装箱分配推高 GC,Int? 的「未测」语义已由 BooleanArray 表达
- 证据:报告页滑动 10 秒 P90 帧耗时 22.8ms -> 13.4ms,GC 14 -> 2
- 复核:@客户端组 2 人,2025-06-02
- 到期:2026-12-02

## W-011 · 数值计算代码免抽 0/1/2 常量
- 破的条目:不允许任何魔法值直接出现在代码中【强制】
- 范围:app/src/main/java/com/example/health/math/** 纯数值计算
- 理由:循环下标、数组索引、数学恒等式里的 0/1/2 抽成常量后可读性下降,
        且与手册同节【推荐】「不要用一个常量类维护所有常量」冲突
- 证据:CR 记录 2025-04-08,扫描白名单 diff
- 复核:@规范 owner,2025-04-08
- 到期:2026-10-08
{
  "items": [
    {"file": "app/src/main/java/com/example/health/io/UploadPools.java",
     "line": 42, "waiver": "W-007", "expired": false},
    {"file": "app/src/main/java/com/example/health/render/TrendRenderer.kt",
     "line": 17, "waiver": "W-009", "expired": false}
  ]
}
条目手册级别初衷能否破我的做法证据
包装类型比较用 equals强制引用语义被误当值语义不能照做,且写进增量扫描M-2417
覆写方法加 @Override强制签名漂移静默失效不能交给编译器编译期报错
ThreadLocal 必须回收强制线程池复用串数据不能try/finally + 扫描老链路排查
不用 Executors 创建线程池强制无界资源耗尽可换实现显式参数 + 有界队列 + 背压P99 1810→742ms
foreach 里不 remove/add强制fail-fast 依赖数据位置规范内更快高删除比例用 removeIf30% 时 8.8x
POJO 用包装类型强制null 表达额外业务状态分场合边界层保留,绘制层紧凑表示P90 22.8→13.4ms
金额用最小单位整型强制浮点误差不破,本来就是性能解long 分 + 结算层 BigDecimal3.8 vs 142.6 ns/op
集合初始化指定大小推荐减少 resize按 JDK 重推公式capacityFor(n) 取 2 的幂扩容 1→0 次
SimpleDateFormat 不 static强制共享 Calendar 状态可换实现DateTimeFormatter,老链路 ThreadLocal + remove2680→98 ns/op
日志拼接用占位符强制说明写的是性能窄范围豁免无参固定字面量直写112.0 vs 118.6 ns/op
不允许魔法值强制常量漂移窄范围豁免数值计算代码免抽 0/1/2CR 记录
避免 Apache BeanUtils强制反射开销与静默转换换实现手写编译期映射4100→28 ns/op

三条豁免后来都回到了 spec/rules.yaml:日志规则补了「无变量调用除外」,魔法值规则补了「纯数值计算除外」,包装类型规则补了「绘制层紧凑表示除外」,每条都带 valid_until。破规矩不是削弱规范,是给规范补上例外条件——一条没有例外条件的规则,迟早会有人在暗处绕过去,而绕过去的那次不会留下任何证据。

让生成器写代码之后,规范反而更有用了

2025 年组里开始用 IDE 里的助手写一些模板代码。第一周我以为麻烦会是「它不守规范」,结果反过来——它太守了。

生成一段报告页的数值计算,它把每个 0、1、2 都抽成了 ZERO / ONE / TWO 常量;我让它把采样点改成 IntArray,下一轮它又改回 List<Int>,理由是「POJO 属性应当使用包装数据类型」;连一句不带参数的固定日志,它都硬塞了个 {} 进去。它读的是网上那套通用手册,不知道我们为这三条写过豁免单、贴过 JMH 数据、约过到期日。

那一刻我才把规范的用处想明白一层:它不再只是约束人的文档,它是喂给机器的边界说明。以前一条规则写含糊了,最多是 CR 里争两句;现在含糊的地方会被生成器按最保守的通用解释放大成几十行代码,再让人去删。

具体变了三处。代码形状越统一,生成器越容易补全、越不容易在几种写法之间摇摆;规则文件可以直接当成上下文投喂,不用每次在对话里重讲一遍;生成结果必须过同一套机械检查,人只审那些机器判不了的判断。前面讲初衷时提的第三条「可预测」,到这里才算真正兑现。

我做了一轮不太严谨但足够说明方向的对照:同一个需求拆成 12 段小代码,A 组只给需求描述,B 组额外附上 spec/rules.yaml 的本地规则、spec-waiver.md 三张豁免单和裁剪表。两组的生成结果都过同一套增量扫描,统计三项。

12 段模板代码 · 同一模型 · 每段生成一次 · 人工逐行数修改量
                              A 组(只给需求)   B 组(附规则与豁免)
至少命中 1 条 block 规则        9 / 12            2 / 12
改写已有豁免范围的合法实现       0 / 12            1 / 12   ← 「过度合规」
平均人工修改行数                23.4              6.1
平均生成耗时                   8.2s              11.7s

B 组那 1 段「过度合规」挺有意思:它把 W-009 读成了「基本类型数组不许用」,于是把已经优化过的 IntArray 又改回 List<Int?>。原因是豁免单里写的是「绘制层允许紧凑基本类型数组」,它只抓住了「允许」二字,没抓住范围限定。后来我把豁免单的格式改成三段式——适用范围 / 明确禁止什么 / 允许什么,并且要求每条豁免附一个正例一个反例,这类误读才停。生成耗时多出来的 3.5 秒是读上下文的代价,跟省下的 17 行人工修改比,划算。

给生成器看的规则文件,我放在 spec/AI_RULES.md,不同 IDE 各有各的项目规则读取路径,内容复制过去就行。关键是别把整本手册塞进去:

<!-- spec/AI_RULES.md · 约 120 行,只放本地规则 -->
# 本仓库的编码边界

## 适用范围
Kotlin / Java,minSdk 24,compileSdk 34,AGP 8.2.2。
零第三方依赖优先;新增依赖需走评审。

## 硬约束(违反即阻断)
- R-014:包装类型字段参与业务判断不得用 ==,用 Objects.equals 或 Kotlin ==。
- R-021:热点路径不得用 Array<Int> 承载采样点。
- R-018:ThreadLocal.set 必须有对应的 remove,放在 finally。
- R-022:日志与崩溃上报不得出现 idCard / phone / patientName / token 原值。
- 所有覆写方法带 override / @Override。

## 已批准例外(照抄规格,不要自行推广)
- W-007:不含任何变量的固定字面量日志,允许直接写字符串,不要加 {}。
  适用范围:固定文案的 debug/warn;禁止:error 级别、含变量的日志。
- W-009:绘制层允许 IntArray + BooleanArray 表达采样点与有效性;
  适用范围:TrendRenderer 及输入转换;禁止:DTO / RPC 边界层。
  正例:IntArray + BooleanArray;反例:List<Int?> 逐点判空。
- W-011:纯数值计算代码免抽 0/1/2 常量;
  适用范围:math 包;禁止:业务阈值、缓存 key、状态码。

## 输出要求
- 只给改动文件与 diff,不复述规则条目。
- 涉及性能的说法必须附测量口径(工具、预热轮数、分位数、机型、样本量)。
- 不确定的地方写「需补哪几项数据」,不要编造脚本路径或数字。
- 生成结束时自行列出:本次改动触碰了哪几条规则、有没有落在豁免范围里。

三段提示词是我平时直接复制用的,跟前面「事故复盘转规则」那三段不重样:

生成前 · 注入边界
上下文:@spec/AI_RULES.md、@spec/local-adaptation.md 的裁剪表、本次需求描述。
任务:先输出一份不超过 8 行的实现计划,逐行标注会触碰哪条规则。
约束:计划里不许出现「遵循最佳实践」这类空话;
      遇到豁免范围,写明是 W-00X 的哪一段适用范围;
      如果需求本身缺少边界条件(例如 null 表达什么业务状态),先提问再动手。
验收:把计划里的每条规则在 AI_RULES.md 里核对一遍,贴出行号。
生成后 · 对抗性自查
上下文:@本次生成的 diff、@spec/AI_RULES.md、@tools/scan_spec.py。
任务:以「挑刺的评审」身份列出三件事:
      (1) 有没有为了合规把热点路径改回低效实现;
      (2) 有没有把豁免单的适用范围推广到别的包;
      (3) 有没有凭空造出脚本路径、类名或性能数字。
约束:每条都要给 diff 里的具体行号;不许只说「看起来没问题」。
验收:实跑一次增量扫描命令,贴出 exit code 与命中行。
规则更新后 · 回归验证
上下文:@spec/rules.yaml 的本次 diff、@spec/AI_RULES.md、@spec/baseline.json。
任务:判断规则文件的改动有没有同步到 AI_RULES.md,并找出因此失效的旧提示词样例。
约束:只依据文件内容与度量数判断,不许用「应该差不多」下结论;
      发现不一致就给出具体补丁,不要重写整份文件。
验收:给出「需要改哪几行」的清单,以及一条能验证同步结果的命令。

这中间返工过三次。第一版我把整本手册的七个维度全塞进规则文件,结果生成器每次输出前先花半屏解释条目适用性,产出反而变慢,还时不时引用一条在我们项目里已经删掉的规约。第二版砍到 40 条,仍然偏厚。最后只留 14 条本地规则、3 张带正例反例的豁免单、一张裁剪表,约 120 行,误读才基本消失。规则文件不是越厚越准,厚度本身就是噪音——这条跟人对规范的反应其实一模一样。

还有个边界得说死:生成器不会替你判断业务语义。「限领一人一张」到底按用户算还是按批次算、null 是「未测」还是「同步失败」,它只能照着你写下的契约来;契约本身写错了,生成出来的代码再合规也是错的。M-2417 的第一层 == 现在能被扫描器拦住,第二层「服务端没有幂等」依然只能靠人在需求评审那道闸门上问出来。

所以规范的价值只是多了一层:既让人少犯错,也让机器按我们划定的边界生成。而这份边界里最值钱的部分,恰恰是那几张写着到期日、附了实测数据的例外单——它们记录了我们在什么条件下、为什么、以及到什么时候为止,可以不按通用规则来。

踩过的坑,按当时看到的报错排

前几篇我习惯把排查记录攒成一张表,这次也一样。按「现象 → 根因 → 修复」排,比我按顺序讲一遍流程省时间。

现象根因修复
限领判断失效,317 笔重复申领两个 Integer 用 ==,128 起各是不同对象Objects.equals + 服务端唯一键幂等
单测两周全绿,灰度第一天就错夹具全是 12 / 13 / 14,落在 IntegerCache 区间边界值表强制 -1 / 0 / 127 / 128 / 129
同一份代码压测环境不复现-XX:AutoBoxCacheMax 把缓存上限调高了固定并记录各环境 JVM 参数
父类改了方法签名,功能静默失效实现类漏 @Override,成了新方法编译器检查 + IDE Inspection 随仓库走
HashSet 里存进两个「相等」对象只重写 equals,没重写 hashCode两个一起重写,或用 data class
缓存怎么读都是 miss"Id#taobao_" 少一个下划线常量集中定义 + 读写同一构造入口
交易涨跌显示 0% 而不是横线基本类型默认值吞掉了 RPC 失败的 null包装类型 / 可空类型表达「无数据」
记录的创建时间被刷成当前时间POJO 属性设了 new Date() 默认值,更新时顺带写入不设默认值,写入方显式赋值
Long a = 2l 被同事问「是 21 吗」小写 l 与数字 1 难分一律大写 L
子列表强转 ArrayList 抛 ClassCastExceptionsubList 返回的是视图,不是 ArrayListnew ArrayList<>(list.subList(...))
改完原集合再读子列表抛 ConcurrentModificationExceptionmodCount 与视图期望值不一致先拷贝再改,或改完重新取视图
Arrays.asList(...).add(...) 抛 UnsupportedOperationException返回的是数组适配器,没实现修改方法包一层 ArrayList,或改设计别原地增删
foreach 里 remove 有时抛异常、有时静静漏掉删倒数第二个时不进下一次 next()Iterator.remove();批量删用 removeIf
批量删三成元素慢得离谱Iterator.remove 每次触发 System.arraycopyremoveIf 用 BitSet 一趟压缩,30% 时 8.8x
日期串偶尔串到别的年份static SimpleDateFormat 共享 CalendarDateTimeFormatter;老链路 ThreadLocal
线程池里读到上一个请求的用户信息ThreadLocal.set 后没 removetry/finally 成对,扫描脚本阻断
补传 2000 份报告越来越慢,却不报错Executors 无界队列堆到 1992有界队列 + CallerRuns 背压 + 拒绝计数
金额对账差一分new BigDecimal(0.1) 把二进制误差锁进去了BigDecimal("0.1");存储用 long 分
new HashMap<>(1024) 放 1024 个还是扩了一次阈值是 capacity * 0.75 = 768capacityFor(n) 取满足阈值的 2 的幂
给所有 Map 都填初始容量 16,内存反而涨手册说明后半句「不确定就默认」被忽略按预估量算,不确定就留默认
LOG.debug("{}", expensive()) 关掉级别照样慢参数位置在调用前就求值了级别判断包住昂贵计算,量了再决定惰性写法
Kotlin 惰性日志在热循环变慢每次调用分配一个 lambda只在低频路径用惰性,热路径实测
为消魔法值堆出两千行 Consts.java把【强制】一刀切,忽略同节【推荐】按功能归类 + 数值计算白名单
告警清零但代码一点没变好字符串搬进 StringPool、@SuppressWarnings 一键压制度量换成同类事故复发数
首次全量扫描 3000 条,团队两周都在清告警没跑基线就全量阻断只扫增量,存量单独排期
报告页滑动掉帧,heap dump 没什么异常List<Int> 装箱推高分配率与 GC绘制层 IntArray + BooleanArray
Kotlin 里写 "test".equals(obj) 被同事圈出来语言已把空安全与值比较收进 ==做一张「前提已消失」清单
minSdk 24 上 java.time 编译不过没开 core library desugaring构建配置补齐,或老链路保留 ThreadLocal
一条豁免躺了十四个月没人记得豁免没写到期检查CI 每日扫 expired,到期自动红
助手产出的规则指向不存在的脚本没验收就合进了规则文件约束里写死「必须实跑 check 命令并贴 exit code」
JMH 第一次跑出的数字离谱没预热,JIT 还没介入5 轮预热 5 轮测量,保留 Score Error

命令速查

这些命令不复杂,难的是输出里到底该看哪一行。

# 把自动装箱摊开看:确认 == 到底比的是什么
javap -c -p IntegerEqDemo.class | grep -A3 -E 'boxedCompare|if_acmpne'
#   1: invokestatic  #2   // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
#  12: if_acmpne     19   <-- 引用比较,不是数值比较
# 直接验证缓存边界,比背结论快
jshell <<'EOF'
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);   // true
System.out.println(c == d);   // false
System.out.println(c.equals(d)); // true
EOF

# 换个 JVM 参数再跑一次,看结论怎么变
java -XX:AutoBoxCacheMax=1000 -jar IntegerEqDemo.jar
#   128 那组也会变成 true —— 这正是「环境 × 数据」型 bug 难查的原因
# 增量扫描,只查本次改动
python3 tools/scan_spec.py --rule R-014 --diff origin/main
# {
#   "rule": "R-014",
#   "hits": [
#     {"file": ".../CouponChecker.java", "line": 42,
#      "code": "if (coupon.getBatchId() == rule.getBatchId()) {", "level": "block"}
#   ]
# }
# exit code = 1  ->  流水线阻断合并
# 规则、豁免、度量三样一起过一遍
python3 tools/check_waiver_expiry.py --today "$(date +%F)"
python3 tools/check_rule_expiry.py  --rules spec/rules.yaml
python3 tools/collect_metrics.py   --out spec/baseline.json
# 本地基准:JMH 只在测量侧,不进 App 产物
java -jar target/benchmarks.jar LogBench -wi 5 -i 5 -f 1 \
     -rf text -rff captures/log-bench.txt
# Benchmark            Mode  Cnt    Score    Error  Units
# LogBench.concat      avgt    5  112.024 ± 5.183  ns/op   (DEBUG 开)
# LogBench.placeholder avgt    5  118.610 ± 4.702  ns/op
# 看 Error 是否和 Score 同量级;同量级就别下「快了多少」的结论
# Android 侧的分配与帧耗时,跟 JVM 基准分开记口径
adb shell dumpsys gfxinfo com.example.health framestats | tail -20
adb shell dumpsys meminfo com.example.health | grep -E 'PSS|Dalvik|Native'
#  gfxinfo 看 Janky frames 与 90th percentile,
#  meminfo 看 PSS 分项,别拿 Java Heap 一个数解释全部

上线前我盯的四个数

规范落地这件事,最容易骗自己的度量是「告警数」。我盯的是另外四个,每个都写清口径和样本量。

数口径之前之后
同类事故复发数复盘标签为「编码写法类」,12 个月滚动3 起 / 12 个月0 起 / 12 个月
CR 争议时长中位数从提 PR 到合并,仅统计出现规则争论的 PR,30 次样本41 分钟18 分钟
增量扫描阻断率命中 block 级规则次数 / 该规则实际存在的写法次数,90 天人工抽查,无法统计命中 23 次,误报 2 次,漏报 1 次
报告页帧耗时 P90Pixel 6 / Android 13,连续滑动 10 秒,10 次取 P9022.8ms13.4ms

第三个数的「误报 2 次、漏报 1 次」我特意留着。R-014 刚上线时把测试代码里故意构造的 == 也拦了,改了 scope 才收住;漏报那次是有人把比较写进了 Kotlin 的 when 分支,粗匹配没覆盖到,后来接了 IDE 的类型信息才补上。规则不是装上就准的,得跟着误报迭代。

回归也有。W-009 到期前复测,切到 Compose 版本之后,IntArray 那部分收益从 9.4ms 缩到 3.1ms——重组频率变了,瓶颈挪到了别处。我把复测结果贴进豁免单,续期三个月,同时把 metric 从 frame_p90_ms 改成同时盯 recomposition_count。还有一次 JMH 忘了预热,跑出来的数字比现在贴的大三倍,那份数据我直接作废重跑,没往文章里放。

有几次复盘,我故意没加规则

M-2417 之后那半年,组里形成了个习惯:复盘会最后一定问一句「这条要不要加进规范」。有阵子几乎每起事故都加一条,规则池从 6 条涨到 14 条,扫描脚本跟着长,AI_RULES.md 越写越厚,误报开始冒头。2025 年 Q4 连着三次复盘会,我都投了反对票。不是觉得规范没用,是那三起事故的根因压根不在「写法」上。

第一起是报告页日期显示差一天。测试机在 UTC+8,灰度有几位用户在 UTC+0,服务端返回的是毫秒时间戳,客户端用 SimpleDateFormat 按本地默认时区格式化,跨日就错位。有人提议加一条「所有日期必须显式指定时区」。我拦了:这条看着像规范,实际是把一个领域模型问题伪装成编码风格问题。真正的解法是边界层统一 Instant、展示层才按用户时区渲染,再补一组跨时区夹具。加了规则,也只是逼着每个人在每行格式化后面补一个 .withZone(...),漏一处照样错,而且扫描器根本查不出「这个时区是不是该用的那个」。

第二起是某个同步接口偶发超时,重现不了。提议加「所有网络调用必须设置超时时间」——这条其实早就在规则池里了。查下来是服务端连接池在批量任务时被打满,客户端该做的是加重试退避与超时率监控。往规范里再加一条,只会让规则文件多一行谁都不会再看的话。

第三起是优惠券叠加算错,公式把「满减后打折」写成了「打折后满减」。这类业务语义错误,前面讲 bug 分类时我把它放在右下角那块:完全合规、静态扫描一个字不报。它该落到契约测试和对账口径上,跟编码规范没有关系。

后来我把判断写成四问,复盘会里逐条过:

<!-- docs/postmortem-to-rule.md · 决定是否新增规则 -->
1. 根因是「写法」吗?还是设计、契约、环境、依赖服务?
2. 同类事故会复发吗?是一次性配置失误,还是会被反复写出来?
3. 能机械检查吗?检查的误报成本有多高?
4. 加进去之后,规则池多一条,谁在半年后负责复审它?
四问里有两个以上答不上来,就不加,改投到别的闸门。
事故根因在哪该落到哪道闸门加规则的后果
M-2417 重复申领写法 + 缺幂等增量扫描 + 服务端唯一键有效,同类复发归零
报告日期差一天领域模型(时区)设计 + 跨时区夹具逼人逐行补时区,仍会漏
同步接口偶发超时服务端连接池监控 + 重试退避规则池里已有,重复一条
优惠券叠加算错业务公式契约测试 + 对账扫描器查不到,纯噪音
首次全量扫描 3000 告警流程配置只扫增量 + 存量排期属于规则自身的问题,不是新规则

规则池后来稳定在 14 条:删过 4 条(其中两条是我自己加的),2 条从 block 降成 warn,AI_RULES.md 卡在 120 行不再往上加。误报次数从刚上线那个月的 11 次降到最近一个季度的 2 次,靠的不是规则写得更聪明,是砍掉了三条扫得太宽的规则。规范的克制也算一种能力——加一条规则很容易,忍住不加、把问题送到对的闸门上,难得多。

有个挺微妙的副作用:规则少而准的时候,CI 变红大家会认真看;规则多而松的时候,红久了就有人顺手 @SuppressWarnings。前面讲「严格」两个字值钱,其实还有后半句——严格的前提是值得严格。

给还在纠结要不要立规矩的人

这两年组里换了几批人,被问得最多的还是「我们到底要不要搞一套规范」。我后来把说过的话收敛成几件事,每件都配着我们踩过的坑。

先把规则分成三层,别一视同仁。能机械判定的(@Override、包装类型比较、foreach 里 remove)交给 IDE 和 CI;需要人判断的(异常语义、null 的业务含义、并发边界)留在 CR;纯团队约定(命名偏好、目录结构)写进文档但不进流水线。三层混在一起,结果就是 CR 模板二十条、CI 三千告警、谁都嫌烦。我们瘦到六条之后,评审意见里指向业务语义的比例反而上来了。

每条规则都得带理由和反例。半年后复审的人不一定经历过那次事故,rationale 里写「提高代码质量」等于什么都没写;写成「== 对引用比地址,正确与否取决于数据与 JVM 参数」才有人能判断它还成不成立。我们 spec/rules.yaml 里没有理由的条目一律不许合入。

只从真实事故里加规则,不从审美偏好里加。前面那几组研究结论我一直记着:通用规则里真正能预测缺陷位置的往往只有少数几条,而针对本系统写出来的规则效果明显更好。M-2417 之后我们加的那条窄规则,比贴了四年的手册模板管用得多,就是因为它长在自己的工单里。

存量代码先跑基线,别上来就全量阻断。第一次扫描几千条告警,两周时间全花在改多年没动过的老代码上,引入的风险比消除的多。只扫增量,存量单独排期,这条是我们用一次团队倦怠换来的。

度量别用告警数。告警数下降可以靠 @SuppressWarnings 和把字符串搬进另一个类实现,一点质量都没改。换成同类事故复发数、CR 争议时长、阻断率与误报次数、热点路径 P90,这些数不好看,但骗不了人。

给例外一个正式通道。逼着人「绝对不许破」,最后得到的是暗处绕过。waiver 单要求写清范围、理由、测量数据、复核人、到期日,CI 每天检查到期——破规矩这件事一旦留下证据,反而变成了规范里最有价值的那部分例外条件。

半年复审,敢删条目。语言在走,平台在换。Kotlin 的可空类型、java.time、Compose 的重组模型,都让一批手册条目的前提消失了。我维护着一张「前提已消失」清单,到期没复核的规则自动降级成【参考】,逼着人重新看一眼。删掉一条过时规则,比新加三条更能维护规范的权威。

Android / Kotlin 团队要做本地化裁剪。手册从 Java 后端长出来,"test".equals(obj)、POJO 用包装类型、SimpleDateFormat 不 static 这些条目到了 Kotlin 和 ART 上,要么前提没了,要么代价变了。裁剪不是不尊重手册,是承认它的适用边界。我们连 List<Int> 与 IntArray 这种细节都写进了团队规则,因为掉帧是实打实的。

新人只给十条最小子集。一次丢整本手册,结果通常是背下几条口号,遇到具体场景还是不会判断。我给前十条全配了反例和工单号,让他顺着「这一条在第几行起作用」讲一遍,比背结论有用。

别把「插件零告警」当质量指标,也别把规范当质量保证的全部。规范消灭的是已知类别的写法缺陷,优惠券门槛算错、状态机漏分支这类业务语义错误,静态扫描一个字都不会报。契约、边界值测试、灰度对账、线上监控得各自在位。M-2417 的第一层是 ==,第二层是服务端没做幂等,后者才决定了影响面有多大。

让每一次性能争论都带上测量口径。工具、预热轮数、分位数、机型、样本量、误差,缺一样就别下「快了多少」的结论。手册里那些「说明」经不起实测的地方,我都是靠这一条发现的;反过来,我自己的 JMH 数字也只是我机器上的记录,换环境就得重跑。

助手生成的东西一律当草稿。让它把事故复盘转成规则挺省事,但它会写出不存在的脚本路径、把理由写成空话。约束里写死「必须实跑检查命令并贴 exit code」,这条我返工过三次才加对。

还有件小事:规范 owner 别由写规范的人长期兼着。自己复审自己写的条目,下手总会软一点。我们半年轮换一次,第一次轮换就删掉了四条没人说得清理由的规则,其中两条是我加的。

后来

上个月新同事在 CR 里第一次用 W-011 的格式申请例外,把 JMH 输出和到期日都填齐了。我在下面回了一句:把复测命令也贴上来。他补完,我点了通过。

那张 M-2417 的复盘打印件还贴在工位隔板上,纸角已经卷了。有回路过的人问上面那串 317 是什么,我说是三百多张券,也是一条规则的成本。