1. 事故现场:一个叫 null 的用户,让我从晚饭查到了凌晨两点
这事儿过去快一个月了,现在想起来还是觉得又好气又好笑。那天傍晚我正准备收拾东西下班,运营同事在群里艾特我,说后台有个用户的昵称显示成了"null"四个字母,而且这个用户的所有行为数据都是乱的,点进去看,注册时间、最后登录时间、订单数量全都不对劲。
我当时没太当回事,觉得无非就是前端没做空值兜底,把空字符串显示成了 null。结果一查后台数据库,好家伙,用户表里真的躺着一个 username 字段为字符串 "null" 的记录,不是数据库里的 NULL 值,是实实在在的四个字符"null"。这个天才用户,注册的时候把自己的昵称填成了"null"。
问题远没有这么简单。
这个用户填了 null 之后,整个系统的连锁反应就像多米诺骨牌一样倒了下来。他的订单记录查不到、优惠券列表为空、客服工单关联不上用户、推荐系统给他打不上标签,最离谱的是,运营后台按用户名搜索他的时候,SQL 直接查出来了一堆不相关的数据——因为很多查询条件的默认值就是 null,这个字符串和真正的 NULL 在 WHERE 子句里产生了各种诡异的交集。
我从晚上七点开始排查,先看前端传参,再看后端接口日志,然后一条一条对 SQL,最后定位到一串关联查询上。等我彻底搞清楚整个链路,抬头看表已经是凌晨两点十分。这篇文章就是想把这次的完整排查过程和底层原因写清楚,给所有做 Web 开发、App 后端、数据系统的同行提个醒:空值处理不是你写完校验就结束的事,它是一个贯穿全链路的系统工程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前端注册页的三行代码,是整个事故的起点
先说说这个"null"是怎么进来的。
我们当时用的是一套比较常规的前后端分离架构,前端 Vue,后端 Spring Boot,数据库 MySQL。注册接口长这样,我简化了一下核心逻辑:
javascript复制// Vue 组件里的注册提交方法
async function handleRegister(formData) {
const username = formData.username.trim(); // 这里只做了去空格
const payload = {
username: username || getDefaultName(), // 空字符串时给默认值
password: formData.password,
email: formData.email
};
await api.post('/api/user/register', payload);
}
formData.username.trim() 去掉首尾空格,然后 username || getDefaultName() 做空值兜底。从常规的 Web 安全角度看,这段代码没什么大毛病,XSS 注入、空字符串、超长字符串都考虑到了,但唯独漏了一种情况:用户输入的内容本身就是字符串 "null"。
"null" || getDefaultName() 这个表达式的返回值是什么?在 JavaScript 里,"null" 是一个非空字符串,布尔值为 true,所以整个表达式的值就是字符串 "null"。后端接到的 username 参数不是空,不是 NULL,而是实实在在的四个字符。
我后来翻了浏览器的 Network 面板,确认了当时的请求体确实是 {"username":"null","password":"******","email":"null@example.com"}。注意,email 字段用户也填了 null@example.com,这其实是个合法的邮箱格式,所以前端的正则校验也放行了。
更坑的是什么?这个用户不是手动在输入框里打的"null",他大概率是用了某种密码管理器或者浏览器自动填表插件,插件从某个泄露的数据库里读到了 username 为 null 的记录,然后自动填了进去。也就是说,这很可能是另一个系统的空值处理缺陷传染到了我们系统——上游系统把 null 当字符串存了,我们的前端又没拦截,两个"马虎"撞在一起,就产生了这条脏数据。
2.1 为什么常规校验拦不住"null"
很多人看到这里会问:你们的注册接口不是有参数校验吗?后端难道没做 @NotBlank 注解?
做了,但还是漏了。
java复制// DTO 里的校验注解
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Length(min = 2, max = 20, message = "用户名长度必须在2-20之间")
private String username;
@Email(message = "邮箱格式不正确")
private String email;
}
"null" 这个字符串长度是4,符合 2-20 的约束;它不是空白字符串,@NotBlank 也放行了;@Email 校验的是格式,null@example.com 格式合法也放行了。
所以问题的根源就清楚了:所有基于"格式"的校验都认为 "null" 是合法的用户输入,但基于"语义"的业务逻辑认为它不是。 你不可能靠正则表达式拦住 "null",因为 "null" 是一个完全合规的字符串。唯一的做法是把它列入黑名单,或者在前端做更严格的语义校验。
从前端到后端,我们感性的判断都是"用户不会这么无聊",但现实总会给你一记响亮的耳光:在开放注册的 C 端系统里,用户输入的下限就是你想象力的下限,而 "null" 只是其中一个比较文明的例子。
3. 后端接招:一个 fromJson 引发的空指针排查
如果说注册环节只是埋下了隐患,那真正让我熬夜到两点的,是后端某个定时任务在解析用户信息时的空指针。
我们系统里有一个定时任务,每天凌晨同步用户画像数据到推荐系统。这个任务会读取用户表里的扩展信息字段,这个字段在数据库里的类型是 JSON,存的是用户自定义的一些标签和偏好。正常情况下的数据长这样:
json复制{
"tags": ["数码", "运动"],
"preference": {
"language": "zh-CN",
"theme": "dark"
},
"lastActiveTime": "2024-11-01 21:30:00"
}
这
