大家都懂,面试的时候,面试官总喜欢抛出一些“奇奇怪怪”的问题,问你点“看似不可能”的事情。
比如下面这道题:
全国14亿个姓名,统计出重名最多的前10个。
这题乍一看,是不是像极了老板甩下一句:“你用两块钱搞个全公司团建,最后还得拍成《向往的生活》那种质感。”
不过别急,虽然题目看起来“不讲道理”,其实只要抓住核心方法,是有办法搞定的!
一、问题背景分析
1.1 数据规模的挑战
我们先来看看这个问题到底有多“大”:
- 数据量巨大 :假设全国有14亿条姓名记录,每条平均占用10字节,那么整体数据约为 14GB 。
- 超出内存限制 :这已经远超普通单机程序可完全装载进内存的范围(普通服务器内存为64GB~256GB)。
- 单机方法受限 :
- 常规的哈希表统计方法,时间复杂度虽是 O(N),但数据超过内存后,频繁的磁盘I/O会严重拖慢处理速度。
换句话说,用单机方法处理,全量统计的时间可能达到 数小时乃至一天以上 。
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。 这是大佬写的 7701页的BAT大佬写的刷题笔记,让我offer拿到手软
1.2 为何需要分布式计算?
面对如此大体量的数据,我们不得不考虑使用 分布式系统 来解决:
- 数据切分并行处理 :将14亿条数据平均分配到多个计算节点上,每个节点各自处理一部分,充分利用分布式集群的横向扩展能力。
- 解决数据倾斜问题 :分布时要注意数据分区均衡,避免某些节点数据过多,形成瓶颈。
- 高效合并结果 :
- 各节点本地先统计出Top-K名单。
最后集中合并这些结果,使用 高效的 Top-K 算法 保证整体结果准确又高效。
1.3 算法与性能优化
为了保证整个过程高效,我们对统计与合并两个阶段提出如下优化:
(1)统计阶段
- 使用哈希表 :每个节点使用哈希表统计本地姓名出现的频次,操作时间复杂度为 **O(1)**。
- 空间占用小,操作迅速 ,适合并发处理。
(2)合并阶段
- 使用 小顶堆(优先队列) 来维护 Top-K 结果(假设 K=10),插入操作时间复杂度为 **O(logK)**。
- 将所有节点的 Top-K 名单合并后,得到最终的全局 Top-10。
(3)总体复杂度
- 统计阶段:**O(N)**(每条数据哈希计数)
- 合并阶段:**O(N × logK)**(合并所有节点 Top-K)
- 总体时间复杂度:**O(N + NlogK)**,接近线性处理能力,性能优良。
二、系统架构设计
为应对14亿级别的姓名数据处理需求,系统设计可以采用经典的 MapReduce 分布式计算框架,结合 分治策略 与 优先队列算法,实现高效的三阶段处理流程。
2.1 整体技术方案
阶段一:数据分片与分布式统计(Map 阶段)
- 将原始姓名数据通过 哈希分区 分发到多个 Map 任务节点。
- 每个 Map 任务在本地内存中使用 HashMap 对姓名进行频次统计,时间复杂度 O(1)。
- 引入 Combiner(本地聚合器) ,在 Map 输出阶段对重复数据做本地合并,从而显著减少数据传输量。
阶段二:局部结果合并(Reduce 阶段)
- Reduce 任务收集同一分区内所有 Map 输出结果,并进一步统计姓名出现总频次。
- 每个 Reduce 任务输出一个完整的「姓名-频次」统计列表,为最终排序做准备。
阶段三:全局 Top-10 计算
- 遍历所有 Reduce 输出,使用 最小优先队列(小顶堆) 实时维护当前频次最高的前10个姓名。
- 插入操作时间复杂度为 O(logK),最终输出完整的 Top-10 排序结果。
2.2 数据分区策略
为了确保分布式计算过程中各节点负载均衡、避免数据倾斜,采用改进版 一致性哈希算法 实现高质量的数据分区。
示例代码:
public class NamePartitioner extends Partitioner<Text, IntWritable> {
privatefinalint numPartitions;
public NamePartitioner(int numPartitions) {
this.numPartitions = numPartitions;
}
@Override
public int getPartition(Text key, IntWritable value, int numReduceTasks) {
// 使用 MurmurHash3 算法提升哈希分布均匀性
int hash = MurmurHash3.hash32(key.toString().getBytes());
return Math.abs(hash) % numPartitions;
}
}
策略说明:
- MurmurHash3 :相比传统哈希取模,具有更强的“雪崩效应”与更均匀的散列分布,减少热点分区问题。
- 分区数量建议 :
- 设置为 Reduce 节点数的 2~3 倍 ,例如 500 个 Reduce 节点对应 1000 个分区,提升并发处理能力与弹性伸缩能力。
2.3 内存管理策略
为提升Map 阶段处理效率与内存利用率,建议配置如下:
Map 任务内存配置:
- 每个 Map 任务建议配置 8~16GB 内存 ,根据节点硬件资源灵活调整。
HashMap 初始化参数:
- 预计每个 Map 任务处理约 140 万条数据(14亿 / 1000分区)。
- 设置初始容量为 2^18 = 262144 ,负载因子为 0.75 ,避免频繁扩容造成性能损耗。
内存泄漏防控:
- 使用 弱引用(WeakReference) 管理大对象,避免内存泄漏。
- 定期触发 GC,释放无效对象,保证任务稳定运行。
三、核心模块实现
整个统计流程由三个核心模块组成:数据统计模块(Map)、局部合并模块(Combiner) 和 全局合并模块(Reduce)。以下分别对每个模块的实现细节进行说明:
3.1 数据统计模块(Map 任务)
3.1.1 自定义输入格式
为了提高数据读取效率,可以通过自定义 InputFormat 实现对原始数据的定制化读取。
public class NameInputFormat extends TextInputFormat {
@Override
public RecordReader<LongWritable, Text> createRecordReader(
InputSplit split, TaskAttemptContext context) throws IOException, InterruptedException {
returnnew NameRecordReader();
}
privatestaticclass NameRecordReader extends RecordReader<LongWritable, Text> {
privatefinal Text line = new Text();
private LineReader lineReader;
@Override
public boolean nextKeyValue() throws IOException, InterruptedException {
long position = 0;
while (lineReader.readLine(line) > 0) {
currentKey.set(position);
position += line.getLength() + 1;
returntrue;
}
returnfalse;
}
// 省略初始化、关闭方法等...
}
}
说明:
- 实现 LineReader 逐行读取逻辑。
- 保留偏移量作为 key,文本作为 value。
- 在海量数据场景中可根据文件格式进一步优化读取逻辑。
3.1.2 Mapper 实现
public class NameMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
privatefinal IntWritable one = new IntWritable(1);
private Map<Text, Integer> localCount = new HashMap<>();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String name = value.toString().trim();
if (!StringUtils.isBlank(name)) {
localCount.put(new Text(name), localCount.getOrDefault(new Text(name), 0) + 1);
}
}
@Override
protected void cleanup(Context context) throws IOException, InterruptedException {
for (Map.Entry<Text, Integer> entry : localCount.entrySet()) {
context.write(entry.getKey(), new IntWritable(entry.getValue()));
}
}
}
优化点:
- 使用 cleanup 方法在 Map 任务结束时统一输出,减少 I/O 调用次数,提升性能。
- 增加空值判断和去重逻辑,确保数据清洗质量。
- 可按需设置 HashMap 的初始容量,避免扩容。
3.2 局部合并模块(Combiner)
public class NameCombiner extends Reducer<Text, IntWritable, Text, IntWritable> {
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Context context)
throws IOException, InterruptedException {
int sum = 0;
for (IntWritable value : values) {
sum += value.get();
}
context.write(key, new IntWritable(sum));
}
}
说明:
- Combiner 相当于“本地版”的 Reducer,用于在 Map 端提前合并重复数据,减少网络传输量。
- 实际运行中可降低 30%~70% 的数据传输压力。
- 需确保 Combiner 的输出类型与 Reducer 输入类型一致,防止类型不匹配导致错误。
3.3 全局合并模块(Reduce 任务)
public class NameReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
private Map<Text, Integer> globalCount = new HashMap<>();
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Context context)
throws IOException, InterruptedException {
int sum = 0;
for (IntWritable value : values) {
sum += value.get();
}
globalCount.put(key, sum);
}
@Override
protected void cleanup(Context context) throws IOException, InterruptedException {
for (Map.Entry<Text, Integer> entry : globalCount.entrySet()) {
context.write(entry.getKey(), new IntWritable(entry.getValue()));
}
}
}
说明:
- 将每个姓名的频次汇总到 globalCount 中,待所有 reduce 完成后一次性输出。
- 统一输出可以方便后续的 Top-10 汇总计算 。
- 为防止内存溢出,建议对单个 Reducer 限定最大处理量(如不超过 100 万条),可结合分区策略调整 Reduce 数量。
四、Top-10 计算模块
在分布式统计完成后,最终需要从所有 Reduce 任务的输出中找出 重名最多的前 10 个姓名。为此,我们设计了以下两个核心步骤:优先队列管理 和 全局合并流程。
4.1 优先队列实现
为了高效维护当前统计的 Top-10 姓名,我们使用 最小堆(小顶堆) 作为优先队列来存储数据:
实现代码
public class TopNameCollector {
privatefinalint topK;
private PriorityQueue<NameCount> minHeap;
public TopNameCollector(int topK) {
this.topK = topK;
// 小顶堆:根据 count 值进行升序排列
this.minHeap = new PriorityQueue<>(topK, Comparator.comparingInt(NameCount::getCount));
}
public void add(String name, int count) {
NameCount entry = new NameCount(name, count);
if (minHeap.size() < topK) {
// 堆未满,直接加入
minHeap.offer(entry);
} elseif (count > minHeap.peek().getCount()) {
// 如果当前频次大于堆顶,则替换
minHeap.poll();
minHeap.offer(entry);
} elseif (count == minHeap.peek().getCount()) {
// 如果频次相同,按字典序比较,保留字典序小的
if (name.compareTo(minHeap.peek().getName()) < 0) {
minHeap.poll();
minHeap.offer(entry);
}
}
}
public List<NameCount> getTopResults() {
// 将最小堆内容按降序输出
List<NameCount> result = new ArrayList<>(minHeap);
result.sort((o1, o2) -> o2.getCount() - o1.getCount());
return result;
}
}
// 数据封装类
class NameCount {
private String name;
privateint count;
public NameCount(String name, int count) {
this.name = name;
this.count = count;
}
public String getName() {
return name;
}
public int getCount() {
return count;
}
}
设计思路:
- 使用 小顶堆 保存当前 Top-10:
- 堆大小超过 10 时,自动淘汰堆顶最小元素。
维护一个稳定的 Top-10 集合,保证高效插入与删除。
- 处理同频次的姓名时,额外比较字典序:
- 保留字典序更小的姓名,以确保排序稳定性。
4.2 全局合并流程
分布式统计的最后一步是将所有 Reduce 节点 的输出文件(part-* 文件)统一合并,计算出最终的 Top-10 结果。
实现代码
public class GlobalMergeTask {
public List<NameCount> process(Path reduceOutputPath, int topK) throws IOException {
TopNameCollector collector = new TopNameCollector(topK);
FileSystem fs = FileSystem.get(reduceOutputPath.toUri(), new Configuration());
// 遍历所有 Reduce 任务的输出文件
for (FileStatus status : fs.listStatus(reduceOutputPath)) {
if (status.isFile() && status.getPath().getName().startsWith("part-")) {
try (FSDataInputStream inputStream = fs.open(status.getPath());
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split("\t");
if (parts.length == 2) {
String name = parts[0];
int count = Integer.parseInt(parts[1]);
// 将数据加入到优先队列
collector.add(name, count);
}
}
}
}
}
// 最终获取 Top-10 的结果
return collector.getTopResults();
}
}
设计思路:
1、 遍历HDFS中所有part-文件,逐行读取;
2、 使用BufferedReader进行流式处理,解析每行的姓名和频次;
3、 调用TopNameCollector的add方法,将姓名和频次插入最小堆;
4、 最终从堆中导出Top-10,并按频次降序排列输出;
优化细节:
- 使用 BufferedReader 提升文件读取速度,减少 IO 次数。
- 避免一次性加载所有文件到内存,通过流式读取降低内存占用。
- 分布式文件系统(HDFS)的 listStatus 高效扫描目录,确保并发安全。
至此,整个流程的所有核心模块已经完整实现:
1、 Map阶段:本地统计频次;
2、 Combiner阶段:局部聚合;
3、 Reduce阶段:全局统计;
4、 Top-10计算:优先队列筛选;
五、分布式计算框架集成(Hadoop MapReduce)
为实现对大规模姓名数据的高效统计与分析,本系统基于 Hadoop MapReduce 框架进行分布式计算部署。以下为核心作业配置与集群部署建议:
5.1 作业配置驱动类
public class NameCountJob {
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf, "Name Count Job");
job.setJarByClass(NameCountJob.class);
// 设置输入输出格式
job.setInputFormatClass(NameInputFormat.class);
FileInputFormat.addInputPath(job, new Path(args[0]));
job.setOutputFormatClass(TextOutputFormat.class);
FileOutputFormat.setOutputPath(job, new Path(args[1]));
// 设置 Mapper、Combiner 和 Reducer 类
job.setMapperClass(NameMapper.class);
job.setCombinerClass(NameCombiner.class);
job.setReducerClass(NameReducer.class);
// 设置 Map 输出与最终输出的数据类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(IntWritable.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
// 设置自定义分区策略
job.setPartitionerClass(NamePartitioner.class);
job.setNumReduceTasks(1000); // 启动 1000 个 Reduce 任务
// 启动作业
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
5.2 集群部署建议
为确保系统的高并发处理能力与稳定性,建议参考以下部署规范:
- 节点配置 :每个节点建议配置为 8 核 CPU、16GB 内存、1TB 磁盘;
- 集群规模 :建议部署 100~500 台节点,以支撑高并发计算;
- 资源分配 :
- 每个 Map 任务分配 8GB 内存;
每个 Reduce 任务分配 12GB 内存;
- 数据存储 :使用 HDFS,副本数建议设置为 3;
- 任务调度器 :采用 Capacity Scheduler 实现资源的公平调度。
六、性能优化策略
为了提升系统整体计算效率与稳定性,特别引入以下三方面的性能优化策略:
6.1 数据倾斜处理
在海量数据中,部分高频姓名(如“张伟”、“王伟”等)可能导致数据倾斜,影响 Reduce 任务的负载均衡。
高频词预处理策略
// Mapper 中识别高频词并添加特殊前缀
Set<String> highFreqNames = loadHighFreqNamesFromHDFS("/data/high_freq_names.txt");
if (highFreqNames.contains(name)) {
context.write(new Text("__HIGH_FREQ__" + name), one); // 加前缀打散到多个 Reducer
} else {
context.write(new Text(name), one);
}
动态分区调整策略
conf.set("mapreduce.job.reduce.slowstart.completedmaps", "0.8"); // Reduce 启动时机调整
conf.set("mapreduce.job.reduces", "-1"); // 自动计算最优 Reduce 数量
通过动态调整 Reduce 启动比例与任务数量,可在运行时自动应对数据倾斜情况。
6.2 内存优化技巧
在处理超大规模数据时,内存使用效率至关重要:
紧凑型数据结构替换
- 使用 IntWritable 替代 Integer ,减少对象封装带来的额外开销;
- 引入 Fastutil 库的 Int2ObjectMap 替代标准 HashMap ,可节省约 50% 内存;
JVM 参数优化
-Dmapreduce.map.java.opts=-Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
-Dmapreduce.reduce.java.opts=-Xmx12g -XX:+HeapDumpOnOutOfMemoryError
使用G1 收集器,并设置最大 GC 暂停时间,提升垃圾回收效率。
6.3 容错机制实现
为了提高系统的鲁棒性,在 Mapper/Reducer 中加入容错重试机制:
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Context context) {
try {
// 正常处理逻辑
} catch (Exception e) {
// 捕获异常并记录错误计数器
context.getCounter("REDUCE_ERRORS", "RETRY").increment(1);
// 可加入重新处理逻辑或记录日志
}
}
通过自定义计数器 REDUCE_ERRORS,可便于后期监控与调试 Reduce 失败情况。
七、压力测试方案
为验证系统在大规模数据处理下的稳定性与性能,设计如下测试方案:
7.1 数据生成工具
通过以下程序可自动生成模拟姓名数据:
public class NameGenerator {
privatestaticfinal String[] SUR_NAMES = {"张", "王", "李", "刘", "陈"};
privatestaticfinal String[] FIRST_NAMES = {"伟", "芳", "秀英", "建军", "建国"};
public static void generateData(String outputPath, long count) throws IOException {
try (PrintWriter writer = new PrintWriter(new File(outputPath))) {
Random random = new Random();
for (long i = 0; i < count; i++) {
String surName = SUR_NAMES[random.nextInt(SUR_NAMES.length)];
String firstName = FIRST_NAMES[random.nextInt(FIRST_NAMES.length)];
writer.println(surName + firstName);
}
}
}
}
该工具可快速生成包含 5 个姓氏与 5 个名字组合的测试数据,具备可控的高频词分布,适合模拟真实场景下的数据分布情况。
7.2 性能指标监控
- 吞吐量 :应达到 100MB/s 以上;
- 内存利用率 :建议保持在 70% - 80% 之间;
- 处理时间 :对于 14 亿条数据 ,在 500 节点集群 中应控制在 30 分钟以内完成 。
八、扩展方案
本系统设计具备良好的扩展性,可灵活适配 Spark、Flink 等新型大数据处理框架,以满足更复杂的应用场景。
8.1 基于 Spark 的优化实现
Spark 提供更高层次的内存计算抽象,适合迭代式分析任务。以下为 Spark RDD 模型的改写实现:
JavaRDD<String> names = sc.textFile("hdfs://names.txt");
JavaPairRDD<String, Integer> counts = names
.mapToPair(name -> new Tuple2<>(name, 1))
.reduceByKey(Integer::sum)
.cache(); // 缓存中间结果以提升后续性能
JavaPairRDD<String, Integer> top10 = counts
.transformToPair(rdd -> {
List<Tuple2<String, Integer>> topList = rdd.collect();
topList.sort((o1, o2) -> o2._2 - o1._2); // 按频次降序排序
return rdd.context().parallelizePairs(topList.subList(0, 10));
});
优势总结:
- 编程模型简洁,逻辑清晰;
- 利用内存加速计算,适合多次迭代和交互式分析任务;
- 支持丰富的生态组件(如 Spark SQL、MLlib)进行进一步扩展。
8.2 实时流处理扩展(Kafka + Flink)
针对实时新增的姓名数据统计需求,可引入流式计算架构:
- Kafka :作为消息中间件,实时接收用户输入的姓名流;
- Flink :进行流式计算,维护各个姓名的滚动计数;
- KeyedProcessFunction :按姓名键分区,维护各分区 Top-10;
- 窗口合并 :周期性地将所有分区结果合并,形成全局 Top-10。
该方案适用于实时去重、排行榜更新等场景,具备高吞吐与低延迟优势。
九、总结与面试要点
9.1 核心技术点总结
| 技术点 | 描述说明 |
|---|---|
| 分布式分治策略 | 利用哈希分区将数据拆分至多个节点并行处理 |
| 高效数据结构 | 哈希表(O(1) 计数)+ 优先队列(O(logK) 维护 Top-K) |
| MapReduce 模型 | 熟练掌握 Mapper / Reducer / Combiner 的职责划分与协作机制 |
| 性能优化策略 | 包括数据倾斜处理、内存优化、容错机制等 |
9.2 面试应答要点
- 问题拆解能力 :由单机实现切入,逐步过渡到分布式处理,体现架构演进思维;
- 技术选型逻辑 :能清晰对比 MapReduce 与 Spark 的适用场景,并结合业务背景做出合理选择;
- 边界问题思考 :
- 脏数据过滤与异常处理;
高频词导致的数据倾斜;
Reduce 任务内存溢出应急方案;
- 复杂度分析能力 :
- 时间复杂度:整体为 O(N logK),其中 N 为姓名总数,K 为 Top-K 的个数;
空间复杂度:O(M + K),M 为唯一姓名数。
📌 工程能力强调:结合具体应用场景,灵活调整分区逻辑与参数配置,才能真正展现对大数据计算的工程实践能力与优化思维。
最后说一句(求关注,求赞,别白嫖我)
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。 这是大佬写的 7701页的BAT大佬写的刷题笔记,让我offer拿到手软
本文,已收录于,我的技术网站 cxykk.com:程序员编程资料站,有大厂完整面经,工作技术,架构师成长之路,等经验分享
求一键三连:点赞、分享、收藏
点赞对我真的非常重要!在线求赞,加个关注我会非常感激!
**近期技术热文
**面试必看!腾讯面试问:MySQL缓存有几级?你能答上来吗?
行业案例:Shopee 70W人在线的 弹幕 系统,是怎么架构的?
行业经典案例:从30万到100万单,某电商平台如何用16库16表扛住亿级订单?
第3版:互联网大厂面试题包括 Java 集合、JVM、多线程、并发编程、设计模式、算法调优、Spring全家桶、Java、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Memcached、MongoDB、Redis、MySQL、RabbitMQ、Kafka、Linux、Netty、Tomcat、Python、HTML、CSS、Vue、React、JavaScript、Android 大数据、阿里巴巴等大厂面试题等、等技术栈!