业务统计中的日活用户数、独立访客数往往依赖一条看似简单的SQL:SELECT COUNT(DISTINCT user_id) FROM user_visits WHERE ...。当明细表只有几十万行时,这条查询毫秒返回;但当表增长到千万甚至亿级后,同样的查询可能耗时数十秒,数据库CPU和I/O被严重消耗。PostgreSQL为了精确去重,必须对user_id列做排序或哈希聚合,所有去重值都要放进工作内存,超出work_mem就写临时文件。对于允许小幅误差的统计场景,HyperLogLog提供了一种固定内存的近似计数方案,能把这个过程从秒级压缩到毫秒级。

为什么COUNT(DISTINCT)会成为慢查询
在PostgreSQL中,COUNT(DISTINCT col) 的执行计划通常是Aggregate节点,内部使用哈希表记录已经出现过的值。如果去重基数很大,哈希表无法完全放入work_mem,PostgreSQL会切换到基于磁盘的聚合,产生大量临时文件读写。更糟的是,这种查询无法利用索引只扫描部分数据,必须读取所有满足条件的行。对于用户访问日志这类大表,扫描本身就很耗时,再加上去重排序或哈希计算,最终查询时间可能从几十秒到几分钟。
实际优化时,增加work_mem、调整并行度只能缓解部分压力,无法从根本上消除去重计算的复杂度。只要业务要求精确统计不同user_id的数量,数据库就必须处理全部去重值,复杂度至少是O(n)并伴随额外内存或磁盘开销。如果业务方表示可以接受1%到2%的误差,那么近似计数就成为一个性价比极高的选择。
HyperLogLog正是专门解决基数估计问题的概率数据结构。它用极小的固定内存记录集合的近似势,不需要存储每个元素本身,因此处理速度和存储开销都远低于精确去重。接下来从原理说明为何它能做到这一点。
HyperLogLog如何用固定内存估计基数
HyperLogLog的核心思想是把每个元素先哈希成一个64位整数,然后观察哈希值二进制表示中从低位或高位开始的连续零的个数。连续零的个数越多,说明出现这种模式所需要的元素数量越大,这与抛硬币出现连续正面的概率类似。HLL把哈希空间划分成m个桶,每个桶只保留该桶中哈希值前导零的最大值。根据所有桶的平均值,再乘上桶数量和偏差修正系数,就能得到集合基数的估计值。
桶的数量m由精度参数log2m决定,m等于2的log2m次方。PostgreSQL的postgresql-hll扩展默认log2m为11,即2048个桶,整个结构大约只占用1.5KB左右。标准误差约为1.04除以根号m,当m为2048时误差约2.3%。如果提高log2m到16,误差可以降到0.5%以内,但内存会增加到几十KB。这里的内存是固定值,与原始数据量完全无关,这是它相比精确去重最大的优势。
另一个重要特性是可合并性。两个HLL结构可以通过并集操作合并,合并后的HLL能估计两个集合并集的基数。这非常适合按天存储UV,再合并成周UV、月UV,而不需要保留每天的去重用户ID列表。合并操作在内存中只需一次桶最大值比较,开销极低。
在PostgreSQL中启用HyperLogLog
PostgreSQL本身不含HLL类型,需要安装postgresql-hll扩展。在Debian或Ubuntu上,可以通过apt install postgresql-14-hll或者对应版本包安装,也可以从源码编译。安装完成后,在数据库中执行CREATE EXTENSION hll;即可使用hll类型和相关函数。部分云数据库如果未提供该扩展,可能需要联系云厂商或使用第三方镜像。
设计统计表时,通常不把hll列直接放在明细表上,因为每次插入明细都更新hll会带来写放大。更常见的方式是保留原始访问日志表,另建一张按天汇总的统计表,用hll列保存当天用户ID的近似集合。可以定期通过调度任务批量重建,或在应用层双写维护。下面是一套完整示例。
-- 创建扩展
CREATE EXTENSION IF NOT EXISTS hll;
-- 用户访问明细表
CREATE TABLE user_visits (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
visit_time timestamp NOT NULL
);
-- 按天统计UV,user_hll列保存用户ID近似集合
CREATE TABLE daily_uv (
stat_date date PRIMARY KEY,
user_hll hll NOT NULL
);
-- 批量构建当天HLL,WHERE条件中的比较符号已转义
INSERT INTO daily_uv (stat_date, user_hll)
SELECT current_date, hll_add_agg(hll_hash_bigint(user_id))
FROM user_visits
WHERE visit_time >= current_date::timestamp
AND visit_time < (current_date + 1)::timestamp;
-- 查询当天近似UV
SELECT stat_date, hll_cardinality(user_hll) AS uv
FROM daily_uv
WHERE stat_date = current_date;
-- 合并最近7天
SELECT hll_cardinality(hll_union_agg(user_hll)) AS weekly_uv
FROM daily_uv
WHERE stat_date >= current_date - 6;
上面的hll_hash_bigint负责把bigint类型的user_id哈希成HLL可处理的64位值,hll_add_agg是聚合函数,将一批哈希值添加进同一个HLL结构,hll_cardinality返回近似基数。如果用户ID是字符串,可以使用hll_hash_text。日常查询只需读取daily_uv中几KB的hll列,速度极快。
如果不想每天重建,也可以在明细表上建触发器,每当插入一行访问记录,就用hll_add更新对应日期的hll列。这种方式会稍微增加写入开销,但查询端可以随时拿到实时近似UV。对于并发写密集的场景,可以把更新放进队列异步执行。
性能对比与适用场景
在千万级用户访问日志上,精确的COUNT(DISTINCT user_id)通常需要几秒甚至几十秒,具体取决于硬件和work_mem设置。而预先维护的HLL统计表查询只需读取一个或几个hll列,执行hll_cardinality计算,整个响应时间在毫秒级。即便需要合并过去30天的数据,也只是合并30个几KB的结构,开销可以忽略。
当然,近似计数不是银弹。默认精度下误差约2%,如果某个用户的访问数据刚好落在哈希边界附近,估算值可能略高或略低。对于要求绝对准确的场景,比如财务对账、订单数量、支付笔数,仍然必须使用精确COUNT。HLL更适合用于运营分析、风控监控、趋势看板等允许小幅误差的场景。如果对误差要求更高,可以通过提高log2m参数降低误差,但会增加存储空间。
使用HLL前还需要注意扩展兼容性以及数据保留策略。原始明细表仍应保留,以便需要精确回算或审计时使用。HLL列不适合用来判断某个用户是否访问过,它只能回答大约有多少个不同的用户。另外,HLL结构一旦生成就不可逆,无法提取原始用户ID,因此不能替代去重明细表。只要把这些边界梳理清楚,HyperLogLog就能成为PostgreSQL慢查询优化中非常实用的工具。
PostgreSQL慢查询优化HyperLogLog近似计数修改时间:2026-09-25 20:18:36