01一次点击到底以什么为准——用户按下的那一下,还是实际到达广告主网站?
用户点击广告后会收到一个 302 重定向到广告主网站——点击在这一跳于服务端记录,所以广告拦截器和不稳定的客户端都无法让它丢失。
免费完整指南
设计广告点击聚合系统。请覆盖High-throughput ingestion of K-M clicks/sec via a log/stream (Kafka) before aggregation, Exactly-once vs at-least-once delivery and idempotent aggregation sinks, Windowed aggregation...
面试节奏保持精简,让页面能把注意力花在真正的设计决策上。
不要只是陈述需求,要主动问出来。每张卡把设计约束和一句你可以在画架构前说出口的澄清问句配成一组。
01一次点击到底以什么为准——用户按下的那一下,还是实际到达广告主网站?
用户点击广告后会收到一个 302 重定向到广告主网站——点击在这一跳于服务端记录,所以广告拦截器和不稳定的客户端都无法让它丢失。
02广告主需要什么粒度和实时度?
广告主可以查询随时间变化的点击指标,最小粒度为一分钟,并在点击发生后约一分钟内就能查到。
03同一次点击送来两次——连点或重试——应该只算一次吗?
同一个曝光被重复点击只算一次——每条展示过的广告本来就带有唯一的 impression ID。
04只做点击,还是也做曝光和 CTR?
第一天只做点击;曝光数和 CTR(click-through rate)是明确的后续需求。
05一分钟以外还需要哪些粒度——每小时、每日?
广告主也需要小时和每日的视图;一分钟仍是最细的粒度。
06预算上限需要近乎实时地停掉广告吗?
要——打到预算上限的广告必须在约一分钟内停止投放。
范围外广告投放和广告选择(要展示哪一条广告) · 跨设备点击追踪 · 线下营销渠道集成
01我们要按多高的峰值点击率来设计?
峰值每秒 10K 次点击零丢失——点击数据就是计费数据;整条管线就是为零丢失采集而打造。
02广告主仪表盘必须多快响应?
查询在任意时间范围内都要在一秒内响应。
03数字要多新鲜?
近乎实时:一次点击应该在发生后约一分钟内就能被查到。
04这些数字是直接拿去计费的吧——所以绝不允许多算?
不行——重复和重试绝不能灌大数字;这些是账务级别的数字。
05数字要多精确——短暂误差若事后会修正,可以接受吗?
最初几分钟的微小暂时误差可以容忍,但账务数字必须精确,并在一天内收敛。
真实面试探得比一份整齐清单深得多。这些范围问句,区分出真正拷问问题的人和只是背诵的人。
把每个估算都当成一种压力,用来合理化一个组件:缓存、队列、分片、副本、worker pool 或退路。
每日事件量
峰值每秒 10K 次点击,每日约 1 亿次点击100M events × ~100 B ≈ 10 GB/day 原始
原始事件放在数据湖永久保存的成本很低——这正是每日校正之所以可行的原因。
聚合行数
假设约 1000 万条活跃广告 × 每分钟 1 行10M 条广告 × 一天 1,440 分钟(24 小时 × 60 分)≈ 14B rows/day 最坏情况——但只有有点击的广告才会产出行,实际上 ≪ 1%
稀疏的分钟行让 OLAP store 小到足以做亚秒级的范围扫描。
去重缓存大小
假设一个 impression ID 在约 1 小时的点击窗口内有效10K/s × 3,600 s × ~50 B ≈ 1.8 GB
整个去重窗口能轻松放进一个 Redis 集群。
流处理器挂掉时的丢失窗口
Flink 每个分钟窗口做一次 checkpointcrash → 从上一个 checkpoint 重放 ≈ 最多重算 1 分钟
Kafka 保留(数天)加上 checkpoint,意味着处理器崩溃只会重算,永不丢失。
为什么不直接查原始事件
原始点击表 vs 一次跨 30 天的广告主仪表盘查询原始表每月增长 3B 行;即使是按广告切分、以分钟粒度建索引的切片,每次查询仍要扫数百万行——离亚秒级差得远
在这里预先聚合不是一种优化——它就是设计本身。
决策示例
每秒一万次点击就是钱在流进来:每丢失一次点击就是一笔没计费的支出,而每重复计一次就是一个生气的广告主。
我会把点击放在重定向那一跳,并在做任何其他事之前先写进一个持久的流(Kafka)——接着用流处理器以一分钟窗口持续聚合,把结果刷进仪表盘查询的 OLAP store。每条展示过的广告都带一个签名过的 impression ID,一个 Redis 检查把重复的丢掉。每天一次,一个批处理作业从数据湖重读原始事件并校正流出来的数字。
我不会做的事:把原始点击存进数据库、每次仪表盘查询都跑 GROUP BY——每次跨 30 天查询要面对三十亿行,会整个垮掉。我也不会纯粹按 ad ID 分割流:一条爆红广告就会把单个分区打爆。解法很无聊但有效——给热门 ad ID 加一个随机后缀,把它们铺在 N 个分区上,写聚合时再把后缀剥掉。挑错分区 key,就是亲手打造出一个热分区。
如果广告主接受 5 分钟的新鲜度,我会把流层整个拿掉、改跑 micro-batch——少一半的活动部件,同样的准确度,只是慢一点。
先看一张完整的图,再把每条路径各自画成一张图 —— 写入路径与读取路径承载不同流量,合理化不同的组件。
完整全貌
路径 1
路径 2
在优化之前,先让契约可被检视:端点、实体、所有权、重试与状态。
GET/ads/{ad_id}/click?impression_id=
响应302 Location: advertiser_url
重定向那一跳把每次尝试都持久记录下来,接着流处理器在任何计费聚合或 sink 写入之前,先以 impression_id 去重。缓存可以加速重复检查,但计费的正确性必须在缓存丢失和重放后依然成立。
GET/metrics?ad_id&from&to&granularity=1m
响应200 [{ minute, clicks, unique_users }]
由 OLAP store 服务,永远不从原始事件——对数十亿行做 GROUP BY 正是这个设计要避免的事。
核心实体
ClickEventimpression_id (PK) · ad_id · user_id · clicked_at
impression ID 在广告展示时铸造并以 HMAC 签名,所以点击无法被伪造或重放。
MinuteAggregatead_id · minute · clicks · unique_users
OLAP store 服务的就是这个;每条广告每分钟一行。
在面试最后三分之一挑一条路线。每条路线给你主题、它该回答的面试官问题,以及要避免的失败模式。
用户连点两下,或一次重试触发。计数怎么维持在一?
每条展示的广告都带签名的 impression ID:去重缓存会挡掉点击窗口内的重复,分钟级 upsert 也以这个 ID 幂等——重试永远加不出第二笔。
按 user+ad 去重——再营销会合理地把同一条广告再次展示给同一个用户。
单条广告突然吃掉全部点击的一半。哪个组件最先到达极限?你又如何把负载铺开?
把热键加盐——把 ad_id 拆成 ad_id#0..N 让负载摊到多个分区,再用一个小小的合并步骤把部分计数折回同一分钟行。
只按 ad ID 分割流——一条爆红广告会把所有事件塞进同一个分区,自己造出热键。
Flink 在窗口中途挂掉。有多少点击丢失,你又怎么知道?
什么都不会丢:Kafka 留着原始事件,处理器从最后一个 checkpoint 重启、最多重算一个窗口,幂等 upsert 用覆写取代重复计数。
把流当成真相来源——Kafka 保留加上 checkpoint 意味着重放,而非丢失。
流已经实时聚合了——每日批处理作业还能加上什么?
流式数字会漂移——迟到事件、宕机、bug。批处理每天重读原始事件并覆写重算的每一分钟:流买到新鲜度,批保证账单正确。
跳过校正——计费级的准确度不能寄托在尽力而为的流上。
一个事件在它的点击 5 分钟后才落地。它算进哪一分钟,窗口又要等多久?
以点击的事件时间计数,而非到达时间:窗口会等一段有限的迟到时间(几分钟),更晚到的由每日对账补回。
忽略事件时间和处理时间之分——按到达时间计数会悄悄把钱在分钟之间挪来挪去。
把 广告点击聚合系统 大声讲一遍,让 AI 为你的说明评分。