三次线上事故后,我终于理解了什么叫”空指针恐惧症”

2026-09-02 10 0

三次线上事故后,我终于理解了什么叫"空指针恐惧症"

做后端开发这些年,我最怕的不是逻辑写错,而是null

你以为null只是一个"空值"?不,它是程序员职业生涯的定时炸弹。我用三次血淋淋的线上事故,换来了这份关于null的深刻理解。


事故一:那个悄悄"吃掉"了订单的bug

先说第一次事故。某天晚上十点,运营疯狂@我:"有个用户说他的订单状态不对,付款了但显示未付款"。

我翻了半天日志,最后在代码里找到了凶手:

public OrderStatus getOrderStatus(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    // 这里如果 order 是 null,直接返回 null
    // 然后前端拿到的 null 被当成"未付款"处理
    return order.getStatus();
}

order存在但是status字段是null,order.getStatus()直接返回null。前端一看null,想当然地显示成了"未付款"。

问题在哪?方法签名声明返回OrderStatus(一个枚举),但实际返回了null。Java的类型系统在这一点上完全没帮上忙。

正确的做法应该是:

public Optional<OrderStatus> getOrderStatus(Long orderId) {
    return Optional.ofNullable(orderMapper.selectById(orderId))
                   .map(Order::getStatus);
}

或者如果不想用Optional,至少应该在方法注释里写清楚:什么情况会返回null,返回null代表什么意思。


事故二:一场"静默失败"引发的血案

第二次事故更骚。某次大促,接口响应时间暴增,DB的CPU打满。我排查了半天,发现是N+1查询问题——但这不是今天要说的重点。

重点是:在排查过程中,我手滑执行了这么一段代码:

try {
    List<User> users = userService.getAllActiveUsers();
    for (User user : users) {
        // 这里如果 user.getVipLevel() 是 null...
        processVipUser(user.getVipLevel().getName());
    }
} catch (Exception e) {
    log.error("处理VIP用户失败", e);
}

user.getVipLevel()返回了null,然后null.getName()直接抛NPE。try-catch吃掉了异常,然后日志里只有一行"处理VIP用户失败",具体是哪个用户、哪个字段出了问题,一概不知。

这次事故教会我两件事:

  1. 不要用Exception来控制业务逻辑。如果你在用try-catch判断"是不是VIP",那设计本身就出了问题。
  2. catch之后必须记录足够的上下文。至少要记录入参、关键变量的值,否则排查起来就是噩梦。

现在的写法:

List<User> users = userService.getAllActiveUsers();
for (User user : users) {
    VipLevel vipLevel = user.getVipLevel();
    if (vipLevel == null) {
        log.warn("用户[{}]没有VIP等级,跳过VIP处理", user.getId());
        continue;
    }
    processVipUser(vipLevel.getName());
}

事故三:第三方接口返回的null,差点让我被祭天

第三次事故是跟第三方支付接口对接。对方的SDK文档写得很漂亮,接口返回的是一个PaymentResult对象,调用getTransactionId()就能拿到交易流水号。

结果上线后,有大概0.1%的订单对不上账。查了半天发现:支付失败的时候,PaymentResult对象是存在的,但getTransactionId()返回null

对方文档只字未提这个case。我的代码直接拿null当流水号存进了数据库,导致对账的时候一堆null在那飘着。

这次事故的教训:永远不要相信第三方接口的返回值文档。哪怕文档写了"不会返回null",你也要当它会返回null。

正确的处理方式:

public String getTransactionId(PaymentResult result) {
    if (result == null) {
        throw new IllegalStateException("支付结果为空");
    }
    
    String txnId = result.getTransactionId();
    if (Strings.isNullOrEmpty(txnId)) {
        log.error("支付结果异常: code={}, message={}, result={}", 
                  result.getCode(), result.getMessage(), result);
        throw new IllegalStateException("第三方返回的流水号为空,支付结果: " + result);
    }
    
    return txnId;
}

关键点:fail fast。出了问题立刻抛异常,别让它悄悄溜到下游去。


关于null的哲学思考

说了这么多事故,你有没有发现一个规律?null的问题本质上是契约问题

当你设计一个方法的时候,你其实是在跟调用者签订一份契约:"给我X,我还你Y"。但null让这份契约变得模糊——我到底是因为找不到Y而返回null,还是Y本身就是空?

所以我在项目中强制推行几条规则:

规则一:返回值尽量不用null

用空集合、空字符串、Optional、Result模式替代。Go语言的error模式我很喜欢——"有值或者有错误,不会同时两者"。

// 反面教材
public User findById(Long id) {
    return userMapper.selectById(id); // 可能返回null
}

// 正面教材
public Optional<User> findById(Long id) {
    return Optional.ofNullable(userMapper.selectById(id));
}

// 或者用Result模式
public Result<User> findById(Long id) {
    User user = userMapper.selectById(id);
    if (user == null) {
        return Result.error("用户不存在");
    }
    return Result.success(user);
}

规则二:必须用null的场景要有明确注释

有些场景确实需要返回null(比如缓存未命中)。这时候必须在方法注释里写清楚:什么情况返回null,返回null代表什么意思。

规则三:防御性编程不是矫情

每次访问可能为null的对象之前,都问自己一个问题:"如果这里突然来了个null,会怎样?" 如果答案是"会出事",那就加个判断。

// 防御性编程示例
String cityName = user.getAddress() != null 
    ? user.getAddress().getCity() != null 
        ? user.getAddress().getCity().getName() 
        : "未知城市" 
    : "未知地址";

// 用Optional更优雅
String cityName = Optional.ofNullable(user)
    .map(User::getAddress)
    .map(Address::getCity)
    .map(City::getName)
    .orElse("未知城市");

规则四:日志要记录null

当一个变量变成null的时候,这是值得记录的。特别是在边界条件、异常分支里,null出现往往意味着"出了你没预料到的情况"。

if (user.getVipLevel() == null) {
    log.warn("检测到异常用户[{}]:没有VIP等级但完成了VIP才能的操作", user.getId());
    // 然后该处理处理,该报警报警
}

写到最后

这三次事故之后,我现在看到null就条件反射地想给它加个判断。这大概就是所谓的"空指针恐惧症"吧。

但这种恐惧不是坏事。它让我在写代码的时候多了一层思考:"如果这里出错了,会影响什么?" 这种思考方式,比任何设计模式都管用。

null是静态类型语言的一座桥,连接着"类型系统告诉你的世界"和"运行时真实的世界"。这两者之间的gap,需要靠经验和纪律来填平。

下次当你写下一行xxx.getYyy()的时候,停下来想一想:这个getYyy(),有可能返回null吗?如果返回了null,你的代码能handle住吗?

想清楚再动手。这是我能给你的最实在的建议。

至于那些"加了@NotNull注解所以不可能是null"的想法——我劝你趁早放弃。注解只存在于编译期,null可不管你那一套。

祝你的代码再也没有NPE。(做梦去吧)

相关文章

SQL优化:那些你以为用对了但偷偷在拖慢你系统的索引潜规则
连接池翻车实录:我是如何把服务器搞挂的
你的数据库连接池,正在慢慢杀死你的应用
删库跑路?不,是连接池炸了——一次MySQL超时事故复盘
那些年我们踩过的API设计坑:一份让前端少骂你的实战指南
为什么你的API设计得像一坨屎,而大厂的设计就是优雅?

发布评论