摘要:从一次"明明有数据却查不出来"的诡异bug出发,深度剖析MySQL中NULL值的5个反直觉行为。通过真实案例展示NULL = NULL返回NULL、COUNT(column)不统计NULL、NOT IN遇到NULL全军覆没、索引对NULL的特殊处理、以及排序规则的陷阱,配合时序图和测试用例,揭秘NULL值在SQL中的诡异逻辑,给出避坑指南和最佳实践。
💥 翻车现场
周一早上,测试同学在群里@了哈吉米。
测试同学:@哈吉米 用户列表查询有bug,明明有10个未填手机号的用户,怎么查不出来?
哈吉米:发下你的查询条件
测试同学:查询"手机号不等于138开头"的用户
哈吉米看了下SQL:
-- 查询手机号不是138开头的用户
SELECT * FROM user WHERE phone != '138%';
-- 结果:0行
哈吉米:"怎么可能?明明有用户没填手机号(phone是NULL)啊!"
看了下数据:
SELECT id, username, phone FROM user;
+----+----------+---------------+
| id | username | phone |
+----+----------+---------------+
| 1 | alice | 13800138000 |
| 2 | bob | 13900139000 |
| 3 | charlie | NULL | ← NULL怎么查不出来?
| 4 | david | NULL |
| 5 | eve | 15000150000 |
+----+----------+---------------+
哈吉米:"phone != '138%' 应该包括NULL吧?为什么查不出来?"
下午,南北绿豆和阿西噶阿西来了。
南北绿豆:"这是NULL值的第一个坑:NULL和任何值比较,结果都是NULL!"
哈吉米:"???"
阿西噶阿西:"NULL在SQL里是个特殊值,有很多反直觉的行为,我给你讲讲。"
🕳️ 坑1:NULL = NULL 返回NULL
问题重现
-- 测试
SELECT NULL = NULL;
+-------------+
| NULL = NULL |
+-------------+
| NULL | ← 不是TRUE,也不是FALSE,是NULL!
+-------------+
-- 再测试
SELECT NULL != NULL;
+--------------+
| NULL != NULL |
+--------------+
| NULL | ← 还是NULL
+--------------+
-- 甚至
SELECT NULL = 1; -- NULL
SELECT NULL != 1; -- NULL
SELECT NULL > 1; -- NULL
SELECT NULL < 1; -- NULL
核心原则:NULL和任何值比较(=、!=、>、<),结果都是NULL,不是TRUE也不是FALSE。
为什么会这样?
南北绿豆:"因为NULL表示'未知',未知和任何值比较,结果都是未知。"
NULL的含义:
- 不是空字符串('')
- 不是0
- 而是"未知"、"不存在"
逻辑:
- NULL = 1?不知道(NULL可能是1,也可能不是)→ NULL
- NULL != 1?不知道(NULL可能不是1,也可能是)→ NULL
- NULL = NULL?不知道(两个未知值不一定相等)→ NULL
正确的NULL判断
-- ❌ 错误写法
SELECT * FROM user WHERE phone = NULL;
-- 0 rows(查不到任何数据)
SELECT * FROM user WHERE phone != NULL;
-- 0 rows(也查不到任何数据)
-- ✅ 正确写法
SELECT * FROM user WHERE phone IS NULL;
-- 查到phone为NULL的行 ✅
SELECT * FROM user WHERE phone IS NOT NULL;
-- 查到phone不为NULL的行 ✅
回到翻车现场
-- 原始SQL(错误)
SELECT * FROM user WHERE phone != '138%';
-- 逻辑:NULL != '138%' → NULL → WHERE子句为NULL → 不返回这行
-- 正确写法
SELECT * FROM user WHERE phone NOT LIKE '138%' OR phone IS NULL;
-- 或者
SELECT * FROM user WHERE (phone IS NULL OR phone NOT LIKE '138%');
执行流程:
graph TD
A[扫描user表] --> B{phone != '138%'?}
B -->|phone = '13800138000'| C[FALSE,不返回]
B -->|phone = '15000150000'| D[TRUE,返回]
B -->|phone = NULL| E[NULL,不返回]
F[正确写法] --> G{phone IS NULL OR phone NOT LIKE '138%'?}
G -->|phone = NULL| H[TRUE,返回 ✅]
G -->|phone = '15000150000'| I[TRUE,返回 ✅]
style E fill:#FFB6C1
style H fill:#90EE90
哈吉米:"卧槽,原来WHERE子句的结果如果是NULL,这行就不会返回!"
阿西噶阿西:"对!WHERE子句必须是TRUE才返回,NULL不行。"
🕳️ 坑2:COUNT(column)不统计NULL
问题重现
-- 测试数据
CREATE TABLE test_count (
id INT,
name VARCHAR(50),
phone VARCHAR(20)
);
INSERT INTO test_count VALUES
(1, 'alice', '13800138000'),
(2, 'bob', NULL),
(3, 'charlie', NULL),
(4, 'david', '15000150000');
-- 查询
SELECT
COUNT(*) AS count_all,
COUNT(phone) AS count_phone,
COUNT(1) AS count_1
FROM test_count;
+-----------+-------------+---------+
| count_all | count_phone | count_1 |
+-----------+-------------+---------+
| 4 | 2 | 4 |
+-----------+-------------+---------+
对比:
| 写法 | 统计NULL吗? | 结果 |
|---|---|---|
COUNT(*) | ✅ 统计 | 4 |
COUNT(1) | ✅ 统计 | 4 |
COUNT(phone) | ❌ 不统计 | 2(只统计非NULL的phone) |
南北绿豆:"这是COUNT的设计:COUNT(column)会跳过NULL值!"
实际案例
-- 统计有手机号的用户数
SELECT COUNT(phone) FROM user; -- 8(有8个用户填了手机号)
-- 统计没有手机号的用户数
SELECT COUNT(*) - COUNT(phone) FROM user; -- 2(有2个用户没填)
-- 或者
SELECT COUNT(*) FROM user WHERE phone IS NULL; -- 2
坑:SUM/AVG也会忽略NULL
CREATE TABLE score (
student_id INT,
math_score INT,
english_score INT
);
INSERT INTO score VALUES
(1, 90, 85),
(2, 80, NULL), -- 英语缺考
(3, 75, 88);
-- 计算平均分
SELECT
AVG(math_score) AS avg_math,
AVG(english_score) AS avg_english
FROM score;
+----------+-------------+
| avg_math | avg_english |
+----------+-------------+
| 81.67 | 86.50 |
+----------+-------------+
-- 注意:
-- avg_math = (90 + 80 + 75) / 3 = 81.67
-- avg_english = (85 + 88) / 2 = 86.50 ← 只统计了2个人,忽略了NULL
阿西噶阿西:"如果想把NULL当作0,需要用COALESCE。"
SELECT
AVG(COALESCE(english_score, 0)) AS avg_english_with_zero
FROM score;
+----------------------+
| avg_english_with_zero |
+----------------------+
| 57.67 | -- (85 + 0 + 88) / 3
+----------------------+
🕳️ 坑3:NOT IN遇到NULL全军覆没
问题重现
-- 查询不在黑名单的用户
SELECT * FROM user
WHERE user_id NOT IN (
SELECT blocked_user_id FROM blacklist
);
-- 假设blacklist表数据:
SELECT blocked_user_id FROM blacklist;
+------------------+
| blocked_user_id |
+------------------+
| 100 |
| 200 |
| NULL | ← 有一个NULL值
+------------------+
-- 结果:0 rows(一个都查不出来!)
哈吉米:"卧槽,为啥一个都查不出来?"
南北绿豆:"因为NOT IN遇到NULL会出问题。"
原理分析
-- 展开NOT IN的逻辑
WHERE user_id NOT IN (100, 200, NULL)
-- 等价于
WHERE user_id != 100
AND user_id != 200
AND user_id != NULL
-- 但是 user_id != NULL 永远是 NULL
-- 所以整个表达式变成
WHERE user_id != 100 AND user_id != 200 AND NULL
-- 任何值 AND NULL = NULL
-- WHERE子句为NULL,不返回任何行
执行流程:
graph TD
A[user_id = 1] --> B{NOT IN 100, 200, NULL?}
B --> C{1 != 100?}
C -->|TRUE| D{1 != 200?}
D -->|TRUE| E{1 != NULL?}
E -->|NULL| F[TRUE AND TRUE AND NULL = NULL]
F --> G[不返回 ❌]
style G fill:#FFB6C1
解决方案
方案1:过滤掉NULL
SELECT * FROM user
WHERE user_id NOT IN (
SELECT blocked_user_id FROM blacklist
WHERE blocked_user_id IS NOT NULL -- 关键
);
方案2:用NOT EXISTS(推荐)
SELECT * FROM user u
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b
WHERE b.blocked_user_id = u.user_id
);
-- NOT EXISTS不受NULL影响 ✅
方案3:用LEFT JOIN
SELECT u.*
FROM user u
LEFT JOIN blacklist b ON u.user_id = b.blocked_user_id
WHERE b.blocked_user_id IS NULL;
IN也有问题(但不致命)
SELECT * FROM user
WHERE user_id IN (100, 200, NULL);
-- 等价于
WHERE user_id = 100 OR user_id = 200 OR user_id = NULL
-- user_id = NULL 是NULL
-- 但 TRUE OR NULL = TRUE
-- 所以能查到user_id=100和200的行
-- 结论:IN遇到NULL,影响不大
-- 但NOT IN遇到NULL,全军覆没
🕳️ 坑4:索引对NULL的特殊处理
NULL值可以建索引吗?
哈吉米:"NULL可以建索引吗?"
阿西噶阿西:"可以,而且InnoDB会把NULL值存在索引里。"
CREATE TABLE test_null_index (
id INT PRIMARY KEY,
phone VARCHAR(20),
INDEX idx_phone(phone)
);
INSERT INTO test_null_index VALUES
(1, '13800138000'),
(2, NULL),
(3, '15000150000'),
(4, NULL);
-- 查询phone IS NULL
SELECT * FROM test_null_index WHERE phone IS NULL;
EXPLAIN:
type: ref ← 走索引了
key: idx_phone
rows: 2
索引中NULL的存储:
索引树 idx_phone:
NULL ← NULL值存在最前面
NULL
'13800138000'
'15000150000'
但有个坑:索引统计不准
-- MySQL估算索引选择性
SELECT
CONCAT(table_name, '.', index_name) AS 'index',
cardinality,
table_rows,
ROUND(cardinality / table_rows * 100, 2) AS selectivity
FROM information_schema.STATISTICS s
JOIN information_schema.TABLES t USING (table_schema, table_name)
WHERE table_schema = 'test' AND index_name = 'idx_phone';
+------------------+-------------+------------+-------------+
| index | cardinality | table_rows | selectivity |
+------------------+-------------+------------+-------------+
| test_null_index.idx_phone | 2 | 4 | 50.00 |
+------------------+-------------+------------+-------------+
-- 问题:cardinality是估算值,NULL可能导致统计不准
唯一索引对NULL的处理
CREATE TABLE test_unique (
id INT PRIMARY KEY,
email VARCHAR(100),
UNIQUE INDEX uk_email(email)
);
-- 可以插入多个NULL(NULL不算重复)
INSERT INTO test_unique VALUES (1, 'alice@example.com');
INSERT INTO test_unique VALUES (2, NULL);
INSERT INTO test_unique VALUES (3, NULL); -- ✅ 允许
-- 但不能插入重复的非NULL值
INSERT INTO test_unique VALUES (4, 'alice@example.com'); -- ❌ 报错
南北绿豆:"唯一索引认为NULL和NULL不相等,所以允许多个NULL。"
🕳️ 坑5:ORDER BY排序的坑
NULL的排序规则
SELECT * FROM test_null_index ORDER BY phone;
+----+---------------+
| id | phone |
+----+---------------+
| 2 | NULL | ← NULL排最前面
| 4 | NULL |
| 1 | 13800138000 |
| 3 | 15000150000 |
+----+---------------+
SELECT * FROM test_null_index ORDER BY phone DESC;
+----+---------------+
| id | phone |
+----+---------------+
| 3 | 15000150000 |
| 1 | 13800138000 |
| 2 | NULL | ← NULL排最后面(DESC时)
| 4 | NULL |
+----+---------------+
规则:
- 升序(ASC):NULL排最前面
- 降序(DESC):NULL排最后面
如何让NULL排在最后?
MySQL 8.0+:
-- NULL排在最后
SELECT * FROM test_null_index
ORDER BY phone IS NULL, phone;
-- 原理:
-- phone IS NULL 返回 0(FALSE)或 1(TRUE)
-- 先按IS NULL排序(0在前,1在后)
-- 再按phone排序
或者用COALESCE:
SELECT * FROM test_null_index
ORDER BY COALESCE(phone, 'ZZZZZZZZ'); -- NULL替换成最大值
🛡️ 避坑指南
指南1:尽量避免NULL
设计表时:
-- ❌ 不推荐
CREATE TABLE user (
id INT PRIMARY KEY,
username VARCHAR(50),
phone VARCHAR(20) -- 允许NULL,容易出问题
);
-- ✅ 推荐
CREATE TABLE user (
id INT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
phone VARCHAR(20) NOT NULL DEFAULT '' -- 默认空字符串,不用NULL
);
什么时候可以用NULL?
1. 确实需要表达"未知"、"不存在"的语义
- 例如:用户的生日(可能不愿意填)
- 例如:订单的发货时间(还没发货)
2. 不用NULL的替代方案:
- 数字:用-1、0表示"未知"
- 字符串:用空字符串''表示"未知"
- 日期:用特殊值'1970-01-01'表示"未知"
指南2:查询NULL用IS NULL
-- ❌ 错误
WHERE phone = NULL
WHERE phone != NULL
-- ✅ 正确
WHERE phone IS NULL
WHERE phone IS NOT NULL
指南3:NOT IN改成NOT EXISTS
-- ❌ 危险(子查询可能有NULL)
WHERE user_id NOT IN (SELECT ...)
-- ✅ 安全
WHERE NOT EXISTS (SELECT 1 FROM ... WHERE ...)
-- 或者过滤NULL
WHERE user_id NOT IN (SELECT ... WHERE column IS NOT NULL)
指南4:聚合函数注意NULL
-- COUNT(column)会忽略NULL
SELECT COUNT(phone) FROM user; -- 只统计非NULL
-- SUM、AVG也会忽略NULL
SELECT AVG(score) FROM test; -- 忽略NULL的score
-- 如果要把NULL当作0
SELECT AVG(COALESCE(score, 0)) FROM test;
指南5:使用COALESCE处理NULL
-- COALESCE:返回第一个非NULL值
SELECT
username,
COALESCE(phone, email, 'No contact') AS contact
FROM user;
-- 示例
username | phone | email | contact
---------|-------------|-----------------|----------------
alice | 13800138000 | NULL | 13800138000
bob | NULL | bob@example.com | bob@example.com
charlie | NULL | NULL | No contact
🎓 面试标准答案
题目:MySQL中NULL有哪些坑?
答案:
5个核心坑:
-
NULL = NULL 返回NULL
- NULL和任何值比较都返回NULL,不是TRUE也不是FALSE
- WHERE子句为NULL,不返回该行
- 正确判断:用IS NULL、IS NOT NULL
-
COUNT(column)不统计NULL
- COUNT(*)统计所有行
- COUNT(column)只统计非NULL行
- SUM、AVG也会忽略NULL
-
NOT IN遇到NULL全军覆没
- 子查询有NULL时,NOT IN返回空集
- 解决方案:用NOT EXISTS或过滤NULL
-
索引对NULL的特殊处理
- 唯一索引允许多个NULL
- NULL值存在索引最前面
- 可能导致统计信息不准
-
ORDER BY的排序规则
- ASC:NULL排最前面
- DESC:NULL排最后面
避坑建议:
- 设计表时尽量NOT NULL + DEFAULT
- 查询NULL用IS NULL
- NOT IN改成NOT EXISTS
- 用COALESCE处理NULL
🎉 结束语
晚上7点,哈吉米终于把所有NULL相关的坑都搞清楚了。
哈吉米:"原来NULL这么多坑!以前都是稀里糊涂地用。"
南北绿豆:"对,NULL是SQL里最特殊的值,很多反直觉的行为。"
阿西噶阿西:"记住:设计表时尽量避免NULL,查询时小心NULL的陷阱。"
哈吉米:"还有NOT IN遇到NULL全军覆没,这个太坑了!"
南北绿豆:"对,所以推荐用NOT EXISTS,更安全。"
记忆口诀:
NULL比较永返NULL,IS NULL才是正确判
COUNT列名会跳过,NOT IN遇NULL全军覆
唯一索引允多NULL,排序规则要记牢
设计表时避NULL值,COALESCE来兜底
希望这篇文章能帮你避开NULL的5个大坑!下次写SQL,记得小心NULL!💪