本文是新人第一次在KM上发表的文章,记录踩MySQL NULL值的坑及学习总结。本文写作目的有二:其一,从坑中倒逼自己复盘根因,消除知识盲区,养成敬畏每一个技术细节的意识;其二,用自己吃的一堑,涨他人一智,也算是一种“贡献”。当然,这是一个开发新手的总结,能力有限,若有纰漏,还请指正。
1.MySQL中的NULL是什么?
以下是引自MySQL官方文档中对NULL值的定义:
NULL值表示“没有数据”。NULL可以写成大写或小写。请注意NULL值不同于数字类型的0或字符串类型的空字符串
上述定义,如果我们用不同形状来表示不同性质的物体,可以表示成:
也就是说在MySQL中NULL(null),作为一个独立的个体,是区别于数值0以及空字符串的。那么为什么作为开发人员,我们会潜意识的把它们混在一起呢?这里一定存在一些历史经验的原因,这个原因,我大致总结为以下几点:
C/C++语言的习惯。对于我们大多数人,大学第一门计算机语言课当属C/C++语言,在这类语言中,NULL与0确实存在着某种等价关系,我们扒一下stdio.h中空指针的源码,会发现有如下定义:
#if !defined(NULL) && defined(__NEEDS_NULL)
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
以上表明,在C++中,NULL被定义为0,在C中,NULL被定义为(void *)0。如果有以下表达式:
char *p=0;
此时p是一个空指针(null pointer),不指向任何实际对象。
此为第一点:C系语言中,0与NULL是可能等价的。
那么空字符串我们又是怎么把它和NULL牵扯在一起的呢?
在java语言org.apache.commons.lang3.StringUtils中,使用isBlank()方法对null和空字符串进行判断,结果都为true。也就是通过isBlank()方法,这两者被关联起来,但我们却忽略了这种等价不满足传递性(即f(A)=C,f(B)=C无法推出A=B),我们看一下源码:
public static boolean isBlank(final CharSequence cs) {
final int strLen = length(cs);
if (strLen == 0) {
return true;
}
for (int i = 0; i < strLen; i++) {
if (!Character.isWhitespace(cs.charAt(i))) {
return false;
}
}
return true;
}
// 计算长度时null的长度为0
public static int length(final CharSequence cs) {
return cs == null ? 0 : cs.length();
}
由于null与空字符串""的长度都是0,所以都被判断为blank。所以我们容易把null与空字符串混淆在一起。
以上偏主观的分析,其实并非没有意义,因为在我们的认知过程中,总会被一些学习痕迹给带偏,这是我们需要避免的。
2.MySQL中NULL的反直觉天坑
NULL的长度值是多少?
我们可以执行以下命令验证(InnoDB引擎,下同):
mysql> select length(''), length(null);
+------------+--------------+
| length('') | length(null) |
+------------+--------------+
| 0 | NULL |
+------------+--------------+
1 row in set (0.00 sec)
可见MySQL中NULL的长度是NULL,而不是0,挺反直觉的,因此我们得出结论:
MySQL中NULL的长度是NULL,而不是0
COUNT函数是否会统计NULL字段?
我们在一张用户会话表core_session表实验一下。
首先统计下mobile_no字段有多少是为NULL的:
有63次会话记录中手机号mobile_no为NULL。
我们用COUNT(*)统计,结果为:
COUNT(*) 为3099条。
接下来我们用COUNT(mobile_no)统计,结果为:
COUNT(mobile_no) 为3036条,而这和总记录数不等,且加上不为NULL的数量刚好为总记录数。因此引出第二条结论:
COUNT(*):统计所有行数 COUNT(某个字段):该字段为NULL不计入统计
NULL参与排序会怎样?
首先我们按照升序对mobile_no字段排序:
可见升序排序时,NULL会排到非NULL的前面。
再看一下降序排序:
可见降序时,NULL会排到非NULL的后面。因此我们可以得出结论:
NULL参与排序时,降序会排在其他值前面,升序会排在其他值后边。但并不是NULL就比其他值小,只是MySQL的一种约定而已。
唯一索引如果插入多个NULL会怎样?
我们建一张test表,将mobile_no字段设置为允许为NULL且是唯一索引(验证需要,实际开发中不能这样做):
CREATE TABLE `test` (
`id` varchar(64) COLLATE utf8_bin NOT NULL DEFAULT '' COMMENT 'id',
`customer_id` varchar(32) COLLATE utf8_bin DEFAULT '' COMMENT 'APP客户ID',
`mobile_no` varchar(16) COLLATE utf8_bin DEFAULT NULL COMMENT '手机号',
PRIMARY KEY (`id`) USING BTREE,
KEY `ind_mobile_no` (`mobile_no`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='测试表'
多次插入mobile_no为NULL,结果为:
以上并未因为mobile_no是唯一索引而报错,即唯一索引如果为NULL时是允许多条记录的,这恰好与“唯一索引”的设计初衷相悖,所以唯一索引字段,应不能为NULL。
唯一索引如果插入多个NULL,是不受唯一性约束的。所以唯一索引不能设置为NULL。
下面这个坑非常重要!
字段a的取值为('a', 'A', NULL),那么 a != NULL 结果是怎样的?
为了验证这个问题,我们在上面的表中插入一个手机号:
我们执行以下语句:
上述语句竟然没有命中一条数据,同时验证了 '18012345678' != null 这个判断 是false
这是可以解释的,NULL在MySQL中表示“没有值”,一个不存在的值,和任何值去做不等式比较,都应该为false。如果为true,说明NULL这是一个可比较的值,并不是一个不存在的值,与定义相违背。
任意值 ≠ NULL
的判断,结果都是false
3.一个NULL导致的生产问题
某个表demo的biz_type字段允许NULL值,因版本兼容需要,旧版本要将biz_type新增的类型值(假设新增的为005、006)过滤掉,只展示存量类型(001-004)
伪代码:
if 旧版本:
查询不含005/006的记录: selectOld(id);
else
全部查询:selectAll(id);
问题出现在selectOld(id):
select * from demo where biz_type not in ('005', '006') and id = 'id-xxx';
以上SQL语句,希望将该用户的记录查询出来,但不包含新增场景005/006的记录。但是查询结果中,场景003对应的记录也被过滤掉了。
经分析,003场景在数据库中对应的biz_type字段值其实是NULL。即:
biz_type not in ('005', '006') 会将为NULL的一起过滤掉。
根因分析:
MySQL中,not in的实现原理是,将字段值与list(即括号中值)中的值一一比较,因此:
biz_type not in ('005', '006')
等价于
biz_type != '005' and biz_type != '006'
由第3节的铺垫我们知道:
任何值 != NULL
的结果都是false,
所以该导致整个where的结果为false,因此为NULL的就查不出来。
修复:
select * from demo where (biz_type is null or biz_type not in ('005', '006')) and id = 'id-xxx'
;
总结: NULL值不应该作为默认值存储。SQL语句后一定要先在测试环境验证,并结合行内SQL分析工具检查结果,同时要提示测试回归对应场景,以防遗漏。
最后,贴上《高性能MySQL》中的这段关于NULL的描述作为结尾:
4.结束语
本文主要是分析和实验了MySQL中NULL的特性,存在很多反直觉的坑,是开发人员应该特别留意的。为了能减少NULL带来的坑,我们还是要如《高性能MySQL》中建议:尽量避免NULL。其次,SQL语句一定要先执行验证甚至可让运维同事帮忙生产验证,再结合SQL分析工具优化,如果涉及到旧功能,需主动提醒测试回归覆盖该场景。