如何在 1000 亿级数据规模下实现高效的去重统计?

原文链接:

  1. www.tinybird.co/blog-posts/...
  2. www.tinybird.co/blog-posts/...

原文作者:Ariel Pérez

译者:菜小鸟魔王

我最近看到一篇有趣的LinkedIn post,讲述了 Reddit 在 2017 年如何设计帖子浏览量和独立访客统计系统:

他们用 Kafka、Redis 和 Cassandra 搭建了一套超高效的解决方案,仅用 12KB 的存储空间就能实现 1000 亿个不同的 64 位 ID 的去重统计。令人惊叹!

但作为一个长期帮助企业解决这类问题的人,我不禁想:"肯定还有更简单的方法。"

01 Reddit 的架构方案

他们的实现方式:

  • Kafka 接收浏览事件并判断本次浏览是否需要被计算在内
  • 合格的浏览事件会写入另一个 Kafka topic 进行统计
  • 消费者更新 Redis 维护独立访客(UV)计数。
  • Cassandra 每 10 秒从 Redis 拉取数据进行备份(防止内存数据被驱逐)
  • 使用 HyperLogLog 进行基数估算(节省大量存储空间)

当然,这个方案是有效的!但是,仅仅为了统计一些数据就引入这么多组件,未免太复杂了。

02 更简单的解决方案

设想一下,如果能够:

  • 将所有原始事件集中存储
  • 仅用一条 SQL query 统计独立用户(UV)数
  • 获取实时结果
  • 按时间进行筛选
  • 同时保持极致的高效率

事实证明,这完全可以实现。具体方法如下文所示。

03 实施方案

首先,我创建了一个 Tinybird 项目来存储和统计文章浏览量。通过 Tinybird CLI 中的 tb create 命令,并输入 prompt 来生成初始版本。

sql 复制代码
tb create --prompt "A project to count Reddit post views. It'll capture views with a post_id, a timestamp in seconds, and a viewer_id as a long. It should also have an API for counting exact unique post viewers by post_id, filterable by date. A post_id will always be required for querying."

我发现,在使用项目生成器时,需求描述得越具体,得到的结果就越理想。

这是 Tinybird 首次生成的方案:

datasources/post_views.datasource

javascript 复制代码
DESCRIPTION >
    Reddit post views events with post_id, timestamp and viewer_id

SCHEMA >
    `post_id` String `json:$.post_id`,
    `timestamp` DateTime `json:$.timestamp`,
    `viewer_id` Int64 `json:$.viewer_id`

ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
ENGINE_SORTING_KEY "post_id, timestamp"

endpoints/unique_post_viewers.pipe

vbnet 复制代码
DESCRIPTION >
    API to count unique viewers by post_id with optional date filtering

NODE unique_post_viewers_1
SQL >
    %
    SELECT 
        post_id,
        uniqExact(viewer_id) as unique_viewers
    FROM post_views
    WHERE 
        post_id = {{String(post_id, '')}}
        {% if defined(start_date) %}
        AND timestamp >= {{DateTime(start_date, '2023-01-01 00:00:00')}}
        {% end %}
        {% if defined(end_date) %}
        AND timestamp <= {{DateTime(end_date, '2024-12-31 23:59:59')}}
        {% end %}
    GROUP BY post_id

TYPE endpoint

该 endpoint pipe 会自动成为 REST API(当前部署在本地):

rust 复制代码
curl -G 'http://localhost:7181/v0/pipes/unique_post_viewers.json' \
   -H 'Authorization: Bearer YOUR_TOKEN' \
   -d 'post_id=abc123' \
   -d 'start_date=2024-01-01'

Tinybird 还会生成与数据源结构匹配的模拟数据,因此我得以验证该 endpoint 确实能准确统计事件日志中的独立访客量。

进展顺利。虽然 API 已可运行,但我仍对 post_id 参数做了强制校验:

可以看到,保存修改后 Tinybird 立即热重载并验证构建结果。

随后通过 tb --cloud deploy 命令将其部署至云端,一个可用于生产环境的独立访客统计 API 就此诞生。

04 但能扩展吗?

方案简洁固然很好,但若无法扩展便失去其意义了。

于是我用 Mockingbird 进行测试:模拟 1 篇文章获得 1000 万次浏览、约 100 万独立访客(原始数据约 1GB)。结果如下:

  • 存储:压缩后约 57MB
  • 延迟:查询响应约 20 毫秒(对时间参数进行筛选后更快)
  • 吞吐:实时写入速度达 10 万事件/秒

虽未达到原文所述的千亿级规模,但已能看出读取单篇文章数据的速度。考虑到数据的分区和排序方式,我认为即使帖子数量增加,性能也会保持稳定(提示:是对主键的二分查找,复杂度O(log2 n))。

随着单篇文章的浏览量和独立访客增加,系统资源消耗(如计算时间、存储空间)与数据增长量成正比关系。此时根据时间进行筛选的优势尤为明显。由此推算,假设 1 万篇文章各获 1000 万浏览(总计 1000 亿事件):

  • 消耗存储空间约 550 GB(注意:这里存储的是timestamp+post_id+viewer_id。文章开头 LinkedIn post 所指的那种方案仅viewer_id就占 800 GB)
  • 查询延迟仍保持在 20-40 毫秒!这就是O(log2 n)的魅力

当然,正式投产前仍需进行压力测试,以确保估算无误。

最重要的是,这里存储的是全量原始数据 ------ 而非预聚合计算结果(pre-aggregated counts)。如需按小时进行细分?增加维度?自定义筛选条件?只需修改 SQL 即可。

05 The trade-offs

没有最完美的方案,故需明确该方案的局限性:

  • 需要存储更多的原始数据(但现代列式压缩(columnar compression)技术效率惊人,且这些数据可复用)
  • 查询需实时计算(而非直接读取预存结果)
  • 极端情况下,可能需要进一步优化

但以下这些问题我完全不需要处理

  • 复杂的数据管道
  • 多服务/存储系统间的同步
  • 需要额外监控的子系统
  • 分布式系统带来的难题

06 该方案何时会引发问题?

前文的简单实现方案将浏览事件按 post_id 排序存储,而我们的计数器通过 post_id 进行筛选,这意味着主要的扩展性挑战来自每篇文章的浏览量。Endpoint 性能可能会因以下原因下降:

  • 单篇文章需要扫描的事件过多(数十亿行)
  • 热门文章上的并发查询

我们来看看不同浏览量帖子的一些真实数据:

  • 1000 万次浏览 ≈ 57MB 压缩数据
  • 1 亿次浏览 ≈ 565MB 压缩数据
  • 10 亿次浏览 ≈ 5.5GB 压缩数据

即使采用压缩技术、设置了比较好的索引和使用 post_id 进行筛选,扫描如此大量的浏览数据仍会比较耗时:

  • 1000 万次浏览 ≈ 20 毫秒
  • 1 亿次浏览 ≈ 200-400 毫秒
  • 10 亿次浏览 ≈ 2-4秒

浏览量每增加 10 倍,查询时间就会相应增加 10 倍。而这还只是单次查询的情况。当多个用户同时查看浏览量时,耗时还会进一步增加。

07 什么时候 uniqExact 开始显露出其局限性?

扫描每篇文章数百万的浏览量还不是最令人头疼的问题。真正的瓶颈出现在处理大量独立访客(UV)数据时,这正是 uniqExact 函数开始崩溃的时刻。

查询时间还受到两个因素的影响:

  • 哈希集合插入操作(与 unique values 线性相关)
  • 内存分配(同样是线性关系,但会遭遇 performance cliffs)

随着独立访客(UV)的数量增加,哈希集合不断膨胀并需要更频繁地调整大小,导致 CPU 停滞。但这还只是开始。当哈希集合(hash set)溢出 L3 缓存并蔓延到主内存时,真正的痛苦才会降临。如果内存压力迫使系统进行磁盘交换,那就自求多福吧。随着数据集的增长,哈希碰撞也会增加,这对性能又是一个负担。

08 现实世界的相关数据指标

我曾多次与客户讨论过这个问题。以下是独立访客(UV)统计的实际情况:

内存需求 vs 性能(64 位 viewer_id)

独立访客数 内存占用 存储位置 查询时间(10%的独立唯一值
1M ~16MB CPU L3 Cache 10-20ms
10M ~160MB RAM ~20-60ms
100M ~1.6GB RAM ~2s-5s
1B ~16GB RAM + Potential Swap ~15-20s

我将其划分为三个性能区间:

• L3 Cache 区(<100万 唯一值)快如闪电

• 内存区(100万-1亿 唯一值)"勉强可用"的性能缓降区

• 危险区(>1亿 唯一值)性能断崖式下跌区

即便服务器硬件性能良好(32GB内存),当独立访客达到 5 亿左右时,真正的痛苦就会降临:查询耗时暴增(超过 1 秒)、凌晨三点出现的内存报错、以及让财务团队质询的基础设施成本。而这还没考虑并发查询的情况。

09 两条优化路径

如何在万亿级浏览量的数据库表中统计每篇内容的数十亿独立访客,同时还不超出资源预算?

9.1 使用 uniqCombined64 进行近似统计

最简单的优化是将 uniqExact 替换为 uniqCombined64:

sql 复制代码
SELECT
   post_id,
   uniqCombined64(viewer_id) as unique_viewers
FROM post_views
WHERE post_id = {{ String(post_id, required=True) }}
GROUP BY post_id

我倾向选择 uniqCombined64 而非 uniqHLL12,因为其内存管理更智能。uniqCombined64 会根据数据规模在三种计数模式间动态切换:

• 数组模式:当唯一值较少时使用简单数组 • 哈希模式:随着唯一值增长切换为稀疏哈希集合 • HyperLogLog 模式:数据量达到超大规模时启用 full HLL 模式(这正是 Reddit 在 Redis 中实现高效浏览量统计的技术)

这种自适应机制意味着:在低基数时获得更高精度,同时不丧失处理数十亿唯一值的能力。相比之下,uniqHLL12 虽针对大场景进行了优化,但会更早切换到 HLL 模式,可能在面对较小数据集时损失精度。

为什么 uniqCombined64 在现实场景中能够胜出? ✓ 小规模数据下更精确,不会过早近似 ✓ ~0.8% 的误差率,在数据分析这一场景中足够优秀 ✓ 内存占用恒定(约 80KB / 每次聚合),内存消耗可预测 ✓ 高效扩展至数十亿唯一值,精度损失极小 ✓ 速度大幅超越 uniqExact(10 亿唯一值下 250ms vs 10s),规避昂贵的哈希表内存开销

许多团队因追求完美的精度而默认使用 uniqExact,最终却发现 uniqCombined64 99.2% 的精度已完全够用。更安心的是,查询不会再引发数据库内存溢出(OOM)。

虽然这样解决了内存问题,还提升了查询性能,但可能去扫描数十亿的浏览记录这一问题依然存在。

9.2 使用物化视图进行预聚合

当我们需要进行精确计数,但更重要的是需要更快的查询速度时,预聚合是一种理想选择。

物化视图数据源

javascript 复制代码
DESCRIPTION >
   'Materialized daily unique viewers per post'

SCHEMA >
   `date` Date,
   `post_id` String,
   `unique_viewers_state` AggregateFunction(uniqExact, Int64)

ENGINE "AggregatingMergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(date)"
ENGINE_SORTING_KEY "date, post_id"

物化视图查询 pipe

vbnet 复制代码
DESCRIPTION >
   'Pre-aggregates unique viewers per post'

NODE daily_unique_viewers
SQL >
   SELECT
       post_id,
       toDate(timestamp) as date,
       uniqExactState(viewer_id) as unique_viewers_state
   FROM post_views
   GROUP BY post_id, date

TYPE materialized
DATASOURCE daily_post_viewers_mv

此时您的 API endpoint 将变为:

vbnet 复制代码
DESCRIPTION >
   'Fast exact unique viewers with pre-aggregated data'

NODE optimized_post_analytics
SQL >
   %
   SELECT
       post_id,
       uniqExactMerge(unique_viewers_state) as unique_viewers
   FROM daily_unique_viewers
   WHERE post_id = {{ String(post_id, required=True) }}
   {% if defined(start_date) %}
        AND date >= {{ Date(start_date) }}
   {% end %}
   {% if defined(end_date) %}
       AND date <= {{ Date(end_date) }}
   {% end %}
   GROUP BY post_id

TYPE endpoint

这种方法的优势:

  • 保持计数的精确性
  • 大幅缩短查询时间
  • 由于仅统计每日的唯一值,单次查询内存占用更低
  • 支持实时更新
  • 过预聚合(如物化视图)优化查询性能时,会限制我们对任意时间范围进行灵活查询的能力。

10 组合各种方法,实现极致的可扩展性

面对超大规模的数据,可以混合运用这些策略:

  1. 对常用时间粒度(日/月)使用物化视图预聚合 uniqExact

  2. 实时查询时对任意时间范围使用 uniqCombined64 聚合

  3. 使用 uniqCombined64 对常见时间范围(日、月)进行预聚合,并在查询时使用 uniqCombined64 对原始视图进行聚合,以填补空白。

以下示例展示了保持简洁的混合方案。

物化视图数据源:

javascript 复制代码
DESCRIPTION >
   'Materialized daily unique viewers per post'

SCHEMA >
   `date` Date,
   `post_id` String,
   `unique_viewers_state` AggregateFunction(uniqCombined64, Int64)

ENGINE "AggregatingMergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(date)"
ENGINE_SORTING_KEY "date, post_id"

物化视图 pipe:

vbnet 复制代码
DESCRIPTION >
   'Materializes daily unique viewers per post using uniqCombined64'

NODE daily_post_viewers_1
SQL >
SELECT
   toDate(timestamp) as date,
   post_id,
   uniqCombined64State(viewer_id) as unique_viewers_state
FROM post_views
GROUP BY date, post_id

TYPE materialized
DATASOURCE daily_post_viewers_mv

Endpoint:

vbnet 复制代码
DESCRIPTION >
'API to count unique viewers per post_id with optional date filtering. Uses materialized view for complete days and post_views for partial days or when no dates provided.'

NODE full_days
DESCRIPTION >
   'Gets the unique state for full days within the query range'

SQL >
%
   SELECT
       post_id,
       unique_viewers_state
   FROM daily_post_viewers_mv
   WHERE
       {% if defined(start_date) or defined(end_date) %} 
           post_id = {{String(post_id, required=True)}}
           {% if defined(start_date) %}
             AND date > toDate({{DateTime(start_date)}})
           {% end %}
           {% if defined(end_date) %}
             AND date < toDate({{DateTime(end_date)}})
           {% end %}
           GROUP BY post_id
       {% else %}
          0
       {% end %}

NODE start_day
DESCRIPTION >
   'Gets the unique state for the partial day at the start of the query range'

SQL >
%
   SELECT
       post_id,
       unique_viewers_state
   FROM daily_post_viewers_mv
   WHERE
       {% if defined(start_date) %}
           post_id = {{String(post_id, required=True)}}
           AND toDate(timestamp) = toDate({{DateTime(start_date)}})
           AND timestamp >= {{DateTime(start_date)}}
           GROUP BY post_id
       {% else %}
           0
       {% end %}

NODE end_day
DESCRIPTION >
   'Gets the unique state for the partial day at the end of the query range'

SQL >
%
   SELECT
       post_id,
       unique_viewers_state
   FROM daily_post_viewers_mv
   WHERE
       {% if defined(end_date) %}
           post_id = {{String(post_id, required=True)}}
           AND toDate(timestamp) = toDate({{DateTime(end_date)}})
           AND timestamp <= {{DateTime(end_date)}}
           GROUP BY post_id
       {% else %}
           0
       {% end %}

NODE endpoint
DESCRIPTION >
   'Aggregates the unique state across the entire query range'

SQL >
%
   {% if defined(start_date) or defined(end_date) %}
       SELECT
           post_id,
           uniqCombined64Merge(unique_viewers_state) as unique_viewers
       FROM
           (
               SELECT post_id, unique_viewers_state FROM full_days
               UNION ALL
               SELECT post_id, unique_viewers_state FROM start_day
               UNION ALL
               SELECT post_id, unique_viewers_state FROM end_day
               )
       GROUP BY post_id
   {% else %}
       SELECT
           post_id,
           uniqCombined64(viewer_id) as unique_viewers
       FROM post_views
       WHERE post_id = {{String(post_id, required=True)}}
       GROUP BY post_id
   {% end %}

TYPE endpoint

11 方案选型指南

初级方案:先用 uniqExact,直到遇到性能问题

快速应对:内存告急时切换 uniqCombined64

规模扩展:查询性能敏感时引入预聚合

终极形态:混合多种方法,实现灵活性与性能的平衡

相关推荐
却道天凉_好个秋2 分钟前
系统架构设计(二):基于架构的软件设计方法ABSD
架构·系统架构
泯泷5 分钟前
「译」解析 JavaScript 中的循环依赖
前端·javascript·架构
抹茶san8 分钟前
前端实战:从 0 开始搭建 pnpm 单一仓库(1)
前端·架构
iuyou️37 分钟前
Spring Boot知识点详解
java·spring boot·后端
一弓虽1 小时前
SpringBoot 学习
java·spring boot·后端·学习
南客先生1 小时前
互联网大厂Java面试:RocketMQ、RabbitMQ与Kafka的深度解析
java·面试·kafka·rabbitmq·rocketmq·消息中间件
姑苏洛言1 小时前
扫码小程序实现仓库进销存管理中遇到的问题 setStorageSync 存储大小限制错误解决方案
前端·后端
烛阴1 小时前
JavaScript 的 8 大“阴间陷阱”,你绝对踩过!99% 程序员崩溃瞬间
前端·javascript·面试
光而不耀@lgy1 小时前
C++初登门槛
linux·开发语言·网络·c++·后端
方圆想当图灵2 小时前
由 Mybatis 源码畅谈软件设计(七):SQL “染色” 拦截器实战
后端·mybatis·代码规范