☰
分布式 TopK 问题全解:哈希分区、小顶堆与全局归并
2026/10/15 4:36:08 网站建设 项目流程

最近跟几个做后端的朋友聊面试,发现“分布式环境下的 Top K 问题”几乎是所有大厂算法题的钉子户,尤其做电商、搜索、推荐业务的公司。某出海电商平台的面试官就用了一道很经典的题:假设一天有十亿条用户点击日志,散落在上百台机器上,怎么统计出点击量最高的 100 个商品?要求先讲思路,再写伪代码,最后还要估算内存和延迟。

这题表面上考的是“小顶堆”,但实际上考察的是你能不能把单机算法改造成分布式系统方案。面试官真正想听的不是“我会用 PriorityQueue”,而是你能不能讲清楚数据怎么分区、哪些聚合能在本地做、哪些必须网络传输、如何保证结果精确。下面我把这道题的完整解法和实战经验拆开讲,刷到这题的朋友可以直接拿去用。

1. 先别急着写代码:把题目拆清楚再动手

很多人一上来就说“每台机器算局部 TopK,再汇总”,这个思路听起来对,实际上有个隐蔽的大坑,后面会专门讲。我们先看题型本身,因为这个题在不同前置条件下答案完全不一样。

1.1 Top K 问题的三个常见变体

日常业务里的 Top K 至少能分出三种完全不同的场景,方案差别很大。

第一种是计数型 TopK,比如统计点击量最高的商品、搜索次数最多的关键词、销售额排名前 10 的店铺。这类问题核心是“先按 key 聚合求和,再取 TopK”,本质是分组聚合 + 排序,难点在分布式下的正确性。

第二种是去重型 TopK,比如统计被独立用户访问最多的商品 Top100。这里需要先对同一个用户的访问做去重,再去计数。去重本身在分布式中是相当麻烦的事,因为同一个用户可能在不同机器上都出现过,直接分区统计会把一个人当成好多人,结果就偏了。

第三种是流式场景的 TopK,比如最近 5 分钟的热搜词榜单。数据是持续到达的,总量未知,窗口在滑动,没法用批处理那套“先落盘再算”的思路。这种实时 TopK 又要换另一套工具。

面试时,正确做法是先把题目条件问清楚。数据量级是多少?K 有多大?key 的数量级?允许精确还是可以近似?数据是批量还是实时?这题只有在“精确、批处理、数据量大到必须分布式”的前提下,才适用下面这套最经典的解法。你先反问这些,面试官通常会很认可,因为这是进入真实系统设计的第一步。

1.2 为什么电商场景最爱考这道题

电商平台上,每天都会产生海量行为日志:曝光、点击、加购、下单、支付。一件商品的点击量要从分散在几十上百台服务器的日志里聚合出来,天然就是分布式的。从高并发的日志采集到数据管道清洗,再到离线统计或实时大屏,每个环节都能延伸到 Top K 的计算上。

所以面试官考这道题,其实不只是考算法,更是在模拟真实的业务链路。你给出的方案要能回答:日志从哪收集,路由到哪台机器,中间怎么传,最后怎么吞吐。能把这个链路在脑子里完整走一遍的人,和背过小顶堆实现的人,差别一眼就能看出来。

2. 精确解法:哈希分区 + 局部 TopK + 全局归并

这套组合拳就是分布式 TopK 精确解的基本盘,下面把每一步的原理、代码和复杂度拆开讲清楚。

2.1 最大的坑:随机分片下的“局部 TopK”不等于全局 TopK

先说那个最经典的坑。假设你不管三七二十一,把日志均匀分到 100 台机器上,每台机器统计完取局部 Top100,最后汇总 100×100 个候选,再取一次全局 Top100。

这样看似合理,但会漏数据。举个例子:商品 A 在 100 台机器里的点击分布分别是每台 99 次、99 次……也就是分布非常均匀但单台最高只有 99 次。假设某台机器的局部 Top100 阈值是 100 次,商品 A 就进不了这台的局部榜单。可实际上 A 的全局总量是 9900 次,妥妥的第一名。结果呢?它直接被局部 TopK 过滤掉了。

明白了吗——问题出在分区方式而不是 TopK 算法。只要同一个 key 被分散到了多个分区,任何“局部取 TopK,再汇总”的做法在理论上都是不精确的。要保证精确,必须让同一个 key 的全部记录落到同一台机器上,这样局部统计出来的就是这个 key 的全局值。

唯一的例外情况是每个分区只负责自己独有的 key,这通常不现实。所以核心结论就一句话:用 key 做哈希分区,而不是随机分区。

2.2 完整流程设计与代码骨架

整个流程分四步走,我用一个具体的电商点击日志场景描述,下面给出伪代码和关键步骤。

第一步,按 key 哈希路由。上游日志系统把每条日志的 itemId 做哈希,partitionId = (itemId.hashCode() & Integer.MAX_VALUE) % numWorkers,然后发送到对应 worker。这一步保证了同一个商品的所有点击事件必然到达同一台机器。

为什么要用& Integer.MAX_VALUE?因为 Java 的hashCode()可能返回负数,直接取模会得到负数分区,导致路由异常。这种细节就是老手和新手的分水岭,面试时主动提一句会让面试官觉得你真跑过分布式任务。

第二步,单机聚合计数。每个 worker 内部先做一次完整的聚合统计,把日志变成(itemId, count)的键值对。这一步用普通的 HashMap 即可:

Map<Long, Long> localCounter = new HashMap<>(); for (ClickLog log : nodeLogs) { localCounter.merge(log.itemId, 1L, Long::sum); }

这里要注意,因为第一步已经做了哈希分区,本机统计出来的count就是该商品在全网的完整点击量,不需要再考虑其他机器是否还有遗漏。

第三步,用堆取局部 Top K。这里用小顶堆而不是大顶堆,因为堆顶是当前最小的候选,新元素进来如果比堆顶大,就替换掉堆顶然后调整堆。如果上来就用大顶堆,比如大顶堆天然保留最大的元素,你需要把 100 亿个元素全部灌进去才能保证正确,内存会直接爆炸。

PriorityQueue<Map.Entry<Long, Long>> minHeap = new PriorityQueue<>( Comparator.comparingLong(Map.Entry::getValue) ); for (Map.Entry<Long, Long> entry : localCounter.entrySet()) { if (minHeap.size() < K) { minHeap.offer(entry); } else if (entry.getValue() > minHeap.peek().getValue()) { minHeap.poll(); minHeap.offer(entry); } }

这段代码的复杂度是O(P log K),P 是本机上的商品种类数。如果 P 是百万级,K 是 100,这个量级非常小,单机毫秒级就能算完。

第四步,协调节点全局归并。每个 worker 把本地的 TopK 候选上传到 Coordinator,总共是N × K条记录。Coordinato 先做一个二维合并:用 HashMap 把不同 worker 传上来的同一商品次数加总,因为极端情况下,同一个 key 有可能被哈希到同一台机器吗——不会,第三步已经保证了同 key 必然同分区,但上传阶段多个 worker 不会出现同一个商品。这里 HashMap 合并是为了防御性写法。合并完成后再跑一次堆,拿到最终最终榜单。

这里有个重要的工程优化:第四步的全局归并用 Java 实现时,应该先把所有候选Map.Entry塞进 HashMap 做聚合,再一次堆。如果边聚合边维护堆,遇到同一 key 出现多次时会需要更新堆里的旧条目,处理起来非常麻烦。先汇总再排序,代码简单且性能仍然很快,因为候选集很小。

2.3 复杂度推导与参数设计

假设总日志量 M = 10 亿条,分区数 N = 100,商品种类 P = 1000 万,K = 100。

单机处理的数据量是M/N = 10^7条,单机 HashMap 的条目数约P/N = 10^5(因为哈希分区均匀),单机堆操作次数是P/N = 10^5次,每次O(log K) ≈ 7,总共百万次级别操作,非常快。

网络传输量是整道题最亮眼的部分:N × K = 100 × 100 = 10^4条记录,也就是一万条。就算一条记录 100 字节,总传输量也只有 1MB。这就是“本地聚合之后再 shuffle”的巨大优势——如果不用局部 TopK 而是直接把所有(itemId, count)都上传,那就是 P=1000 万条记录,成本是候选方案的 1000 倍。

内存估算也要会算。单机 HashMap 一条 Entry 大概 50 字节,10^5条就是 5MB,加上堆 100 个条目,总共 6MB 不到。所以单机内存压力极小。如果 P 特别大、分区数少,比如 P=1 亿、N=10,那单机 HashMap 就是 1000 万条目,约 500MB,开始变得危险。这时候要么进更多机器,要么改成近似方案。

这个参数推导过程,面试时一定要讲。面试官问“为什么可以精确?”你可以答:“因为哈希分区把 key 的信息完整保留在某一个节点上,局部聚合结果等于全局值,归并时只传小顶堆留下的候选,正确性和开销都可控。”

3. 拿一道模拟题做全流程推演:商品点击 Top100

方案讲完,很多人觉得懂了,但一到白纸推演具体数据就发懵。这里我把完整流程手把手推一遍,包括数据量估算和每一步产物的大小。

3.1 场景设定与数据量估算

假设某电商平台一天产生 10 亿条点击日志,日志格式是(userId, itemId, timestamp),每条原始日志大约 60 字节,总共约 6GB 原始数据。线上有 100 台计算节点,目标是求点击量最高的 100 个商品,精确结果即可。

商品种类按 1000 万估算,不同用户每天点击的商品高度集中,实际活跃商品可能只有几百万。K 是 100,相对商品总量非常小,这就让“局部 TopK”的剪枝效果非常明显。

3.2 从原始日志到最终榜单的完整步骤

日志进入处理系统时,第一步不是直接算,而是先做分区路由。如果直接用消息队列分发,消费者端读取后按 itemId 哈希再发到下游 worker。这样做的额外好处:下游 worker 拿到的是“同一商品的全量点击”,不需要再去其他节点凑数据。

第二步是每个 worker 的本地计数。假设商品总数均匀分布,每个 worker 只需要维护1000万/100 = 10万个商品的计数器。统计完,每个 worker 得到一张局部哈希表。此时,这张表里的每个 value 就是该商品的全球点击总量——这是哈希分区的核心红利。

第三步,每个 worker 跑一次局部 Top100。这意味着每个 worker 丢弃了 10 万条目中的 99.9%,只留下 100 条。尤其要注意,这里的局部 Top100 在整个数据里就是正确的全局 Top100 候选,因为同一个商品不会出现在两台机器上。

第四步,100 个 worker 各自上传 100 条,Coordinator 收到 1 万条候选。按商品 ID 去重合并(极端情况出现重复可防御)后,再小顶堆取全局 Top100。这里数据量太小,用什么排序都无所谓,甚至可以 List.sort。

整个过程的中间产物:原始 6GB → 每台 10 万计数(约 5MB)→ 每台 100 条候选(约 10KB)→ 汇总 1 万条(约 1MB)。从大到小的层级压缩非常清晰,面试时把这个曲线画出来,基本就是加分项。

3.3 热点 Key 与数据倾斜的处理

真实日志里商品点击不是均匀的,爆款商品可能占全部流量的 10%,甚至 30%。如果按 itemId 直接哈希分区,那个爆款所在 worker 会收到远超其他节点的数据,导致单机热点、任务长尾、OOM 风险。

主流工程做法是“先打散、再聚合”的两阶段聚合。具体来说,把爆款 itemId 加一个随机后缀拆成多个虚拟子 key,比如itemId + "_" + (i % 10)。拆开后,同一个爆款的日志被分散到 10 个分区各自计数,然后再对这 10 个分区的结果做一次合并,还原出该爆款的总点击量。

代价是多了一次二次聚合的网络开销。只有高频 key 值得加盐,低频 key 保持原样,否则所有 key 都加盐等于把之前的哈希分区优势全丢了。还要可以动态识别热点 key,比如在某个窗口内计数突增的 key,单独触发加盐重新分区。

如果面试官问“怎么知道哪个 key 是热点”,可以答:先用近似统计或者上游采样识别 Top 高频 key,再针对这一小撮做特殊处理。热点只影响极少数 key,方案必须聚焦在极少数 key 上,否则复杂度会失控。

4. 流式与近似场景的补充方案

很多面试官在主方案问完以后,会追加一句:如果这些日志是实时到达的,或者数据量大到精确方案内存扛不住了,怎么办?这一节的两个数据结构是答好追问的关键。

4.1 蓄水池采样:应对总量未知的流式抽取

有时候我们不需要精确的 TopK,而是想从海量事件流里取一个均匀随机样本,再在样本上做分析或训练。蓄水池采样就是为这个场景设计的,它的特点是:不需要知道数据流总量,空间占用固定为 K。

算法特别简单。维护一个大小为 K 的数组作为蓄水池,前 K 个元素直接放进去。从第 i 个元素开始(i > K),以K/i的概率决定是否替换蓄水池中的随机一个元素。

Random random = new Random(); int[] reservoir = new int[K]; int seen = 0; while (stream.hasNext()) { int value = stream.next(); seen++; if (seen <= K) { reservoir[seen - 1] = value; } else { int index = random.nextInt(seen); if (index < K) { reservoir[index] = value; } } }

为什么每个元素被选中的概率最终都等于 K/N?理解方法:第 i 个元素进来时,它以 K/i 概率被放入池子;之后它要被后面每一个新元素挤出去才能离开。留到最后的概率是 K/i × 后面所有替换都没选中它的概率,这些概率相乘最后正好是 K/N。所以采样是严格无偏的。

在分布式 TopK 场景里,蓄水池采样不是用来直接求 TopK 的,而是用来抽样本的。你可以先对每个分片做蓄水池抽样,把样本汇总后算出近似 TopK,或者拿样本去识别热点 key,减少精确计算的范围。

4.2 Count-Min Sketch:允许近似时内存降低两个量级

如果面试官说“数据量太大,精确方案内存 hold 不住,允许 1% 以内的误差”,这时候你可以亮出 Count-Min Sketch。

这数据结构用一个 d 行 × w 列的计数器矩阵表示。每个 key 经过 d 个不同的哈希函数,各自映射到某一行的一个计数器,每命中一次就把这 d 个计数器全部加 1。查询某个 key 的计数时,看它在每行对应的计数器值,取其中最小的那个,这就是它的近似计数。

为什么要取最小值?因为不同 key 的哈希可能碰撞到同一个计数器,导致计数被高估。取多行最小值可以减小碰撞带来的噪声。严格来说,CMS 只会上偏,不会下偏,所以取最小值是对真实计数最保守的估计。

空间和误差的关系:

  • w = ceil(e / ε),控制误差幅度。如果总数据量是 10 亿,允许每 key 误差 0.01%,w 大概取 27000。
  • d = ceil(ln(1/δ)),控制误差概率。δ 取 0.01% 时,d 大约 10。

整个矩阵就是10 × 27000个 long 型计数器,算下来约 2.2MB。对比精确方案里 HashMap 可能需要几百 MB,CMS 只需要几个 MB,这就是“允许近似”的杠杆。

面试时可以主动说:如果要同时维护 TopK 结果,可以用一个小顶堆维护“当前候选 key + 近似计数”,每次更新 CMS 后检查新候选是否足够进入 TopK。CMS 的误差分析要给一个直观结论:如果商品点击量在百万级,误差比例控制在 1% 以内,完全够用。

4.3 Bloom Filter 配合去重统计

回到去重型 TopK,比如统计独立访客数最多的商品。分布式环境下,同一个用户可能在多台机器上都点击了同一商品,直接按商品计数会把重复访客算多次。

简单方案是给每个商品维护一个 HashSet 记录访客 ID,但内存开销巨大。Bloom Filter 的变体 Counting Bloom Filter 可以做到:对每个商品维护一个计数器,当某个用户首次访问时,Bloom Filter 判断“没见过”,才加 1;如果判断“可能见过”,就不加。

Bloom Filter 的代价是存在误判率。如果用户实际第一次访问但 Bloom Filter 误判为“已见过”,这个用户的访问就会被漏计。所以这个方案结果会有少量误差。如果业务上能容忍“曝光 UV 少统计千分之几”,这是个非常节省内存的方案。

关键是讲清楚权衡:精确 but 大内存 vs 近似 but 小内存。面试官要听的就是你能不能主动把这个 trade-off 摆到桌面上。

5. 进阶追问:避免踩坑的几个高频点

答完主方案的代码和推演,面试还没结束。真正的分水岭在追问阶段。

5.1 分组 TopK 和全局 TopK 是两回事

面试官特别喜欢追加一句:“如果现在不是要看全站 Top100,而是每个商品类目下的 Top10 呢?”

这就是分组 TopK。表面上只是加了个 where 条件,实际上分布式方案要做调整。正确做法是先把商品按类目分组,同一个类目的数据发到同一台机器上,类目内排序取 Top10;最后汇总时,每个类目的结果需要单独归并,而不是合并成一个全局榜单。

如果你直接复用全局 TopK 方案,全局榜单里的 TopK 可能大部分集中在热门类目,冷门类目的商品根本进不了候选集,分组 TopK 就彻头彻尾地错了。所以,必须按分组维度分区。这又是一个“分区决定了正确性”的例子。

5.2 分片数、内存、误差如何“现场估算”

面试官要求做内存估算时,不要只报“大概几百 MB”。把估算公式列出来才显得专业。

精确方案单机内存 = HashMap 条目数 P/N × Entry 大小 + 局部堆 K × 条目大小。假设 Entry 50字节,P=1000万,N=100,则约 5MB。如果 N=10,就是 50MB,还撑得住;但如果 P=1亿且 N=10,单机 500MB,这就需要警惕。

CMS 方案的内存 = d × w × long 字节数。同样的量级,误差 0.01%,内存约 2.2MB。两者差距两个数量级,这就是经验。

还需要考虑数据倾斜倍数。真实数据的倾斜系数可能到 5~10 倍,也就是说某些 worker 的内存可能是平均值的 10 倍。如果你的估算没有 buffer,一到高峰就会 OOM。所以“预留 2~3 倍 buffer”这句话,面试里一定要加进去。

5.3 真正落地时,工程框架怎么选

这里可以稍微展开工程面向,面试官通常看重你会不会因地制宜选工具。

批处理场景,优先 MapReduce 或 Spark。Spark 里的核心方法是reduceByKey之后用takeOrdered(K),或者用mapPartitions在每个分区先取局部 TopK,再收集到 Driver 归并。后者的 shuffle 数据量和前面推演的一样,从全量变成N×K。

流式实时场景,优先 Flink。Flink 可以用增量聚合函数配合窗口,在窗口内直接维护 TopN 状态。更通用的方法是 ProcessFunction 手动管理一个有序状态列表,每来一条数据更新一次。

线上榜单场景,很多人想到 Redis 的 ZSet。ZSet 用ZINCRBY累加,ZREVRANGE取 TopK,非常简单。但注意 ZSet 是全量存储,如果商品种类千万级,Redis 内存依然有压力。更优做法是:本地算好局部 TopK,异步把 TopK 结果批量同步到 Redis,避免每次点击都写一次 Redis。这样既能支撑高并发,又能让 Redis 里的榜单保持秒级新鲜度。

另外,当节点扩缩容时,普通哈希分片会导致原有 key 大量迁移。一致性哈希可以把迁移范围控制在一个节点附近的哈希环范围内。像 Redis 集群、Kafka 分区的设计都基于一致性哈希思想,面试时可以顺带提一句,展示你有系统设计意识。

6. 高频追问速查与我的经验笔记

最后把常见的追问和排查思路整理成一张表,方便记。这张表是我结合多轮面试反馈整理的,出现频率极高。

6.1 易错点速查表

追问套路核心陷阱正确思路
“每台取 TopK 汇总就可以吗?”随机分区导致 key 跨节点,局部 TopK 漏数据必须按 key 哈希分区,或收集全量 key 计数
“内存不够怎么办?”硬上 HashMap 导致 OOM增大分片数,或换 CMS 近似方案
“某个爆款商品导致单机热点怎么办?”数据倾斜拖慢整个任务热点 key 加盐拆散,二次聚合
“实时流数据怎么算?”用批处理范式套实时蓄水池采样、滑动窗口、Flink 增量聚合
“UV 型 TopK 怎么去重?”跨节点重复计数精确用分布式去重,近似用 Bloom Filter
“Redis ZSet 能直接做吗?”单点写放大导致 Redis 崩溃本地 TopK + 异步批量同步

6.2 面试准备时最值得投入精力的三件事

我自己准备这类题时,有三件事投入产出比最高,分享给大家参考。

第一,把复杂度推导背成肌肉记忆。不只是“O(N logK)”,要能把“M=10 亿、N=100、K=100、P=1000 万”代入公式,算出每台机器几 MB 内存、网络传输 1MB,掌握这种数量级感,这在面试中非常讨喜。

第二,把每个方案讲清“解决了什么、牺牲了什么、什么时候不可用”。比如蓄水池采样节省内存,但结果只是个随机样本,不代表频率 TopK;CMS 内存小,但结果是近似的,而且只支持增不支持减,删除场景就废了。把局限和适用场景一并讲,比背一堆定义更能体现工程判断力。

第三,准备一个自己熟悉的数据处理链路作为例子。不需要真实项目经历,可以是某个开源 Demo 里跑通过的流程,重点是用一贯的方式把链路讲顺:采集、路由、聚合、归并、展示。哪怕只是“自己搭过一个模拟项目”,能讲清楚这条链路中每一步选型的原因,就已经达到面试要求了。

回到最开始那道题。下次面试官再问“分布式 TopK”,你可以一口气把答案整理成三条主线:精确解用哈希分区 + 局部小顶堆 + 全局归并;数据倾斜用加盐二次聚合;允许近似用 CMS。这三条线彼此呼应,成了一个完整的分布式数据处理体系。

我个人实际面试时还有一个小体会:答完主方案后,我会主动反问一句“这个场景是允许近似还是必须精确”,这句话往往能打开一片新的讨论空间,让面试从“你在背题”变成“我们一起做设计”。比起继续堆八股,这样的交流方式更能留下印象。希望这篇拆解能让你在下次遇到这类题时,多一分从容,少一分卡顿。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询