最近跟几个做后端的朋友聊面试,发现“分布式环境下的 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。这三条线彼此呼应,成了一个完整的分布式数据处理体系。
我个人实际面试时还有一个小体会:答完主方案后,我会主动反问一句“这个场景是允许近似还是必须精确”,这句话往往能打开一片新的讨论空间,让面试从“你在背题”变成“我们一起做设计”。比起继续堆八股,这样的交流方式更能留下印象。希望这篇拆解能让你在下次遇到这类题时,多一分从容,少一分卡顿。