周五晚上十一点,手机弹出一条告警:订单查询接口5xx比例飙升。打开日志,满屏都是NullPointerException,定位到代码行时,我下意识骂了一句——
java复制if (order.getRemark().equals("加急")) {
// 走加急处理流程
}
再看一眼数据库,order.remark 是 NULL。这行代码的写作者大概从没想过这个字段会是 null,因为建表的时候,DDL 写的是 remark varchar(255) DEFAULT NULL。
这种问题在 Spring Boot 项目里太常见了。空指针不是一朝一夕写出来的,而是从建表那一刻就埋下了种子。很多人以为空指针是代码问题,但真正的源头往往在数据库设计。这篇文章就围绕 varchar 字段展开,讲清楚为什么 Spring Boot 项目里 varchar 字段不要用 NULL、NULL 和空字符串到底差在哪、NULL 是怎么一步步变成空指针的,以及建表时应该怎么写、存量表怎么改。适合正在写 Spring Boot 接口、维护 MySQL 表结构的后端同学,以及被空指针折磨过、想从根上解决问题的团队。
1. NULL 是不该存在的“第三种状态”
1.1 NULL 不是空字符串,而是“未知”
先搞清楚基本概念。NULL 和空字符串是完全不同的两样东西。
空字符串是一个确定的值,表示“这个字段的值就是空”。比如用户没填昵称,存一个 '',语义很清楚:用户存在,但昵称为空。
NULL 表示的是“没有值”,或者更准确地说,是“值未知”。相当于表格里这个格子什么都没写。问题在于,程序和数据库打交道时,NULL 导致的结果是“无法判断”,而不是“已知为空”。
我用一个生活化的类比来解释。假设你面前有一张登记表:
- 空字符串相当于在“备注”栏写了“无”;
- NULL 相当于“备注”栏被涂掉或者整栏缺失,你看不到,也不确定它到底是没填还是被撕掉了。
'' 是一个可操作、可比较的值,而 NULL 是一种状态。SQL 标准里引入 NULL 时,就为它定义了一套完全不同的比较规则,这就是著名的三值逻辑。
1.2 三值逻辑:为什么 NULL 不等于 NULL
学过 SQL 的人都知道,WHERE nickname = NULL 是永远查不出数据的,哪怕表里正好有一行 nickname 是 NULL。原因很简单:NULL = NULL 的结果不是 TRUE,也不是 FALSE,而是 UNKNOWN。
这在日常开发里会引出大量反直觉的坑。比如你想找出所有昵称等于某个值的用户:
sql复制SELECT * FROM user WHERE nickname = '张三';
如果有一行 nickname 是 NULL,这行不会出现在结果里,这很正常。但如果你写:
sql复制SELECT * FROM user WHERE nickname != '张三';
这行 NULL 的仍然不会出现。因为 NULL != '张三' 的结果也是 UNKNOWN。你本意是“排除张三,保留其他”,结果把 NULL 也排除了。
类似的还有:
sql复制WHERE nickname IN ('张三', '李四');
WHERE nickname NOT IN ('张三', '李四');
后者会漏掉所有 NULL 的行。这种 SQL 逻辑错误比空指针更隐蔽,因为代码不报错,只是结果不对。
还有一个更经典的问题:
sql复制UPDATE user SET nickname = '王五' WHERE nickname = NULL;
这行 SQL 永远不会更新任何记录,但 MySQL 不会报错,只是告诉你“0 rows affected”。如果你没注意这个提示,就会以为更新成功了。
1.3 唯一约束、排序、聚合函数里的“特殊公民”
NULL 在很多场景下都有特殊对待,这也是它被称为“特殊公民”的原因。我把最常见的几个行为和空字符串做个对比:
| 操作 | NULL | 空字符串 '' |
|---|---|---|
| 与任意值比较 | 结果 UNKNOWN | 正常比较 |
COUNT(字段) |
不统计 | 统计 |
CONCAT('你好', 字段) |
整体返回 NULL | 你好 拼接正常 |
| 升序排序 | 排在最前面 | 按字典序排列 |
| 唯一索引 | 允许重复多个 | 只能有一个 |
GROUP BY |
单独成组 | 单独成组 |
| Java 读取 | null | 空字符串 |
展开说几个实际影响。
唯一约束失效。这个坑非常典型。假设你用 email 做用户唯一标识:
sql复制CREATE TABLE user (
id BIGINT PRIMARY KEY,
email VARCHAR(128) NULL,
UNIQUE KEY uk_email (email)
);
当 email 是 NULL 时,MySQL 允许插入多行 NULL。因为 NULL != NULL,唯一索引对 NULL 不生效。也就是说,你本想用唯一约束防止重复注册,结果只要用户没填邮箱,就可以无限次插入。而如果 email 默认是空字符串 '',唯一约束就会生效,第二个空字符串用户就会被拦截。
排序结果混乱。按昵称升序排列:
sql复制SELECT nickname FROM user ORDER BY nickname ASC;
MySQL 里 NULL 被视为最小值,所以所有 NULL 昵称排在最前面,然后才是空字符串、正常昵称。如果团队里一半数据是 NULL、一半是 '',你得到的排序列表会看起来非常奇怪:一批没有昵称的人排在前面,但内部顺序又不一致。
聚合函数跳过 NULL。COUNT(remark) 统计的是 remark 不为 NULL 的行数,而不是表行数。AVG(score) 如果遇到 NULL,也不会把它当作 0 计算,而是直接跳过。你在做数据报表时,如果没意识到这一点,统计结果会偏小。
字符串拼接返回 NULL:
sql复制SELECT CONCAT('用户:', nickname) FROM user;
nickname 是 NULL 的话,结果是 NULL,而不是 用户:。你预期的拼接结果直接消失了。
这些特性单独拎出来,每一条都不难理解,但它们叠加在一起,就会让代码变得极其不稳定。你在 SQL 里要时刻提防“万一这个字段是 NULL”,尤其当字段是 varchar 时,这种提防尤其折磨人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Boot 全链路:从 JDBC 到 JSON 的 NPE 传播路径
数据库里的 NULL 不是只影响 SQL,它会在 Spring Boot 项目的每一层留下隐患。我按数据流的顺序拆解一下,你会看到,一个 NULL 字段是怎么从数据库一路炸到前端的。
2.1 MyBatis 映射:数据库 NULL 变成 Java null 只在一瞬间
Spring Boot 项目用 MyBatis 还是 JPA,本质是一样的:ORM 框架把数据库行映射成 Java 对象时,NULL 直接变成 Java 的 null。
以 MyBatis 为例,实体类:
java复制public class User {
private Long id;
private String nickname;
private String remark;
// getter / setter
}
数据库里 remark 是 NULL,MyBatis 执行结果集映射时,remark 字段就是 null。这个赋值过程非常安静,没有任何报错,但你拿到这个 Java 对象后,只要调用:
java复制user.getRemark().equals("加急")
或者:
java复制String upper = user.getNickname().toUpperCase();
瞬间空指针。
如果是一个字段还好,但实体类往往十几个字段,你不可能在每一处都判空。而 NULL 不像空字符串,空字符串调用 .equals()、.length()、.toUpperCase() 都不会报错,这是两者在 Java 世界里最大的差异。
2.2 MyBatis 动态 SQL:if test 里最隐蔽的坑
除了结果映射,MyBatis 的动态 SQL 也和 NULL 纠缠不清。先看一个常见查询:
xml复制<select id="searchUsers" resultType="User">
SELECT * FROM user
<where>
<if test="nickname != null and nickname != ''">
AND nickname = #{nickname}
</if>
</where>
</select>
这个写法的逻辑是:当传入的 nickname 不为空时,才拼接查询条件。看起来没问题,但一旦表里存在 nickname 为 NULL 的数据,问题就来了——你搜索“张”时,NULL 昵称的用户不会被查出来,这好理解;但你想查所有“没有昵称的用户”时,传一个空字符串 nickname = '',条件不拼接,返回的是所有用户,不光是没有昵称的。你没法精确表达“我要查昵称为空的人”。
另一个场景是更新语句:
xml复制<update id="updateUser">
UPDATE user
<set>
<if test="nickname != null">
nickname = #{nickname},
</if>
</set>
WHERE id = #{id}
</update>
如果前端没传 nickname,这个字段就不更新,保留原值。但假如原值是 NULL,业务上用户清空了昵称,传了空字符串 '',这里能正常更新为 ''。可如果代码里把空字符串转成了 null 再传入,这个字段会被跳过,数据库里依然是 NULL,业务上表现得就像“永远清不掉昵称”。这种问题排查起来特别费劲,因为日志里看不出报错,只是数据不一致。
2.3 Jackson 序列化与前端联调:NULL 字段的连锁反应
Spring Boot 项目前后端分离后,后端返回 JSON 的格式直接决定前端能不能正常工作。默认情况下,Jackson 会把值为 null 的字段输出为:
json复制{
"nickname": null,
"remark": null
}
前端拿到这个 JSON,如果直接做字符串操作:
javascript复制if (user.nickname.startsWith("张")) {
// ...
}
也会报错。很多前端同学被迫在每个字段边上写 || '' 兜底,这本质上就是在为数据库里的 NULL 买单。
Spring Boot 里可以配置全局忽略 null 字段:
yaml复制spring:
jackson:
default-property-inclusion: non_null
或者在类上标注:
java复制@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserVO {
private String nickname;
}
但这只是让 null 不输出,并不能解决“字段本身是 null”的问题。尤其是前端需要知道“这个用户到底有没有设置昵称”时,null 语义模糊,前端还是得猜。
2.4 业务代码与 Lambda 表达式的崩溃现场
在 Java 8 的 Stream 流式编程里,NULL 的破坏力会被进一步放大。举个例子,要筛选所有昵称以“张”开头的用户:
java复制List<User> users = userMapper.selectList(...);
List<User> result = users.stream()
.filter(u -> u.getNickname().startsWith("张"))
.collect(Collectors.toList());
如果 u.getNickname() 是 null,直接 NPE。你可能说“加个判空不就行了”,但一个是 u.getNickname() != null && u.getNickname().startsWith("张"),一个是直接写 u.getNickname().startsWith("张"),在代码可读性上不是一个量级。数据库层面统一约束后,你就能放心地直接写,不需要每处都堆防御代码。
再比如说,用 StringUtils 做判断:
java复制StringUtils.hasText(user.getNickname())
如果数据库传回来的是 null,这个方法返回 false,没问题。但如果数据库传回来的是 '',也返回 false。看上去没毛病,可一旦业务逻辑想区分两者,你会发现数据库里 NULL 和 '' 混在一起,已经无从区分了。
总之,一个 NULL 字段在 Spring Boot 项目里,至少会在 MyBatis 映射、动态 SQL、Jackson 序列化、业务代码四个环节找人麻烦。你可以在每一层都做防御,但最省力的方案,是从源头就不让它出现。
3. 建表规范:varchar 字段的“标准答案”长什么样
3.1 一张示例表,直接照抄
既然 NULL 这么坑,那正确的建表姿势是什么?核心原则一句话:所有 varchar 字段,一律 NOT NULL DEFAULT ''。
这是一张用户资料表的示例:
sql复制CREATE TABLE user_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
mobile VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
avatar_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像地址',
status VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
PRIMARY KEY (id),
UNIQUE KEY uk_mobile (mobile)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料表';
注意几个细节:
DEFAULT ''保证代码里漏传字段时,数据库自动补一个空字符串;NOT NULL保证任何一条数据都不可能写入 NULL;COMMENT写清楚字段业务含义,注释是给后来的开发者看的;- utf8mb4 别用 utf8,这是另一个坑,MySQL 的 utf8 最多支持 3 字节,存不了 emoji 和生僻字。
有了这张表的约束,MyBatis 映射出来的实体类字段就永远是空字符串,而不是 null。前端拿到的 JSON 也不会出现 "nickname": null,后续所有代码都可以放心使用 String 方法。
3.2 需要区分“未填写”时:状态位优于 NULL
有人会问:有些业务里,我需要区分“用户从未设置昵称”和“用户主动清空了昵称”,这两种状态用 NULL 表示“从未设置”不是正好吗?
我不推荐这么做。原因有两个:
第一,NULL 和 '' 并存,会让所有读数据的 SQL 和 Java 代码都变得复杂。你以为自己“表达能力强”,实际是给自己和同事埋雷。
第二,MySQL 的 NULL 和 '' 在查询、索引、统计、排序上的行为不一致,单纯为了表达两个状态,付出的代价远大于收益。
真正的解法是:增加一个独立的状态位。
sql复制nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称,无值则为空字符串',
nickname_set TINYINT(1) NOT NULL DEFAULT 0 COMMENT '昵称是否已设置:0-未设置,1-已设置'
当 nickname_set = 0 时,表示用户从未设置过昵称,此时 nickname 是空字符串;当 nickname_set = 1 时,表示用户设置过昵称,此时 nickname 可能是空字符串(用户清空了),也可能是正常文字。
这样表达力更强,而且 nickname 字段本身永远是空字符串,不引入 NULL,查询、排序、代码判断都干干净净。状态位带来的额外成本只有一个小 TINYINT 字段,但换来的是所有层级的清爽。
3.3 枚举、逻辑删除、关联字段怎么定
除了普通字符串,还有几类特殊字段也值得统一规范。
枚举字符串,比如状态字段:
sql复制status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/PROCESSING/DONE'
默认值直接给一个合法的业务初始状态,不要给空字符串,也不要允许 NULL。这样代码里解析枚举时,永远有一个可用值。
逻辑删除标志:
sql复制deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'
别用 is_deleted VARCHAR(1) DEFAULT NULL,用 TINYINT 配合 0/1 最省心。
关联字段,比如外键、创建人 ID:
sql复制creator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人ID,0表示系统操作'
关联字段用 NULL 表示“没有关联”,在 JOIN 时也会出现意想不到的行为。推荐用 0 或者空字符串做“无关联”的语义标记,根据字段类型选择。
3.4 建表评审清单:上线前 15 分钟检查完
我团队里执行的一套建表评审清单,很简单,但覆盖率很高。新表上线前,评审人花 15 分钟就能过一遍:
- 所有 varchar/char/text 字段是否都是
NOT NULL DEFAULT ''; - 所有枚举型字符串是否有明确的业务默认值;
- 所有 TINYINT 逻辑标志是否有
DEFAULT 0; - 所有数值字段是否有
DEFAULT 0; - 日期时间字段如果有特殊语义(比如“从未处理”),是否单独用状态位表达;
- 唯一索引是否建立在可能为 NULL 的字段上;
- 所有字段是否都有
COMMENT注释。
任何一条不满足,打回修改。这套规则很死板,但正是这种死板,能最大程度减少后续开发的“惊喜”。数据库表结构是永久性的,改表成本远高于改代码,所以建表时多花十分钟,后面能省十小时。
4. 存量表怎么还债?改造步骤与兼容性方案
道理大家都懂,但现实里肯定已经有很多表建成了 DEFAULT NULL。存量表怎么改?我提供一套经过验证的流程。
4.1 先做数据体检:NULL 分布到底有多少
不要上来就 ALTER TABLE,先搞清楚现状。用 SQL 统计一下:
sql复制SELECT
COUNT(*) AS total_rows,
COUNT(nickname) AS nickname_not_null,
SUM(nickname IS NULL) AS nickname_null
FROM user_profile;
用 COUNT(字段) 会跳过 NULL 的特性,一行 SQL 就能算出 NULL 总量。也可以查得更细:
sql复制SELECT nickname, COUNT(*) AS cnt
FROM user_profile
WHERE nickname IS NULL
GROUP BY nickname;
还有一个系统表级别的扫描方法,可以在不知道具体业务表的情况下,快速找出整个库里哪些 varchar 字段允许 NULL:
sql复制SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND DATA_TYPE IN ('varchar', 'char', 'text')
AND IS_NULLABLE = 'YES';
这条 SQL 会列出所有允许 NULL 的字符串字段,是制定改造清单的利器。用 information_schema 定期扫描,发现一个改一个,能有效控制历史债扩张。
4.2 分批 UPDATE:大表不要一次性梭哈
确认 NULL 数据后,先把 NULL 统一更新为 '':
sql复制UPDATE user_profile
SET nickname = ''
WHERE nickname IS NULL;
如果表数据量不大,直接执行没问题。但如果是百万行以上的大表,DELETE/UPDATE 一次性执行会持有大量行锁,造成主从延迟和业务阻塞。这时要分批操作:
sql复制UPDATE user_profile
SET nickname = ''
WHERE nickname IS NULL
AND id BETWEEN 1 AND 10000;
用一个循环,每次更新 1 万行,间隔几秒再执行下一批。也可以用 LIMIT 配合主键范围,但注意 MySQL 的 UPDATE 语法里 LIMIT 不是所有版本都支持得很好,用主键范围最稳妥:
sql复制UPDATE user_profile
SET nickname = ''
WHERE nickname IS NULL
AND id > :lastProcessedId
ORDER BY id
LIMIT 1000;
每批执行完记录一下 :lastProcessedId,直到影响行数为 0。这个过程不需要停服,但建议放在业务低峰期,并且优先处理订单、用户这类核心表。
4.3 ALTER TABLE 修改约束:注意锁表和备份
数据清理干净后,终于可以修改表结构:
sql复制ALTER TABLE user_profile
MODIFY COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称';
这里有几个注意事项:
- 执行前先备份表或开启 DDL 审批流程,物理机环境建议
mysqldump备份单表; - MySQL 5.6 以上支持
ALGORITHM=INPLACE,但MODIFY COLUMN改的是列属性,很多时候仍然需要重建表,执行期间可能锁写,建议在低峰期操作:sql复制如果数据库版本或表结构不支持,MySQL 会降级处理并提示,这时就要评估停服窗口了;ALTER TABLE user_profile MODIFY COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称', ALGORITHM=INPLACE, LOCK=NONE; - 一次 ALTER 尽量只做一张表的一个字段,避免一个大事务长时间占用资源;
- 改完之后立刻跑一遍查询,确认历史 NULL 数据已经变成
'',且查询结果正常。
4.4 改造后的回归测试重点
表结构改造最大的风险不是 SQL 语法错误,而是业务逻辑受隐性影响。回归测试至少要覆盖:
- 插入:不传 nickname 字段,能否正常写入空字符串;
- 插入:显式传 NULL,是否被数据库拒绝或转换(严格模式下会报错,这其实是在提醒应用层别传 NULL);
- 查询:按空字符串搜索、按正常字符串搜索、空字符串排序是否符合预期;
- 唯一约束:email 为
''时第二次插入是否被拦截; - 代码层:原有的
if (xxx != null)判断是否还有必要保留,不必要的老判断可以顺手删掉,简化代码。
回归测试不只是测“功能正常”,更要测“以前存在的坑是否消失”。我一般会写一个很小的 JUnit 测试,专门验证所有 varchar 字段在读取时都不为 null,用这个测试卡住存量表的改造质量。
5. 代码层兜底:别把宝全押在数据库上
数据库规范是根基,但在彻底改造完之前,代码层也得有兜底策略。这里分享几个我常用的方案。
5.1 应用层统一收口:入库前一律 defaultIfBlank
最直接的做法是,在 Controller 或 Service 层统一处理:
java复制public void createUser(UserCreateRequest request) {
User user = new User();
user.setNickname(StringUtils.defaultIfBlank(request.getNickname(), ""));
user.setRemark(StringUtils.defaultIfBlank(request.getRemark(), ""));
userMapper.insert(user);
}
StringUtils.defaultIfBlank(str, "") 会把 null 和空字符串都转成空字符串,保证写入数据库的永远不是 NULL。虽然 MyBatis 动态 SQL 可以避免 insert 时显式写入 null,但那种方式有风险:如果业务上需要把字段清空,动态 SQL 会跳过更新,导致清不掉。统一在应用层转 '' 是最可控的。
5.2 MyBatis TypeHandler:全局统一处理 null 映射
如果你项目里历史表很多,代码里到处都有 null 风险,可以自定义一个 String TypeHandler,让 MyBatis 在结果映射时,把 NULL 自动转成空字符串:
java复制@MappedTypes(String.class)
public class StringNullTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
throws SQLException {
ps.setString(i, parameter == null ? "" : parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return StringUtils.defaultIfBlank(rs.getString(columnName), "");
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return StringUtils.defaultIfBlank(rs.getString(columnIndex), "");
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return StringUtils.defaultIfBlank(cs.getString(columnIndex), "");
}
}
然后在 MyBatis 配置里注册:
yaml复制mybatis:
type-handlers-package: com.example.handler
这样所有 String 类型的字段,无论数据库里是 NULL 还是 '',Java 对象里统一都是空字符串。这个方案对存量代码侵入最小,不用改业务逻辑,空指针问题能在短时间内大幅下降。
不过要提醒一点:TypeHandler 是全局生效的,如果你的业务真的需要区分 NULL 和 '',这个方案会掩盖这种差异,让两种状态混在一起。所以在用之前,先确认业务需求里没有依赖 NULL 语义的地方。
5.3 Jackson 配置:让 null 字段不参与序列化
在前端对接层面,至少把默认的 Jackson 行为改一下:
yaml复制spring:
jackson:
default-property-inclusion: non_null
这样后端返回的 JSON 里不会出现 null 字段,前端少了一堆判空逻辑。但这只是“眼不见为净”,真正的问题还得从数据库和代码层解决。如果某天需要把 null 和空串区分开,这种配置反而会掩盖问题,所以别把它当成万能药。
5.4 团队约定:CR 评审时必须查的硬性项
最后,技术方案再完美,也需要人来执行。我团队里把这几条写进了代码评审规范,属于一票否决项:
- 实体类 String 字段不允许赋 null,特殊情况必须加注释说明原因;
- MyBatis 动态 SQL 的
<if test="字段 != null">以外的 null 处理必须可视化; - 新增 varchar 字段的表结构,必须满足
NOT NULL DEFAULT ''; - 接口返回值如果是 String 类型,不允许有 null 语义。
没有这些硬性约定,过两个月又会有人以“特殊业务需要”为由写出一个 DEFAULT NULL 的字段。空指针问题就是这样一点一点卷土重来的。
6. 从一次事故反推出来的建表习惯
回到文章开头那个凌晨告警。那次事故的最后,根本修复并不只是把 order.getRemark().equals("加急") 改成 "加急".equals(order.getRemark()),而是把所有订单表的 varchar 字段全部改成 NOT NULL DEFAULT '',并在代码里做了全量收口。
我在实际项目中形成的习惯是:建表时先想清楚“这个字段存 NULL 有什么意义”,如果只是“可能没值”,那就用默认值,而不是 NULL。varchar 字段尤其如此,因为空字符串是它的天然默认值,放着空字符串不用,非要用 NULL,等于主动给自己埋雷。
如果你现在正面临一堆历史表的历史债,我建议分三步走:先用 information_schema 扫出所有允许 NULL 的 varchar 字段,排出业务优先级;然后按本文第 4 节的方法,一周改造一两张表,逐步收口数据;最后在建表评审和 CR 评审上把好关,防止新增表继续带着 NULL 上线。这套流程执行三个月后,你会发现空指针的排查量会明显下降,团队代码里那些到处可见的判空语句也能删掉不少。
最后再分享一个小技巧:把“扫描含 NULL 的 varchar 字段”写成一条定时 SQL,每周跑一次发到群里,谁负责的表有新增 NULL 字段,一目了然。技术债这东西,贵在早发现,而不是等它变成下一个凌晨告警。
