原文链接:
原文作者: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 组合各种方法,实现极致的可扩展性
面对超大规模的数据,可以混合运用这些策略:
-
对常用时间粒度(日/月)使用物化视图预聚合 uniqExact
-
实时查询时对任意时间范围使用 uniqCombined64 聚合
-
使用 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
规模扩展:查询性能敏感时引入预聚合
终极形态:混合多种方法,实现灵活性与性能的平衡