在 SQL 开发中,DISTINCT 是使用频率极高的关键字,但它也是最容易被误用的特性之一。本文从数据建模的本质出发,深入探讨对多列(尤其含小数类型)使用DISTINCT 去重的根本错误、潜在危害,以及正确的替代方案。
目录
去重的对象应该是业务实体,而不是度量指标。这个看似简单的原则,正是大量 DISTINCT 误用的根源。
在关系数据库的理论基础中,每张表都在描述某种业务实体(Entity)——订单表描述订单,用户表描述用户,支付记录表描述一次支付行为。每个实体都有其唯一标识符(主键),而金额、税率、折扣这类字段是该实体的度量属性(Measure),它们描述实体但不定义实体。
两条记录是否"相同",应由业务主体的唯一标识决定,而非由其属性值碰巧相等来判断。用 DISTINCT 去重本质上是在说"属性值完全相同 = 同一个实体"——这是一个在逻辑上就站不住脚的假设。
考虑这个典型场景:
❌ 语义错误的写法
SELECT DISTINCT
user_id, amount, status
FROM
orders;这条 SQL 隐含了一个危险假设:如果两条订单记录的 user_id、amount、status 完全相同,它们就是同一条订单。但现实中存在两种截然不同的情况:
场景 | 数据状态 | 正确操作 | DISTINCT 的处理 |
|---|---|---|---|
同一用户支付两笔相同金额的独立订单 | 正常数据 | 保留两条 | 错误合并 |
ETL 管道重复写入同一条订单 | 数据冗余 | 去重保留一条 | 结果正确(但原因是巧合) |
DISTINCT 无法区分这两种场景,它只是机械地比较列值,将"碰巧所有属性相同"的行合并——有时结果是对的,有时结果是错的,而且不会有任何报错提示。
当 DISTINCT 的列中包含 FLOAT、DOUBLE 等浮点类型时,问题会从"语义错误"升级为"结果不可预测"。
IEEE 754 标准下的浮点数使用二进制来存储十进制小数,而大多数十进制小数(如 0.1、0.3、99.99)在二进制中是无限循环小数,无法被精确存储,只能近似表示。
-- 在 MySQL 中执行
SELECT 0.1 + 0.2 ;
-- 输出:0.30000000000000004 ← 不是 0.3!
SELECT 0.1 + 0.2 = 0.3;
-- 输出:0(false)DISTINCT 通过精确相等比较(即 = 操作符)来判断两行是否重复。将其用于浮点列时,结果完全不可控:
⚠业务上完全相同的两个金额值(如都表示 99.99 元),因浮点存储误差的微小差异,在 DISTINCT 的视角中可能是两个不同的值,导致去重失败——而这一切不会产生任何错误或警告。
更隐蔽的是,当多列中存在多个浮点字段时,每个字段都是潜在的精度陷阱,组合爆炸式地增加了"假不重复"的概率,结果集行数偏多,但偏差极小,人工审查几乎无法察觉。
在实际开发中,DISTINCT 被用来"修复"查询结果中莫名出现的重复行,但重复行往往是 JOIN 条件不完整导致的笛卡尔积膨胀:
❌ 用 DISTINCT 压制笛卡尔积
SELECT DISTINCT
o.user_id, o.amount, p.payment_method
FROM
orders o
JOIN
payments p
ON
o.user_id = p.user_id;
-- ⚠ 缺少 order_id 关联,一个用户有 N 笔支付则结果膨胀 N 倍
-- DISTINCT 让结果"看起来正常",但数据已经被错误地交叉组合过这是最危险的一类误用:数据在 JOIN 阶段就已经被错误地扩散,DISTINCT 只是在最终结果上打了掩护,真正的 bug 隐藏在 SQL 逻辑中,在数据量增大或业务变化时随时可能爆发。
❌ 用 DISTINCT 绕过 ETL 重复写入
-- 数仓的 ETL 任务缺少幂等性保障,导致数据重复落库
-- 报表查询加上 DISTINCT 后结果"正常了"
SELECT DISTINCT
user_id, order_date, amount
FROM
dw.fact_orders;
-- 源头 bug 没有修复,下游 SUM(amount) 仍然翻倍查询层的 DISTINCT 只能修复这一条 SQL 的结果,但其他查询、其他报表、下游的聚合计算仍然会受到重复数据的影响。修复症状而不修复病根,技术债务持续积累。
如果一张表的查询必须依赖 DISTINCT 才能得到正确结果,这往往意味着该表缺乏明确的唯一性约束,主键设计存在问题。用 DISTINCT 绕过这个问题,让表设计的缺陷永远不会被修复。
理解误用的根源,是建立正确规范的前提。以下是 DISTINCT 被滥用的几个典型心理模式:

出现重复行时,首先要回答:为什么会有重复? 是 JOIN 逻辑问题、ETL 管道问题还是表设计问题?从根源修复,而不是在查询层打补丁。
❌ 浮点类型(不精确)
amount DOUBLE,
discount FLOAT,
tax_rate REAL✅ 精确类型(推荐)
amount DECIMAL(18, 4)
discount DECIMAL(10, 4)
tax_rate DECIMAL(6, 4)DECIMAL 是精确数值类型,基于十进制存储,不存在二进制近似误差,是金融场景金额字段的标准选择。
❌ 依赖属性值相同来去重
SELECT DISTINCT
user_id, amount, status
FROM
orders;✅ 明确基于业务主键去重
SELECT
user_id,
amount,
STATUS
FROM
orders
WHERE
order_id IN (
-- 每个业务主键只保留一条
SELECT
MIN(order_id)
FROM
orders
GROUP BY
business_order_no
);这是最推荐的去重模式。它强制开发者明确两个核心问题:什么是"同一实体"(PARTITION BY),以及出现重复时保留哪一条(ORDER BY)。
✅ 语义清晰的去重写法
SELECT
order_id,
user_id,
amount,
STATUS
FROM
(
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY
business_order_no
-- 定义"同一实体"
ORDER BY
updated_at DESC
-- 保留最新一条
) AS rn
FROM
orders
) t
WHERE
rn = 1;❌ JOIN 条件不完整 + DISTINCT 掩盖
SELECT DISTINCT
o.user_id, o.amount, p.payment_method
FROM orders o
JOIN payments p
ON o.user_id = p.user_id;✅ 补全 JOIN 关联条件
SELECT
o.user_id, o.amount, p.payment_method
FROM
orders o
JOIN
payments p
ON o.order_id = p.order_id;
-- 通过正确的关联键,从根本上消除重复,无需 DISTINCTDISTINCT 本身并非错误的语法,但它被广泛地误用于两个不应该承担的职责:掩盖数据管道的重复写入问题,以及替代基于业务主键的精确去重逻辑。
当这种误用遇上 FLOAT/DOUBLE 等浮点类型时,问题进一步升级为不可预测的精度错误——正确的结果只是一种巧合,而非设计的保证。
每次使用 DISTINCT 前,先问自己:我知道这些数据重复的根本原因吗?我是在基于业务实体的唯一标识去重,还是在依赖属性值碰巧相同来去重?如果无法清晰回答第一个问题,DISTINCT 就不应该出现在这里。
本文由 PawSQL 规则引擎团队整理,相关 SQL 审查规则已集成至 PawSQL审核引擎。
🌐关于PawSQL
PawSQL专注于数据库性能优化自动化和智能化,提供的解决方案覆盖SQL开发、测试、运维的整个流程,广泛支持多种主流商用、国产和开源数据库,为开发者和企业提供一站式的创新SQL优化解决方案。
