2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
从零构建一个 Serverless Impression Tracking 系统:设计思路与 AWS 数据管道实践
前言
最近在工作中需要设计一个 impression tracking 系统,用于统计特定链接在合作伙伴网站上的 impression 情况。这个需求看似简单,但实际落地时会遇到不少有趣的工程问题。本文记录一下我的设计思路,以及在使用 AWS S3、Glue、Athena 构建数据管道过程中的一些经验,希望能对有类似需求的同学有所帮助。
注:这套系统刚刚上线不久,后续我会根据生产环境的运行情况持续更新这篇文章。
为什么不直接用主程序数据库?
在深入架构之前,值得先解释一下为什么我没有直接把事件写入主应用数据库。
最直接的方案是:Client → API → Sidekiq Job → PostgreSQL。简单、熟悉,而且使用现有的基础设施。但这种方案有明显的可扩展性问题:
-
数据库压力:Impression 事件可能产生极高的流量——每天可能数百万次写入。即使使用 Sidekiq 异步处理,这个量级也会对主数据库造成巨大压力,与核心业务事务争夺资源。
-
队列饱和:Sidekiq 队列容量有限。Impression 事件的流量高峰可能会淹没队列,延迟其他重要的后台任务,比如邮件发送或支付处理。
-
运维风险:将分析数据采集与主应用耦合会带来不必要的风险。分析流量激增不应该影响核心应用性能。
-
成本效率低:像 PostgreSQL 这样的行式数据库并不适合分析查询。对数百万事件进行聚合会很慢且消耗大量资源。
这让我选择了一条独立的分析管道——与主应用解耦,专门为高吞吐量写入和分析查询优化。
一、需求分析
业务背景
我们的 publisher 通过两种方式集成我们的内容:
- 内联链接:Publisher 直接在他们的文章和内容中插入我们的链接
- 嵌入式 Widget:Publisher 通过 iframe 嵌入我们的 widget,其中包含多个链接
为了追踪这两种场景的 impression,我们在两个地方部署 JavaScript SDK:
- Publisher 的页面上:追踪内联链接,并监控 iframe 的可见性
- iframe 内部:追踪 widget 内的链接
这两个 SDK 实例协同工作——父页面的 SDK 告诉 iframe 内的 SDK 何时 iframe 对用户真正可见,从而实现精确的 impression 追踪,无论链接出现在哪里。
核心需求
技术需求可以概括为:
- 前端采集:检测页面上特定链接何时真正进入用户 viewport
- 数据传输:将 impression event 可靠地发送到后端
- 数据存储:以低成本方式存储海量 event 数据
- 数据分析:支持灵活的 SQL 查询,便于业务分析
看起来需求不复杂,但”进入用户 viewport”这个定义就值得推敲:链接出现在页面上就算 impression 吗?用户一闪而过也算吗?如果链接在 iframe 里,而 iframe 本身被滚出了 viewport 呢?
二、客户端 SDK 设计
2.1 什么是”真正的 Impression”
并非每次出现都算有效的 impression。定义需要两个关键参数:
- 可见性阈值:元素需要有多大比例可见?
- 持续时间阈值:元素需要保持可见多长时间?
调整这些参数的考量是:
- 可见性阈值太高会漏掉大量有效 impression(用户可能只看到部分内容就已经注意到了)
- 时间太短可能统计到快速滚动时的”伪 impression”
- 时间太长则可能漏掉用户快速浏览时的真实 impression
具体的数值取决于业务需求,可以根据数据分析来调优。
2.2 IntersectionObserver 的妙用
检测元素可见性,第一反应可能是监听 scroll 事件然后计算元素位置。但这种方式有两个问题:性能开销大,且计算逻辑复杂。
现代浏览器提供了 IntersectionObserver API,它能高效地告诉你一个元素何时进入或离开 viewport,以及可见比例是多少。基本用法:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= VISIBILITY_THRESHOLD) {
// 元素达到可见性阈值
startTimer(entry.target);
} else {
clearTimer(entry.target);
}
});
}, {
threshold: [0, VISIBILITY_THRESHOLD]
});
observer.observe(linkElement);
2.3 父页面与 iframe SDK 的协作
这就是我们双 SDK 架构发挥作用的地方。记住:SDK 同时运行在 publisher 的页面和我们的 iframe widget 内部。对于 publisher 页面上的内联链接,追踪很简单。但对于 iframe 内的链接,我们面临一个挑战:链接在 iframe viewport 中可见并不意味着用户能看到它——iframe 本身可能被滚出了父页面的 viewport。
解决方案是两个 SDK 实例之间的跨框架通信:
// 父页面 SDK:监听 iframe 可见性,并通知 iframe SDK
const iframeObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
iframe.contentWindow.postMessage({
type: 'visibility-update',
isVisible: entry.isIntersecting,
visibleRegion: entry.intersectionRect
}, '*');
});
}, { threshold: [0, 0.1, 0.5, 1.0] });
iframeObserver.observe(iframe);
// iframe SDK:接收父页面 SDK 的可见性信息
window.addEventListener('message', (e) => {
if (e.data.type === 'visibility-update') {
parentVisibility = e.data;
// 重新评估内部链接的真实可见性
// 只有当链接在 iframe viewport 中可见 且 iframe 在父页面 viewport 中可见时,链接才算"可见"
}
});
2.4 数据发送策略
频繁发送请求会增加服务器压力和网络开销,但过度延迟又可能导致用户离开页面时数据丢失。我采用的策略是:
- Batching:累积最多 20 个 event 或等待 2 秒后统一发送
- 最大等待时间:无论如何,第一个 event 入队后 5 秒内必须发送
- 页面卸载时使用 sendBeacon:这个 API 能保证在页面关闭时完成请求
// sendBeacon 在页面卸载时比 fetch 更可靠
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const blob = new Blob([JSON.stringify(events)], { type: 'text/plain' });
navigator.sendBeacon(apiUrl, blob);
}
});
2.5 Payload 压缩
Impression event 发送频繁,payload 大小直接影响带宽成本。我采用了字段名缩写的方式:
// 完整格式
{ event_type: 'link_impression', timestamp: 1701234567890, link: '...', label: '...' }
// 压缩格式
{ et: 'link_impression', ts: 1701234567890, l: '...', lb: '...' }
同时将 page URL、referrer 等 batch 内共享的字段提取到外层,避免重复传输。
三、后端架构设计
3.1 为什么选择列式存储
在讨论架构之前,先来理解一下为什么列式存储非常适合分析场景。
行式存储 vs 列式存储
传统数据库如 PostgreSQL 按行存储数据。当你查询 SELECT page, COUNT(*) FROM events GROUP BY page 时,数据库必须读取整行数据,即使你只需要一个列。对于一个有 20 个列和 1000 万行的表,这是极大的浪费。
列式数据库按列存储数据。同样的查询只需要读取 page 列——I/O 可能减少 20 倍。配合压缩效果更明显:同一列内相似的值比跨行的混合值压缩效率高得多。
为什么选择 Parquet?
Parquet 是一种开源的列式文件格式,它把这些优势带到了基于文件的存储中:
- 列式布局:只读取查询需要的列
- 高效压缩:SNAPPY 压缩通常能达到 3-5 倍的压缩比
- 谓词下推:跳过不匹配过滤条件的数据块
- 内嵌 Schema:自描述格式,基本读取不需要外部 schema
- 生态系统支持:支持 Spark、Athena、BigQuery、DuckDB 等几乎所有分析工具
对于我们的场景,Parquet 存储在 S3 上提供了持久性、可扩展性和成本效益。S3 的定价(Standard 大约 $0.023/GB/月)比数据库存储便宜几个数量级。
3.2 Serverless 优先
对于这类 event 收集系统,流量模式往往很不均匀——高峰期可能是平时的 10 倍以上。Serverless 架构的按需付费和自动扩缩特性非常适合这种场景。
最终架构如下:
Client SDK → CloudFront → API Gateway → Lambda → Kinesis Firehose → S3 (Parquet)
↓
Athena
3.3 为什么选择 Kinesis Firehose
刚开始我考虑过让 Lambda 直接写 S3,但很快意识到问题:
- 每次请求写一个文件会产生海量小文件,严重影响后续查询性能
- 自己实现 buffer 逻辑既复杂又容易出错
Firehose 正好解决了这些问题:
- 自动 buffer,可配置按大小(如 128MB)或时间(如 15 分钟)flush
- 内置支持 Parquet 格式转换
- 自动按时间 partition 写入 S3
理解 Athena 查询的工作原理
要理解为什么文件大小很重要,先来看看 Athena 是如何处理查询的:
- 列出相关 S3 分区中的所有文件
- 打开每个文件,读取 Parquet 元数据(列统计信息、行组)
- 应用谓词下推,跳过不相关的行组
- 只读取匹配行组中需要的列
每个文件都有开销:S3 API 调用、连接建立、元数据解析。如果有成千上万个小文件(比如每个 100KB),这些开销会主导查询时间。而较少的大文件(比如每个 128MB),开销相对于实际数据扫描就可以忽略不计了。
Buffer 设置:权衡考量
Firehose 在达到大小阈值或时间阈值时会将数据刷新到 S3——以先到者为准。
考量因素:
-
大小阈值:较大的文件通过减少每个文件的开销来提升查询性能。但是,过大的文件(超过 256MB)反而可能拖慢查询,因为 Athena 无法在单个文件内有效并行处理。最优的大小取决于你的查询模式和数据量——这需要实验来确定。
-
时间阈值:确保低流量期间数据不会在缓冲区滞留太久。如果需要接近实时的数据,可以降低到几分钟,代价是会产生更小的文件。
-
低流量的现实:在流量低谷期,因为时间阈值先触发,文件会小于你设置的大小阈值。这是可以接受的——少量小文件不会显著影响查询性能。
UTC 分区的局限性
一个限制:Firehose 只能按 UTC 时间戳分区。!{timestamp:yyyy} 语法使用的是 Firehose 处理记录时的时间,而不是你数据中的时间戳,而且始终是 UTC。
这意味着如果你的业务在 UTC+8 时区运行,查询”1月15日工作日”可能需要扫描 1月14日和1月15日两个 UTC 分区。实际上这不是大问题:
- Athena 的查询成本基于扫描的数据量,而不是打开的分区数
- 多扫描一天的分区通常只增加很少的成本(多扫描几 MB)
- 替代方案(自定义分区逻辑)会增加显著的复杂性
对于我们的场景——每日批量聚合——扫描 2-3 天的分区而不是 1 天完全可以接受。
配置示例(使用 Pulumi):
const firehose = new aws.kinesis.FirehoseDeliveryStream("events-firehose", {
destination: "extended_s3",
extendedS3Configuration: {
bucketArn: analyticsBucket.arn,
prefix: "events/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/",
bufferingSize: 128, // 128 MB
bufferingInterval: 900, // 15 分钟
dataFormatConversionConfiguration: {
enabled: true,
outputFormatConfiguration: {
serializer: {
parquetSerDe: { compression: "SNAPPY" }
}
},
schemaConfiguration: {
databaseName: glueDatabase.name,
tableName: glueTable.name,
roleArn: firehoseRole.arn
}
}
}
});
3.4 Glue:不只是 ETL 工具
很多人对 AWS Glue 的印象是”ETL 工具”,但实际上它的 Data Catalog 功能才是真正的亮点。
Glue Data Catalog 本质上是一个 metadata 管理服务,你可以用它定义 table schema,然后 Firehose、Athena、Spark 等服务都能读取这个 schema。
const glueTable = new aws.glue.CatalogTable("events-table", {
databaseName: glueDatabase.name,
tableType: "EXTERNAL_TABLE",
parameters: {
"classification": "parquet",
"parquet.compression": "SNAPPY"
},
storageDescriptor: {
location: `s3://${bucket}/events/`,
columns: [
{ name: "event_type", type: "string" },
{ name: "page", type: "string" },
{ name: "timestamp", type: "bigint" },
{ name: "link", type: "string" },
// ... 其他字段
]
},
partitionKeys: [
{ name: "year", type: "string" },
{ name: "month", type: "string" },
{ name: "day", type: "string" }
]
});
一个重要的点是 partitionKeys。通过按日期 partition,查询时如果指定了日期范围,Athena 只需扫描相关 partition 的文件,而不是全量扫描——这对成本和性能的影响是数量级的。
3.5 Athena 查询实践
Athena 是一个 Serverless 的 Presto 引擎,按扫描数据量计费。配合 Parquet 格式和合理的 partition 策略,可以做到非常低的查询成本。
几个实用经验:
1. 新增 partition 的处理
Firehose 按日期自动创建新目录,但 Glue table 不会自动识别新 partition。有两种处理方式:
-- 方式一:手动添加 partition
ALTER TABLE events ADD PARTITION (year='2024', month='01', day='15')
LOCATION 's3://bucket/events/year=2024/month=01/day=15/';
-- 方式二:自动发现(简单但慢)
MSCK REPAIR TABLE events;
生产环境建议用 Lambda 定时执行方式一,或者启用 Glue Crawler 自动发现。
2. 查询时务必指定 partition
-- 好:只扫描一天的数据
SELECT COUNT(*) FROM events
WHERE year = '2024' AND month = '01' AND day = '15';
-- 坏:全量扫描,费钱又慢
SELECT COUNT(*) FROM events;
3. 利用 Parquet 的列式存储
-- 好:只读取需要的列
SELECT event_type, COUNT(*) FROM events GROUP BY event_type;
-- 坏:读取所有列
SELECT * FROM events LIMIT 100;
3.6 成本优化
数据增长后,存储成本不可忽视。我配置了 S3 lifecycle 规则:
new aws.s3.BucketLifecycleConfiguration("lifecycle", {
bucket: analyticsBucket.id,
rules: [{
status: "Enabled",
transitions: [
{ days: 90, storageClass: "STANDARD_IA" }, // 90 天后转 Infrequent Access
{ days: 365, storageClass: "GLACIER" } // 1 年后归档
]
}]
});
3.7 为什么选择 AWS 生态(而不是 ClickHouse)
一个自然的问题是:为什么不使用 ClickHouse 这样的专用分析数据库?
ClickHouse 是一个优秀的列式数据库,查询性能极快。它特别适合需要亚秒级查询延迟的实时分析仪表盘。如果我需要构建一个支持即时过滤和聚合的交互式分析 UI,ClickHouse 会是一个很有竞争力的选择。
但对于这个特定场景,AWS S3 + Athena 方案更合适:
选择 S3 + Athena 的权衡考量:
-
批处理足够用:我们只需要每天运行一次聚合查询,将汇总数据同步回主数据库。不需要实时仪表盘或亚秒级查询延迟。
-
运维开销最小:S3 和 Athena 是完全托管的。不需要配置服务器、扩展集群、配置备份。ClickHouse(自建或 ClickHouse Cloud)需要更多运维关注。
-
成本效益高:对于不频繁的大数据集查询,Athena 的按查询付费模式非常划算。只在查询时付费(每 TB 扫描 $5),配合 Parquet + 分区,实际扫描成本很低。
-
数据持久性:S3 提供 99.999999999%(11 个 9)的持久性。数据自动跨可用区复制。
-
生态系统集成:S3 上的数据今天可以用 Athena 查询,以后也可以用 Spark、Presto、DuckDB,甚至加载到 ClickHouse 中。我们没有被锁定在特定的查询引擎上。
什么时候考虑用 ClickHouse:
- 需要亚秒级查询的实时分析仪表盘
- 高查询频率(每天数百或数千次查询)
- 能受益于 ClickHouse 优化的复杂分析查询
- 需要物化视图或实时聚合
将原始数据以 Parquet 格式存储在 S3 的好处是,如果需求变化,我们随时可以加入 ClickHouse——直接指向相同的数据即可。
四、踩过的坑
4.1 Firehose 的 Parquet 转换需要完整 schema
最开始我想偷懒,只定义几个核心字段。结果发现 Firehose 转换 Parquet 时,如果 JSON 中有 schema 未定义的字段,会直接丢弃。
教训:要么在 Glue table 中定义所有可能出现的字段,要么在 Lambda 中严格过滤 payload。
4.2 Partition 字段不能出现在 columns 里
Glue table 定义中,partitionKeys 里的字段不能重复出现在 columns 里,否则会报错。Partition 字段的值来自 S3 路径(如 year=2024),而不是 Parquet 文件内容。
4.3 IntersectionObserver 在 Safari 的兼容性
Safari 对 IntersectionObserver 的支持相对较晚,而且某些 iOS 版本有 bug。建议做好 feature detection,必要时降级到 scroll 监听方案。
五、总结
这套系统刚刚上线,随着积累更多生产数据和经验,我会持续更新这篇文章。初步结果很不错——架构能很好地处理流量,成本也很低。
几点总结:
- 将分析与主数据库解耦:高吞吐量的事件采集不应该放在事务数据库里。独立的管道能保护核心应用。
- 列式存储很重要:对于分析场景,Parquet 的列式格式相比行式格式能大幅减少 I/O 和存储成本。
- IntersectionObserver 是检测元素可见性的正确姿势,性能远优于手动计算
- Kinesis Firehose 解决了数据 buffer 和格式转换的问题,比自己实现省心太多
- Glue Data Catalog 是连接存储和查询的桥梁,schema 定义要仔细
- Athena 按扫描量计费,partition 和列式存储是成本控制的关键
- 根据访问模式选择合适的工具:实时仪表盘可能需要 ClickHouse,但批量分析用 S3 + Athena 效果很好,成本只是前者的一小部分
Serverless 按需付费的模式意味着我们只为实际使用付费——没有闲置服务器成本。而且通过将原始数据以 Parquet 格式存储在 S3 上,我们保留了随着需求演进采用不同查询引擎的灵活性。
随着系统的成熟,我会更新这篇文章,分享生产环境的指标和经验教训。如果你也在做类似的系统,欢迎交流。