searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

多列 DISTINCT 的坑:NULL 值参与去重时,结果和你想的不一样

2026-07-08 13:43:34
2
0

一、先回顾:DISTINCT 到底在干什么

DISTINCT 的作用是消除结果集中的重复行。它逐行比较你指定的所有列,如果两行在这些列上的值完全相同,就只保留一行。

这个逻辑在所有值都是确定值的时候,没有任何歧义。比如两行的姓名都是"张三",年龄都是25,那它们就是重复的,留一条就够了。

但问题在于,SQL 的世界里有一种特殊的值——NULL。它代表"未知"或"不存在"。而 NULL 的参与,会让"完全相同"这个判断变得极其反直觉。


二、NULL 的三值逻辑:一切混乱的根源

要理解 NULL 在去重中的行为,必须先搞懂 SQL 的三值逻辑。

在普通编程语言中,两个值的比较结果只有两种:相等或不相等。但在 SQL 中,当比较涉及 NULL 时,结果有三种可能:TRUE(真)、FALSE(假)、UNKNOWN(未知)。

关键规则只有一条:NULL 不等于任何值,包括它自己。

也就是说,NULL = NULL 的结果不是 TRUE,而是 UNKNOWN。同样,NULL <> NULL 的结果也是 UNKNOWN。这不是 Bug,这是 SQL 标准的设计哲学——既然 NULL 代表"未知",那两个未知的值之间当然无法判断是否相等。

这个规则在 WHERE 条件过滤中已经让很多人吃过亏了。但真正让人崩溃的,是它在 DISTINCT 去重中的表现。


三、单列 DISTINCT:看起来没问题

先从简单的场景开始。假设你有一列数据,里面有若干个 NULL。

当你对这一列使用 DISTINCT 时,所有的 NULL 值会被合并成一个。也就是说,不管原始数据里有十个 NULL 还是一百个 NULL,DISTINCT 之后只会出现一个 NULL。

这个行为符合大多数人的直觉。因为 DISTINCT 在比较时,虽然 NULL = NULL 返回 UNKNOWN,但 SQL 的去重逻辑会把所有"不确定是否相等"的行视为同一组,从而合并它们。所以单列场景下,NULL 的处理结果看起来是正常的。

但这只是暴风雨前的宁静。


四、多列 DISTINCT:陷阱正式登场

现在把场景升级。假设你有一张用户表,包含三列:姓名、邮箱、电话。你想取出所有不重复的"姓名+邮箱"组合。

数据大概长这样:

姓名 邮箱 电话
张三 mailto:zhangsan@example.com 13800001111
张三 NULL 13800002222
张三 NULL 13800003333
李四 mailto:lisi@example.com 13800004444
李四 NULL 13800005555

现在执行 SELECT DISTINCT 姓名, 邮箱 FROM 表,你期待的结果是什么?

很多人的直觉是:张三出现两次(一次有邮箱,一次邮箱为空),李四也出现两次。总共四行。

但实际结果是五行:

姓名 邮箱
张三 mailto:zhangsan@example.com
张三 NULL
张三 NULL
李四 mailto:lisi@example.com
李四 NULL

等一下,为什么张三的两条 NULL 邮箱记录没有被合并?

这就是多列 DISTINCT 最反直觉的地方。


五、为什么会这样?逐行拆解

DISTINCT 是对你指定的所有列进行整体比较。在上面的例子中,它比较的是"姓名+邮箱"这个组合。

  • 第一行:张三 + mailto:zhangsan@example.com
  • 第二行:张三 + NULL
  • 第三行:张三 + NULL

现在逐对比较:

第一行和第二行比较:姓名相同(张三 = 张三),但邮箱不同(一个有值,一个是 NULL)。由于 NULL 参与比较时结果为 UNKNOWN,这两行被判定为"不确定是否相同",所以不合并,两行都保留。

第二行和第三行比较:姓名相同(张三 = 张三),邮箱都是 NULL。按前面说的规则,NULL = NULL 返回 UNKNOWN,所以这两行也被判定为"不确定是否相同",同样不合并。

最终结果:三行全部保留。

你可能会问:等一下,第二行和第三行的邮箱都是 NULL,为什么不算重复?

答案就在三值逻辑里。DISTINCT 的去重判断依赖于"两行是否相等"。而在 SQL 的定义中,两个 NULL 并非相等,它们的关系是"未知"。既然无法确认为相等,那就不能去重。

这就是为什么多列 DISTINCT 在遇到 NULL 时,每一行 NULL 组合都会被当作独立的一行保留下来。你的结果集会比预期多出很多行。


六、更隐蔽的坑:NULL 的位置不同,结果也不同

上面的例子中,NULL 出现在邮箱列。如果换一个场景,NULL 出现在姓名列呢?

姓名 邮箱 电话
NULL mailto:zhangsan@example.com 13800001111
NULL mailto:zhangsan@example.com 13800002222
张三 mailto:zhangsan@example.com 13800003333

执行 SELECT DISTINCT 姓名, 邮箱,结果会怎样?

前两行:姓名都是 NULL,邮箱相同。由于 NULL = NULL 为 UNKNOWN,这两行不会被合并,两行都会出现在结果中。

第三行:姓名是张三,邮箱相同。姓名不同,当然不重复。

最终结果:三行全部保留。

但如果你反过来,先看邮箱再看姓名,逻辑完全一致。NULL 出现在任何一列,都会导致该行与其他含 NULL 的行无法被判定为重复。

更极端的情况:如果你选了三列,而这三列全部是 NULL,那么每一行都会被保留下来,因为没有任何两行能被判定为"完全相等"。


七、不同数据库引擎的微妙差异

虽然上述行为是 SQL 标准的定义,但不同的数据库引擎在实现细节上存在差异。

有些引擎在内部对 NULL 的处理做了优化,在某些特定场景下会把多个 NULL 视为相同。但这种优化往往不稳定,而且不会在所有情况下生效。你不能依赖它,因为一旦换了引擎或者升级了版本,行为可能突然改变。

还有些引擎提供了特殊的语法或选项,允许你自定义 NULL 的去重行为。但这些属于扩展功能,并非标准 SQL 的一部分,可移植性存疑。

最稳妥的做法,是假设所有引擎都严格遵循三值逻辑:NULL 不等于 NULL,多列 DISTINCT 时每一行 NULL 组合都独立存在。


八、实战中最容易踩坑的几个场景

场景一:统计去重用户数。 你用 COUNT(DISTINCT 姓名, 邮箱) 来统计有多少个独立用户。但如果很多用户的邮箱是 NULL,那么同一个姓名配合 NULL 邮箱会被算作多个不同用户,导致统计结果虚高。

场景二:数据导出去重。 你从多张表 JOIN 后提取唯一记录,用于数据迁移或报表生成。结果发现导出的行数远超预期,排查后发现是 NULL 值在多列组合中没有被正确去重。

场景三:权限判断。 你用 DISTINCT 取出用户的权限组合,用于做访问控制。但由于 NULL 的存在,同一个用户可能出现多条权限记录,导致权限判断出现漏洞。

这些场景的共同特点是:NULL 不是异常数据,而是业务中合理存在的值(比如用户尚未填写邮箱)。你无法通过"清洗数据"来规避,必须在查询逻辑层面解决。


九、怎么绕过这个坑?

既然知道了问题所在,解决思路也就清晰了。核心原则只有一个:不要让 NULL 参与去重判断,或者在参与之前把 NULL 转换成确定的值。

方案一:用 COALESCE 或等价函数替换 NULL。 在 DISTINCT 之前,把所有可能为 NULL 的列用 COALESCE 转换成一个不会出现在真实数据中的值。比如把 NULL 邮箱替换成空字符串,这样所有 NULL 邮箱都会变成相同的空字符串,去重时就能正确合并了。

这个方案的优点是简单直接,适用范围广。缺点是你需要提前知道一个"不会出现在真实数据中"的值,否则可能引入新的冲突。

方案二:只对非 NULL 的列做 DISTINCT,NULL 单独处理。 先查出所有非 NULL 的唯一组合,再单独查出包含 NULL 的记录,最后用 UNION 合并。这样逻辑清晰,NULL 的行为完全可控。

方案三:使用 GROUP BY 替代 DISTINCT。 GROUP BY 在处理 NULL 时的行为与 DISTINCT 本质相同,但它提供了更灵活的聚合能力。你可以在 GROUP BY 中配合 COALESCE 使用,同时对其他列做聚合操作,一举两得。

方案四:在应用层去重。 如果数据量不大,可以把原始结果全部拉到应用层,用编程语言的数据结构(比如集合或字典)做去重。在应用层中,NULL 通常有明确的等价判断规则,行为比 SQL 更可预测。但这个方案不适合大数据量场景,因为网络传输和内存消耗会成为新的瓶颈。


十、一个值得深思的问题

NULL 在 SQL 中的设计,本质上是一种"谨慎"的哲学。它宁可让你得到"未知"的结果,也不愿给你一个可能错误的答案。这种设计在数据完整性要求高的场景中是合理的,但在去重这种需要明确判断的场景中,它就成了绊脚石。

很多开发者在写 SQL 时,习惯把 NULL 当作"空值"来理解,觉得两个 NULL 应该是一样的。这种直觉在日常思维中完全合理,但在 SQL 的三值逻辑中,它是错误的。

这个认知偏差,就是所有问题的起点。


结语

多列 DISTINCT 遇到 NULL 时的行为,是 SQL 中最容易被忽视、却最容易造成生产事故的细节之一。它不会报错,不会抛异常,只是悄悄地给你一个和预期不符的结果集。等你发现数据对不上的时候,往往已经过去了好几天。

记住这条规则:在多列去重中,NULL 不等于 NULL,每一行含 NULL 的组合都会被独立保留。

下次写 DISTINCT 之前,先扫一眼数据里有没有 NULL。如果有,要么转换它,要么绕开它。别让一个"未知"的值,毁掉你整个查询的可信度。

0条评论
0 / 1000
c****t
1019文章数
1粉丝数
c****t
1019 文章 | 1 粉丝
原创

多列 DISTINCT 的坑:NULL 值参与去重时,结果和你想的不一样

2026-07-08 13:43:34
2
0

一、先回顾:DISTINCT 到底在干什么

DISTINCT 的作用是消除结果集中的重复行。它逐行比较你指定的所有列,如果两行在这些列上的值完全相同,就只保留一行。

这个逻辑在所有值都是确定值的时候,没有任何歧义。比如两行的姓名都是"张三",年龄都是25,那它们就是重复的,留一条就够了。

但问题在于,SQL 的世界里有一种特殊的值——NULL。它代表"未知"或"不存在"。而 NULL 的参与,会让"完全相同"这个判断变得极其反直觉。


二、NULL 的三值逻辑:一切混乱的根源

要理解 NULL 在去重中的行为,必须先搞懂 SQL 的三值逻辑。

在普通编程语言中,两个值的比较结果只有两种:相等或不相等。但在 SQL 中,当比较涉及 NULL 时,结果有三种可能:TRUE(真)、FALSE(假)、UNKNOWN(未知)。

关键规则只有一条:NULL 不等于任何值,包括它自己。

也就是说,NULL = NULL 的结果不是 TRUE,而是 UNKNOWN。同样,NULL <> NULL 的结果也是 UNKNOWN。这不是 Bug,这是 SQL 标准的设计哲学——既然 NULL 代表"未知",那两个未知的值之间当然无法判断是否相等。

这个规则在 WHERE 条件过滤中已经让很多人吃过亏了。但真正让人崩溃的,是它在 DISTINCT 去重中的表现。


三、单列 DISTINCT:看起来没问题

先从简单的场景开始。假设你有一列数据,里面有若干个 NULL。

当你对这一列使用 DISTINCT 时,所有的 NULL 值会被合并成一个。也就是说,不管原始数据里有十个 NULL 还是一百个 NULL,DISTINCT 之后只会出现一个 NULL。

这个行为符合大多数人的直觉。因为 DISTINCT 在比较时,虽然 NULL = NULL 返回 UNKNOWN,但 SQL 的去重逻辑会把所有"不确定是否相等"的行视为同一组,从而合并它们。所以单列场景下,NULL 的处理结果看起来是正常的。

但这只是暴风雨前的宁静。


四、多列 DISTINCT:陷阱正式登场

现在把场景升级。假设你有一张用户表,包含三列:姓名、邮箱、电话。你想取出所有不重复的"姓名+邮箱"组合。

数据大概长这样:

姓名 邮箱 电话
张三 mailto:zhangsan@example.com 13800001111
张三 NULL 13800002222
张三 NULL 13800003333
李四 mailto:lisi@example.com 13800004444
李四 NULL 13800005555

现在执行 SELECT DISTINCT 姓名, 邮箱 FROM 表,你期待的结果是什么?

很多人的直觉是:张三出现两次(一次有邮箱,一次邮箱为空),李四也出现两次。总共四行。

但实际结果是五行:

姓名 邮箱
张三 mailto:zhangsan@example.com
张三 NULL
张三 NULL
李四 mailto:lisi@example.com
李四 NULL

等一下,为什么张三的两条 NULL 邮箱记录没有被合并?

这就是多列 DISTINCT 最反直觉的地方。


五、为什么会这样?逐行拆解

DISTINCT 是对你指定的所有列进行整体比较。在上面的例子中,它比较的是"姓名+邮箱"这个组合。

  • 第一行:张三 + mailto:zhangsan@example.com
  • 第二行:张三 + NULL
  • 第三行:张三 + NULL

现在逐对比较:

第一行和第二行比较:姓名相同(张三 = 张三),但邮箱不同(一个有值,一个是 NULL)。由于 NULL 参与比较时结果为 UNKNOWN,这两行被判定为"不确定是否相同",所以不合并,两行都保留。

第二行和第三行比较:姓名相同(张三 = 张三),邮箱都是 NULL。按前面说的规则,NULL = NULL 返回 UNKNOWN,所以这两行也被判定为"不确定是否相同",同样不合并。

最终结果:三行全部保留。

你可能会问:等一下,第二行和第三行的邮箱都是 NULL,为什么不算重复?

答案就在三值逻辑里。DISTINCT 的去重判断依赖于"两行是否相等"。而在 SQL 的定义中,两个 NULL 并非相等,它们的关系是"未知"。既然无法确认为相等,那就不能去重。

这就是为什么多列 DISTINCT 在遇到 NULL 时,每一行 NULL 组合都会被当作独立的一行保留下来。你的结果集会比预期多出很多行。


六、更隐蔽的坑:NULL 的位置不同,结果也不同

上面的例子中,NULL 出现在邮箱列。如果换一个场景,NULL 出现在姓名列呢?

姓名 邮箱 电话
NULL mailto:zhangsan@example.com 13800001111
NULL mailto:zhangsan@example.com 13800002222
张三 mailto:zhangsan@example.com 13800003333

执行 SELECT DISTINCT 姓名, 邮箱,结果会怎样?

前两行:姓名都是 NULL,邮箱相同。由于 NULL = NULL 为 UNKNOWN,这两行不会被合并,两行都会出现在结果中。

第三行:姓名是张三,邮箱相同。姓名不同,当然不重复。

最终结果:三行全部保留。

但如果你反过来,先看邮箱再看姓名,逻辑完全一致。NULL 出现在任何一列,都会导致该行与其他含 NULL 的行无法被判定为重复。

更极端的情况:如果你选了三列,而这三列全部是 NULL,那么每一行都会被保留下来,因为没有任何两行能被判定为"完全相等"。


七、不同数据库引擎的微妙差异

虽然上述行为是 SQL 标准的定义,但不同的数据库引擎在实现细节上存在差异。

有些引擎在内部对 NULL 的处理做了优化,在某些特定场景下会把多个 NULL 视为相同。但这种优化往往不稳定,而且不会在所有情况下生效。你不能依赖它,因为一旦换了引擎或者升级了版本,行为可能突然改变。

还有些引擎提供了特殊的语法或选项,允许你自定义 NULL 的去重行为。但这些属于扩展功能,并非标准 SQL 的一部分,可移植性存疑。

最稳妥的做法,是假设所有引擎都严格遵循三值逻辑:NULL 不等于 NULL,多列 DISTINCT 时每一行 NULL 组合都独立存在。


八、实战中最容易踩坑的几个场景

场景一:统计去重用户数。 你用 COUNT(DISTINCT 姓名, 邮箱) 来统计有多少个独立用户。但如果很多用户的邮箱是 NULL,那么同一个姓名配合 NULL 邮箱会被算作多个不同用户,导致统计结果虚高。

场景二:数据导出去重。 你从多张表 JOIN 后提取唯一记录,用于数据迁移或报表生成。结果发现导出的行数远超预期,排查后发现是 NULL 值在多列组合中没有被正确去重。

场景三:权限判断。 你用 DISTINCT 取出用户的权限组合,用于做访问控制。但由于 NULL 的存在,同一个用户可能出现多条权限记录,导致权限判断出现漏洞。

这些场景的共同特点是:NULL 不是异常数据,而是业务中合理存在的值(比如用户尚未填写邮箱)。你无法通过"清洗数据"来规避,必须在查询逻辑层面解决。


九、怎么绕过这个坑?

既然知道了问题所在,解决思路也就清晰了。核心原则只有一个:不要让 NULL 参与去重判断,或者在参与之前把 NULL 转换成确定的值。

方案一:用 COALESCE 或等价函数替换 NULL。 在 DISTINCT 之前,把所有可能为 NULL 的列用 COALESCE 转换成一个不会出现在真实数据中的值。比如把 NULL 邮箱替换成空字符串,这样所有 NULL 邮箱都会变成相同的空字符串,去重时就能正确合并了。

这个方案的优点是简单直接,适用范围广。缺点是你需要提前知道一个"不会出现在真实数据中"的值,否则可能引入新的冲突。

方案二:只对非 NULL 的列做 DISTINCT,NULL 单独处理。 先查出所有非 NULL 的唯一组合,再单独查出包含 NULL 的记录,最后用 UNION 合并。这样逻辑清晰,NULL 的行为完全可控。

方案三:使用 GROUP BY 替代 DISTINCT。 GROUP BY 在处理 NULL 时的行为与 DISTINCT 本质相同,但它提供了更灵活的聚合能力。你可以在 GROUP BY 中配合 COALESCE 使用,同时对其他列做聚合操作,一举两得。

方案四:在应用层去重。 如果数据量不大,可以把原始结果全部拉到应用层,用编程语言的数据结构(比如集合或字典)做去重。在应用层中,NULL 通常有明确的等价判断规则,行为比 SQL 更可预测。但这个方案不适合大数据量场景,因为网络传输和内存消耗会成为新的瓶颈。


十、一个值得深思的问题

NULL 在 SQL 中的设计,本质上是一种"谨慎"的哲学。它宁可让你得到"未知"的结果,也不愿给你一个可能错误的答案。这种设计在数据完整性要求高的场景中是合理的,但在去重这种需要明确判断的场景中,它就成了绊脚石。

很多开发者在写 SQL 时,习惯把 NULL 当作"空值"来理解,觉得两个 NULL 应该是一样的。这种直觉在日常思维中完全合理,但在 SQL 的三值逻辑中,它是错误的。

这个认知偏差,就是所有问题的起点。


结语

多列 DISTINCT 遇到 NULL 时的行为,是 SQL 中最容易被忽视、却最容易造成生产事故的细节之一。它不会报错,不会抛异常,只是悄悄地给你一个和预期不符的结果集。等你发现数据对不上的时候,往往已经过去了好几天。

记住这条规则:在多列去重中,NULL 不等于 NULL,每一行含 NULL 的组合都会被独立保留。

下次写 DISTINCT 之前,先扫一眼数据里有没有 NULL。如果有,要么转换它,要么绕开它。别让一个"未知"的值,毁掉你整个查询的可信度。

文章来自个人专栏
文章 | 订阅
0条评论
0 / 1000
请输入你的评论
0
0