深蓝黑背景封面:右侧大标题「装不下,就切开再合并」,副标题「分治、外部排序与哈希分片的超内存数据处理方法论」;左侧原创示意图——一个装得满满溢出的大文件箱子,被切成三个小方块分流进三台机器轮廓,出口处三条线重新汇成一股;左上标注「知识专栏 · 外排方法论」,右上标注「Day 09 · 10-27」 图 1:本文封面,装不下就切开再合并。

之前解析「海量日志里找访问最多的 IP」这道真题时,核心动作只有一个:按 IP 哈希分片。那篇文章把这道题解完就停了,这篇把镜头拉远——分片只是整套「超内存数据处理方法论」的第一步,后面还有片内处理、全局合并,以及从单机走向分布式的那一步迁移。

先把结论立住:**单机装不下时,通用解法永远是同一条三段式——分片、片内处理、全局合并,而整条链路的根基只有一个假设:同一键的所有记录必须聚在同一片。**分片规则选错,后面全错。

**第一步,哈希分片。**把大文件按键的哈希值散到 N 个小文件,N 按可用内存倒推:内存能装 500MB,数据有 20GB,就切到 64 片留出余量。用哈希而不是顺序切,是为了让相同的键必然落进同一片——统计类问题的正确性全靠这一条。顺序切片的后果是同一个 IP 散落在几十片里,每片的「冠军」都不是全局冠军,合并时根本无从下手。

**第二步,片内处理。**每片单独读进内存,统计类用哈希表计数,排序类直接内存排序后写回磁盘。片与片互不依赖,这一步天然可以并行。

**第三步,全局合并。**统计类问题逐片汇总计数再取极值;排序类问题用多路归并把若干有序小文件合成一个有序整体,这就是外部排序的收尾动作。归并时每路只需在内存里保留一个读缓冲,内存占用与文件总量脱钩。

方法论流程图:上方主线三步——第一步哈希分片,把大文件按键的哈希值散到 N 个小文件,相同键必然落进同一片,片的大小按可用内存倒推;第二步片内处理,每个分片单独读进内存,用哈希表统计或排序,片与片互不依赖;第三步全局合并,统计类问题直接逐片汇总取极值,排序类问题用多路归并把有序小文件合成有序整体。下方两个变体卡片:左卡片「外部排序」——内存里排小块写回磁盘,再大容量归并,核心是用磁盘容量换内存上限;右卡片「走向分布式」——分片天然独立,把「读本地小文件」换成「读远端分片」就是 MapReduce 的朴素形态。底部提示:这套方法论的根基只有一个假设——同一键的所有记录必须聚在同一片,分片规则选错,后面全错 图 2:分片、片内处理、全局合并,根基假设是「同键同片」。

光讲道理不踏实,用 Node.js 22.15.0 把三段式和外排序各跑一遍(node shard.js)。先造 50 万行偏斜日志,再严格按「哈希分片→片内统计→全局合并」执行,最后和「假设内存装得下」的直接统计对照;外排序用 10 万个数、每块 1 万演示:

'use strict';

// 可复现随机数与稳定字符串哈希(FNV-1a)
function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function fnv1a(s) {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  return h >>> 0;
}

// 造一份偏斜日志:少数 IP 贡献大部分访问
const rand = mulberry32(20261027);
const lines = [];
for (let i = 0; i < 500000; i++) {
  const rank = Math.floor(10000 * rand() * rand());
  lines.push(`10.${(rank >>> 16) & 255}.${(rank >>> 8) & 255}.${rank & 255}`);
}

// 第一步:哈希分片——同一 IP 必然落进同一片
const shards = Array.from({ length: 8 }, () => []);
for (const ip of lines) shards[fnv1a(ip) % 8].push(ip);

// 第二步:片内处理——每片独立装进内存统计,取本片冠军
function topOf(shard) {
  const count = new Map();
  for (const ip of shard) count.set(ip, (count.get(ip) ?? 0) + 1);
  let best = null, bestN = 0;
  for (const [ip, n] of count) if (n > bestN) { best = ip; bestN = n; }
  return { count, best, bestN };
}
const partials = shards.map(topOf);

// 第三步:全局合并——同一 IP 只存在于一个分片,逐片汇总即全局
const merged = new Map();
for (const p of partials) for (const [ip, n] of p.count) merged.set(ip, n);
let top = null, topN = 0;
for (const [ip, n] of merged) if (n > topN) { top = ip; topN = n; }

// 校验一:同一 IP 是否只出现在一个分片
const seen = new Map();
let distinct = 0, violations = 0;
for (let s = 0; s < 8; s++) {
  for (const ip of new Set(shards[s])) {
    distinct++;
    if (seen.has(ip) && seen.get(ip) !== s) violations++;
    seen.set(ip, s);
  }
}
// 校验二:与「假设内存装得下」的直接统计对照
const direct = topOf(lines);
console.log(`日志 50 万行,分片大小:${shards.map((s) => s.length).join(' / ')}`);
console.log(`不同 IP 共 ${distinct} 个,跨片违规 ${violations} 次`);
console.log(`分片加合并的冠军:${top}(${topN} 次);直接统计的冠军:${direct.best}(${direct.bestN} 次)`);
console.log(`两种算法结果一致:${top === direct.best && topN === direct.bestN}`);

// 外部排序最小演示:10 万个数,内存只装 1 万——分块排序再多路归并
const numbers = Array.from({ length: 100000 }, () => Math.floor(rand() * 1000000));
const chunks = [];
for (let i = 0; i < numbers.length; i += 10000) chunks.push(numbers.slice(i, i + 10000).sort((a, b) => a - b));
const heads = chunks.map(() => 0);
const out = [];
for (let k = 0; k < numbers.length; k++) {
  let pick = -1;
  for (let c = 0; c < chunks.length; c++) {
    if (heads[c] < chunks[c].length && (pick === -1 || chunks[c][heads[c]] < chunks[pick][heads[pick]])) pick = c;
  }
  out.push(chunks[pick][heads[pick]++]);
}
console.log(`外部排序:分块排序后归并,与整体排序一致:${out.join() === [...numbers].sort((a, b) => a - b).join()}`);

实测输出:

日志 50 万行,分片大小:62548 / 62357 / 62780 / 62467 / 62636 / 62636 / 62086 / 62490
不同 IP 共 9785 个,跨片违规 0 次
分片加合并的冠军:10.0.0.0(516 次);直接统计的冠军:10.0.0.0(516 次)
两种算法结果一致:true
外部排序:分块排序后归并,与整体排序一致:true

第二行是根基假设的实证:9785 个不同 IP 没有一个跨片,所以第三行的分片冠军才能与直接统计逐字节一致。注意分片大小几乎均匀——哈希把数据摊平了;如果键本身有热点(某个键占三成数据),那一颗片会超重,这时要对热点键单独切分或做两阶段统计。

**从单机到分布式只差一步替换。**分片天然独立,把「读本地小文件」换成「读远端分片」、把「写回磁盘」换成「网络洗牌」,就是 MapReduce 论文描述的朴素形态:map 处理分片产生中间键值对,框架按键把中间结果重新分组,reduce 做合并。看懂了单机三段式,就懂了分布式计算框架为什么长这个样子。

**常见误区。**一是用顺序切片做统计,破坏同键同片假设;二是分片数拍脑袋,正确做法是先采样估算键基数,再按内存倒推片数;三是忘记合并阶段的内存账——统计类合并要装得下全局键表,键太多时合并本身也要分层;四是把外排序的归并路数开到几百路,每路一个读缓冲,缓冲区总量超内存,反而换页抖死。

落到行动上:下次遇到「机器装不下」的题,先回答三个问题——键是什么、片怎么切、合并要多少内存。你在线上处理过最大的单机装不下的数据集是多大?用的什么切法?欢迎分享。