Administrator
发布于 2024-10-19 / 3272 阅读
71

代码评审清单:这些年我总结的 Review 要点

三个人都点了 Approve,上线还是出了事故

9 月底的一次线上故障,起因是个不到 200 行的 PR:修改优惠券核销逻辑。三个人 review 过,两个 LGTM 加一个 Approved,上线两小时后,监控发现优惠券超发了 1.7 万张。

复盘时我把三个人的评论翻出来看,发现一个规律:

评审人 A:字段名建议改成 camelCase
         这个 if 可以合并
评审人 B:日志用 log.debug 吧
         加个注释说明下
评审人 C:LGTM

三个人看的全是命名、格式、注释这些表层问题。真正的 bug 藏在并发控制里——原代码用 synchronized 锁的是 String 常量,新代码改成了从数据库读出来的配置对象,锁对象变了,等于没锁。

这件事让我意识到:我们缺的不是评审,是评审的着力点

我现在用的清单,按五层排

我不再从上往下逐行读代码了,改成按下面五层依次过。顺序有讲究,先看会出事的,再看好不好看。

第一层:正确性

这层出错直接导致线上故障,必须逐条看。

  • 边界条件:空集合、单元素、null 参数、超长字符串。我见过最多的 bug 是 list.get(0) 没判空。
  • 异常吞掉了没catch (Exception e) {} 这种直接打回,至少要 log.error("xxx", e)。空 catch 是最难排查的问题来源。
  • 金额和浮点:所有金额用 BigDecimal,比较用 compareTo 不用 equalsnew BigDecimal("1.0").equals(new BigDecimal("1"))false)。
  • 时间处理SimpleDateFormat 非线程安全,用了 static 就是埋雷。统一用 DateTimeFormatter
  • 幂等:接口重复调用会怎样。特别是 MQ 消费者和支付回调,没做幂等的一律打回。

第二层:并发与事务

这次事故就栽在这层,现在我最花时间的地方。

  • 锁对象是不是稳定的synchronizedString、锁每次 new 出来的对象、锁 Integer(超过 127 就出缓存),都是无效锁。
  • 事务范围对不对。事务里调 RPC、发 MQ、写文件,这些都不会回滚,还会拉长事务持有时间。我见过一个方法加了 @Transactional,里面调了个耗时 8 秒的外部接口,数据库连接池直接被拖死。
  • 事务会不会失效@Transactional 在同一个类内部调用、方法是 privatefinal、异常被 catch 掉了,这三种情况事务都不生效。
  • 线程池参数。用 Executors.newCachedThreadPool() 的一律打回,队列是无界的,流量一来就 OOM。

第三层:性能

  • 循环里查数据库。这个太常见了,见过最夸张的是循环 2000 次每次查一次库,接口耗时 47 秒。
  • 大集合操作List.contains() 在循环里用,10 万数据就是 100 亿次比较,改成 HashSet
  • 慢 SQL。看有没有走索引,EXPLAIN 一下。字段上加函数、隐式类型转换、like '%xxx' 开头这三种会让索引失效。
  • 返回体大小。列表查询没分页,或者把整个大对象序列化返回。

第四层:可维护性

这层不出事故,但影响团队长期效率。

  • 方法超过 80 行就该拆。不是说长方法一定错,而是它没法被有效 review。
  • 重复代码出现第三次就抽。一两次可以容忍,第三次说明模式已经稳定了。
  • 魔法值。状态码 if (status == 3) 这种,要么用常量,要么用枚举。
  • 日志够不够排查。关键分支有没有日志,日志里有没有 traceId、业务主键。

第五层:安全

  • SQL 注入:用 ${} 而不是 #{} 的 MyBatis 语句。
  • 越权:查数据带没带租户 ID 或用户 ID。我们踩过一次,订单查询接口没带用户 ID,改一下 URL 就能看别人的订单。
  • 敏感信息脱敏:日志里打了手机号、身份证、密码。

能用机器查的,别让人看

上面清单里"第四层可维护性"和一部分"第一层",机器做得比人好。我们现在流水线里挂了这些:

工具查什么阻断策略
Checkstyle命名、格式、import 规范error 级别阻断
SpotBugs空指针、资源未关闭、equals 错误高危阻断
SonarQube圈复杂度、重复代码、覆盖率新增代码覆盖率 < 60% 阻断
ArchUnit分层依赖约束阻断

ArchUnit 是我后来加的,用来卡分层,比如 Controller 不能直接调 Mapper:

@AnalyzeClasses(packages = "com.example.order")
class LayerTest {
    @ArchTest
    static final ArchRule controller_should_not_access_mapper =
            noClasses().that().resideInAPackage("..controller..")
                       .should().dependOnClassesThat().resideInAPackage("..mapper..");

    @ArchTest
    static final ArchRule service_should_not_use_servlet =
            noClasses().that().resideInAPackage("..service..")
                       .should().dependOnClassesThat().haveFullyQualifiedName(
                           "jakarta.servlet.http.HttpServletRequest");
}

加了自动化之后,评审意见里"命名不规范""没加日志"这类评论从 60% 降到了 12%,人的注意力终于能集中到逻辑和并发上。

评审文化比清单更难搞

清单再好,没人认真看也是废纸。我们做了几条约定,效果比工具明显:

  1. PR 控制在 400 行以内。超过 400 行的 PR,缺陷发现率会断崖下跌。我们的数据显示:200 行以内的 PR,评审平均能发现 1.3 个问题;800 行以上的 PR,平均发现 0.4 个,因为没人看得完。遇到大改动就拆成多个 PR,按"重构 PR + 功能 PR"分开提交。
  2. 4 小时响应。PR 挂超过一天,作者已经切到别的事情上了,回来改上下文切换成本高。我们在飞书群里做了机器人提醒,超过 4 小时没人看就 @ 一下。
  3. 区分评论语气。我们约定在评论前加标记:[必须] 表示不改不能合,[建议] 表示可以讨论,[ nit ] 表示吹毛求疵可改可不改。这个小小的约定解决了"提意见怕得罪人"的问题。
  4. 作者自己先过一遍 diff。提交前自己看一遍 diff,能发现一半以上的低级错误。我现在养成了习惯,提交前把 diff 从头读一遍,经常能发现自己写的半成品代码。
  5. 事故 PR 要复盘。出了线上故障,把那个 PR 的评审记录翻出来看,问一句"为什么当时没发现"。这不是追责,是为了更新清单。上面"锁对象不稳定"这一条就是这么加进去的。

小结

评审这事儿,我最大的体会是:人脑的带宽是稀缺资源,要花在机器判断不了的地方。格式、命名、空指针这些交给工具,人去想"这个并发场景对不对""这个异常会不会导致数据不一致""这个接口被恶意调用会怎样"。

至于清单本身,它不是一成不变的,每次事故复盘都是往里加一条的机会。我们这份清单从最初的 12 条涨到现在的 31 条,每一条背后都有一次踩坑。

参考