在 Postgres 上使用 HyperLogLog 进行去重计数

2,768 阅读8分钟

原作者/ BURAK YUCESOY
翻译&编辑 / 马叔

在数据库上运行 SELECT COUNT(DISTINCT)是非常常见的。在应用程序中,通常有一些分析大屏可以突出显示去重项的数量,例如去重的用户,去重的产品或去重的访问。虽然传统的 SELECT COUNT(DISTINCT)查询在单机设置中运行良好,但在分布式系统中却很难解决。当你有这种类型的查询时,你不能只是将查询推送到从属节点然后把结果加起来,因为很可能,在不同的从节点中存在重叠的记录。相反,你可以这样做:

  • 将所有不同的数据拉到一台机器上并在那里计数。(伸缩性差)

  • 做 map / reduce。(有伸缩性,但很慢)

这就是可以用到近似算法或草算(sketches)的地方。草算(sketches)是概率算法,可以在数学可证明的误差范围内有效地生成近似结果。这样的草图有很多,但今天我们只专门讲一个:HyperLogLog (HLL)。HLL 非常擅于估算列表中的去重元素计数。首先,我们来看一下 HLL 的内部结构,来帮助我们了解为什么 HLL 算法可以伸缩地解决去重计数问题,并解决如何将其以分布式的方式应用。然后,我们来看一些使用 HLL 的例子。

HLL 在后面都在做什么?

对所有元素求散列(哈希)

HLL 和几乎所有其他概率计数算法都依赖于数据的均匀分布。但是,因为在现实世界中,我们的数据一般不是均匀分布的,所以 HLL 首先就会对每个元素进行散列,使数据分布更均匀。这里说到的均匀分布,其意思是:元素的每一位都有 0.5 的概率成为 0 或 1.我们很快就会看到为什么这是有用的。除了均匀性,散列让 HLL 可以用相同的方式处理所有数据类型。只要你的数据类型有散列函数(哈希函数),你就可以使用 HLL 进行基数估算。

观察罕见的数据模式

在散列了所有元素后,HLL 将查找每个散列元素的二进制形式。它主要是看这里是否存在不太可能发生的比特模式(bit patterns)。如果存在这种罕见的模式,就意味着我们正在处理大数据集。

为此,HLL 在每个元素的散列值中找首位零比特,并找到首位零比特的长度。基本上,为了能够观察到 k 个首位零,我们需要 2k + 1 次试验(即,散列数)。因此,如果数据集中,首位零的最大数目为 k,HLL 就得出结论,这里存在大约 2k + 1 个不同的元素。

这是非常直接、简单的估算方法。然而,它具有一些重要的特性,在分布式环境中尤其明显。

  • HLL 的内存占用非常低。对于最大数 n,我们只需要存储 log log n 比特。例如,如果我们将元素散列成 64 比特整数,我们只需要存储 6 比特来进行估算。这比起朴素法(需要记住所有值),大量节省了内存。

  • 我们只需要对数据进行一次遍历(扫描),找到首位零的最大值。

  • 我们可以使用流数据。在计算了首位零的最大值之后,如果这时来了一些新的数据,我们就可以不用再过一遍整个数据集,可以直接将它们包含进去进行计算。我们只需要查找每个新元素的首位零的数,将它们与整个数据集里首位零的最大数进行比较,如果需要的话,就可以更新首位零的最大值。

  • 我们可以有效地合并两个单独数据集的估算结果。我们只需要选择首位零数值更大的那个作为合并数据集首位零的最大值。这就让我们可以将数据分片,估算它们的基数,以及合并结果。这被称为可加性,可加性让我们可以在分布式系统中使用 HLL。

随机平均

如果你认为上述的估算还不是很好,你是对的。首先,我们的预测总是以 2k 的形式。其次,如果数据分布没有足够统一,我们可能会得出偏差特别大的估算。

一个可能解决这些问题的方案是,用不同的散列函数来重复这个过程,然后取平均值。这应该是可行的,但是,对所有的数据进行多次散列十分昂贵。 HLL 解决了这个问题,这个方法叫做随机平均。基本上就是,我们将数据分成桶,并分别对每个桶使用上述算法。然后我们就取这些结果的平均值。我们用哈希值的前几个比特来确定某个元素属于哪个存储桶,然后使用剩余的比特来计算首位零的最大值。

此外,我们还可以选择一些桶来划分数据,以此来定制 / 调整精度。我们需要为每个存储桶存储 log log n 个比特。由于我们可以将 log log n 比特中的每个估算值都存储起来,所以就算我们创建大量的存储桶,最终也只会使用非常少的内存。在进行大规模数据操作时,这么小的内存占用十分重要。要合并两个估算,我们会合并每个桶,然后取平均值。因此,如果我们打算进行合并操作,我们应该保留每个数据桶中首位零的最大值。

还有什么?

为了提高估算的准确性,HLL 还做了一些其他的事情,不过观察比特模式和随机平均仍然是 HLL 的关键点。在这些优化之后,HLL 可以使用 1.5 kB 的内存来估算数据集的基数,其典型错误率为2%。当然,如果用更多的内存,可以提高准确度。我们不会详细介绍其他步骤,网上有关 HLL 的内容有很多很多。

分布式系统中的HLL

正如我们提到的,HLL 具有可加性的特质,这意味着,你可以将数据集分为几个部分,使用 HLL 算法对其分别操作,查找每个部分的去重元素数量。然后,不用回顾原始数据,你也可以有效地合并中间的 HLL 结果、查找所有数据的去重元素数量。

如果处理大规模数据,并将数据保存在不同的物理机器中,那么,无需将整个数据拖放到一个位置,你就可以使用 HLL 来计算所有数据的去重计数。实际上,Citus 可以帮你做这个操作。有一个为 PostgreSQL 开发的 HLL 扩展包,它与 Citus 完全兼容。如果你已经安装了 HLL 扩展包,并且想在分布式数据表上运行 COUNT(DISTINCT)查询,Citus 会自动启用 HLL。配置后,你不需要额外做任何事情。

使用 HLL

建立

要使用 HLL,我们将使用 Citus Cloud 和 GitHub 事件数据集。你可以从这里看到并了解更多有关 Citus Cloud 的信息。假设你创建了你的 Citus Cloud 实例,并通过 psql 与它连接,你可以通过下面这个创建 HLL 扩展:

CREATE EXTENSION hll;

你应该在主节点和从节点创建扩展。 然后通过设置 citus.count_distinct_error_rate 配置值来启用计数不同的近似值。当配置值设置的较低时,可以提供更准确的结果,但需要更长的时间和更多的内存进行计算。 我们建议将其设置为0.005。

SET citus.count_distinct_error_rate TO 0.005;

与之前在博客中用到的不同,我们将只使用 github_events 表 和 large_events.csv 数据集;

CREATE TABLE github_events
(
    event_id bigint,
    event_type text,
    event_public boolean,
    repo_id bigint,
    payload jsonb,
    repo jsonb,
    user_id bigint,
    org jsonb,
    created_at timestamp 
);

SELECT create_distributed_table('github_events', 'user_id');

\COPY github_events FROM large_events.csv CSV

例子

在分发了数据表格后,我们可以使用常规的 COUNT(DISTINCT)查询来找出多少去重用户创建了事件:

SELECT
    COUNT(DISTINCT user_id)
FROM
    github_events;

它应该返回类似这样的东西:

 count
--------
 264227

(1 row)

看起来,这个查询与 HLL 没有任何关系。 但是,如果你将 citus.count_distinct_error_rate 设置为大于0,并发出 COUNT(DISTINCT)查询,Citus 就会自动使用 HLL。对于这种简单的用例,你甚至不需要更改查询。创建事件的用户,准确的去重计数是 264198,所以我们的错误率略大于 0.0001。

我们也可以使用约束条件来过滤掉一些结果。 例如,我们可以查询创建 PushEvent 的去重用户数量:

SELECT
    COUNT(DISTINCT user_id)
FROM
    github_events
WHERE
    event_type = ‘PushEvent'::text;

它会返回:

count
--------
 157471

(1 row)

类似地,该查询的准确去重计数是157154,我们的错误率略大于0.002。

结论

如果在 Postgres 中,你有关于count (distinct)伸缩性的问题,可以看一下 HLL,如果足够近似的计数对你来说可行,这就可能会很有用。 关于“使用 Citus 进一步扩展计数事件”,如果你有任何问题,请与我们联系


翻译如有不当之处,欢迎指正。欢迎探讨技术问题。

原文链接:citusdata