10亿条ID数据去重,这问题看着像个面试题,其实特别贴近实战。你去任何一家有点体量的公司,做数据清洗、用户标签、消息推送、风控名单比对,早晚都得碰上这种量级。一条ID哪怕只占8字节,10亿条就是7.45GB,这还没算Java对象头、String对象开销这些乱七八糟的东西。单机JVM堆内存动辄8G起步,但你可不能真把一个7.5GB的裸数据全load进堆里,那GC直接能把服务拖死。
所以这个问题的本质不是“如何写一个去重函数”,而是“在内存装不下的时候,怎么用合理的代价把问题拆掉”。我见过不少刚工作一两年的同学,一上来就说“我直接用HashSet,10亿条也就几个G,服务器内存大没事”。这句话有几个问题:ID不一定是纯数值,可能是字符串UUID,内存翻几倍很正常;就算内存勉强够,HashSet的扩容、hash冲突、GC暂停都够你喝一壶;而且很多时候你面对的不是一个文件,而是分布在多台机器上的离线数据。所以这道题真正的考点是——你是否理解“数据量超出单机内存时,如何通过分治、压缩、允许误差、或分布式手段来解决问题”。
下面我按方案一个个拆,从原理到代码思路到踩坑,全部讲透。
1. 先算一笔账:10亿条ID到底是什么概念
1.1 不同类型ID的容量估算
做技术方案不能凭感觉,先拿计算器说话。我们假设ID是常见的几种类型:
| ID类型 | 单条原始大小 | 10亿条裸数据 | 放入HashSet后的估算内存 |
|---|---|---|---|
| int(自增主键) | 4字节 | 约3.73GB | 底层数组+节点开销,通常10GB以上 |
| long/bigint(雪花ID等) | 8字节 | 约7.45GB | 通常20GB以上 |
| String UUID | 36字节(不含引号) | 约33.5GB | 通常50GB以上 |
| String 手机号 | 11字节(纯数字) | 约10.2GB | 通常25GB以上 |
这里“放入HashSet后的估算内存”比裸数据膨胀很多,原因是Java/HashSet底层不是直接把元素连续排列,而是用Node对象+数组+链表/红黑树,每个Node还有next指针、hash值等额外字段。一条String UUID实际占用的内存可能在70到100字节,比原始36字节翻了一倍多。
所以你会发现一个冷酷的事实:即便是最便宜的int型ID,10亿条数据用单纯HashSet去重,内存也已经很紧张了。如果是UUID那种字符串,单机直接HashSet基本等于宣判死刑。
1.2 三个去重场景要区分开
在做技术选型之前,我习惯把需求先问清楚,因为“去重”这个词实在太笼统。同样叫去重,背后完全可能是三种需求:
- 只要一个去重后的数量。比如用户访问量UV统计,只需要知道多少个不同的ID,不需要把ID列表输出。这种情况可以用极省内存的近似算法,比如HyperLogLog,也可以允许小误差时用Bloom Filter。
- 只要判断某个ID是否出现过。比如反作弊名单判断、URL是否已抓取。这是一个“查询是否已存在”的场景,Bloom Filter特别合适,还能接受“小概率误判”。
- 要输出完整的去重后数据集。比如清洗一份10亿条的投放ID表,要求把重复的去掉,最后产出一份新的文件。这种情况要求精确,不能牺牲准确性,就必须走精确去重方案。
不同类型的需求,技术选型完全不同。很多人一上来就掉进“怎么在内存里去重”的坑,其实真正应该先问的是“业务允许误差吗?要数量还是要明细?”。搞清楚了这些,后面方案就好选了。
1.3 这个问题真正的约束条件
抛开源码层面,你会发现处理10亿条数据去重的本质约束就三个:内存、磁盘IO、CPU时间。这三个资源你在任何一台服务器上都不可能无限拿。
- 内存是最贵的,往往你只能分到几个GB给一个任务。
- 磁盘IO是另一个瓶颈,如果方案设计成多轮读写,时间成本直线上升。
- CPU反而相对充裕,hash计算的成本可以忽略不计。
因此,一个好的去重方案,本质是在这三个约束里找平衡。要么牺牲内存换时间,要么牺牲时间换内存,要么牺牲一点准确性换资源。理解了这一点,再看下面几个方案,思路会清晰很多。
2. 方案一:BitMap位图法,极限压缩内存
2.1 基本原理
BitMap的思路极其朴素:一个bit只存0或1,用bit的位置代表ID值,bit的值代表这个ID是否出现过。
比如ID范围是0到7,我们只需要申请8个bit的内存,初始全部为0。来了一个ID=3,就把第3个bit置为1。查询ID是否出现过,直接看对应bit是0还是1。去重过程就是把所有ID逐个“画”到位图上,最后数一下有多少个bit是1,或者遍历一次把所有为1的bit转成ID输出。
这种方案最暴力的一点是内存占用极低。我们算一下:如果有10亿个连续的ID(0到999999999),需要的bit数是10亿个,也就是10亿/8 = 1.25亿字节,约119MB,不到0.12GB。10亿条数据,一百多MB就能搞定,这个内存占用非常夸张。
2.2 适用前提是ID密集且为数值
BitMap最大的限制在于:它要求你能提前知道ID的最大值,而且ID范围不能太稀疏。
假设ID不是连续的,而是随机的64位长整型,最大值可能接近2的63次方。我们要申请这么大的bit数组,就算地球上所有内存都给你也装不下。所以BitMap只适合那种ID相对密集、无符号整数、范围可预估的场景,例如自增主键、用户ID在某个区间段内基本连续。
如果ID是UUID那种字符串,BitMap压根没法直接用,你需要先把UUID映射成一个密集的整数ID。但如果你已经有这个映射关系,那何必还去重?这明显是个鸡生蛋问题。所以实际工作中BitMap用到的场景比想象中少,更多时候我们会先做一层归一化,把字符串ID哈希成数值再分段。
2.3 代码思路与关键点
用Java结合RoaringBitmap这类压缩位图库来写最方便,RoaringBitmap内部会把稀疏的bitmap自动拆成小块,内存优化比裸的BitMap好很多:
import org.roaringbitmap.RoaringBitmap; public class BitMapDedup { public RoaringBitmap dedup(long[] ids) { RoaringBitmap bitmap = new RoaringBitmap(); for (long id : ids) { if (id >= 0 && id <= Integer.MAX_VALUE) { bitmap.add((int) id); } else { throw new IllegalArgumentException("RoaringBitmap for int range, use long bitset instead"); } } return bitmap; } }RoaringBitmap的优势是它内部用了分块策略,数据稀疏时不会申请一整块大数组,而是用数组或run容器来压缩,比裸的java.util.BitSet更省内存。但无论如何,ID的可枚举性和范围是这个方案的生命线,脱离了这一点,BitMap再厉害也发挥不出来。
3. 方案二:Hash分治 + HashSet,正统且精确
3.1 核心思想:把大问题切成能装进内存的小问题
假设我们现在面对一份10亿条记录的文件,单机内存不足,但又要精确去重、输出完整结果。最正统的办法不是硬扛,而是分治。
原理很简单:给每条ID计算一次hash值,然后对N取模,根据结果把这条ID写入第N个小文件。同一个ID的hash值是固定的,所以它永远只会进入同一个小文件。这样,不同小文件之间不可能存在重复的ID,我们只需要对每个小文件分别去重,再把所有小文件的去重结果合并,就是全局去重后的结果。
这个思路对应了那句面试金句——“相同的元素一定会被分到同一批”。它把“对10亿条全局去重”这个大问题,拆成了“对N个百万级小文件分别去重”的小问题,每个小文件完全可以在内存中用HashSet解决。
3.2 分片数N怎么定
分片数N的选择是个关键。假设我们每个小文件去重时最多用512MB内存,每条ID在HashSet中的开销按50字节算,那么一个小文件最多装1000万条左右。10亿条数据就至少需要100个分片。稳妥起见,我通常会把N设成200到500,这样每个小文件只有200万到500万条,内存余量很充足。
还要考虑的另一个因素是文件句柄数。如果你一次性打开500个小文件写数据,操作系统默认的文件句柄限制通常是1024,虽然够用,但如果后续任务并行度再高一点,可能就超限了。所以我通常会控制同时打开的文件句柄,或者分批写入,边写边关,避免踩到这个坑。
3.3 组内去重与结果合并
小文件生成之后,对每个小文件做一次精确去重。这里手段就很多了,最简单的是读入HashSet,或者用排序去重。我一般推荐排序去重,因为排序好了之后合并阶段处理也方便,而且跨小文件的全局有序在后续输出时更友好。
合并阶段更简单:N个小文件之间不存在交叉重复,直接按文件顺序拼接输出即可。如果你希望最终文件是有序的,那就需要先保证每个小文件内有序,再做多路归并排序。这一步会多花一些IO时间,但能换来下游处理效率的提升,值不值取决于业务。
3.4 实操时的几个关键细节
分治做法看起来简单,真正落地时坑并不少。我逐一列一下:
hash函数选择与取模符号。Java里Object.hashCode()可能返回负数,如果直接对N取模,会出现负数下标,导致文件写入混乱。正确做法是(hash & 0x7fffffff) % N,先把符号位去掉再取模。
小文件输出前要不要缓冲。10亿条ID散列到几百个小文件时,如果每条都直接写FileOutputStream,性能会很差。建议用BufferedOutputStream或更大的缓冲区,比如8KB或者64KB,减少系统调用次数。
二次去重时不要重复读全量。有的同学会把所有小文件再全部读一遍,或者不删中间文件导致磁盘占用翻倍。我建议流程中就直接把小文件作为中间产物,去重完一组删一组,保证磁盘占用始终可控。
Hash碰撞不是问题。这里要澄清一个容易混淆的概念:我们说的hash分治不是用hash值相等来判断元素相同,而是用hash分片保证“相同元素进同一个文件”。即使两个不同ID碰巧hash值相等,也只会在同一个文件里出现,最终由文件内的HashSet精确判断是否相同,不会影响结果正确性。
3.5 代码骨架
下面是一个足够清晰的伪代码/核心逻辑示例,可以直接按这个思路改造:
public class HashShardingDedup { public static final int SHARD_NUM = 200; public void shard(String inputPath, String outputDir) throws Exception { File dir = new File(outputDir); if (!dir.exists()) dir.mkdirs(); BufferedWriter[] writers = new BufferedWriter[SHARD_NUM]; for (int i = 0; i < SHARD_NUM; i++) { writers[i] = new BufferedWriter(new FileWriter(outputDir + "/shard_" + i + ".txt")); } BufferedReader reader = new BufferedReader(new FileReader(inputPath)); String line; while ((line = reader.readLine()) != null) { String id = line.trim(); int shard = (id.hashCode() & 0x7fffffff) % SHARD_NUM; writers[shard].write(id); writers[shard].newLine(); } for (BufferedWriter writer : writers) { writer.close(); } reader.close(); } public void dedupEachShard(String outputDir, String finalPath) throws Exception { BufferedWriter finalWriter = new BufferedWriter(new FileWriter(finalPath)); for (int i = 0; i < SHARD_NUM; i++) { File shardFile = new File(outputDir + "/shard_" + i + ".txt"); BufferedReader reader = new BufferedReader(new FileReader(shardFile)); HashSet<String> set = new HashSet<>(); String line; while ((line = reader.readLine()) != null) { set.add(line.trim()); } for (String id : set) { finalWriter.write(id); finalWriter.newLine(); } reader.close(); // 注意:这里可以边处理边删除该小文件,节约磁盘 shardFile.delete(); } finalWriter.close(); } }这段代码大约在10亿条级别、N=200的情况下怎么跑都能内存安全。实际生产里你会用Spark或者MapReduce去实现同样的分治逻辑,但核心思想一模一样。
4. 方案三:Bloom Filter,允许误差时的极致压缩
4.1 原理:一个位数组加多个hash函数
如果你只需要“判断某个ID是否出现过”,而且可以接受“小概率把没出现过的ID误判成出现过”,那么Bloom Filter几乎是内存效率最高的方案。
Bloom Filter的原理不复杂。假设我们有一个长度为m的位数组,初始全为0。来了一个元素,我们用k个hash函数分别计算它,得到k个位置,将这些位置都置为1。判断一个元素是否存在时,同样计算这k个位置,如果全部为1,说明这个元素大概率出现过。只要有一个位置是0,就说明这个元素一定没出现过。
这个结构的神奇之处在于,它不需要存储原始ID,只存一个位数组,所以内存非常省。代价是存在误判率,也就是某个元素明明没出现过,但它的k个位置恰好都被其他元素置成了1,导致被误判成“出现过”。这种情况叫false positive,业务上能不能容忍,是选型前要决策好的。
4.2 内存量化计算
Bloom Filter的几个参数有现成的公式。根据误判率p和预计数据量n,可以算出最优的位数组长度m和hash函数个数k:
- m = - (n * ln(p)) / (ln(2) 的平方)
- k = (m / n) * ln(2)
以n=10亿、p=1%为例,ln(0.01)约等于-4.605,ln2平方约等于0.4805,代入公式:
m = - (10亿 * -4.605) / 0.4805 = 45.05亿 bit ≈ 536MB
k = (45.05亿 / 10亿) * 0.693 ≈ 3.1,取整为3个或4个hash函数。
也就是说,10亿条ID,用536MB左右的内存,就能把误判率压在1%以内。比起动辄几十GB的HashSet,这个内存开销几乎可以忽略。如果把误判率放宽到5%,内存还能降到350MB左右;如果业务要求极其严格,p降到0.01%,内存大约在1.8GB左右,仍远低于HashSet。
4.3 适用场景与不适用场景
Bloom Filter最适合的场景是“判存在”而不是“收集明细”。经典案例包括:
- 爬虫URL去重:判断一个URL是否已经抓过,偶尔漏抓一个,下次再抓就是,问题不大。
- 推荐系统已读内容过滤:误判为已读最多导致一条内容不展示,业务影响很小。
- 风控黑名单预过滤:先快速过滤掉确定安全的ID,剩下的再走精确查询,整体性能提升巨大。
- 数据库和存储引擎的bloom filter索引,比如LevelDB、RocksDB,都是这个思想,避免无效磁盘IO。
不适用场景也很明确:如果你是要“输出一份去重后的清单”,任何一个ID都不能被误杀,Bloom Filter就绝对不能作为唯一的去重结构。这时候可以换一种思路,用Bloom Filter做第一轮预过滤,把绝大多数重复项先挡掉,剩下的小规模“疑似重复”数据再精确去重,这种混合玩法在工业界也特别常见。
4.4 Java里的实现方案
Java生态里推荐的库是Google Guava的BloomFilter。它封装了位数组、hash函数和调参逻辑,用起来很简单,还支持通过Funnel自定义对象的hash方式。
import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.nio.charset.StandardCharsets; public class BloomFilterDemo { public static void main(String[] args) { long expectedInsertions = 1_000_000_000L; double fpp = 0.01; BloomFilter<String> filter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedInsertions, fpp ); // 模拟写入 for (int i = 0; i < 10_000_000; i++) { filter.put("id_" + i); } // 模拟判断 System.out.println(filter.mightContain("id_9999999")); // 大概率true System.out.println(filter.mightContain("id_not_exist")); // 大概率false } }这里需要注意的是,BloomFilter一旦创建后不支持删除元素,因为多个元素共享同一个bit位置。如果业务上有删除ID的需求,那得考虑Counting Bloom Filter(每个计数器有额外计数)或者直接换方案。
5. 方案四:外部排序去重,磁盘换内存的通用兜底
5.1 思路:排序后重复项自然相邻
如果你不想引入分片逻辑,也不想依赖分布式框架,那还有一个老派但非常可靠的方案:外部排序(External Sort)。
核心思想:把一个大文件切成若干个能装进内存的小块,对每块在内存中排序并写回磁盘,然后用多路归并的方式把所有有序的小块合并成一个全局有序的大文件。一旦文件全局有序,去重就变得无比简单——只需要顺序扫描,比较当前元素和上一个元素是否相同,不同就保留,相同就跳过,一趟即可完成。
这个方案的优点在于通用性和精确性。它不要求ID是数值、不要求范围连续,任何可比大小的字符串、数值都能处理。缺点也很明显,就是慢。10亿条数据做外部排序,会产生大量磁盘IO读写,时间成本比BitMap和Hash分治高不少。
5.2 与Hash分治对比:谁优谁劣
Hash分治和外部排序都算精确去重方案,区别在于:
- Hash分治:用hash打散,让“相同元素必然进同一分片”,分片内去重即可,IO次数少,速度快。
- 外部排序:先全局排序或小分块排序,再归并,IO量更大,但胜在不需要设计hash函数,对元素类型没有要求。
数据分布较均匀、ID类型可hash时,我推荐Hash分治。如果ID有严重倾斜,比如某个固定前缀占了90%,那hash分治会导致某个小文件特别大,局部内存溢出的风险反而更高。此时外部排序反而更稳定,因为切分小文件时可以按大小均匀切,不受ID分布影响。
5.3 实战步骤与Java思路
第一步,按固定行数或固定字节数切分大文件为多个小块,保证每个小块能在内存中排序。10亿条数据,按每个小块500万条切,大约切成200块。
第二步,对每块在内存中排序,排序结果写回磁盘,块内有序。
第三步,使用堆(PriorityQueue)做多路归并,每次从所有块中取出当前最小的ID写入输出,同时从对应块取下一个ID。去重逻辑放在输出阶段即可,相同ID只输出一次。
这里可以把多路归并去重的核心代码简化为:
public void mergeDedup(List<BufferedReader> shardReaders, BufferedWriter writer) throws Exception { PriorityQueue<Element> heap = new PriorityQueue<>(Comparator.comparing(e -> e.value)); for (int i = 0; i < shardReaders.size(); i++) { String line = shardReaders.get(i).readLine(); if (line != null) { heap.offer(new Element(line, i)); } } String last = null; while (!heap.isEmpty()) { Element e = heap.poll(); if (!e.value.equals(last)) { writer.write(e.value); writer.newLine(); last = e.value; } String next = shardReaders.get(e.fileIndex).readLine(); if (next != null) { e.value = next; heap.offer(e); } } writer.flush(); }外部排序整体实现复杂度比Hash分治高,但也是一个非常经典的兜底方案。当你有现成的sort命令可以用时,直接sort -u file能解决大部分中小规模问题,对于几百GB级别,则建议用Linux的高端sort参数调整内存和临时目录各区。
6. 方案五:上分布式计算框架,换个人干这活
6.1 什么时候必须用分布式
看到10亿条数据,总有人觉得必须上大数据框架。但我的判断标准是先看数据规模和可用资源。如果单机几百GB内存、几个CPU,其实上面的Hash分治、外部排序已经能搞定,没必要非上Spark。但当以下条件出现时,建议直接上分布式:
- 数据量达到TB级,单机磁盘IO已经成为瓶颈。
- 数据不在一个文件里,而是分布在几十台甚至上百台机器上。
- 业务要求实时或准实时处理,单机跑批时间无法接受。
- 团队已经有现成的大数据集群,自研单机方案维护成本反而更高。
“换个人干这活”指的是把去重逻辑交给分布式计算框架,让框架调度多台机器并行处理。最常见的做法是用Spark、Hive或MapReduce。
6.2 Spark/Hive里去重这么写
如果是离线批处理,Hive SQL和Spark SQL都很简单:
-- 对全表id去重 SELECT DISTINCT id FROM your_table; -- 或者按id分组取一条 SELECT id FROM your_table GROUP BY id;Spark DataFrame API则通常这样写:
val df = spark.read.parquet("hdfs://path/to/raw_data") df.select("id").distinct() .write.mode("overwrite") .parquet("hdfs://path/to/dedup_data")这些框架内部做了大量优化,例如map端聚合、combiner、shuffle时的分区和排序。你基本不用操心内存问题,只需关注资源参数的配置,比如executor内存、shuffle分区数、并行度等。
6.3 数据倾斜问题
上分布式框架之后,最经典的问题就是数据倾斜。如果某个ID重复率特别高(比如一亿条数据都是同一个ID),或者hash分区后某个key占比极大,那么负责处理那个分区的task就会负载过重,其他task早执行完了,整个Job卡在最后一个task上。
解决思路有几个:
- 加盐(salting):对ID加随机前缀打散后再去重,去重完再清理盐值。
- 调整分区数:增加shuffle分区数,让每个分区的数据量更小。
- 使用Hive的
GROUP BY加skew处理参数,或者在Spark里用repartition重新平衡。
这里我提一下加盐的技巧。比如你有一个异常高热的ID,正常分区时所有记录都进了一个分区,你可以先改成concat(id, '_', rand())进行第一次分组,把数据随机散开,最后再去掉盐再做一次group by。代价是两轮计算,但能显著均衡负载。
6.4 分布式也不是万能药
分布式框架解决了容量问题,但引入了新问题:资源管理、网络传输、任务调度。一个10亿条的去重任务,如果单机方案只要20分钟,但Spark集群的排队、调度、shuffle可能需要40分钟,那分布式反而更慢。所以小数据量时别迷信分布式,先用单机方案更经济。
7. 方案对比与选型决策表
我平时遇到这类问题,会先用一个矩阵把事情理清楚。以下表格是我个人的经验总结,适合直接把需求参数套进去做决策:
| 方案 | 内存占用(10亿条估算) | 精确性 | 速度 | 适用条件 | 典型场景 |
|---|---|---|---|---|---|
| BitMap/RoaringBitmap | 0.15GB左右 | 精确 | 极快 | ID为连续/可枚举数值 | 用户ID去重、UV统计 |
| Hash分治+HashSet | 可控(分片后每片<1GB) | 精确 | 快 | 任何可hash的ID,数据量大 | 离线清洗、文件去重 |
| Bloom Filter | 0.5GB左右(误判率1%) | 有误判 | 极快 | 只需判存在,容忍小概率误判 | URL去重、黑名单预过滤 |
| 外部排序去重 | 可控(分块排序) | 精确 | 慢(磁盘IO为主) | 任意可比数据 | 通用兜底方案 |
| Spark/Hive/MapReduce | 取决于集群资源 | 精确 | 取决于集群规模 | 数据分布在分布式存储上 | 大数据离线ETL、数仓去重 |
选型时我一般会问自己三句话:
第一句,这个ID集合的最大取值空间是多少?连续吗?如果连续就优先考虑位图。
第二句,业务允不允许误差?如果允许1%以内的误差,Bloom Filter的内存优势太大了。
第三句,输出要明细还是要数量?要明细就只能精确去重,Hash分治或外部排序二选一。
这三句话问完,基本能定位到1到2个方案。如果实在纠结,选Hash分治永远不会错,它足够通用、实现简单、性能合理,是这一类问题的最稳妥答案。
8. 实操踩坑记录:那些年我在这类任务上交过的学费
8.1 坑一:字符串trim不彻底导致去重失败
最隐蔽也最常见的坑。原始数据文件里ID可能有前后空格、换行符、\r,如果读入后不统一做trim(),一条ID是“10001”,另一条是“10001 ”(带个空格),明明是一个用户,却会被当成两个ID保留下来。处理10亿条数据时这种脏数据比例哪怕只有千分之一,都会白白多出几百万条“重复”。我现在的习惯是读入后统一line.trim(),并顺手用正则或字符串替换把不可见字符清掉。宁可多花一点CPU,也不想在结果评审时被业务方指着说“这俩ID明明是一个人”。
8.2 坑二:文件句柄超限
Hash分治时如果分片数N设得太大,比如5000个分片,一次性打开5000个BufferedWriter,大概率会碰到linux的“Too many open files”错误。虽然可以用ulimit -n调高限制,但更合理的做法是控制分片数在几百以内,或者采用“轮转写文件”的方式,每次只保持部分文件处于打开状态。文件句柄是很容易被忽略的系统资源,等到报错再慌就晚了。
8.3 坑三:中间文件不放干净
Hash分治会生成几百个小文件,外部排序会生成几十个有序块,如果程序中途挂了,这些中间文件会残留在磁盘上。10亿条级别下,这些中间文件可能轻松占掉几十GB甚至上百GB空间。我吃过一次亏,当时跑完就忘了清理,结果第二天磁盘写满,整个Hadoop节点告警。解决方案很简单:程序开始前检查并清理上次残留,程序结束后无论成功失败都执行清理逻辑。最好是代码里finally块统一处理,别指望人肉清理。
8.4 坑四:hash取模时踩了负数下标
Java的String.hashCode()返回int,可能为负。如果用id.hashCode() % N取分片下标,负值直接数组越界。我见到过不少新手在分片逻辑里踩这个坑,解决方案就是先(id.hashCode() & 0x7fffffff) % N,保证下标非负。另一个更稳妥的办法是用Guava的Hashing类里的一致哈希(consistent hash),但那个本身就返回一个无符号值,不需要额外处理。
8.5 坑五:Bloom Filter用错hash次数
有的同学从网上copy Bloom Filter代码时,不管数据量,直接固定用3个hash函数。这种做法在数据规模变化时很危险,k过大或过小都会让误判率急剧上升。正确的做法是根据n和p代入公式算出最优k和m。Guava的BloomFilter自动帮你做了这件事,所以尽量别自己手写一个,除非你要跟一个已有的位图结构兼容。
8.6 坑六:只关注去重,不关注排序
如果你下游要的是有序ID列表,而你去重后只做了无序输出,后面再想排序等于把10亿条数据重新折腾一遍,成本极高。所以分治时尽量让块内有序,归并时顺便做多路有序合并;或者Spark里直接用orderBy("id")。有时候同一份数据下游会有多个消费方,一份全局有序的去重结果能省掉无数后续麻烦。
8.7 坑七:忽略脏数据占比,导致估算失效
上面所有方案的内存估算都建立在“数据量是10亿条”这个基础上。如果原始数据里重复度极高,比如就100万条不重复ID,其余全是重复,那Bloom Filter的参数还是按10亿来设计,白白浪费内存。反过来,如果每条ID不是字符串而是带了很长的时间戳、JSON字段,那内存估算就要按字段全量重新算。实际操作时我会先用命令抽样或者Spark快速统计一下总量、重复率、最大长度,再做精准方案,而不是拿到需求就闷头开干。
我个人在实际操作中最常用的是“Hash分治+排序归并”的组合拳。先用hash打散成几百个小文件,每个小文件内排序去重,再做多路归并输出有序结果。这套方案不管数据是int还是string,不管ID分布是否均匀,都能把内存控制住,结果也是精确的。等小文件数量降到两位数的时候,再考虑直接全部读入HashSet做最终合并,性能通常都是分钟级别的事。
如果你正在准备面试,这道题建议按“先问清需求-再算容量-最后给方案”的步骤来回答。面试官真正想看到的不是你会背BitMap公式,而是你能不能在自己机器内存不足时,冷静地选择一个合理的工程方案,并且把边界条件、异常场景、资源估算全都考虑进去。如果是在实际工作中遇到,我建议先写个采样脚本,拿1万条数据跑一遍全流程验证方案可行性,再放心去跑10亿条。毕竟数据量越大,返工成本越高,先用小数据证明思路是对的,才是老工程师的做法。