深蓝黑背景品牌封面:居中大标题「海量日志,找出访问最多的 IP」,副标题「分治 + 哈希统计:内存装不下,就切开算」;下方装饰——一个巨型日志文件经分流箭头切成三份小文件,各自统计后汇入冠军领奖台;顶部标注「历史题目复盘 · 数据处理」与「Day 03 · 09-21」 图 1:本文封面,点明题目与「切开算」的核心思路。

先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。海量数据处理类题目有一个共同的开场白:数据大到内存装不下。这道题也不例外——一天的访问日志可能有几十上百 GB,问:访问次数最多的那个 IP 是谁?

想象面试现场,面试官补了一句约束:机器内存只有 2GB,日志文件 100GB。

第一步,审题。

直接的想法是建一张哈希表,key 是 IP、value 是次数,扫一遍文件就统计完了。问题是装不下:几千万乃至上亿个不同 IP 同时驻留内存,哈希表本身就爆了。所以题目真正考的只有一个思想——分治:把大问题切成若干个内存装得下的小问题,各自求解,再汇总。

第二步,基础回答:哈希切分 + 局部统计 + 全局汇总。

核心操作是逐行读日志(流式读取,文件再大也不进内存),对每行的 IP 算一个哈希值,按 hash(IP) mod 100 把这行追加到 100 个小文件之一。这一步有个必须讲透的性质:同一个 IP 的哈希值固定,所以它必然全部落进同一个小文件,绝不会被拆散。这就是「可以分别统计再汇总」的合法性来源。

切完之后,每个小文件只有 1GB 量级(假设 IP 分布大致均匀),可以整个装进内存,用哈希表统计出这个小文件的 Top1。最后把 100 个局部 Top1 放在一起比大小,最大的就是全局答案。时间复杂度 O(n),全程只需一次文件扫描加 100 次小文件统计。

流程图:超大日志文件远超内存,逐行读取并按 hash(IP) mod 100 把每条记录写入对应编号的小文件——同一个 IP 哈希值固定,必然全部落进同一个小文件;每个小文件已能装进内存,用哈希表统计 IP 出现次数并取出局部 Top1;最后把 100 个局部 Top1 汇总比较得到全局访问最多的 IP。底部变体说明:要 Top-K 就把单文件内的统计接一个小顶堆;单机扛不住时同一思路平移到多台机器,即 MapReduce 的雏形 图 2:哈希切分保证同一 IP 不拆散,局部 Top1 汇总即全局答案。

第三步,用可运行代码验证关键性质。

下面这份代码模拟整个流程,并额外验证「同一 IP 只进一个桶」这条命脉性质,用 Node.js 22.15.0 直接运行(node top-ip.js)即可:

'use strict';

// 生成一批模拟日志行(真实场景是逐行读文件,不整体进内存)
function* generateLogs(total, distinctIps, hotIp, hotCount) {
  for (let i = 0; i < hotCount; i++) yield hotIp; // 埋一个明显的热点 IP
  for (let i = 0; i < total - hotCount; i++) {
    yield `10.0.${(i * 7) % 250}.${i % distinctIps}`;
  }
}

// 稳定的字符串哈希(多项式滚动)
function hash32(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) {
    h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
  }
  return h >>> 0; // 转无符号
}

// 第一步:按 hash(IP) % 分片数把记录分流到对应桶
function partition(logs, buckets) {
  const parts = Array.from({ length: buckets }, () => new Map());
  for (const ip of logs) {
    const idx = hash32(ip) % buckets;
    parts[idx].set(ip, (parts[idx].get(ip) ?? 0) + 1);
  }
  return parts;
}

// 第二步:每个桶内取局部 Top1
function localTop1(counter) {
  let best = null;
  for (const [ip, cnt] of counter) {
    if (best === null || cnt > best.cnt) best = { ip, cnt };
  }
  return best;
}

// 第三步:汇总所有局部 Top1,得到全局 Top1
function findTopIp(logs, buckets) {
  const parts = partition(logs, buckets);
  let global = null;
  for (const counter of parts) {
    const local = localTop1(counter);
    if (local && (global === null || local.cnt > global.cnt)) global = local;
  }
  return global;
}

const TOTAL = 100000;
const HOT_IP = '203.0.113.7';
const HOT_COUNT = 9001;
const BUCKETS = 100;

const top = findTopIp(generateLogs(TOTAL, 500, HOT_IP, HOT_COUNT), BUCKETS);
console.log(`日志总量 ${TOTAL} 条,分 ${BUCKETS} 个桶统计`);
console.log(`全局 Top1:${top.ip},出现 ${top.cnt} 次`);

// 正确性复核:同一 IP 是否只出现在一个桶里
const parts = partition(generateLogs(TOTAL, 500, HOT_IP, HOT_COUNT), BUCKETS);
const appearIn = parts.filter((c) => c.has(HOT_IP)).length;
console.log(`热点 IP 出现的桶数:${appearIn}(应为 1,证明同一 IP 不会被拆散)`);
if (appearIn !== 1 || top.ip !== HOT_IP || top.cnt !== HOT_COUNT) {
  throw new Error('结果校验失败');
}

实测输出如下:

日志总量 100000 条,分 100 个桶统计
全局 Top1:203.0.113.7,出现 9001 次
热点 IP 出现的桶数:1(应为 1,证明同一 IP 不会被拆散)

热点 IP 被准确找出,且它只出现在一个桶里——这正是整个方案正确的前提,代码替你把这条性质钉死了。

可能的追问。

「要的是 Top-10 呢?」桶内统计完接一个大小为 K 的小顶堆,每个桶出局部 Top-K,再全局归并。「某个 IP 特别热,单个桶还是装不下怎么办?」对这个桶二次哈希切分,切得更细。「单机扛不住扫描怎么办?」同样的切分逻辑搬到多台机器,就是 MapReduce 的雏形——这也是 AI 工程里统计训练日志、请求日志的日常套路。

易错点清单。

一,试图一次性把整个文件读进内存,方案从第一步就死了。二,切分时用随机分桶而不是按 IP 哈希,同一个 IP 被拆到多个文件,局部 Top1 汇总出错误答案——这是本题最致命的错。三,只答「分治」两个字,讲不出为什么按哈希切才能保证正确。四,忽略追问空间,Top-K、热点桶、分布式都是现成的下一问。

回到面试现场,建议的答题节奏:先点破「内存装不下」是题眼,给出哈希切分三步框架,主动强调同一 IP 不拆散这条性质,再谈 Top-K 与分布式的延伸。性质讲透了,方案的可信度就立住了。

你在工作中处理过「装不下」的数据吗——当时是分治切开的,还是直接上了分布式框架?欢迎聊聊。

参考资料

  • Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,生成器与 Map 行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/