深入剖析:认识Oracle 中的 NULL 值

杨廷琨,网名 yangtingkun

云和恩墨技术总监,Oracle ACE Director,ACOUG 核心专家

经常看到很多人提出和NULL有关的问题。NULL其实是数据库中特有的类型,Oracle中很多容易出现的错误都是和NULL有关的;下面简单总结一下NULL的相关知识。

NULL的基础概念和由来

NULL 是数据库中特有的数据类型,当一条记录的某个列为 NULL,则表示这个列的值是未知的、是不确定的。既然是未知的,就有无数种的可能性。因此,NULL并不是一个确定的值。这是 NULL 的由来、也是 NULL 的基础,所有和 NULL 相关的操作的结果都可以从 NULL 的概念推导出来。

判断一个字段是否为 NULL,应该用 IS NULL 或 IS NOT NULL,而不能用‘=’。对 NULL 的判断只能定性,而不能定值。简单的说,由于 NULL存在着无数的可能,因此两个 NULL 既不是相等的关系,又不是不相等的关系,同样不能比较两个 NULL 的大小,这些操作都是没有意义的,得不到一个确切的答案的。因此,对 NULL 的=、!=、>、<、>=、<=等操作的结果都是未知的,也就算说,这些操作的结果仍然是NULL。同理,对 NULL 进行+、-、*、/等操作的结果也是未知的,所以也是 NULL。

所以,很多时候会这样总结 NULL,除了 IS NULL、IS NOT NULL 以外,对 NULL 的任何操作的结果还是 NULL。

上面这句话总结的很精辟,而且很好记,所以很多时候人们只记得这句话,而忘了这句话是如何得到的。其实只要清楚 NULL 的真正含义,在处理 NULL 的时候就不会出错。

说了怎么多,来看一个经典的例子:

上面两个过程是否是等价的?对于熟悉 C 或 JAVA 的开发人员来说,可能认为二者是等价的,但是在数据库中,则还要考虑到 NULL 的情况。

当输入为 NULL 时,可以看到上面两个过程不同的输出:输入为 NULL 时,上面两个过程中的判断的结果都是一样的,不管是 NULL >= 0还是NULL < 0结果都是未知,所以两个判断的结果都是 NULL。最终,在屏幕上输出的都是 ELSE 分支中的输出值。

NULL 的布尔运算的特点

由于引入了 NULL,在处理逻辑过程中一定要考虑 NULL 的情况。同样的,布尔值的处理也是需要考虑 NULL 的情况,这使得布尔值从原来的 TRUE、FALSE 两个值变成了 TRUE、FALSE 和 NULL 三个值。

下面是 TRUE 和 FALSE 两种情况进行布尔运算的结果:

AND 操作:

上面是熟悉的 TRUE 和 FALSE 两个值进行布尔运算的结果,如果加上一个 NULL 的情况会怎样?NULL 的布尔运算是否会像 NULL 的算术运算那样结果都是 NULL呢?下面通过一个过程来进行说明:

由于 NULL 是未知,所以 NULL AND NULL、NULL OR NULL、NULL AND TRUE 和 NULL OR FALSE 的值都是未知的,这些的结果仍然是 NULL。

那么为什么 NULL AND FALSE 和 NULL OR TRUE 得到了一个确定的结果呢?这就需要从 NULL 的概念来考虑。NULL 是未知的,但是目前 NULL 的类型是布尔类型,虽然 NULL 的值不确定,但是 NULL 所在的类型确定了值的范围,因此 NULL 只有可能是 TRUE 或者 FALSE 中的一个。

而根据前面的表格,TRUE AND FALSE 和 FALSE AND FALSE 的结果都是 FALSE,也就是说不管 NULL 的值是 TRUE 还是 FALSE,它与 FALSE 进行 AND 的结果一定是 FALSE。

同样的道理,TRUE AND TRUE 和 FALSE AND TRUE 的结果都是 TRUE,所以不管 NULL 取何值,NULL 和 TRUE 的 OR 的结果都是 TRUE。

AND 操作图表变为:

下面来看一个例子:

对于 IN 和 NOT IN 与 NULL 的关系前面并没有说明,不过可以对其进行简单的变形:表达式 TNAME IN (‘T’, ‘T1’, NULL) 等价于 TNAME = ‘T’ ORTNAME = ‘T1’ OR TNAME = NULL,根据前面的布尔运算结果,当查询到 T 或 T1 这两条记录时,WHERE 条件相当于 TRUEAND FALSE AND NULL,其结果是 TRUE,因此返回了两条记录。

而表达式 TNAME NOT IN (‘T’, ‘T1’, NULL) 等价于 TNAME != ‘T’AND TNAME != ‘T1’ AND TNAME != NULL,这时 WHERE 条件相当于 TRUE AND TRUE AND NULL 或 TRUE AND FALSE ANDNULL,其最终结果不是 NULL 就是 FALSE,所以查询不会返回记录。

下面讨论一下 NULL 的布尔值运算 NOT。对于 TRUE 和 FALSE 的 NOT 运算很简单,NOTTRUE=FALSE,NOT FALSE=TRUE,那么如果包含 NULL 的情况呢:

现在看到了一个很有趣的结果,NOT NULL 的结果仍然是 NULL。可能很多人对此并不理解,下面还是从 NULL 的基本概念来解释。

NULL 表示的是未知的含义,而增加一个 NOT 操作后,并不能使 NULL 变为一个确定的值,如果 NULL 的值是 TRUE,NOT TRUE 将变为 FALSE,如果值是 FALSE,NOTFALSE将变为 TRUE,因此即使进行了 NOT 操作,NULL 本身的不确定性是仍然存在的。这就是最终结果仍然是 NULL 的原因。

这里需要注意:这个 NOT NULL 是一个布尔操作,要和 SQL 中的 NOT NULL 约束区分开。NOT NULL 约束是一个定性的描述,表示列中的数据不允许为 NULL。而这里的布尔操作,是在求值,要得到对 NULL 取非后的结果,所以仍然得到 NULL。

NULL 的默认数据类型

Oracle 的 NULL 代表的含义是不确定,那么不确定的东西也会有确定的数据类型吗?或者换个说法,NULL 在 Oracle中的默认数据类型,下面通过两个例子来说明这个问题。

NULL 的默认类型是字符类型,确切一点应该是 VARCHAR2 类型。我们知道一个字段不管是何种类型的,都可以插入 NULL 值,也就是说,NULL 可以随意的转换为任意的类型。

绝大部分的函数输入值为 NULL,返回的结果也为 NULL,这就阻止了我们通过函数的返回结果判断 NULL 的类型的企图。我们最常用来分析数据的 DUMP 函数,这次也失效了:

而且试图通过 CREATE TABLE AS 来判定 NULL 的类型也是不可能的:

发现 NULL 的数据类型的过程比较偶然,下面通过一个例子来简单的说明:

返回结果需要按照T中的 ID 的升序显示数据,SQL如下:

需求还有一点额外的要求,返回结果中0值比较特殊,其他结果正常排序,但是0排在所有非0值的后面。实现的方法有很多,比如使用 UNION ALL 将非0值和0值分开,或者将0值转换为一个很大的数值,不过这两种方法都有小缺点,前者需要扫描表两次,而后者无法解决ID最大值不确定的情况。因此选择了在排序的时候将0转化为 NULL 的方法,这样利用排序时 NULL 最大的原理,得到了希望的结果。

0确实排在了最后,但是返回结果并不正确,15居然排在了8的前面。这种结果感觉似乎是根据字符类型排序得到的。

检查排序的 DECODE 函数:

看到 DECODE 函数的结果,就知道问题所在了。果然是 DECODE 将结果变为了字符类型。(字符类型结果在 SQLPLUS 显示左对齐,而数值类型是右对齐),可是现在处理的是数值类型,为什么会得到字符类型的输出呢。在 DECODE 函数中,输入的4个参数中,两个 ID 和0都是 NUMBER 类型,而只有 NULL 这一个输入参数类型是不确定的,看来导致问题的原因就是 NULL。

为了验证 NULL 是导致问题的原因,检查标准包中 DECODE 函数的定义。

下面的 DECODE 函数定义是从 STANDARD 中摘取出来的部分内容:

观察上面的定义不难发现,虽然 Oracle 对 DECODE 函数进行了大量的重载,且 DECODE 函数支持各种的数据类型,但是 DECODE 函数具有一个规律,就是 DECODE 函数的返回值的类型和 DECODE 函数的输入参数中第一个用来返回的参数的数据类型一致。这句话可能不太好理解,举个简单的例子:

从这两个简单的例子就可以看出,DECODE 的返回值的数据类型和 DECODE 函数中第一个表示返回的参数的数据类型一致。

那么从这一点就可以推断,NULL 的默认数量类型是字符类型,这才导致 DECODE 的结果变成了字符串,而查询根据字符串规则进行排序比较,因此’15’小于’8’。

上面问题解决的方法很多比如:

有人可能会认为这时 DECODE 函数的特性而已,下面还有一个比较特别例子,同样可以说明这个问题。

虽然建表的时候使用 NULL 会报错,但是创建视图并不会报错,而且观察视图的定义,可以发现,Oracle 把 NULL 当作 VARCHAR2 类型来处理。

空字符串’’与 NULL 的关系

很多人对空字符串’’不是很清楚,这里简单总结一下。

以前我总说空字符串’’等价于 NULL,不过有些人喜欢钻牛角尖,所以我改一下说法,空字符串’’是 NULL 的字符类型的表现格式。

证明空字符串就是 NULL 是很容易的:

上面三个 SQL 语句,任意一个都足以证明空字符串’’就是 NULL。

有些人可能会说,既然’’就是 NULL,为什么不能进行 IS ’’的判断呢:

其实从上面的错误信息就可以看到答案。原因就是 IS NULL 是 Oracle 的语法,在 Oracle 运行的时刻’’是 NULL,但是现在 Oracle 还没有运行这条 SQL,就由于语法不正确被 SQL 分析器挡住了。Oracle 的语法并不包含 IS ’’的写法,所以,这一点并不能成为’’不是 NULL 的理由。

那么为什么要说’’是 NULL 的字符表示形式呢:因为’’和 NULL 还确实不完全一样,对于 NULL 来说,它表示了各种数据类型的 NULL 值。而对于空字符串’’来说,虽然它也具有 NULL 的可以任意转化为其他任何数据类型的特点,但是无论是从形式上还是从本质上它都表现出了字符类型的特点。

下面通过一个例子来证明’’本质是字符类型的 NULL。

利用重载的原理,字符类型输出 VARCHAR2,而数值类型输出 NUMBER。输入为空字符串时,输出为 VARCHAR2,从这一点上可以看出’’实际上已经具备了数据类型。所以我将’’表述为空字符串是 NULL 的字符类型表现形式。

上面根据重载的特性证明了空字符就是 NULL 的字符表现形式。下面简单描述一下字符串合并操作||的特殊性。

根据 NULL 的定义,NULL 是不确定、未知的含义,那么为什么字符类型的 NULL 是一个空字符呢?而且,对于 NULL 的加、减、乘、除等操作的结果都是 NULL,而为什么字符串合并操作||,当输入字符串有一个为空时,不会得到结果 NULL。

上面两个问题需要从 NULL 的存储格式上解释。Oracle 在存储数据时,先是存储这一列的长度,然后存储列数据本身。而对于 NULL,只包含一个 FF,没有数据部分。简单的说,Oracle 用长度FF来表示 NULL。

由于 Oracle 在处理的数据存储的时候尽量避免0的出现,因此,认为这里FF表示的是长度为0也是有一定道理的。或者从另一方面考虑,NULL 只有一个长度,而没有数据部分。

而对于字符串来说,不管是长度为0的字符串还是没有任何数据的字符串,所代表的含义都是一个空字符串。从一点上讲,空字符串就是 NULL 也是有一定的道理。如果认为空字符串是字符形式的 NULL,那么||操作的结果就不难理解了。

最后需要说明的是,不要将 ORACLE 里面的空字符串’’与 C 语言里面的空字符串””混淆。C 里面的空字符串并非不包含任何数据,里面包含了一个字符串结束符\0。C 语言中的空字符串””对应 Oracle 中 ASCII 表中的0值,即 CHR(0)。

但 CHR(0)是一个确定的值,它显然不是 NULL。

NULL 和索引

本身 NULL 类型就比较容易出错,而索引则让 NULL 又一次成为问题的焦点。

有一句很有名的话:索引不存储 NULL 值。这句话其实比不严谨。如果采用比较严谨的方式来说:B 树索引不存储索引列全为空的记录。如果把这句话用在单列索引上,就是前面提到的B树索引不存储 NULL。

首先索引分为 BTREE 和 BITMAP 两种,对于 BTREE 索引,是不存储 NULL 值的,而对于 BITMAP 索引,是存储 NULL 值的。

而从索引列的个数来划分,索引分为单列索引和复合索引。单列索引很简单,如果一条记录中索引字段为空,那么索引不会保存这条记录的信息。但是对于复合索引,由于存在着多个列,如果某一个索引列不为空,那么索引就会包含这条记录,即使索引中其他所有的列都是 NULL 值。

Oracle 的优化器在确定是否使用索引的时候,第一标准是能否得到一个正确的结果。由于OBJECT_ID 是可以为空的,而索引列不包含为空的记录。因此通过索引扫描无法得到一个正确的结果,这就是 SELECT COUNT(*) FROM T 不会使用 OBJECT_ID 上的索引的原因。

而对于 BITMAP 索引,则是另外的情况:

从上面的结果不难看出 BITMAP 索引中是包含 NULL 的。

下面看看复合索引的情况:

虽然结果中包含了 NULL 值,但是 Oracle 并没有读取表,而仅通过索引扫描就返回了最终结果,这证实了复合索引中是可以包含 NULL 值的。

这里说明了索引和NULL值的关系。但是并没有对反键索引(reverse)、逆序索引(desc)、函数索引(FBI)和CLUSTER索引进行说明。原因是这些索引其实并没有脱离 BTREE 索引和 BITMAP 索引的范畴。不必关心索引是否倒序或反键,只要是 BTREE 索引,就不会存储全 NULL 记录;反之,只要是 BITMAP 索引就会存储 NULL值。

唯一需要注意的是函数索引,函数索引的真正索引列是函数的计算结果而不是行记录中的数据,清楚了这一点函数索引其实和普通索引就没有什么区别了。

顺便在提一下域索引。由于域索引的实现本身可能会很复杂,Oracle 可能在内部是用一套表和过程来实现的,因此对于域索引是否存储 NULL,要根据域索引的实现来具体分析了。

上面结束了 NULL 在索引中的存储,下面来看看 NULL 与索引使用的关系。

很多人有一些错误的观点,认为指定 IS NULL 或者 IS NOT NULL 后是没有办法使用索引的,事实上很多和 NULL 相关的观点是 RBO 时代遗留下来的,已经不适用于 CBO优化器了。

观点一:判断一个列 IS NOT NULL 不会使用索引。

其实这个观点从一般意义上也解释不通,因为 B 树索引本身不存储键值全为 NULL 的记录,所以通过索引扫描得到的结果一定满足 IS NOT NULL 的要求。

由于索引的存储特性和 IS NOT NULL 访问本身没有冲突,因此,这种情况下很容易通过索引来得到相应的结果。

观点二:判断一个列 IS NULL 不会使用索引。

这里就不用 BITMAP 索引来举例了,即使是 B 树索引,这个观点也是不正确的。B 树索引不存储键值全为空的记录,所以对于单列索引而言,确实 IS NULL 操作是无法使用索引的。但是复合索引是可能存储一部分 NULL 值的,所以 IS NULL 操作也并非不可能使用索引。

从上面的两个例子可以看到,Oracle 的 CBO 并不会因为 SQL 语句中指定了 IS NOT NULL 或 IS NULL 操作就不再使用索引。CBO 选择索引只要满足结果正确和代价最小这两个条件就可以了。

NULL 的其他方面特点

NULL 的一个显著特点就是两个 NULL 是不相等的。无法通过等号来判断两个 NULL 是否相等,从唯一约束的特点也可以证实这一点,对于建立了唯一约束的列,Oracle 允许插入多个 NULL 值,这是因为 Oracle 不认为这些 NULL 是相等的。

但是有的时候,Oracle 会认为 NULL 是相同的,比如在 GROUP BY 和DISTINCT 操作中。这个时候,Oracle 会认为所有的 NULL 都是一类的。

还有一种情况,就是在 DECODE 函数中。如果表达式为 DECODE(COL, NULL, 0, 1),当 COL 的值为 NULL 时,Oracle 会认为输入的 NULL 与第二个参数的 NULL 值相匹配,DECODE 的结果会返回0。不过这里只是给人感觉 NULL 值是相等的,而Oracle 在实现 DECODE 函数的时候,仍然是通过 IS NULL 的方式进行的判断。

对于大多数的常用函数来说,如果输入为 NULL,则输出也是 NULL。NVL、NVL2、DECODE 和||操作是个例外。他们在输入参数为 NULL 的时候,结果可能不是 NULL。不过归结其原因是因为,这些函数都有多个参数,当多个参数不全为 NULL时,结果可能不是 NULL,如果输入参数均为 NULL,那么得到的输出结果也是 NULL。

NULL 还有一个特点,就是一般聚集函数不会处理 NULL 值。不管是 MAX、MIN、AVG 还是 SUM,这些聚集函数都不会处理 NULL。注意这里说的不会处理 NULL,是指聚集函数会直接忽略 NULL 值记录的存在。除非是聚集函数处理的列中包含的全部记录都是 NULL,这种情况下,上面这些聚集函数会返回 NULL 值。

聚集函数中比较特殊的是 COUNT,第一个特殊点是 COUNT 不会返回 NULL 值,即使表中没有记录,或者 COUNT(COL) 中,COL 列的记录全为 NULL,COUNT也会返回0值而不是 NULL。第二个特殊点就是 COUNT(*) 或 COUNT (常量)的形式。这种形式使得 COUNT 可以计算包含 NULL 记录在内的记录总数。

注:最后简单说一下 AVG,AVG(COL) 等价于 SUM(COL)/COUNT(COL),不等价于 SUM(COL)/COUNT(*):

-----the end

原文发布于微信公众号 - 数据和云(OraNews)

原文发表时间:2016-09-06

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Jimoer

JVM学习记录-类加载的过程

类的整个生命周期的7个阶段是:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(I...

562
来自专栏Java面试笔试题

运行时异常与受检异常有何异同?

异常表示程序运行过程中可能出现的非正常状态,运行时异常表示虚拟机的通常操作中可能遇到的异常,是一种常见运行错误,只要程序设计得没有问题通常就不会发生。受检异常跟...

591
来自专栏Vamei实验室

Python标准库13 循环器 (itertools)

在循环对象和函数对象中,我们了解了循环器(iterator)的功能。循环器是对象的容器,包含有多个对象。通过调用循环器的next()方法 (__next__()...

1728
来自专栏猿人谷

结构体字节对齐

结构体字节对齐       在用sizeof运算符求算某结构体所占空间时,并不是简单地将结构体中所有元素各自占的空间相加,这里涉及到内存字节对齐的问题。从理论上...

1876
来自专栏每日一篇技术文章

Java_数据类型_03

先从一个话题开始,有人说java语言具有较高的安全性和健壮性,以及夸平台的特点,大家有没有思考过为什么? 那我就围绕这个话题展开讨论。 数据类型

400
来自专栏JAVA高级架构

Java内存区域与虚拟机类加载机制

一、Java运行时数据区域 ? 1、程序计数器   “线程私有”的内存,是一个较小的内存空间,它可以看做当前线程所执行的字节码的行号指示器。Java虚拟机规范...

3318
来自专栏阿炬.NET

Reflector、reflexil、De4Dot、IL指令速查表

3105
来自专栏程序员互动联盟

【面试宝典】Java如何打印数组

面试官: 如何打印一个数组? 小白:用for循环。 面试官:如何打印一个List? 小白:用for循环。 面试官:如果打印一个二维数组? 小白:还是for循环。...

3439
来自专栏测试开发架构之路

C语言之函数

  结构化程序设计主张按功能来分析需求,主要原则自顶向下,逐步求精,模块化等。 主张按功能把软件系统逐步细分,每个功能都负责对数据进行一次处理,每个功能接收一些...

2614
来自专栏糊一笑

正则表达式学习笔记

正则表达式 1. 使用正则 创建正则表达式有两种方式,一种是以字面量方式创建,另一种是使用RegExp构造函数来创建。 var expression = / p...

2574

扫描关注云+社区