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"
}
这个"null"用户注册之后,前端传给他的扩展信息字段是空的,但后端在创建用户的时候,给这个字段赋了一个默认值——字符串 "null"。我当时写这个默认值的时候,脑子里的想法是"用 null 表示空",结果存进数据库的时候,ORM 框架把字符串 "null" 原样塞了进去。
于是定时任务跑起来,用 Jackson 做反序列化的时候,报错信息长这样:
code复制Cannot construct instance of `com.example.UserProfile`
(although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('null')
这个报错的意思是:Jackson 尝试把 JSON 字符串 "null"(不是真正 JSON 里的 null 字面量,而是四个字符)反序列化成 UserProfile 对象,发现 UserProfile 没有接受 String 参数的构造函数,直接抛了异常。
如果只是抛异常还好,关键是这个异常被捕获之后,我们的代码吞掉了,只在日志里打了一行 warn,然后整个用户的画像同步就被跳过了。更隐蔽的是,跳过的判定逻辑有问题,导致这个用户后面所有的新数据也都不同步了,形成了一个永久性的死区。
3.1 ObjectMapper 配置的两个大坑
这次排错让我重新审视了系统里所有 JSON 反序列化的地方,发现了两个普遍存在的大坑。
第一个坑:FalL_ON_NULL 的语义被误用。
很多团队为了图省事,在 ObjectMapper 上配置了 FAIL_ON_NULL_FOR_PRIMITIVES,但真实的生产环境里,我们更应该关注的是 ALLOW_NULL_VALUES 或者 NON_NULL 的序列化策略。如果整个项目在创建 ObjectMapper 的时候,配置了 DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES 为 true,那么只要有字段值是 null,反序列化就会直接失败——但边界情况的字符串 "null",这个配置完全管不了。
第二个坑:字符串 "null" 会被某些框架默认转换成 Java 的 null。
这个是我们在排查过程中发现的,某些版本的 MyBatis 或者 Spring 的 JSON 转换器,在把字符串 "null" 解析为 Java 对象时,出于"宽容性"设计,会把它当作 JSON 的字面量 null 来对待——字段变成了真正的 null 引用,而不是包含四个字符的字符串。这就导致日志里看起来是"这个用户没问题啊,字段是空的",但实际业务逻辑里拿到的却是 null 引用,一调用方法就 NPE。
我们最后定位到的根因是:上游消息队列里推送过来的用户扩展信息,在某次序列化时不规范,把 null 字段写成了字符串 "null",而我们的消费者没有做防御性处理,直接信任了消息内容。
3.2 防御性反序列化的正确姿势
吃了一次亏之后,我把系统里所有 JSON 反序列化的入口都加上了防御性检查,核心思路是:信任边界必须清晰,外部输入一律按"不可信"处理。
以 Jackson 为例,反序列化之前先判断字符串本身是不是 "null"、""、空白符这些特殊值:
java复制public static <T> T safeParseJson(String json, Class<T> clazz) {
if (json == null || json.trim().isEmpty() || "null".equals(json.trim())) {
return null;
}
try {
return objectMapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
log.error("JSON解析失败, input={}", json, e);
return null;
}
}
这个工具方法看着简单,但治好了我们很多顽疾。它把"空数据"和"脏数据"在这个边界上统一拦截了,下游拿到 null 之后再做业务兜底,而不是拿到一个半解析成功的残缺对象——残缺对象比 null 可怕得多,因为你不知道它缺了哪块,也就不知道它在哪个环节会炸。
4. 数据库里的 NULL 与 "null":SQL 三值逻辑到底是怎么坑人的
解决完后端反序列化的问题,我以为这事就完了。结果运营同事又找过来说,这个"null"用户在后台列表里永远排在第一位,而且按用户名精确搜索搜不到,按模糊搜索又能搜出来一堆无关记录。
这时候才进入了让我最崩溃的环节——SQL。
我们在后台用户列表页写的查询语句,核心逻辑是这样的:
sql复制SELECT * FROM user
WHERE username = #{keyword}
OR username LIKE CONCAT('%', #{keyword}, '%')
ORDER BY username ASC;
如果传入的关键词是"null",这条 SQL 会怎么执行?username = 'null' 这一句确实能匹配到那个脏数据用户,但 username LIKE '%null%' 会匹配到所有用户名里包含"null"这四个字符的用户——注意,是任何位置的"null",比如"nullpointer"、"annulling"、"nullified",全都会被捞出来。
这还不是最坑的。真正让我折腾到凌晨的,是排序逻辑。ORDER BY username ASC 排序的时候,MySQL 的默认规则是:NULL 值排在最前面(ASC 时),字符串 "null" 排在所有正常字符串中间(按字母序,'null' 在 'abc' 之后,在 'xyz' 之前)。 如果数据库里既有 NULL 值(真正空白的用户名,因为历史原因存在),又有字符串 "null",排序结果就会像神经病一样:某个真实用户明明是正常注册的,但他的 username 字段是 NULL(比如第三方登录没有回传用户名),他就和"null"用户一起被挤到列表最前面。
然后我顺着这条线翻出了一个大坑:SQL 的 NULL 三值逻辑。
4.1 三值逻辑与 NULL 比较的经典陷阱
SQL 不同于其他编程语言,它处理 NULL 用的是三值逻辑:真(TRUE)、假(FALSE)、未知(UNKNOWN)。任何和 NULL 的比较运算,结果都是 UNKNOWN,而不是 TRUE 或 FALSE。
sql复制-- 这三条语句的结果都是 UNKNOWN,一条都查不出来
SELECT * FROM user WHERE username = NULL;
SELECT * FROM user WHERE username != NULL;
SELECT * FROM user WHERE username <> NULL;
正确写法是:
sql复制SELECT * FROM user WHERE username IS NULL;
SELECT * FROM user WHERE username IS NOT NULL;
这个知识点我相信大多数开发都看过,但真正让人头秃的是三值逻辑与 OR、NOT 结合时的行为。比如下面这条查询:
sql复制SELECT * FROM user
WHERE username = 'admin'
OR NOT (username = 'null');
直觉上你会认为:username = 'admin' 匹配 admin,NOT (username = 'null') 匹配除了 "null" 以外的所有用户,两者取并集,应该还是全表。但实际执行的时候,对于 username 为 NULL 的那些记录,username = 'null' 的结果是 UNKNOWN,NOT UNKNOWN 的结果还是 UNKNOWN,UNKNOWN 在 WHERE 条件里等同于不满足条件,所以 username 为 NULL 的用户全被过滤掉了。如果你不知道这条 SQL 里的三值逻辑,你怎么也查不出为什么这个用户"神秘失踪"了。
4.2 排序时 NULL 的位置:ASC 和 DESC 的隐藏差异
除了查询,排序是另一个经常让新手翻车的点。MySQL 的 ORDER BY 默认把 NULL 视作最小值,因此在 ASC(升序)时排在最前面,在 DESC(降序)时排在最后面。但注意,这只是 MySQL 的默认行为,PostgreSQL 默认却把 NULL 视作最大值,ASC 时排最后,DESC 时排最前。如果你做过跨数据库迁移,这种"行式一致但语义不同"的行为差异,会让你的列表接口在换了数据库之后出现诡异的排序变化。
更隐蔽的是,如果我们的排序字段不只是 username,而是 ORDER BY username ASC, created_at DESC,那么当用户名为 NULL 时,第一排序键决定的顺序可能让 NULL 用户永远钉在列表第一页的顶部,而运营同事想做修正排序,只能手动改 SQL 加 IS NULL 判断:
sql复制SELECT * FROM user
WHERE username LIKE CONCAT('%', #{keyword}, '%')
ORDER BY
CASE WHEN username IS NULL THEN 1 ELSE 0 END,
username ASC,
created_at DESC;
4.3 MySQL 严格模式与 NOT NULL 约束
排查到这里,我又顺手查了一遍数据库表结构,发现 username 字段虽然逻辑上应该是有值的,但表定义里居然没加 NOT NULL 约束。早年为了兼容第三方登录(微信登录、手机号登录)的旁路注册场景,这个字段被允许为 NULL,于是一堆历史脏数据就攒下来了。
更麻烦的是,团队后来启用了 MySQL 的严格模式(sql_mode = 'STRICT_TRANS_TABLES'),这带来了一个新的坑:在严格模式下,向 NOT NULL 字段插入 NULL 值,会直接报错,而不是像宽松模式那样静默写入隐式默认值。
假设我们的注册服务升级后,在某个旁路场景没有显式给 username 赋值,代码里用的是:
java复制user.setUsername(null);
那么在严格模式下,这条插入会直接抛异常:Column 'username' cannot be null。这时候新用户注册直接失败,而老用户的数据又是脏的——同一个字段,老数据不合法但存在,新数据想合法但进不来,典型的存量与增量撕裂。
我在事故处理完后,专门拉了一个数据修复工单,分三步走:
- 先把存量数据里 username 为字符串 "null" 的记录全部筛选出来,逐条人工确认归属;
- 把真正的空字符串、纯空格、NULL 值统一替换为占位用户名 + 随机后缀;
- 再对表结构加
NOT NULL约束,同时补充字段注释说明允许的取值边界。
这套流程走完,username 字段才算是真正"干净"了。
5. 加密协议解析返回 null:排查链路里的意外收获
处理完数据库的事,我以为已经功德圆满,结果在回归测试的时候,发现一个更隐蔽的关联问题:我们有个服务调用第三方支付接口,解析回调通知的签名摘要时,获取某个字段的值为 null,导致验签直接失败,支付单状态卡在"待支付"永远不动。
这个问题虽然和"null"用户没有直接因果关系,但排查路径是一模一样的——都是从空值异常开始,一层层往里挖。
5.1 报错信息里的"null"到底是从哪一层传过来的
当时日志里报的错误是:
code复制Cannot read field "intCompact" because "subtrahend" is null
这个报错来自于 Java 的 BigInteger。intCompact 是 BigInteger 内部的一个 int 字段,我在对两个金额做减法时,其中一个参数是 null,导致内部构造方法直接抛 NPE。但为什么参数会是 null?继续往下追,发现是从一个消息队列的报文里解析出来的字符串,解析方法是:
java复制BigDecimal amount = new BigDecimal(map.get("payAmount").toString());
消息队列推送的 JSON 里,payAmount 字段的值为 JSON 的字面量 null。map.get("payAmount") 返回 Java 的 null 引用,对这个 null 引用调用 .toString(),一行报错就是 NPE;而如果直接把这个 null 传给 new BigDecimal(null),报错信息就是 NumberFormatException: null——两种报错长得完全不同,但根源都是同一个:上游把 null 当作一个普通的 JSON 字段值传了下来。
5.2 第三方返回的"内容不支持"也是 null 的变种
顺带提一句,有一类特别常见的第三方接口报错,长这样:
json复制{
"error": {
"code": "unsupported_country_region_territory",
"message": "country, region, or territory not supported",
"param": null,
"type": "request_forbidden"
}
}
注意那个 "param": null,如果我们的 SDK 在解析错误时不做空值判断,直接拿 error.param.toUpperCase() 做日志增强,又是一次 NPE。这类问题在对接海外服务商的支付、地图、内容分发 API 时特别常见——上游的字段是可选的,但你的代码把它当成必填了。
我们的处理方式是在全局的错误解析器里统一做空值降级:
java复制private String safeParam(String param) {
return (param == null || param.isEmpty()) ? "unknown" : param;
}
然后由日志平台把 "unknown" 统一聚合,方便按错误类型分桶。这里就不涉及具体服务商的业务逻辑了,但思路是通用的。
5.3 从这一次排错里总结的防御性编程清单
说来也很简单,下面这几条是从这次"null"用户事件里沉淀下来的,分享给大家。
字符串判空必须统一口径。 不要一部分代码用 == null,另一部分用 StringUtils.isEmpty(),还有一部分用 StringUtils.isBlank()。三者的差异在正常字符串上不明显,但遇到 "null"、" "、"\u0000" 这种特殊输入时,会各自给出不同的结果,导致排查时互相矛盾。建议全团队锁定一个工具类,覆盖 null、空串、纯空白、字符串 "null" 四种情况。
所有 JSON 字段必须显式声明是否可空。 在 Java 的 DTO 里,用 Optional 明确表达"这个字段有可能缺失",而不是等运行时才知道。对于历史遗留的老字段,逐步补充 @Nullable、@NonNull 之类的标注,至少在静态检查阶段就能拦住一批。
外部输入经过边界时,统一做一层"消毒"。 无论数据来自前端表单、消息队列、第三方 API 还是数据库,只要它是系统之外传进来的,就必须在一个明确的边界位置做合法性检查。检查的规则宁可严格不可宽松,因为宽松的代价就是你在凌晨两点排查一个看起来毫无逻辑的空指针。
6. 哪些框架和工具最容易踩"null"的雷
这次的经历让我反思了很久,为什么"null"这个看起来普普通通的字符串能引发这么大的连锁反应?很大程度上是因为我们依赖的框架和中间件对 null 的语义不一致,每层各有各的理解,拼接在一起就成了灾难。
6.1 MySQL 严格模式下 NULL 值默认值报错
MySQL 5.7 开始默认开启严格模式,从 8.0 开始更是默认 STRICT_TRANS_TABLES。这意味着向 NOT NULL 字段插入 NULL 值会直接报错。但很多人不知道的是,如果你没有显式设置字段的 DEFAULT 值,INSERT 语句又不包含这个字段,MySQL 会用隐式默认值填充。 隐式默认值的规则取决于字段类型:字符串类型的隐式默认值是空字符串,数值类型是 0,日期时间是当前时间戳。
这里的坑在于:隐式默认值往往不是业务想要的。 比如 username 字段,隐式默认值空字符串 "",业务后期按 username = '' 筛选时,会发现大量"空心用户"。而如果你在表定义里写了 DEFAULT NULL,插入时又没给值,严格模式下就是光明正大地报错,提醒你业务逻辑有漏洞——从这个角度看,严格模式是个好消息,它让问题在最早的时间点暴露出来,而不是在 SQL 查询环节变成一个永远排在前面的"幽灵用户"。
6.2 Nacos 命名空间一直为 null:Spring Cloud 配置中心的常见坑
顺带说一个我们团队之前在微服务改造时踩过的类似坑。Nacos 作为配置中心,如果你在 bootstrap.yml 里没有正确配置 namespace,控制台会显示命名空间为空,服务启动时拉取配置时就会拿到一堆 null 值。
排查方式也很直接:先看 Nacos 控制台上服务注册时显示的命名空间 ID 是不是 public(默认命名空间),再看客户端日志里 ConfigService 初始化时传入的 namespace 参数是否为 null。大多数情况下是环境变量或者配置文件的 key 拼写不一致(比如 spring.cloud.nacos.discovery.namespace 和 spring.cloud.nacos.config.namespace 搞混了),导致服务发现用的是 A 命名空间,配置拉取用的是 B 命名空间,两边数据对不上。
6.3 RocketMQ 连接报错 connect to null failed
还有一次是 RocketMQ 生产环境的报错:connect to null failed。这个报错信息直译是"连接到 null 失败了",看起来像是个笑话,但实际原因是 producer.setNamesrvAddr() 传入的参数为 null,或者是你的 producer 实例从 Spring 容器里取出来时,nameServer 地址没有被注入。
排查思路和这次的"null"用户事件一脉相承:先确认 null 是从哪里传进来的,再确认为什么传进来的是 null,最后才是修复空值判断。
我当时用的排查命令大致是:
bash复制# 查看进程实际生效的配置
jinfo -flag namesrvAddr <pid>
# 或者通过 Spring Boot Actuator 暴露的配置端点
curl http://localhost:8080/actuator/env | grep namesrv
6.4 大数据组件和命令行工具里的 null
如果你工作上会碰到大数据组件或者命令行操作,那 null 的坑更是无处不在。比如 MySQL 导入导出时报 --secure-file-priv is set to null,意思是 secure_file_priv 参数为空,导致 LOAD DATA INFILE 和 SELECT ... INTO OUTFILE 被限制。这个参数的含义是"限定导入导出文件的操作目录",为 null 表示完全禁用。解决办法是修改 my.cnf:
ini复制[mysqld]
secure-file-priv = /var/lib/mysql-files/
还有 Windows 下执行 chcp 65001 > null 想静默切换代码页,结果发现 > null 创建了一个名为 null 的文件,因为 Windows 的重定向黑魔法只能识别 NUL(注意是 NUL 不是 NULL)。这种跨平台的习惯差异,和 "null" 字符串引发的 bug 本质上是一类问题——你用一种语义写代码,但运行环境理解成另一种语义。
7. 如何在团队层面系统性堵住"null"这类问题
最后聊聊防范,毕竟熬夜通宵排查一次就够了。如果你们团队也做的是数据密集型、C 端注册量大的系统,我建议从四个层面建立防线。
7.1 编码规范层面:约定优先于框架
在团队的编码规范里,明确对 null 的态度。我们的经验是推"禁止返回 null,优先返回空集合"以及"外部传入必须防御"两条原则。
具体落地时,Java 项目用 Optional 封装可空返回值,Kotlin 项目直接用空安全类型,Spring 项目尽量在 Controller 层用 @Valid 做参数校验,Service 层不再相信任何参数。需要注意的是,Optional 不要用在字段级别的 DTO 里,否则序列化出来是一堆 {"present": true, "value": x} 的奇怪结构,既难读又会给前端造成新的困惑。
7.2 数据模型层面:约束从源头收紧
新表一律不允许可空字段(除非有绝对的业务理由),并给每个字段设置明确的 DEFAULT。老表逐步迁移,把脏数据修复后再加约束。具体到这个"null"用户事件,我修复完之后执行了这样的校验查询:
sql复制-- 检查是否存在字符串形式的 "null"
SELECT COUNT(*) FROM user WHERE username = 'null';
-- 检查是否存在歧义空值
SELECT COUNT(*) FROM user WHERE username IS NULL OR username = '' OR username = ' ';
如果这两个查询任意一个结果大于 0,就说明数据没修干净,表结构加 NOT NULL 约束之前必须让它们归零。
7.3 技术方案层面:全链路日志追踪和告警
空值异常最麻烦的点在于,它往往发生在业务逻辑很深的地方,而日志里只有一层模糊的 NPE 堆栈。所以我们上线了全链路 traceId 方案,从前端请求进来的时候生成一个 traceId,贯穿整个调用链,这样任何一个环节抛出空指针,都能顺着 traceId 把上下游日志串起来。
同时给空值异常设置独立的监控告警,不要和业务异常混在一起。比如我们给 NPE 单独设了一个 error code,告警阈值是每分钟超过 5 次就通知到值班群。这样下次再出现"null"用户的类似事件,至少不用等到运营同事来找你,系统已经先吼起来了。
7.4 测试层面:把"null"写进异常用例
很多团队的单元测试覆盖了正常路径、边界路径、异常路径,但很少有人会把字符串 "null" 当作测试用例。我把这次的案例沉淀成了两个固定的测试用例,放在注册接口和用户查询接口的测试套件里:
java复制@Test
void register_withNullStringUsername_shouldReject() {
RegisterRequest request = new RegisterRequest();
request.setUsername("null");
// 断言返回 400 参数错误,而不是 200 成功
}
@Test
void query_withNullKeyword_shouldNotReturnSensitiveUsers() {
// 断言用 "null" 做模糊搜索时,不会把 username 为 NULL 的用户捞出来
}
类似的测试也加到了 JSON 反序列化的工具类测试里,确保 safeParseJson 在校验字符串 "null" 时返回 null 而不是抛异常。把"null 字符串"当成一种和空指针、数组越界同级别的异常场景来对待,你会发现它能提前拦住很多线上事故。
8. 凌晨两点收工前的最后一道检查清单
写到这里,这次事故的复盘基本就完整了。最后分享一下我收工前在工单里记录的一道检查清单,也是我后来每次上线前都会过一遍的:
- 注册/登录/编辑资料等用户输入入口,是否显式拒绝 "null"、"undefined"(注意不是 undefined 类型,是字符串)、"NaN" 三种特殊字符串;
- 所有 JSON 工具类是否有统一的空值兜底方法,是否覆盖了字符串 "null" 输入;
- 数据库查询是否注意到三值逻辑,是否有人写
WHERE field = NULL而不是IS NULL; - ORDER BY 排序时,业务上是否关心 NULL 值的位置,如果关心,是否写了 CASE WHEN;
- 第三方接口返回的字段是否做了可空声明,错误信息里的 param/message 字段是否有空值降级;
- 新表设计时是否有可空字段,如果有,是否说明为什么;
- 日志里如果出现无法理解的 null 报错,是否有一个固定的人肉排查流程而不是每次从零开始。
这条清单亏过我一次,也救过我两次。说实话,空值问题本身不难,难的是它有无数种伪装,而且每一种伪装都披着"正常"的外衣。这次的事件对我最大的启发是:不要把空值处理当成校验层的事,它是一个从输入到存储到输出全链路的契约问题。 契约清晰了,"null"再会折腾也翻不出大浪。
