【背景】
去年我们一条日吞吐 40 亿条的行情写入链路(Java 17 + Netty + 自研序列化),P99 长期卡在 3.2ms 下不来。profile 下来发现,真正的序列化 + 落盘只占 1.1ms,剩下 2ms 几乎全花在:
- 每层 DTO 的
if (field == null) throw new IllegalArgumentException(...)校验(一个请求 4 层、每层 12 个字段,合计 48 次 null 判断 + 4 次对象校验器初始化) - 每层的
try { ... } catch (Exception e) { log.error("...", e); throw e; }——catch 块里那个log.error触发了 SLF4J 的 MDC 拷贝 + 字符串拼接,哪怕日志级别是 INFO 不输出,参数拼接也已经执行了 - 一个
Metrics.counter("write.attempt").inc()在每层都打了一次(4 次 counter inc,跨 3 个 MetricRegistry 实例)
砍掉重复校验、把 catch 里的日志改成 if (log.isDebugEnabled()) 守卫、合并 counter 到最外层,P99 直接降到 1.6ms。
这件事让我开始认真想一个问题:我们热路径上堆的"防御",到底是在保护系统,还是在拖慢系统?
【常见分歧 / 几种做法】
我观察到的团队实践大致分三派:
派一:防御拉满,"宁可多判不可 NPE"
每层入参都校验,每个外部调用都 try-catch,日志打满。逻辑是:线上环境不可预测,任何一层漏判都可能变成 NPE 雪崩。代表做法:Spring 的 @Valid + 自定义 Validator,每层都包一层。
派二:只在边界防御,内部信任 入口(Controller / gRPC handler / 消息 consumer)做一次性完整校验,内部方法假设入参合法,不重复判 null。逻辑是:热路径上每多一次判断就多一次分支预测失败的可能(尤其字段是"偶尔为 null"时),而且重复校验本身就是一种代码异味——说明数据契约没在正确的位置表达。
派三:防御用编译期 / 类型系统兜底
用 Optional<T> 代替 null、用 sealed class 代替枚举 switch、用 builder 模式强制必填字段。逻辑是:把"运行时防御"前移到"编译期约束",热路径上零开销。但代价是代码量膨胀、新人上手成本高。
【我的倾向与理由】
我倾向派二 + 派三的混合,核心原则是:防御的成本应该由"出错概率 × 爆炸半径"决定,而不是由"代码洁癖"决定。
具体判据:
- 爆炸半径 = 1 的字段(比如一个可选的 remark 字段):不判,null 了让它自然传播到序列化层统一处理,不值得为它多一次分支。
- 爆炸半径 = 全链路的字段(比如 orderId 为 null 会导致下游写错库):必须在边界判一次,用 fast-fail 把错误挡在最早的位置。
- try-catch 只捕获"你会处理"的异常:catch 了不处理只 log,等于把异常变成了"静默成功"——这比不 catch 更危险。如果你不打算做降级 / 重试 / 熔断,就别 catch,让它直接抛出去让上层框架处理。
- 日志的代价不是"打不打",是"参数拼不拼":SLF4J / Logback 的参数化日志(
log.info("x={}", x))在级别不匹配时不拼接,但如果你写的是log.info("x=" + x)或者传了一个toString()很重的对象,那无论级别都白拼了。
一句话:热路径上每一行代码都有成本,"防御"不是免费的,它只是把成本从"出 bug 时"转移到了"每次请求都付"。你要做的不是"要不要防御",而是"在哪里防御一次就够了"。
【想听大家怎么选】
几个具体问题想抛出来:
- 你们团队热路径上有没有"每层都校验"的规范?是架构师定的还是开发自己加的?有没有量化过这些校验占了多少 P99?
- 你们用
Optional还是裸 null?在 40 亿条/天的链路里,Optional的额外对象分配(即使 JIT 能消除)你们接受吗? - catch-then-log 不处理——你们 code review 里会不会打回?还是"有总比没有强"?
- 如果给你 3 天时间专门砍热路径上的防御代码(不新增功能),你砍完 P99 能降多少?
我猜大多数团队的答案是"我们没量过"——而这恰恰是最值得量的一件事。
欢迎拍砖,尤其是觉得我"为了快牺牲了安全"的同学,请给我你的场景。