为什么 SELECT COUNT(DISTINCT col) 在大表上很慢?有哪些近似算法(HyperLogLog 等)?
Why is SELECT COUNT(DISTINCT col) slow on large tables? What are some approximation
考察要点
这道题面试官想看你是否懂:
COUNT(DISTINCT)在数据库内核中的执行原理:面试官希望了解你是否清楚数据库为了完成精确去重,需要付出巨大的内存和 CPU 成本。- 精确计数与近似计数之间的权衡(Trade-off):这考察你的业务场景分析和架构设计能力。在海量数据下,能否接受一定的误差以换取数量级的性能提升,是区分初级和资深工程师的关键。
- 现代数据库和大数据系统中常用的近似计数算法:特别是 HyperLogLog (HLL) 的基本思想和应用场景。这部分知识展现了你的技术广度和深度。
核心概念
1. COUNT(DISTINCT col) 的精确计算原理与瓶颈
数据库为了得到一个精确的去重计数值,通常有两种内部实现策略:
策略一:排序(Sorting)
- 从表中读取所有
col列的值。 - 对这些值进行排序。
- 遍历排序后的结果,相邻值如果不同则计数器加一。
- 瓶颈:
- CPU 密集:排序算法的时间复杂度通常是 ,其中 N 是表的总行数。
- I/O 密集:如果数据量巨大,无法全部加载到内存中(超过
work_mem或sort_buffer_size),数据库需要进行外部排序,即在磁盘上创建临时文件进行多路归并排序,这会产生大量的磁盘 I/O,性能急剧下降。
策略二:哈希(Hashing)
- 在内存中创建一个哈希表(Hash Table)。
- 遍历表中的每一行,获取
col的值。 - 将
col的值尝试放入哈希表。如果值已存在,则忽略;如果不存在,则插入。 - 最终,哈希表中元素的数量就是
COUNT(DISTINCT col)的结果。
- 瓶颈:
- 内存密集:哈希表的空间复杂度是 ,其中 Cardinality 是去重后的基数。当
col的唯一值非常多时(例如,用户 ID、设备 ID),哈希表会消耗巨量内存。 - I/O 密集:同样,如果哈希表大小超过内存限制,其部分数据结构也可能需要交换到磁盘,导致性能问题。
- 内存密集:哈希表的空间复杂度是 ,其中 Cardinality 是去重后的基数。当
索引优化:
如果 col 列上存在索引,MySQL/PostgreSQL 可以使用一种称为松散索引扫描(Loose Index Scan / Skip Scan) 的优化。它直接在索引的 B+ 树上进行跳跃式扫描,从一个唯一值直接跳到下一个唯一值,而无需读取所有行。这极大地提升了性能,因为它避免了全表扫描和在内存/磁盘中的排序/哈希操作。但当基数仍然非常大时(千万级以上),即使是索引扫描,其 I/O 开销和 CPU 计算成本依然不可忽视。
2. 近似计数算法:HyperLogLog (HLL)
当业务场景可以容忍微小误差时(例如,统计网站的日活跃用户 UV),近似算法是最佳选择。HyperLogLog 是其中的佼佼者。
-
核心思想:HLL 不存储元素本身,而是通过对元素进行哈希,并观察哈希值的二进制表示中前导零(Leading Zeros) 的数量来估计基数。
- 直觉:如果你随机生成一堆数字并对它们进行哈希,看到一个有很多前导零(如
00000000...1...)的哈希值是一个小概率事件。出现的次数越多,说明你尝试的原始数字就越多。HLL 正是利用这种概率现象来估算总共有多少个不同的数字。
- 直觉:如果你随机生成一堆数字并对它们进行哈希,看到一个有很多前导零(如
-
工作流程(简化版):
- 分桶(Bucketing):HLL 内部维护一个固定大小的数组,称为“桶”或“寄存器”(通常是 个,如 16384 个)。
- 哈希与分配:对于每一个输入值
item,计算其哈希值hash(item)。 - 定位桶与更新:使用哈希值的一部分(如前
k位)来确定它属于哪个桶。 - 计算前导零:使用哈希值的另一部分,计算其前导零的个数
p。 - 更新最大值:将
p与该桶当前存储的值进行比较,只保留较大的那个。即bucket[index] = max(bucket[index], p)。 - 估算:最后,通过对所有桶中的值进行一种特殊的平均(调和平均数),再乘以一个修正因子,得到最终的基数估算值。
-
关键优势:
- 空间恒定:HLL 的内存占用是固定的,与元素的基数无关,只与期望的精度有关。一个标准的 HLL 结构(如 Redis 中的实现)通常只需要 12KB 内存,就能在标准误差约为 0.81% 的情况下,估算上百亿的基数。
- 可合并性:两个 HLL 结构可以进行合并(Merge),合并后的 HLL 代表了两个原始数据集的并集基数。这个特性在分布式计算中极为有用。
HLL(A ∪ B) = merge(HLL(A), HLL(B))。
-
支持情况:
- MySQL/MariaDB: 原生不支持,但可以通过 UDF (User-Defined Function) 插件实现。
- PostgreSQL: 通过
postgresql-hll扩展支持。 - Redis: 内置
PFADD,PFCOUNT,PFMERGE命令。 - ClickHouse, Presto, Trino, Spark, BigQuery: 原生支持 HLL 函数(如
approx_distinct,uniq)。
常见低分回答
- 回答过于笼统:“因为它要扫描全表,所以很慢。” —— 没有抓住重点。
COUNT(*)也要扫描全表,但通常比COUNT(DISTINCT)快得多。核心在于DISTINCT操作带来的排序或哈希的巨大开销。 - 只知其一不知其二:“数据库需要排序,排序很慢。” —— 只提到了排序,没有提到哈希也是一种常见的实现方式,并且忽略了两种方式共同的内存瓶颈。
- 忽略索引的作用:“
COUNT(DISTINCT)没法优化。” —— 这是一个错误的结论。对于精确计算场景,在列上添加索引是首选且最有效的优化手段,面试官很期待听到“松散索引扫描”这个关键词。 - 对近似算法一无所知:当被问到“如果可以接受误差,有什么办法”时,回答“不知道”或“只能分表分库”,这会错失展示技术广度的机会。
- 混淆
COUNT(DISTINCT col)和COUNT(col):COUNT(col)统计col列非 NULL 的行数,而COUNT(DISTINCT col)统计col列非 NULL 的唯一值的数量,两者语义和性能开销完全不同。
工程实践与优化
-
场景判断:精确 vs. 近似
- 必须精确:订单数、用户账户余额、计费相关的统计。这些场景下,任何误差都是不可接受的。
- 可以近似:网站 UV(Unique Visitor)、APP 日活/月活(DAU/MAU)、广告点击去重、搜索词热度分析。这些场景下,百万和百万零一百的差异对业务决策没有影响。
-
精确计算的优化策略
- 加索引:最直接有效的方法。
CREATE INDEX idx_col ON your_table(col);。通过EXPLAIN查看执行计划,确保Extra字段出现Using index for group-by或类似的提示,表明用上了松散索引扫描。 - 预计算/物化视图:创建一张汇总表(Summary Table),通过定时任务(如每日凌晨)提前计算好前一天的
COUNT(DISTINCT)结果并存储。查询时直接读取汇总表。这是典型的数据仓库思想。 - 数据类型选择:在设计表时,如果某列需要频繁进行去重计数,且基数巨大(如各种 ID),应选择空间占用小且定长的数据类型(如
BIGINT代替长字符串),以减少内存和 I/O 负担。
- 加索引:最直接有效的方法。
-
近似计算的落地
- 应用层 + Redis:这是最常见的架构。业务代码在处理用户行为时,将用户 ID 通过
PFADD命令添加到 Redis 的 HLL 结构中。需要查询 UV 时,使用PFCOUNT命令,毫秒级返回结果。
sh1# 用户 123, 456, 789 访问了页面2PFADD page:1:uv user123 user456 user7893# 查询页面 1 的 UV4PFCOUNT page:1:uv5# (integer) 3- 使用支持 HLL 的数据库:如果数据已经存储在 ClickHouse、PostgreSQL(带扩展)等系统中,可以直接在 SQL 中使用其近似计数函数。
sql1-- ClickHouse2SELECT uniq(UserID) FROM hits_table WHERE EventDate = '2023-10-26';34-- PostgreSQL (with hll extension)5SELECT hll_cardinality(hll_agg(user_id)) FROM events WHERE event_date = '2023-10-26'; - 应用层 + Redis:这是最常见的架构。业务代码在处理用户行为时,将用户 ID 通过
常见追问与延伸题目
-
追问:如果要对多个列进行去重计数,比如
COUNT(DISTINCT user_id, order_id),情况有什么不同?- 回答要点:原理是相同的,但计算成本更高。数据库需要将
user_id和order_id拼接成一个组合值,然后对这个组合值进行排序或哈希。 - 优化:最佳优化方式是创建一个覆盖
(user_id, order_id)的复合索引。这样数据库同样可以利用松散索引扫描来高效地统计。 - HLL 方案:在应用层,可以将多个字段拼接成一个字符串(注意使用分隔符避免歧义,如
user_id + ":" + order_id),然后将这个拼接后的字符串添加到 HLL 结构中。
- 回答要点:原理是相同的,但计算成本更高。数据库需要将
-
追问:
COUNT(DISTINCT CASE WHEN ... END)这种用法你了解吗?它有什么应用场景和性能特点?- 回答要点:这是一种非常强大的条件去重计数方法,常用于 BI 报表,可以在一次查询中计算多个维度的去重指标。
- 示例:
sql1SELECT2 COUNT(DISTINCT CASE WHEN platform = 'iOS' THEN device_id ELSE NULL END) AS ios_dau,3 COUNT(DISTINCT CASE WHEN platform = 'Android' THEN device_id ELSE NULL END) AS android_dau4FROM daily_logins5WHERE login_date = CURDATE();
- 关键点:
ELSE NULL是核心,因为COUNT(DISTINCT ...)会忽略 NULL 值,从而巧妙地实现了按条件筛选。 - 性能:这种查询通常无法有效利用
device_id上的简单索引,因为它被包裹在CASE表达式中。如果性能瓶颈严重,最优的索引是(login_date, platform, device_id),这样可以快速定位到特定日期和平台下的设备 ID,并利用索引进行去重。
-
追问:除了 HyperLogLog,你还知道其他近似算法吗?它们有什么区别?
- 回答要点:这表明面试官想考察你的知识广度。可以提另外两种常见的概率数据结构:
- Bloom Filter(布隆过滤器):主要用于判断“一个元素是否存在于一个集合中”。它有可能会误判(False Positive,即报告一个元素存在但实际不存在),但绝不会漏判(False Negative,即报告一个元素不存在但实际存在)。它不用于计数,而是用于高效的成员资格检查,例如在缓存穿透、爬虫 URL去重等场景。
- Count-Min Sketch:主要用于估算数据流中元素的频率(即
GROUP BY ... COUNT(*)的近似版),而不仅仅是基数。例如,可以用它来快速找到访问量 Top K 的 URL。它和 HLL 解决的问题不同,HLL 关注的是“有多少种”,Count-Min Sketch 关注的是“每一种有多少个”。
- 回答要点:这表明面试官想考察你的知识广度。可以提另外两种常见的概率数据结构: