三次线上事故后,我终于理解了什么叫"空指针恐惧症"
做后端开发这些年,我最怕的不是逻辑写错,而是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用户失败",具体是哪个用户、哪个字段出了问题,一概不知。
这次事故教会我两件事:
- 不要用Exception来控制业务逻辑。如果你在用try-catch判断"是不是VIP",那设计本身就出了问题。
- 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。(做梦去吧)