使用过 Oracle 数据库的 IT 从业人员,可能都或多或少踩过空字符串(’’)与 NULL 对比的坑。
在知道 Oracle B 树索引不存储全 NULL 字段的前提下,考虑如下测试用例:
create table test(id number, name varchar2(10) unique); -- Oracle
create table test(id int, name varchar(10) unique); -- PostgreSQL, MySQL
insert into test values(1, 'name1'); -- 1
insert into test values(1, 'name1'); -- 2
insert into test values(2, ''); -- 3
insert into test values(2, ''); -- 4
insert into test values(3, NULL); -- 5
insert into test values(3, NULL); -- 6
在 Oracle, PostgreSQL, MySQL 中,第 2, 4, 6 条语句,哪些能成功执行?哪些会报错?
各个数据库中是否有区别?
以下分别在这些数据库中进行相关用例的测试。
Oracle 中测试
SQL> create table test(id number, name varchar2(10) unique);
Table created.
SQL> insert into test values(1, 'name1');
1 row created.
SQL> insert into test values(1, 'name1');
insert into test values(1, 'name1')
*
ERROR at line 1:
ORA-00001: unique constraint (SYS.SYS_C0050200) violated
SQL> insert into test values(2, '');
1 row created.
SQL> insert into test values(2, '');
1 row created.
SQL> insert into test values(3, NULL);
1 row created.
SQL> insert into test values(3, NULL);
1 row created.
SQL> select id, case when name is null then 'NULL' else name end as name from test;
ID NAME
---------- ----------
1 name1
2 NULL
2 NULL
3 NULL
3 NULL
PostgreSQL 中测试
mydb@[local]:5432 =# create table test(id int, name varchar(10) unique);
CREATE TABLE
mydb@[local]:5432 =# insert into test values(1, 'name1');
INSERT 0 1
mydb@[local]:5432 =# insert into test values(1, 'name1');
ERROR: duplicate key value violates unique constraint "test_name_key"
DETAIL: Key (name)=(name1) already exists.
mydb@[local]:5432 =# insert into test values(2, '');
INSERT 0 1
mydb@[local]:5432 =# insert into test values(2, '');
ERROR: duplicate key value violates unique constraint "test_name_key"
DETAIL: Key (name)=() already exists.
mydb@[local]:5432 =# insert into test values(3, NULL);
INSERT 0 1
mydb@[local]:5432 =# insert into test values(3, NULL);
INSERT 0 1
mydb@[local]:5432 =# select id, case when name is null then 'NULL' else name end as name from test;
id | name
----+-------
1 | name1
2 |
3 | NULL
3 | NULL
(4 rows)
MySQL 中测试
mysql> create table test(id int, name varchar(10) unique);
Query OK, 0 rows affected (0.03 sec)
mysql> insert into test values(1, 'name1');
Query OK, 1 row affected (0.01 sec)
mysql> insert into test values(1, 'name1');
ERROR 1062 (23000): Duplicate entry 'name1' for key 'name'
mysql> insert into test values(2, '');
Query OK, 1 row affected (0.00 sec)
mysql> insert into test values(2, '');
ERROR 1062 (23000): Duplicate entry '' for key 'name'
mysql> insert into test values(3, NULL);
Query OK, 1 row affected (0.01 sec)
mysql> insert into test values(3, NULL);
Query OK, 1 row affected (0.01 sec)
mysql> select id, case when name is null then 'NULL' else name end as name from test;
+------+-------+
| id | name |
+------+-------+
| 1 | name1 |
| 2 | |
| 3 | NULL |
| 3 | NULL |
+------+-------+
4 rows in set (0.00 sec)
结果分析
从以上案例的实际测试结果可以看出:
- Oracle 中字面量 '' (空字符串) 在入库时被当成 NULL 处理了,而 Oracle 索引不存储全 NULL 字段,所以第 4 和 6 步骤均不会报错
- PostgreSQL 与 MySQL 中,'' (空字符串) 与 NULL 在处理时有明确区分,所以第 4 步报错,而第六步不会报错(NULL 的特殊处理)
在不了解 Oracle 对空字符串的处理逻辑情况下,还可能会对下面的过程感到费解:
SQL> delete from test;
5 rows deleted.
SQL> insert into test values(1, '');
1 row created.
SQL> select * from test where name = '';
no rows selected
为什么命名插入了一行记录,却无法查询到? 实际上就是因为在内部存储时,已经将 name 字段处理为 NULL 值字段了。所以,在 Oracle 中,对于这种情况,只能使用 IS NULL 来判断查询:
SQL> select * from test where name IS NULL;
ID NAME
---------- ----------
1
而在 PostgreSQL, MySQL 以及 SQL Server 等数据库中,空字符串与 NULL 都是有明确区分的,不容易造成歧义。
各位可以对比如下两个表达式在这些数据库中的结果,也会有同样惊奇的发现。
'' = ''
'' IS NULL
简单总结
基于 Oracle 对空字符串的特殊处理,在异构数据库迁移或同步过程中,可能会出现一些不兼容的问题:
- 从 Oracle 到 PostgreSQL/MySQL/SQL Server 等数据库时,若唯一索引对应的列数据,在应用数据入库时允许空值,那么可能存在 Oracle 数据库能运行的语句,在其他数据库中报错的情况(空字符串重复)
- 从 PostgreSQL/MySQL/SQL Server 等数据库到 Oracle 数据库,则会存在部分
IS NULL
过滤条件的数据不准确,且= ''
无法查询数据的问题 - 总体来说,我觉得还是 PostgreSQL/MySQL/SQL Server 等数据库对空字符串的处理方式比较容易理解与接受
知识扩展
前面讲到 Oracle B树索引不会存储全为 NULL 字段,下面以 PostgreSQL 数据库为例,验证 PosgreSQL 中索引对空值的处理。
PostgreSQL 索引列空值
PostgreSQL 中的 pageinspect 扩展模块不仅仅能查看表 Page 及其相关行信息,还能查看索引 Page 及索引列的记录数据。
查看 B 树索引相关函数有 4 个,说明如下:
-
bt_metap(relname text) returns record
返回 B 树索引的元数据 Page 相关信息,通常这是 B 树索引的第一个 Page (即 0 号 Page) -
bt_page_stats(relname text, blkno int) returns record
返回 B 树索引中指定 Page 的汇总信息,B 数索引的数据 Page 通常从第二个 Page 开始(即 1 号 Page) -
bt_page_items(relname text, blkno int) returns setof record
返回 B 树索引中指定 Page 中的索引记录信息 -
bt_page_items(page bytea) returns setof record
返回 B 树索引中指定 Page 中的索引记录信息,与上一个函数不同之处在于,该函数接受从get_raw_page
函数得到的原始 Page 数据作为参数
环境准备
注意: 以下 DISTRIBUTE BY REPLICATION
是 AntDB 中的语法,在分布式环境中指定数据分布方式,普通的 PostgreSQL 一般不需要此语句。
DROP TABLE TEST_NULL;
CREATE TABLE TEST_NULL(ID INT, FIRST_NAME VARCHAR(10), LAST_NAME VARCHAR(10)) DISTRIBUTE BY REPLICATION;
CREATE UNIQUE INDEX IDX_TEST_NULL_NAME ON TEST_NULL(FIRST_NAME, LAST_NAME);
INSERT INTO TEST_NULL VALUES(1, 'FIRST1', 'LAST1');
INSERT INTO TEST_NULL VALUES(2, NULL, 'LAST2');
INSERT INTO TEST_NULL VALUES(3, 'FIRST3', NULL);
INSERT INTO TEST_NULL VALUES(4, NULL, NULL);
查看索引信息
mydb@[local]:5432 =# \dt+ test_null
List of relations
Schema | Name | Type | Owner | Size | Description
--------+-----------+-------+--------+------------+-------------
public | test_null | table | pguser | 8192 bytes |
(1 row)
mydb@[local]:5432 =# \di+ idx_test_null_name
List of relations
Schema | Name | Type | Owner | Table | Size | Description
--------+--------------------+-------+--------+-----------+-------+-------------
public | idx_test_null_name | index | pguser | test_null | 16 kB |
(1 row)
可以看到,即使这个表很小,其中也仅仅只有 4 行数据,整个表数据大小也只有 8K 的情况下,其索引的大小却已经达到 16KB 了。这是因为 PostgreSQL 中 B 树索引的第一个块中存储了索引的元数据,而真正的索引记录从第二个块开始存储。
查看索引元数据
mydb@[local]:5432 =# SELECT * FROM bt_metap('idx_test_null_name');
magic | version | root | level | fastroot | fastlevel
--------+---------+------+-------+----------+-----------
340322 | 2 | 1 | 0 | 1 | 0
(1 row)
查看索引 Page 的汇总信息
mydb@[local]:5432 =# SELECT * FROM bt_page_stats('idx_test_null_name', 0);
ERROR: block 0 is a meta page
mydb@[local]:5432 =# SELECT * FROM bt_page_stats('idx_test_null_name', 1);
blkno | type | live_items | dead_items | avg_item_size | page_size | free_size | btpo_prev | btpo_next | btpo | btpo_flags
-------+------+------------+------------+---------------+-----------+-----------+-----------+-----------+------+------------
1 | l | 4 | 0 | 22 | 8192 | 8044 | 0 | 0 | 0 | 3
(1 row)
可以看到,0 号 Page 属于 meta page,无法查看汇总信息。从 1 号 Page 开始,可以查看其当前 Page 的汇总信息。
当前的 1 号 Page 中,可以看到有 2 行记录,没有 dead 记录,平均每个记录大小为 16。
查看索引 Page 中的记录信息
mydb@[local]:5432 =# SELECT * FROM bt_page_items('idx_test_null_name', 0);
ERROR: block 0 is a meta page
mydb@[local]:5432 =# SELECT * FROM bt_page_items('idx_test_null_name', 1);
itemoffset | ctid | itemlen | nulls | vars | data
------------+-------+---------+-------+------+-------------------------------------------------
1 | (0,1) | 24 | f | t | 0f 46 49 52 53 54 31 0d 4c 41 53 54 31 00 00 00
2 | (0,3) | 24 | t | t | 0f 46 49 52 53 54 33 00
3 | (0,2) | 24 | t | t | 0d 4c 41 53 54 32 00 00
4 | (0,4) | 16 | t | f |
(4 rows)
同理, 0 号 meta page 中,也没有记录信息,索引记录从 1 号 Page 开始存储。但从索引中的记录数据来看,NULL 值也与正常值一样存储进了索引。
不管是前导列为 NULL,还是后续列为 NULL,甚至是两个字段全为 NULL,都在索引中存在记录。
验证 IS NULL 是否可走索引
postgres=# explain analyze select last_name from test_null where first_name is null;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on test_null (cost=4.18..12.63 rows=4 width=38) (actual time=0.216..0.219 rows=3 loops=1)
Recheck Cond: (first_name IS NULL)
Heap Blocks: exact=1
-> Bitmap Index Scan on idx_test_null_name (cost=0.00..4.18 rows=4 width=0) (actual time=0.025..0.025 rows=3 loops=1)
Index Cond: (first_name IS NULL)
Planning time: 0.120 ms
Execution time: 0.286 ms
(7 rows)
postgres=# explain analyze select last_name from test_null where first_name is null and last_name is null;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
Index Only Scan using idx_test_null_name on test_null (cost=0.15..8.17 rows=1 width=38) (actual time=0.057..0.061 rows=2 loops=1)
Index Cond: ((first_name IS NULL) AND (last_name IS NULL))
Heap Fetches: 2
Planning time: 0.128 ms
Execution time: 0.109 ms
(5 rows)
可以看到,在 PostgreSQL 中,通过 IS NULL 过滤条件的 SQL 也是能够正常使用索引的。不管是只使用前导列,还是索引两列都使用 IS NULL 过滤,均能正常走索引扫描。
Oracle 索引列空值
同样的测试环境,在 Oracle 中执行测试
DROP TABLE TEST_NULL;
CREATE TABLE TEST_NULL(ID NUMBER, FIRST_NAME VARCHAR2(10), LAST_NAME VARCHAR2(10));
CREATE UNIQUE INDEX IDX_TEST_NULL_NAME ON TEST_NULL(FIRST_NAME, LAST_NAME);
INSERT INTO TEST_NULL VALUES(1, 'FIRST1', 'LAST1');
INSERT INTO TEST_NULL VALUES(2, NULL, 'LAST2');
INSERT INTO TEST_NULL VALUES(3, 'FIRST3', NULL);
INSERT INTO TEST_NULL VALUES(4, NULL, NULL);
查看索引数据
Oracle 中没有函数可以直接查看索引数据,一般有两种方式可以间接查看相关数据:
- 通过 BBED 工具,直接查看对应的数据块,早期版本直接提供,使用有一定的难度
- 通过转储的方式,将部分数据块转储为日志文件
以下我们通过转储的方式查看索引块中的数据
SQL> select extent_id, FILE_ID, BLOCK_ID, BLOCKS from dba_extents where segment_name = 'IDX_TEST_NULL_NAME';
EXTENT_ID FILE_ID BLOCK_ID BLOCKS
---------- ---------- ---------- ----------
0 12 222873 8
SQL> alter system dump datafile 12 block min 222873 block max 222881;
System altered.
SQL> SET LINESIZE 195
SQL> COL "trace file name" FOR A100
SQL> SELECT (SELECT VALUE FROM V$PARAMETER WHERE NAME='user_dump_dest')
2 ||'/'
3 ||TRIM(INSTANCE_NAME)
4 ||'_ora_'
5 ||(SELECT P.SPID FROM V$SESSION S,V$PROCESS P WHERE S.SID=SYS_CONTEXT('USERENV','SID') AND P.ADDR=S.PADDR)
6 ||'.trc' "trace file name"
7 FROM V$INSTANCE;
trace file name
----------------------------------------------------------------------------------------------------
/home/oracle/admin/MYDB/udump/MYDB_ora_54657.trc
以下截取 Dump 文件中关于索引记录相关的信息:
row#0[8011] flag: ------, lock: 2, len=21, data:(6): 03 03 66 95 00 00
col 0; len 6; (6): 46 49 52 53 54 31
col 1; len 5; (5): 4c 41 53 54 31
row#1[7980] flag: ------, lock: 2, len=16, data:(6): 03 03 66 95 00 02
col 0; len 6; (6): 46 49 52 53 54 33
col 1; NULL
row#2[7996] flag: ------, lock: 2, len=15, data:(6): 03 03 66 95 00 01
col 0; NULL
col 1; len 5; (5): 4c 41 53 54 32
----- end of leaf block dump -----
可以看到, Oracle 索引中只存储了 3 行数据,这 3 行中,都有至少一列的数据不为 NULL,而全 NULL 的记录并没有存储。
验证 IS NULL 是否可走索引
既然索引中全为 NULL 的数据不存储,那么推测 IS NULL 条件是不可以走索引的,以下加以验证:
SQL> explain plan for select /*+index(test_null, IDX_TEST_NULL_NAME)*/ last_name from test_null where first_name is null;
Explained.
SQL> select * from table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------
Plan hash value: 1897709643
-------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
-------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 2 | 28 | 3 (0)| 00:00:01 |
|* 1 | TABLE ACCESS FULL| TEST_NULL | 2 | 28 | 3 (0)| 00:00:01 |
-------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------
1 - filter("FIRST_NAME" IS NULL)
Note
-----
- dynamic sampling used for this statement
17 rows selected.
SQL> explain plan for select /*+index(test_null, IDX_TEST_NULL_NAME)*/ last_name from test_null where first_name is null and last_name is null;
Explained.
SQL> select * from table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------
Plan hash value: 1897709643
-------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
-------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 14 | 3 (0)| 00:00:01 |
|* 1 | TABLE ACCESS FULL| TEST_NULL | 1 | 14 | 3 (0)| 00:00:01 |
-------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------
1 - filter("FIRST_NAME" IS NULL AND "LAST_NAME" IS NULL)
Note
-----
- dynamic sampling used for this statement
17 rows selected.
可以看到,即使使用了 /*+index(test_null, IDX_TEST_NULL_NAME)*/
提示,SQL 都无法使用到索引。这也很好理解,因为 Oracle 索引中不会记录全为 NULL 的数据,那么如果走了索引,那么全为 NULL 的数据肯定就无法获取,那么最终的结果集就有可能不准确,所以无法通过索引扫描。
简单小结
通过上述实验,验证了 Oracle 中索引不会记录全为 NULL 的数据,而 PostgreSQL 中的索引则会记录所有的行记录数据。
二者这种设计上的差异,也各有各的优势与劣势。
- 忽略 NULL 值,则索引与表的记录数可能会不一致,这也使得索引大小能得到一定程度降低,间接改善非 NULL 值的索引扫描性能
- 记录 NULL 值,使得 IS NULL 的条件也可以使用索引,直接改善这类 SQL 的性能,同时在层级不变的情况下,对非 NULL 值的扫描也不会有太大影响
值得一提的是,在 PostgreSQL 中还直接支持部分数据索引,可以达到与 Oracle 索引一致的需求:
CREATE INDEX IDX_TEST_NULL_NAME ON TEST_NULL(FIRST_NAME, LAST_NAME) WHERE FIRST_NAME IS NOT NULL OR LAST_NAME IS NOT NULL;
在创建索引的时候,指定 WHERE 过滤条件,可以使得部分数据不进入索引中,减少索引大小。上例中指定 FIRST_NAME 或 LAST_NAME 不为 NULL,也就是说,排除了两列均为 NULL 的数据,这与 Oracle 中不存储全为 NULL 的效果相当,但也会导致 IS NULL 无法使用索引。