小集合 VS 大集合:MySQL 去重计数性能优化
- 前言
- 一、场景与问题 🔎
- 二、通俗执行流程对比
- 三、MySQL 执行计划解析 📊
- 四、性能瓶颈深度剖析 🔍
- 五、终极优化方案 🏆
- 六、总结
前言
📈 测试结果:
在对百万级 indicator_log 表进行 去重计数 的测试中,我们发现:
-  SQL1(先去重再计数)耗时 ≈ 0.9s, 
-  SQL2(直接 COUNT(DISTINCT))耗时 ≈ 1.0s。 
🔍 核心原因:
-  SQL1 利用物化临时表批量去重,I/O 可控; 
-  SQL2 在内存哈希/排序中实时去重,内存与 CPU 负载更重,并触发更多 spill-to-disk 。 
最终,通过覆盖式联合索引、内存参数调优及Loose Index Scan等手段,能让两者在大数据量下都达到毫秒级。
一、场景与问题 🔎
-  表结构示例(示例参数): CREATE TABLE indicator_log (obj_id INT, -- 评估对象 ID (:obj_id)plan_id INT, -- 评估计划 ID (:plan_id)del_flag TINYINT, -- 逻辑删除标志 (:del_flag)INDEX idx_plan (plan_id), -- 单独索引 (:plan_id)INDEX idx_delflag (del_flag) -- 单独索引 (:del_flag) );
-  需求:统计某评估计划中、未被逻辑删除的唯一对象数。 
-  SQL1(子查询版): SELECT COUNT(obj_id) FROM (SELECT DISTINCT obj_idFROM indicator_logWHERE plan_id = 312 AND del_flag = 0 ) AS t;
-  SQL2(直接版): SELECT COUNT(DISTINCT obj_id) FROM indicator_log WHERE plan_id = 312 AND del_flag = 0;
二、通俗执行流程对比
-  SQL1:阶段化去重 -  子查询去重 SELECT DISTINCT obj_id FROM indicator_log WHERE plan_id = :plan_id AND del_flag = :del_flag;-  ⚙️ 数据库先从大表中抽取所有唯一的 obj_id,并将结果写入“小篮子”(物化临时表),
-  此阶段只做一次去重,借助外部排序或分区哈希批量处理,I/O 可控、稳定 
 
-  
-  外层快速计数 SELECT COUNT(obj_id) FROM (… 上一步子查询 … ) AS t;-  ⚡ 在“小篮子”上做 COUNT,不涉及任何去重逻辑,
-  仅需对已去重的小结果集扫描一次,CPU 和 I/O 开销极低 
 
-  
 优势:先缩小数据规模,再聚合,适合大数据量场景。 
-  
-  SQL2:一次性去重 SELECT COUNT(DISTINCT obj_id) FROM indicator_log WHERE plan_id = :plan_id AND del_flag = :del_flag;-  实时扫描去重 -  🏃 MySQL 在全表扫描过程中,边读取每行边将 obj_id插入内存哈希表或进行内存排序,
-  每次插入都需判断是否已存在,CPU 和内存压力陡增 。 
 
-  
-  矿山级 Hash / 排序 -  🔄 若待去重行数超过 sort_buffer_size或tmp_table_size,会频繁 spill-to-disk,
-  导致磁盘 I/O 大幅增加,性能抖动明显 
 
-  
 劣势:一次性完成去重+计数,对内存依赖高,遇大数据量易触发磁盘溢写。 
-  
-  索引合并(Index Merge)附加开销 ⚙️ -  在只有单列索引 idx_plan(plan_id)与idx_delflag(del_flag)时,MySQL 必须:-  分别走两个索引扫描; 
-  对扫描结果做行号交集( Index Merge Intersection) ;
 
-  
-  双重扫描 + 交集 也为两种写法都增加了额外 I/O 和 CPU 消耗。 
 
-  
三、MySQL 执行计划解析 📊
-  SQL1 的 EXPLAIN EXPLAIN ANALYZE SELECT COUNT(obj_id) FROM (SELECT DISTINCT obj_idFROM indicator_logWHERE plan_id = 312AND del_flag = 0 ) AS t; 
-  执行计划解析 -  聚合操作:计算 obj_id的总数,执行成本和实际时间较低。
-  表扫描:查询对 t表进行了全表扫描,扫描了约 280,269 行,实际执行时间为 902 毫秒。
-  物化:将中间结果存储在内存中,避免重复计算,时间与表扫描相同。 
-  临时表:查询创建了临时表进行去重,去重操作与物化时间相同。 
-  过滤条件:通过 del_flag = 0和plan_id = 312过滤数据,执行时间较长,返回 165,849 行。
-  交集操作:从两个索引扫描中交集数据,执行时间较长。 
-  索引扫描: 
 -  使用 idx_plan扫描符合plan_id = 312的数据,执行非常快。
-  使用 idx_delflag扫描符合del_flag = 0的数据,执行较慢,因为扫描了大量数据。
 
-  
-  总结 1. Index Merge Intersection ├─ idx_plan (plan_id=:plan_id) └─ idx_delflag (del_flag=:del_flag) 📚 :contentReference[oaicite:3]{index=3} 2. Temporary table with deduplication 📚 :contentReference[oaicite:4]{index=4} 3. Table scan on <temporary> 4. Aggregate: COUNT(obj_id)-  交集扫描:分别走两个单列索引,再取交集,得到 N 条候选行 
-  物化去重:写入临时表后批量排序去重,I/O 可控 
-  快速计数:对临时小表直接 COUNT,耗时极低。
 查询的瓶颈主要在于对 del_flag的过滤和交集操作,建议优化索引或减少数据量。
-  
-  SQL2 的 EXPLAIN EXPLAIN ANALYZE SELECT COUNT(DISTINCT obj_id) FROM indicator_log WHERE plan_id = 312AND del_flag = 0; 
-  执行计划解析 -  聚合操作: count(distinct indicator_log.obj_id),计算obj_id的去重总数,执行成本和时间较低,实际执行时间为 964 毫秒。
-  过滤条件:查询对 indicator_log表进行了过滤,条件为del_flag = 0和plan_id = 312。过滤后返回了 165,849 行数据,执行时间为 341 到 838 毫秒。
-  交集操作:通过 INTERSECT操作结合两个索引扫描结果,筛选符合条件的数据。执行时间为 341 到 837 毫秒,结果包含 165,849 行。
-  索引扫描: 
 -  使用 idx_plan索引扫描plan_id = 312的数据,执行非常快,时间为 0.148 到 85.3 毫秒,扫描了 279,786 行。
-  使用 idx_delflag索引扫描del_flag = 0的数据,执行较慢,时间为 0.051 到 426 毫秒,扫描了大约 1.5 百万行。
 
-  
-  总结 1. Index Merge Intersection ├─ idx_plan └─ idx_delflag 2. Filter predicates 3. Aggregate: COUNT(DISTINCT obj_id) 🔄-  同样交集得出 N 行; 
-  内存去重:逐行插入 HashSet 或排序,边去重边计数 
-  瓶颈:大量内存操作易触发 spill-to-disk 或频繁 GC,性能抖动明显 
 查询主要瓶颈在于对 del_flag = 0条件的过滤,因为这个条件扫描了大量数据。可以通过优化索引或减少数据量来提高查询性能。
-  
四、性能瓶颈深度剖析 🔍
- 索引合并(Index Merge)开销
- 单列索引需做两次范围扫描并交集,I/O 与 CPU 成本陡增 。
- 覆盖式联合索引可一步到位,跳过合并与回表,大幅缩短扫描范围 。
- 去重策略对比
| 特性 | 临时表批量去重 (SQL1) | 内存哈希/排序 (SQL2) | 
|---|---|---|
| 实现方式 | 外部排序 + 临时表 I/O | HashSet/排序,内存优先 | 
| 稳定性 | 高(I/O 可控) | 受限于 tmp_table_size/sort_buffer_size | 
| 典型场景 | 中大规模去重 | 小数据量、快速响应 | 
- I/O vs 内存权衡
-  SQL1:I/O 适当增加,换取稳定去重; 
-  SQL2:依赖内存,当数据量超出配置时表现不稳 。 
- 统计信息影响
-  高选择性 ( plan_id) 与 低选择性 (del_flag) 配合不当,容易让优化器选错计划;
-  保持准确统计信息,定期 ANALYZE TABLE是必备流程 。
五、终极优化方案 🏆
-  覆盖式联合索引 ✨ CREATE INDEX idx_opt ON indicator_log(plan_id, del_flag, obj_id);-  一次扫描完成所有条件过滤: plan_id→del_flag→ 取出obj_id,无需再做索引合并或回表
-  支持索引覆盖(Covering Index),减少磁盘 I/O,聚合与去重都可在索引层直接完成 
 
-  
-  内存与临时表参数调优 🔧 SET GLOBAL tmp_table_size = 256M; SET GLOBAL max_heap_table_size = 256M; SET GLOBAL sort_buffer_size = 64M;-  增大内存阈值,让大多数临时表都在内存中完成,避免频繁落盘 
-  提高排序缓冲区,减少 COUNT(DISTINCT)或ORDER BY时的 spill-to-disk
 
-  
-  启用 Loose Index Scan 🚀 SET SESSION optimizer_switch = 'loose_index_scan=on';-  对于 COUNT(DISTINCT obj_id),MySQL 5.6+ 可以利用“松散索引扫描”
-  在覆盖索引场景下,只需依次跳读不同值的第一条记录,即可高效去重 
 
-  
-  物化视图 / 预聚合表 🗄️ 
-  写时维护:在插入/更新阶段,通过触发器或应用逻辑同步维护 (plan_id, unique_obj_count)
-  定时批处理:夜间或低峰期,将去重结果写入专用聚合表,查询时直接读取,无需在线去重 
六、总结
-  🧺 SQL1 = 小集合计数 先执行子查询: SELECT DISTINCT obj_id …,把所有唯一值抽取到“小篮子”中(临时表或物化表),然后再对这“小篮子”做COUNT(obj_id)。拆分去重和计数两步,使得 I/O 可控、压力分散,性能更稳定 。
-  ⚡ SQL2 = 大集合实时计数 直接在大表上执行 COUNT(DISTINCT obj_id),MySQL 需要边扫描边在内存中维护哈希表或做外部排序来去重并计数。这种“一次性”实时去重对内存和 CPU 依赖极高,一旦超过内存阈值就会频繁 spill-to-disk,性能抖动明显 。
👉 真·性能优化,绝非单点发力,而是「SQL 写法 + 执行计划 + 索引设计 + 系统参数」四位一体,才能在海量数据面前保持高效稳定