0004 MySQL中反人类的NULL

Posted on Sat, Jan 22, 2022
本文是新人第一次在KM上发表的文章,记录踩MySQL NULL值的坑及学习总结。本文写作目的有二:其一,从坑中倒逼自己复盘根因,消除知识盲区,养成敬畏每一个技术细节的意识;其二,用自己吃的一堑,涨他人一智,也算是一种“贡献”。当然,这是一个开发新手的总结,能力有限,若有纰漏,还请指正。

1.MySQL中的NULL是什么?

以下是引自MySQL官方文档中对NULL值的定义:

NULL值表示“没有数据”。NULL可以写成大写或小写。请注意NULL值不同于数字类型的0或字符串类型的空字符串

上述定义,如果我们用不同形状来表示不同性质的物体,可以表示成:

也就是说在MySQL中NULL(null),作为一个独立的个体,是区别于数值0以及空字符串的。那么为什么作为开发人员,我们会潜意识的把它们混在一起呢?这里一定存在一些历史经验的原因,这个原因,我大致总结为以下几点:

1️⃣

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牵扯在一起的呢?

2️⃣

在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分析工具优化,如果涉及到旧功能,需主动提醒测试回归覆盖该场景。