| 方案 | 核心原理 | 100 亿数据内存占用 | 查询性能 | 适用场景 | 致命缺点 |
|---|---|---|---|---|---|
| HashSet | Java 原生哈希表 | >800GB(对象头 + 指针开销) | O(1) | 百万级以下 | 内存爆炸💥 |
| MySQL 唯一索引 | 数据库唯一约束 | 磁盘存储 | 100ms+ | 千万级以下 | 磁盘 IO 瓶颈,无法支撑高并发 |
| Redis Set | 内存哈希集合 | ≈160GB(Redis 编码优化) | O(1) | 亿级以下 | 成本高,160G 云 Redis≈2 万 / 月 |
| 布隆过滤器 | 概率型数据结构 | ≈11.5GB(误判率 0.01%) | O (k)(k 为哈希函数个数) | 百亿级及以上 | 存在极小误判率 |
关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。
关注公众号【Rain的Java大神之路】,回复“Java”即可免费领取,持续更新中。
对于 100 亿数据,误判率 p=0.01%:
m ≈ -n * ln(p) / (ln2)^2 ≈ 100亿 * 9.21 ≈ 921亿位 ≈ 11.5GBk ≈ m/n * ln2 ≈ 7个技术亮点:基于 Redis 官方模块,支持集群部署、持久化、动态扩容,避免 Guava 本地布隆过滤器的分布式一致性问题
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
@Component
public class RedisBloomOrderDeduplicator {
private static final String BLOOM_FILTER_KEY = "order:id:bloom";
private static final long EXPECTED_INSERTIONS = 10_000_000_000L;
private static final double FPP = 0.0001; // 0.01%误判率
@Resource
private RedissonClient redissonClient;
@Resource
private OrderRepository orderRepository;
private RBloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 获取或创建布隆过滤器,仅第一次创建时生效
bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_KEY);
bloomFilter.tryInit(EXPECTED_INSERTIONS, FPP);
}
/**
* 去重判断核心方法
* @return true: 重复, false: 不重复
*/
public boolean isDuplicate(Long orderId) {
// 1. 先查布隆过滤器(纯内存操作,微秒级)
if (!bloomFilter.contains(orderId)) {
// 2. 布隆过滤器说不存在,一定不存在,直接写入
bloomFilter.add(orderId);
return false;
}
// 3. 布隆过滤器说存在,才查数据库兜底(只有0.01%的请求会走到这一步)
return orderRepository.existsById(orderId);
}
/**
* 批量添加历史订单号(冷启动用)
* 技术亮点:使用Redis管道批量操作,性能提升100倍以上
*/
public void batchAdd(List<Long> orderIds) {
bloomFilter.add(orderIds); // Redisson内部已封装管道批量操作
}
}
技术亮点:按订单号前缀分片,每个分片一个独立布隆过滤器,将 11.5GB 大内存拆分为 10 个 1.15GB 小实例,解决 Redis 大 key 问题和单实例内存瓶颈
@Component
public class ShardedBloomOrderDeduplicator {
private static final int SHARD_COUNT = 10; // 分为10个分片
private final List<RBloomFilter<Long>> shards = new ArrayList<>(SHARD_COUNT);
@PostConstruct
public void init() {
for (int i = 0; i < SHARD_COUNT; i++) {
RBloomFilter<Long> shard = redissonClient.getBloomFilter("order:id:bloom:shard:" + i);
shard.tryInit(EXPECTED_INSERTIONS / SHARD_COUNT, FPP);
shards.add(shard);
}
}
private RBloomFilter<Long> getShard(Long orderId) {
// 按订单号最后一位分片,保证均匀分布
int shardIndex = (int) (orderId % SHARD_COUNT);
return shards.get(shardIndex);
}
public boolean isDuplicate(Long orderId) {
RBloomFilter<Long> shard = getShard(orderId);
if (!shard.contains(orderId)) {
shard.add(orderId);
return false;
}
return orderRepository.existsById(orderId);
}
}
技术亮点:多线程分页查询 + Redis 管道批量写入,将 100 亿数据加载时间从数天缩短至数小时
@Component
public class BloomFilterInitializer {
private static final int PAGE_SIZE = 10000; // 每次查询1万条
private static final int THREAD_COUNT = 20; // 20个线程并行加载
@Resource
private ShardedBloomOrderDeduplicator deduplicator;
@Resource
private OrderRepository orderRepository;
@Resource
private ThreadPoolTaskExecutor taskExecutor;
public void initAllHistoryOrders() {
// 1. 获取订单号最小值和最大值,避免全表扫描
Long minId = orderRepository.findMinId();
Long maxId = orderRepository.findMaxId();
long totalPages = (maxId - minId) / PAGE_SIZE + 1;
// 2. 多线程并行加载
CountDownLatch latch = new CountDownLatch((int) totalPages);
for (long page = 0; page < totalPages; page++) {
long startId = minId + page * PAGE_SIZE;
long endId = Math.min(startId + PAGE_SIZE - 1, maxId);
taskExecutor.execute(() -> {
try {
List<Long> orderIds = orderRepository.findIdsBetween(startId, endId);
if (!orderIds.isEmpty()) {
deduplicator.batchAdd(orderIds);
}
} finally {
latch.countDown();
}
});
}
// 3. 等待所有线程完成
latch.await();
log.info("布隆过滤器历史数据加载完成,共加载{}条订单", maxId - minId + 1);
}
}
| 技术难点 | 产生影响 | 解决方案 | 技术亮点 |
|---|---|---|---|
| 历史数据冷启动慢 | 系统启动时间长达数天,无法快速上线 | 1. 按 ID 范围分页查询2. 多线程并行加载3. Redis 管道批量写入4. 提前预生成布隆过滤器文件 | 加载速度提升 100 倍以上,100 亿数据可在 4 小时内完成加载 |
| 布隆过滤器扩容难 | 容量满后误判率急剧上升,无法继续写入 | 1. 双写切换扩容:创建新的更大布隆过滤器,同时写入新旧两个2. 分片扩容:新增分片节点,重新分配哈希槽3. 滚动扩容:逐个分片迁移数据,不影响业务 | 全程无停机,业务无感知,扩容期间性能无明显下降 |
| 分布式一致性问题 | 多实例本地布隆过滤器数据不一致,导致漏判 | 1. 统一使用 RedisBloom 分布式布隆过滤器2. 采用最终一致性模型,数据库唯一索引兜底3. 定期全量校验布隆过滤器与数据库数据 | 保证绝对不出现漏判重复,最终一致性 |
| 误判率控制 | 误判率过高会导致大量请求穿透到数据库 | 1. 精确计算布隆过滤器参数2. 监控实际误判率,超过阈值及时扩容3. 采用分层布隆过滤器:热数据用低误判率,冷数据用高误判率 | 实际误判率稳定在 0.01% 以下,数据库压力降低 99.99% |
| Redis 大 key 问题 | 11.5GB 大 key 导致 Redis 集群迁移、备份失败 | 1. 按订单号前缀分片,拆分为多个小布隆过滤器2. 每个分片大小控制在 2GB 以内3. 冷热分离:热数据放内存,冷数据放磁盘 | 解决 Redis 大 key 所有问题,集群稳定性大幅提升 |
| 高并发下的性能问题 | 高并发写入时布隆过滤器成为瓶颈 | 1. 本地缓存热点布隆过滤器分片2. 读写分离:读本地缓存,写 Redis3. 批量异步写入 Redis | 支持 100 万 + QPS,延迟稳定在 1ms 以内 |
答:不会影响业务正确性,因为还有数据库唯一索引兜底。误判只会导致极少量请求多查一次数据库,0.01% 的误判率对系统性能几乎无影响。
答:提前规划扩容,当填充率达到 70% 时,启动双写切换扩容流程,创建一个 2 倍容量的新布隆过滤器,同时写入新旧两个,待新过滤器数据同步完成后,切换读流量到新过滤器。
答:Roaring Bitmap 适合连续整数去重,而订单号通常是雪花算法生成的分布式 ID,分布稀疏。100 亿稀疏 ID 的 Roaring Bitmap 内存占用会达到 50GB 以上,远大于布隆过滤器的 11.5GB。
同学你好,咱们放松聊。先来个场景设计题:有100亿个订单号,需要做去重,你会怎么设计? 可以把思路逐步说清楚。
好的面试官。我先确认一下前提:订单号通常是 Long 型,8字节,100亿条原始数据大约 80GB,单机内存肯定放不下,所以直接用 HashSet 这条路直接堵死了。
我能先问几个问题帮我细化场景吗?
当然,你问。
好,假设订单号是雪花算法生成的 Long,分布很随机;精确性要求是必须精准,不能有漏网;然后咱们分别聊聊 离线批量 和 实时流 两种场景。
明白了。针对随机 Long 且必须精确,我脑中立刻拆成两套打法。我先说 离线批量去重。
核心思想是“分而治之”。单机装不下 80GB,那就把相同订单号哈希到同一台机器或同一个文件,再局部去重。
具体步骤:
hash(id) % N,均匀写入 N 个小文件(比如 N=1000)。同一个订单号必然落在同一个文件,且每个文件大约 80GB/1000 ≈ 80MB,完全能读进内存。HashSet 去重,或直接排序去重,得到纯净结果。清晰。那如果变成 实时流 呢?每秒几十万订单进来,还要精确去重,你怎么做?
实时就必须用 外部存储 + 快速判断 的架构了。我会用 布隆过滤器 + 分布式KV存储 的组合拳,核心是“布隆做高速闸门,KV 做精确审判”。
展开说说?
相当于布隆拦截了99.9%的查询,只有极少数会穿透到磁盘。
实时订单号 ──> [布隆过滤器] ──(可能存在)──> [HBase/RocksDB精确查重]
│ 绝对没有 │
↓ ↓
直接放行+写入布隆 存在→丢弃
不存在→插入并放行
你这布隆过滤器内存占用和误判率怎么权衡?100亿数据量下具体要多大?
如果允许误判率 0.01%,公式是 -n*ln(p) / (ln2)^2。100亿,p=0.0001,算出来大约需要 18GB 内存来存布隆的位数组。这个大小在一台高配机器上完全可以接受,甚至能用 Redis 集群的 Bitmap 分摊。误判率带来的穿透查询占比极小,对 HBase 压力可忽略。
那如果我不想依赖外部KV,或者要求极低延迟,有没有更“内存化”的精确方案?
那就看订单号有没有局部连续性。如果雪花算法的 Long 有一部分是时间戳,可以考虑 RoaringBitmap 这种压缩位图,它能压缩连续区间,内存占用远低于传统位图。但若完全随机,RoaringBitmap 压缩收益有限,还是得靠“布隆+KV”或分片状态存储。
像 Flink 的 KeyedState 结合 RocksDB 后端,每个订单号状态精确保留,同时利用 TTL 自动过期旧数据,也是不错的实时精确方案。
我把几种方案整理成一张表:
| 方案 | 精确度 | 内存/资源 | 延迟 | 适用场景 |
|---|---|---|---|---|
| 哈希分片+文件排序去重 | 100% | 分片文件,内存占用低 | 高(分钟级) | 离线 T+1 批处理 |
| 布隆过滤器 + HBase 兜底 | 100%(有布隆误判则穿透) | 布隆 ≈18GB,+HBase 集群 | 低(毫秒级) | 实时高吞吐 |
| Flink State(RocksDB) | 100% | 分布式状态后端 | 低 | 有状态流处理 |
| 单机 Bitmap | 100% | 连续ID ~2.5GB | 极低 | 稠密订单号 |
方案讲得不错,那能不能现场写一段核心代码?比如布隆过滤器 + Redis 的判重逻辑,或者分片去重的关键片段。用你擅长的语言就行。
好的,我用 Java 写一下布隆过滤器预判 + Redis 精确去重的核心逻辑,并加上 Lua 脚本保证原子性,这是有技术亮点的地方。
// 布隆过滤器(基于Guava)
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.forName("UTF-8")),
10_000_000_000L, // 预期插入量:100亿
0.0001 // 误判率 0.01%
);
// Redis Lua 脚本:原子性判断并插入
String luaScript =
"local exists = redis.call('GET', KEYS[1]) " +
"if exists then " +
" return 0 " + // 已存在,重复
"else " +
" redis.call('SET', KEYS[1], '1', 'EX', ARGV[1]) " +
" return 1 " + // 不存在,插入成功
"end";
// 去重主流程
public boolean isDuplicate(String orderId) {
// 第一步:布隆过滤器快速拦截
if (!bloomFilter.mightContain(orderId)) {
// 绝对不存在,直接插入布隆并放行
bloomFilter.put(orderId);
return false;
}
// 第二步:布隆返回“可能存在”,穿透到Redis精确查询
try (Jedis jedis = jedisPool.getResource()) {
// 用 Lua 保证 GET + SET 原子,避免并发重复插入
long result = (long) jedis.eval(luaScript,
Collections.singletonList(orderId),
Collections.singletonList("604800") // TTL 7天
);
if (result == 1) {
// 确实不重复,同步更新布隆过滤器
bloomFilter.put(orderId);
return false;
}
return true; // 重复
}
}
GET + SET 在 Redis 单线程中一气呵成,避免高并发下两个请求同时判断为“不存在”而都插入。那如果是离线分片去重,用 MapReduce 或 Spark 实现,核心代码怎么写?
我用 Spark 的 shuffle 去重,它天然就是 hash 分片 + 局部去重的理想模型,一行核心代码就能搞定:
// Spark 精确去重:利用 distinct 算子,底层基于 reduceByKey 做 shuffle
Dataset<String> orders = spark.read().textFile("hdfs://input/orders");
Dataset<String> uniqueOrders = orders.distinct();
uniqueOrders.write().text("hdfs://output/unique_orders");
原理:distinct() 会触发 shuffle,以订单号作为 key 进行 reduceByKey,相同订单号必然进入同一个 partition,然后每个 partition 内部直接去重。这就是我们之前说的“分片 + 局部去重”的分布式实现,代码极简但背后是成熟的 shuffle 引擎。
如果要更精细控制,可以手动 hash 分区:
Dataset<String> unique = orders
.map((MapFunction<String, Tuple2<String, Void>>) s -> new Tuple2<>(s, null),
Encoders.tuple(Encoders.STRING(), Encoders.kryo(Void.class)))
.groupByKey(orders.sparkSession().sparkContext().defaultParallelism()) // hash 分片
.mapGroups((MapGroupsFunction<String, Tuple2<String, Void>, String>) (key, it) -> key,
Encoders.STRING());
技术亮点:充分利用 Spark 的分布式 shuffle 能力,无需手动切文件,天然解决数据倾斜时可以通过加盐、二次聚合等优化。
很好。那接下来咱们聊聊技术难点。在这个 100 亿订单去重的实现中,你觉得会遇到哪些棘手问题,又怎么解决?
我梳理了四个核心难点及解决方案,看这个表格 👇
| 技术难点 | 问题描述 | 解决方案 |
|---|---|---|
| 数据倾斜 | 离线分片时某些订单号(如热点商户)hash 后集中,导致个别分片过大,任务长尾甚至 OOM | 1. 二次聚合:先加随机前缀打散,局部去重再去前缀;2. 自定义分区器,按数据量动态调节;3. 用自适应执行,如 Spark AQE 自动处理倾斜分区 |
| 布隆过滤器不可扩容 | 一旦创建位数组大小固定,数据量超出预期会误判率飙升 | 1. 创建时预估足够容量,留 20%~30% 余量;2. 多层布隆:历史数据用稳定层,增量用新层,查询时多层判断;3. 可扩展布隆过滤器(Scalable Bloom Filter)自动追加新布隆 |
| Redis 内存压力与击穿 | 100 亿订单若长期存 Redis,内存爆炸;高并发下热点 key 击穿 | 1. TTL 严格设置(如7天),保证数据过期清理;2. 热点 key 本地缓存(Caffeine) + 布隆挡大部分;3. Redis 集群分片,单 key 分散;4. 持久化兜底:Redis 仅作热数据,全量在 HBase,冷热分离 |
| 状态一致性 | 实时流处理中,Checkpoint 失败或重启可能导致部分订单漏判或重复插入 | 1. Flink 的 两阶段提交 + Checkpoint 保证 exactly-once;2. 状态后端使用 RocksDB,增量 checkpoint 快速持久化;3. 幂等设计:即便重放,Lua 原子插入也能保证最终一致 |
还有一个实际工程问题:布隆过滤器本身是内存结构,如果服务重启,内存里的布隆数据就丢了,你怎么处理?
确实,这是个常见坑。有几种方案:
writeTo / readFrom。一般生产环境选 RedisBloom 或 内存 + 定期快照 就够了。
非常全面了,从代码到难点都有实战经验的味道。今天就到这里,感谢你的精彩分享!
谢谢面试官,我也从交流中学到很多!
如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。
专注 Java 面试、源码、高并发实战,回复“Java”领取《大厂面试手册》,持续更新。