每小时 10T 日志里找热点:用 Spark 和小根堆计算 CDN 的 TopN

2507 字
13 分钟
每小时 10T 日志里找热点:用 Spark 和小根堆计算 CDN 的 TopN

在百度云 CDN 时,我做过一个热点分析的任务:从 CDN 节点的 nginx 访问日志里,统计出各个维度的热门资源,比如每个域名访问最多的 URL、带来流量最多的 URL、主要的 Referer 和 User-Agent。这类热点数据通常用于控制台展示、排查热点、缓存预热和发现盗链。

日志量很大,小时级最高能到 10T。这篇讲这个任务的核心思路:用小根堆在 MapReduce 里算 TopN。

说明:这篇是 2026 年根据当年在百度做的项目回忆整理的,发布时间标在项目所在的时间段(2019–2021 年)。时间久远,当时的具体代码和参数已经记不清,下面的代码是按通用做法整理的 PySpark 示例,用来说明思路,不是原始实现。

一、要统计什么#

统计分三个层级,从小到大:

  • 域名级:每个加速域名下的 TopN;
  • 用户级:一个客户名下所有域名合在一起的 TopN;
  • 全局:整个平台的 TopN。

每个层级都统计几类对象,按不同的指标排序:

统计对象排序指标
URL请求数(PV)、流量
Referer请求数
User-Agent请求数

这和主流 CDN 厂商控制台提供的 TOP 统计基本一致。比如百度智能云 CDN 的文档里就列了 TOP URL、TOP Referer、TOP HTTP 状态码、TOP 域名,统计范围是「当前域名或当前用户」。

二、为什么不能直接全量排序#

最直接的写法是:按 (域名, URL) 分组求和,然后按值排序取前 N 个。

totals = logs.map(lambda r: ((r.domain, r.url), r.bytes)).reduceByKey(add)
top = totals.sortBy(lambda kv: -kv[1]).take(100)

问题在于规模:

  • 一小时的日志里,不同的 URL 数量是亿级的,带参数的动态 URL 更多;
  • sortBy 是全量排序,所有数据都要按值重新分区(shuffle),代价和数据量成正比;
  • 我们只要每个域名的前 N 个,比如前 100 个,却把上亿条数据完整地排了一遍,绝大部分排序工作都是浪费的。

而且这还只是全局的一个 TopN。要按域名分别取 TopN,就得 groupByKey 把每个域名的所有 URL 拉到一起再排序,大域名的数据会全部挤在一个 executor 上,很容易内存溢出。

三、小根堆:只记住前 N 名#

取 TopN 有一个经典的做法:维护一个大小为 N 的小根堆。

  • 小根堆的堆顶,永远是堆里最小的元素,也就是「当前第 N 名」;
  • 来一个新元素,如果堆还没满,直接放进去;
  • 如果堆满了,就和堆顶比:比堆顶大,说明它能挤进前 N 名,替换掉堆顶;比堆顶小,直接丢弃。
import heapq
N = 100
def push(heap, item):
"""item = (指标值, URL);heap 最多保留 N 个最大的元素"""
if len(heap) < N:
heapq.heappush(heap, item)
elif item > heap[0]: # 比当前第 N 名大
heapq.heapreplace(heap, item)
return heap

为什么用小根堆取最大的 N 个,而不是大根堆?因为我们需要随时知道「门槛」是多少,也就是前 N 名里最小的那个。小根堆的堆顶正好就是这个门槛,比较一次就能决定新元素要不要进来。

它的好处:

  • 内存固定:不管输入多少条,堆里永远只有 N 个元素;
  • 时间省:每个元素最多一次堆操作,复杂度 O(M log N)。M 是元素总数,N 是 100 这样的小数,log N 几乎是常数;而全量排序是 O(M log M)。

四、放进 MapReduce:先局部取 TopN,再合并#

先看整体流程,后面两节逐步展开:

按用户合并

全部合并

Referer、UA 跨域名,每层单独求和

nginx 访问日志

小时级最高 10T

分区 1

分区 2

分区 …

① reduceByKey

按(域名, URL)求和

Map 端先局部求和再 shuffle

分区 1

每个域名一个小根堆

只留前 N

分区 2

每个域名一个小根堆

只留前 N

分区 …

每个域名一个小根堆

只留前 N

② aggregateByKey 合并

同一域名的各分区堆合并

仍只留 N 个

域名级 TopN

(域名数 × N 条)

用户级 TopN

全局 TopN

各层级的

Top Referer / Top UA

按用户合并

全部合并

Referer、UA 跨域名,每层单独求和

nginx 访问日志

小时级最高 10T

分区 1

分区 2

分区 …

① reduceByKey

按(域名, URL)求和

Map 端先局部求和再 shuffle

分区 1

每个域名一个小根堆

只留前 N

分区 2

每个域名一个小根堆

只留前 N

分区 …

每个域名一个小根堆

只留前 N

② aggregateByKey 合并

同一域名的各分区堆合并

仍只留 N 个

域名级 TopN

(域名数 × N 条)

用户级 TopN

全局 TopN

各层级的

Top Referer / Top UA

数据量在每一步都在收缩:原始日志先被求和压缩成「每个 URL 一条」,再被小根堆压缩成「每个域名 N 条」,用户级和全局只是在这个很小的结果上再合并一次。

小根堆在单机上很好用。到了分布式环境,关键是一个性质:

全局的前 N 名,一定在各个分区各自的前 N 名里。

道理很简单:如果某个元素在它所在的分区里连前 N 都排不进,说明那个分区里已经有 N 个比它大的元素了,它在全局也不可能进前 N。

所以计算可以分两步:

  1. Map 端:每个分区各自维护一个小根堆,只保留本分区的前 N 名;
  2. Reduce 端:把各分区的堆合并,合并时还是用同一个 push,最后仍然只保留 N 个。

每个分区交给下游的只有 N 条数据,shuffle 的数据量从「所有 URL」收缩到了「分区数 × N」。

在 Spark 里,「按域名分组、每组维护一个堆」可以用 aggregateByKey 实现,它天然就是这种「分区内先聚合、分区间再合并」的结构:

from operator import add
def merge(heap_a, heap_b):
for item in heap_b:
push(heap_a, item)
return heap_a
# 第一步:先把每个 (域名, URL) 在整个小时内的流量加总
totals = (logs
.map(lambda r: ((r.domain, r.url), r.bytes))
.reduceByKey(add))
# 第二步:按域名维护大小为 N 的小根堆
# seqOp:分区内,把 (流量, URL) 放进该域名的堆
# combOp:分区间,两个堆合并
domain_top = (totals
.map(lambda kv: (kv[0][0], (kv[1], kv[0][1]))) # (域名, (流量, URL))
.aggregateByKey([], push, merge))

注意第一步不能省:同一个 URL 的日志分散在不同的分区里,必须先把它的总流量加起来,才能拿去比大小。第一步的 reduceByKey 也是先在 Map 端局部求和再 shuffle,数据量同样会大幅收缩。

五、多层级:上一层复用下一层的结果#

URL 有一个很好的性质:每个 URL 只属于一个域名,每个域名只属于一个用户。

结合上一节的道理:

  • 一个用户的 Top N URL,一定在他名下各个域名的 Top N URL 里;
  • 全局的 Top N URL,一定在所有域名的 Top N URL 里。

所以用户级和全局的 TopN 不需要从原始数据重新算,直接把域名级的结果按用户合并一次就行:

# domain_owner:域名 → 用户 的映射(广播出去)
user_top = (domain_top
.map(lambda kv: (domain_owner.value[kv[0]], kv[1]))
.reduceByKey(merge))
global_top = domain_top.values().reduce(merge)

域名级的结果一共只有「域名数 × N」条,在这个基础上合并,开销几乎可以忽略。

但 Referer 和 User-Agent 不能这么做:同一个 Referer 可能出现在很多个域名下,它在全局的总量,是在各个域名下的量加起来,而不在某个域名里排第几。一个 Referer 可能在每个域名下都排不进前 N,加起来却是全局第一。所以这类对象,在每个层级都要先按该层级的维度重新求和,再取 TopN。

六、按流量和按请求数#

URL 有两个排序指标:请求数和流量。这两个指标可以在一次遍历里同时计算:第一步求和时,同时累加请求数和流量,第二步分别维护两个堆。

为什么不单独做一个「按带宽排序」?因为在同一个统计时段里,带宽等于流量除以时间,两者的排序结果完全一样,没有必要重复计算。

七、配置化:加指标不用重新发版#

统计哪些对象、按什么指标、取多少个、在哪些层级统计,这些都写在一个 JSON 配置里,作为任务参数传入:

{
"top_n": 100,
"levels": ["domain", "user", "global"],
"metrics": [
{ "name": "top_url_pv", "key": "url", "value": "requests" },
{ "name": "top_url_traffic", "key": "url", "value": "bytes" },
{ "name": "top_referer", "key": "referer", "value": "requests" },
{ "name": "top_ua", "key": "user_agent", "value": "requests" }
]
}

程序根据配置动态生成计算逻辑。运营想加一个新的统计维度,比如 Top 客户端 IP,只要改配置,不用重新打包、部署 Spark 程序。

八、几个实践中的问题#

  • 数据倾斜:头部大域名的日志量可能是普通域名的成千上万倍。好在 reduceByKey 和 aggregateByKey 都会先在 Map 端局部聚合,大域名的数据在进入 shuffle 之前就已经被压缩了,比 groupByKey 稳定得多。
  • URL 归一化:同一个资源带上不同的查询参数(比如时间戳、签名),会被当成不同的 URL,把热点打散。统计前要按业务需要去掉无关参数,这一点和 CDN 缓存 key 的处理是一样的道理。
  • N 要留余量:如果控制台展示前 100,计算时可以多保留一些。这样后续按其他条件过滤(比如去掉某类请求)后,依然能凑够 100 条。

九、总结#

这个任务的核心就是一句话:只需要前 N 名的时候,就不要对全部数据排序。

  • 单机上,用大小为 N 的小根堆,把内存固定在 N,把排序代价降到 O(M log N);
  • 分布式下,利用「全局前 N 一定在各分区前 N 里」,先局部取 TopN 再合并,让 shuffle 的数据量收缩几个数量级;
  • 多层级时,对只属于一个上级的对象(URL),上层直接复用下层结果;对跨上级的对象(Referer、UA),每层单独求和。

参考#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

每小时 10T 日志里找热点:用 Spark 和小根堆计算 CDN 的 TopN
https://tracerzzz.cn/posts/spark-cdn-topn-min-heap/
作者
tracerZzz
发布于
2020-06-15
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
CDN 是怎么收费的:从流量、95 峰值到日峰月均,厂商和客户两边看计费
工作我在百度云 CDN 做过客户账单,在字节做过火山 CDN 的计费推量,也做过融合 CDN 向多家厂商采购的对账和成本分摊,厂商和采购两边都经历过。这篇整理 CDN 常见的计费方式:流量、带宽峰值、95 峰值、日峰月均、月第四峰值、夜间减半、闲忙时、请求数,以及计费区域、包头系数这些容易忽略的细节,并用一个月的模拟数据对比不同方式算出来差多少。
2
CDN 新客户接入前的价格测算:用波形拟合算出最低成本价
工作火山 CDN 经营分析平台上的价格测算功能:销售拿到一个新客户的带宽打点,平台把它和大盘拟合,判断客户是否错峰、算出边际超卖倍数,再结合资源诉求的加权成本和包头系数,给出不同计费方式下的最低成本价。这篇讲它的输入、计算过程、示例和后续的优化方向。
3
CDN 的错峰与超卖:为什么厂商能把同一份带宽卖好几次
工作在 95 计费下,一个新客户接入后,大盘的 95 可能几乎不涨,这就是错峰客户。它让 CDN 厂商可以把同一份带宽卖给多个客户,也就是超卖。这篇用模拟数据讲清楚错峰和超卖,以及衡量它们的两个指标:单个客户的边际超卖倍数,和整个平台的超售比。
4
指标异动归因:从贡献度公式到 CDN 毛利率的层层下钻
工作整理我学习指标异动归因的过程:加法、乘法、比率三类指标分别怎么算贡献度,为什么比率指标要拆出「结构变化」,以及我在字节火山 CDN 经营分析中,如何用这套方法对毛利率、收入、成本做逐层递归归因,把收入变化拆到客户增减、用量变化和价格变化。
5
我的第一项专利:用「95 区间」把 CDN 带宽成本分得更准
工作白话解读我作为第一发明人的发明专利(中国 CN117061354B、美国 US12375742B2):为什么按 95 峰值时间点分摊 CDN 带宽成本会算不准,以及「95 区间」分摊法是怎么解决的。附专利原文 PDF。
随机文章随机推荐

评论区

Profile Image of the Author
tracerZzz
想当木匠的程序员!
分类
标签
站点统计
文章
19
分类
9
标签
48
总字数
79,096
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
ESA Pages
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录