新闻详情

新闻详情

首页 / 资讯中心 / 详情

百万级JavaScript去重方案实测:从复杂度到Set/Map的性能对比

发布时间:2026/9/30 4:58:09来源:尧图网络
百万级JavaScript去重方案实测:从复杂度到Set/Map的性能对比
上周接了个数据清洗的活接口一次拉回来一百二十万条日志里面夹着一堆重复记录。同事原本用 filter 加 indexOf 去重单次跑下来接近七秒CPU 蹭蹭往上飙。他问我JavaScript 里到底哪种去重方式对上百万级数据量级才算高效这个问题我在不同项目里被问过很多回干脆花时间把 JS 主流的几种去重方案全部跑了一遍基准。先说结论数据量到了百万这个量级Set 和 Map 这类哈希结构基本是碾压级的优势但这其中还藏着不少坑比如类型转换、内存占用、数据形态选错了一样卡。这篇就把测试过程、结果和踩坑经验完整记录下来。1. 百万级数据场景下去重考验的到底是什么1.1 复杂度这道坎先从复杂度说起。去重本质上是一个“判定当前元素是否已经出现过”的问题。双循环方案每个元素都要跟结果数组里的元素逐个比较。最坏情况下100 万条数据意味着约 5 乘以 10 的 11 次方次比较。即使单次比较只需要 1 纳秒也要接近 500 秒何况真实环境里还有函数调用、数组访问、类型判断的开销。所以“慢到没法用”是必然的而不是偶然。排序方案先排序再做相邻比较复杂度来到 O(n log n)。100 万条数据大概 2000 万次比较量级通常几百毫秒能搞定比双循环快了不是一个级别。哈希表方案Set、Map、普通对象键值对插入和查找的平均复杂度都是 O(1)。100 万条数据只需要做百万次哈希计算几十毫秒就能结束。这段计算并不复杂但很多人写代码的时候根本不会过一遍。数据量只有几百条时双循环和 Set 的差距是 0.1 毫秒和 0.01 毫秒体感为零一旦放大到百万级差距就从毫秒扩大到秒级。这也是我在开头说“对数据量级要有敏感性”的原因。注意复杂度分析决定的是量级速度上限真实的引擎实现、JIT 优化、数据特征还会让常数项差不少。这也是为什么我们既要理论计算也要实测。1.2 什么业务场景最容易撞上百万级去重我接触过的主要有这几类日志清洗。上游系统导出的访问日志、错误日志一天几百万条很常见需要按消息内容或用户 ID 去重后再写进仓库。数据导入与同步。Excel、CSV 大批量导入或者从老库迁移数据排重是第一步。接口聚合。后端把多个微服务的数据合并返回给前端前端拿到的是一个很大的数组很可能包含重复项。埋点与实时上报。汇总时段内的点击事件去重后统计 UV。这些场景的数据形态也完全不一样。有的全是整数 ID有的全是长字符串还有的是 JSON 对象数组。数据形态直接决定了最佳方案这个后面测试部分会展开。1.3 测试环境与数据集说明为了避免测试结果变成玄学先说清楚环境环境Node.js 18.17 LTSUbuntu 22.04Intel i7-1270032GB 内存。数据集100 万个 0999999 之间的随机整数100 万个 812 位随机字符串10 万个对象引用数组。测量方式performance.now()计时每个方案预热后跑 5 次取中位数内存用process.memoryUsage().heapUsed观察增量。随机整数的构造代码很简单function genIntArray(n, range) { const arr new Array(n); for (let i 0; i n; i) { arr[i] (Math.random() * range) | 0; } return arr; }用 0999999 这个范围生成 100 万个数字按照生日悖论估算重复率大约有三成多去重后能剩 60 万条左右和真实日志场景很像——不是全都重复也不是全都不同而是“夹杂大量重复”。2. 五个高频去重方案逐一写出实测成绩2.1 indexOf 双循环最朴素的写法反而最慢先把最经典但最慢的方案亮出来function dedupeIndexOf(arr) { const result []; for (let i 0; i arr.length; i) { if (result.indexOf(arr[i]) -1) { result.push(arr[i]); } } return result; }逻辑没有任何问题遍历原数组如果当前元素在结果数组里不存在就推进 result。问题在于result.indexOf每次都要从头扫一遍结果数组。结果数组越长单次查找越慢整体呈平方级增长。我实测 100 万随机整数这个方案花了 5.8 秒左右。换成includes或者filter写法也不会好到哪去arr.filter((item, i) arr.indexOf(item) i);看起来简洁但它等价于双重遍历而且每次回调都要重新查一遍实测比双循环还要慢一点。有人可能觉得“数据量没多大忍忍就过去了”但日志场景每天要跑好几次一次五秒就会变成每次任务都慢这不是优化问题是方案问题。2.2 Set 去重工程上的默认答案function dedupeSet(arr) { return [...new Set(arr)]; }Set 内部是哈希结构插入和查找的平均复杂度都是 O(1)。实测 100 万随机整数这个方案大约 5560 毫秒和双循环比差了两个数量级。而且它天然保持第一次出现顺序不会有排序副作用。这里要展开说一下 Set 的“值去重”语义。在 Set 中NaN可以被正确去重1和字符串1会被当作不同元素。这套规则来自 SameValueZero 算法和严格相等差不多但把NaN当成了等于自己。日常工程中这套规则最省心基本符合直觉。如果不想展开成数组而只是要判断重复可以直接用new Set(arr).size连数组都不用建。2.3 Map 去重适合顺带记录出现次数function dedupeMap(arr) { const seen new Map(); for (const item of arr) { if (!seen.has(item)) { seen.set(item, true); } } return [...seen.keys()]; }Map 的查找语义和 Set 一样复杂度同样是 O(1)。实测 100 万随机整数大约 68 毫秒比 Set 略慢一点原因是 Map 的元素结构比 Set 更重每个条目要同时缓存 key 和 value。如果你的目标只是去重直接用 Set 更合适但如果你去重的同时还想统计每个元素出现次数、最后一次出现位置等信息Map 会更顺手。常见用法是统计词频const count new Map(); for (const item of arr) { count.set(item, (count.get(item) || 0) 1); }2.4 对象键值对老式哈希性能不错但坑多function dedupeObject(arr) { const seen Object.create(null); const result []; for (let i 0; i arr.length; i) { const key arr[i]; if (!seen[key]) { seen[key] 1; result.push(key); } } return result; }对象键值对在 ES6 之前是模拟哈希表的唯一手段。实测性能其实不差100 万随机整数大约 75 毫秒和 Set 属于同一梯队。但它有几个非常隐蔽的坑。首先是隐式类型转换。对象 key 只能是字符串或 Symbol数字1会被转成字符串1于是数组里的1和1会被当成同一条数据去重。Set 则不会合并它们。其次是原型链污染。如果直接用{}seen[toString]会命中继承属性!seen[key]变成 false导致toString这个字符串永远无法进入结果数组。我用Object.create(null)解决了这个问题但__proto__这种特殊 key 仍然需要格外小心。提示如果你非要走对象键值对路线请务必使用Object.create(null)并且确认数据类型是纯数字且不区分字符串形式。否则我建议直接上 Set。2.5 排序加相邻比较换一条路也能跑得快function dedupeSort(arr) { if (arr.length 1) return arr.slice(); const sorted [...arr].sort(); const result [sorted[0]]; for (let i 1; i sorted.length; i) { if (sorted[i] ! sorted[i - 1]) { result.push(sorted[i]); } } return result; }思路是先排序让相同的元素相邻再通过一次线性扫描把相邻重复项去掉。复杂度是排序的 O(n log n) 加扫描的 O(n)。实测 100 万随机整数大约 190210 毫秒比 Set 慢三倍左右但比双循环快太多了。这个方案最大的特点是不依赖额外哈希表内存占用低而且结果天然有序。如果你本来就需要一个排序后的唯一列表比如排行榜、去重后按 ID 输出这个方案一举两得。不过注意默认的sort()是按字符串排序的纯数字数组必须传入(a, b) a - b否则10会排在9前面得到的结果虽然去重了但顺序不符合数字排序直觉。2.6 顺带提一句reduce 变体和无效写法网上能搜到不少reduce去重写法arr.reduce((acc, item) (acc.includes(item) ? acc : [...acc, item]), []);这种写法简洁是简洁但每处理一个元素都可能构造新数组还会用includes从头遍历一次复杂度双重爆炸。实测 10 万数据已经卡顿100 万数据直接进入了“分钟级”等待。我的意见很直接这个写法适合面试时展示思路不适合任何生产环境。3. 实测结果与那些反直觉的发现3.1 基准数据对比表把上面几套方案在同样的数据和环境里跑完整理成一张表方案100万整数耗时内存增量输出顺序备注indexOf 双循环约 5800ms约 10MB保持原顺序平方级复杂度数据量再翻倍基本无法使用filter indexOf约 6700ms约 10MB保持原顺序比手动双循环还慢回调有额外开销Set约 57ms约 20MB保持原顺序综合最优工程首选Map约 68ms约 26MB保持原顺序适合顺带做计数或记录位置Object.create(null)约 75ms约 35MB保持原顺序速度尚可但类型和原型坑太多排序 相邻去重约 200ms约 8MB排序后输出内存友好且能直接产出有序结果这些数字在我的机器上是稳定的换到不同 CPU 会有浮动但相对关系基本不会变。3.2 字符串数据和对象数据的差异整数哈希很快因为数字在 V8 里可以直接参与哈希计算。换成一串 812 位随机字符串后所有方案都变慢了Set 大约 130 毫秒Object 大约 155 毫秒排序方案大约 320 毫秒。原因是字符串哈希需要遍历字符本身开销更大排序时字符串比较也比数字慢。但方案之间的顺序关系没有变化。对象数组的情况更麻烦。直接用 Set 去重等价于按对象引用去重两个内容相同的不同对象不会被合并。很多人期望的是“按某个字段去重”比如用户列表里按手机号去重。这时候正确做法是用 Map把手机号当 keyfunction dedupeByField(arr, field) { const seen new Map(); const result []; for (const item of arr) { const key item[field]; if (!seen.has(key)) { seen.set(key, true); result.push(item); } } return result; }如果试图用对象键值对去重对象数组会出大问题所有对象字符串化后都是[object Object]一轮跑完数组里就剩一条数据。这个坑我在代码评审里见过不止一次。3.3 为什么 Set 不总是绝对最优Set 是多数场景下的最优解但不是全部。有几个反直觉的例外值得记住数据已经有序或者你希望输出排序结果。排序方案能在去重的同时完成排序一次遍历解决两个需求整体性价比更高。数据是范围有限的非负整数。比如用户 ID 或渠道编号范围在 0100 万以内。可以用位图或者Uint8Array做标记速度和内存都能跑赢 Set。内存极度紧张又接受乱序输出。排序方案的内存占用通常比 Set 更低因为不会维护巨大的哈希表。位图法是个彩蛋代码极短function dedupeBitmap(arr, maxValue) { const seen new Uint8Array(maxValue 1); const result []; for (const v of arr) { if (!seen[v]) { seen[v] 1; result.push(v); } } return result; }假设数据范围是 0999999这个Uint8Array只占约 1MB 内存去重 100 万整数实测 30 毫秒以内连 Set 都要让位。缺点是只能处理非负整数数据范围不能太大否则内存随范围线性上涨。这是一个典型的“了解数据形态之后可以超越通用方案”的例子。4. 针对不同场景的去重选型与调优4.1 选型速查表把经验浓缩成一张速查表供后续直接参考你的数据长什么样推荐方案核心原因纯数字/字符串量大要求保持原顺序SetO(n)结构轻语义符合直觉需要统计出现次数/记录位置Map在去重的同时多存一份信息输出需要排序排序 相邻去重排序和去重一次完成非负整数且范围有限Uint8Array 位图法时间和内存都最优对象数组按字段去重Map 以字段值为 key对象引用去重不符合业务语义老版本浏览器兼容Object.create(null) 或 Polyfill没有原生 Set 可选时的折中注意表格里最后一行如果项目要兼容很老的环境Set可以通过 polyfill 换成哈希实现但原生性能会打折。真正老的项目里Object.create(null)反而是相对稳妥的方案。4.2 内存与 GC 怎么把控百万级数据去重时间只是表面问题内存才是隐藏杀手。Set 在去重 100 万数字时增量内存大约 20MB看起来还好但如果数据是 100 万个大字符串单个字符串几十甚至几百字节内存占用立刻放大。以下几点建议都是实操出来的能只存一个字段就不要把整行数据放进去。比如日志去重时先抽取出 message 字段去重不要先把整条 JSON 扔进 Set。如果原始数组不再需要处理完后把源变量置为null给 GC 释放空间的机会。arr [...new Set(arr)]这种写法尤其要注意旧数组在赋值完成前不会被回收峰值内存是双份。分批处理。10 万一批做完后把引用清掉再处理下一批避免一次性冲击堆内存。对百万级数据来说分十批跑和一批跑完在结果上没区别但内存曲线平滑很多。数据来源是文件时优先用流式读取和逐行处理而不是一次性把整个文件载入内存。这个看似和去重无关但往往是真正卡掉内存的元凶。4.3 性能测试的正确姿势我自己写基准测试踩过不少坑最典型的是“第一次跑出几十毫秒第二次变了几百毫秒”或者完全反过来。想拿到可信结果至少要这么做先跑一轮预热让 V8 的 JIT 把热点函数编译成机器码再开始计时。同一个方案至少跑 5 次取中位数而不是平均值。平均值容易受到偶发 GC 停顿影响。计时器用performance.now()它返回的是高精度时间戳别用Date.now()。内存使用用process.memoryUsage()看 heapUsed 增量别只看任务管理器。测试数据要贴近真实。如果直接用 0999999 的顺序整数构造数组很多哈希实现会走特殊优化成绩会比真实场景好看得多。4.4 工程化示例日志清洗中的去重用一个完整的例子收尾这一节。假设场景是从上游拿到一百万条原始日志需要去重后再入库同时要把每条日志首尾空白去掉再判断是否重复function cleanLogs(rawLogs) { const seen new Set(); const result []; for (let i 0; i rawLogs.length; i) { const normalized rawLogs[i].trim(); if (!seen.has(normalized)) { seen.add(normalized); result.push(rawLogs[i]); } } return result; }这里有个细节判定是否重复用的是normalized但向结果数组推入的是原始rawLogs[i]这样能保留第一条出现的原始格式。如果业务要求输出清洗后的格式替换一下就变成result.push(normalized)。类似地如果判定规则是忽略大小写可以把normalized rawLogs[i].trim().toLowerCase()。这类归一化去重在真实项目里非常常见。5. 常见问题与避坑实录5.1 Set 明明很快为什么你的代码还是卡见过好几个同事反馈“Set 去重百万数据依然要好几秒”最后定位到原因各不相同。列几个高频原因数据源不是一个简单的原始值数组而是一个很大的对象数组。Set 在比较对象引用等于一百万个对象都要先放进哈希表再逐个判定对象哈希的开销远高于原始值。使用了箭头函数和展开运算符做了多层包装虽然复杂度不变但会让 JIT 的优化路径变差常数项变大。数据是从文件流读取的耗时其实在读取和解析不在去重本身。这种时候要先 profile别没测量就优化。数组遍历用了for...of。for...of迭代器比普通 for 循环慢一些对一百万循环体量会产生可见差距。追求极致性能时优先使用下标 for 循环。const seen new Set(); for (let i 0; i arr.length; i) { seen.add(arr[i]); }这段看起来琐碎但每毫秒都是省出来的。5.2 NaN 和类型陷阱Set 能正确去重NaN但对象键值对不行。原因是对象 key 会把NaN字符串化成NaN于是数组中的NaN和字符串NaN会发生冲突。类似的问题还有undefined变成undefinednull变成null。如果数据结构里混着这些特殊值强烈建议使用 Set 或 Map而不是自己维护对象哈希表。另一个是1和1的问题。Set 认为它们不同对象键认为它们相同。业务上到底该不该合并取决于规则。数据来自不同系统时数字 ID 和字符串 ID 混存的情况不算少见这种时候对象键方案的“隐式合并”可能刚好是你想要的也可能是个定时炸弹。关键是意识到这套隐式规则而不是默默踩进去。5.3 对象数组按属性去重时最隐蔽的错误按字段去重常见的错误是把整个对象存进 Setconst seen new Set(); const result arr.filter(item { if (seen.has(item)) return false; seen.add(item); return true; });这样写等于按引用去重两个内容一样的对象会被保留达不到业务效果。正确做法我在 3.2 节给过是把字段值作为 Map 的 key。这里再补充一个提示如果字段值本身需要归一化比如手机号可能有多余空格或者区号前缀一定要先在 key 上做归一化否则还是去不干净。5.4 内存峰值高到爆怎么办一个特别常见的场景从接口拿到 100 万个对象想按某个字段去重如果直接把原数组和 Set 同时保留内存峰值可能轻松超过 500MB。处理思路是别急着构建结果数组而是先建立索引字段的 Set然后再筛选原数组。或者干脆遍历一次在遍历过程中直接按字段把对象归组。如果数据来源是文件用流式解析配合函数式去重把内存峰值控制在恒定水平。提示遇到内存问题时先用node --max-old-space-size4096调整堆上限定位问题但最终方案一定是减少同时存活的数据量而不是单纯调大内存。5.5 去重后顺序问题引发的逻辑 bugSet 保留的是元素第一次出现的位置排序方案输出的是排序后的顺序。如果下游依赖原数组顺序排序方案不能直接用。我在一个统计报表项目里就遇到过之前用 Set 去重后来为了“顺便排序”改成排序方案结果导致导出的明细顺序和用户操作时间完全对不上最后排查到问题后改回了 Set。6. 说回最初的那个场景开头提到的那个同事最终把filter indexOf换成了 Set 版本接口响应从接近七秒压到一百毫秒以内CPU 占用也明显降了下来。这个处理过程其实没有什么高明的地方唯一的改变是遇到大数据量时先把复杂度账算清楚。在我个人的项目里除非有特殊需求否则默认方案就是 Set 加 for 循环。数据形态明确是非负有限整数时我会选择Uint8Array位图法速度和内存双赢。需要排序输出时排序加相邻去重是顺手的事。再往上如果数据量到千万级或者单机内存真的吃紧就该考虑分批和流式方案了。去重这个问题看着基础但每次深挖都能发现新细节。希望这篇实测记录能帮你省下一些排查时间。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

杰文斯悖论:效率提升为何反而推高总资源消耗? 2026/9/30 5:55:08

杰文斯悖论:效率提升为何反而推高总资源消耗?

最近开发群里有人在问“Jev到底是个什么东西?”,我第一反应是又出了什么新框架或者新工具,结果翻了一圈资料才发现,大家讨论的其实是经济学里那个老掉牙的概念——杰文斯悖论(Jevons Paradox),简…

阅读更多 →
大模型工具调用实战:从协议原理到多模型适配的工程避坑指南 2026/9/30 5:55:05

大模型工具调用实战:从协议原理到多模型适配的工程避坑指南

工具调用这件事,表面上看就是让模型输出一段结构化 JSON,然后你的代码去执行对应函数。但真到生产环境里跑一圈,你会发现坑远比想象中多:模型偶尔给你编一个不存在的函数名、参数类型对不上、多轮对话里工具结果塞回去之后模型开始…

阅读更多 →
RNA-seq转录本组装评估:GFFcompare分类码与六级指标解读 2026/9/30 5:55:04

RNA-seq转录本组装评估:GFFcompare分类码与六级指标解读

做 RNA-seq 转录本组装的人,几乎都会撞上同一个尴尬场面:StringTie 或者 Cufflinks 跑完,手里多了一个几百兆的 GTF 文件,打开一看全是 TCONS_00000001、STRG.1234 这种流水号,既没基因名也没功能注释。你盯着这堆编号…

阅读更多 →
免费模型误读cron表达式?Agent定时任务的三道防线 2026/9/30 5:55:02

免费模型误读cron表达式?Agent定时任务的三道防线

下午排查自养 Agent 的定时任务模块时,翻到一条让我哭笑不得的执行日志:我让免费模型解释一条 cron 表达式,它居然一本正经地回我“这个 cron 表示每天的 7 点到 7 点执行任务”。我当时就愣住了,7 点到 7 点?那到底是…

阅读更多 →
Apache APISIX AI网关:大模型接入的生产级流量治理实践 2026/9/30 5:55:00

Apache APISIX AI网关:大模型接入的生产级流量治理实践

我上个月帮一家创业团队做大模型接入链路的技术评审,发现他们的架构还停留在"两个Python服务包打天下"的阶段:一个负责存API Key、一个负责转发请求,高峰期还要手动重启任务。我在白板上把链路画出来,绕了三个圈又回到了…

阅读更多 →
DeepSeek+Ollama+Dify本地知识库搭建全流程与报错排查指南 2026/9/30 5:54:53

DeepSeek+Ollama+Dify本地知识库搭建全流程与报错排查指南

这两年大家讨论AI本地化的时候,绕不开一个组合:DeepSeek Ollama 知识库。前几天我把整套方案在自己电脑上完整跑了一遍,从模型引擎到知识库检索,再到接入个人文档,中间踩了三个报错,折腾了整整一个晚上。…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉