100亿订单号如何去重

100亿订单号如何去重

📌 第一步:先明确核心约束

  • 数据量:100 亿条订单号(假设每条 64 位 = 8 字节,纯数据量≈80GB)
  • 核心需求:快速判断一个订单号是否已存在(存在即重复)
  • 性能要求:单次查询延迟 < 10ms,支持 10 万 + QPS 高并发写入查询
  • 业务约束:订单号一旦生成永不修改,最终一致性要求高,绝对不允许漏判重复

🚀 第二步:方案演进(从简单到复杂)

方案 核心原理 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.5GB
  • 最优哈希函数个数:k ≈ m/n * ln2 ≈ 7个

🔥 第四步:核心代码与技术亮点实现

1. RedisBloom 分布式布隆过滤器(生产级首选)

技术亮点:基于 Redis 官方模块,支持集群部署、持久化、动态扩容,避免 Guava 本地布隆过滤器的分布式一致性问题

java 复制代码
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内部已封装管道批量操作
    }
}

2. 分片布隆过滤器优化(解决单实例大内存问题)

技术亮点:按订单号前缀分片,每个分片一个独立布隆过滤器,将 11.5GB 大内存拆分为 10 个 1.15GB 小实例,解决 Redis 大 key 问题和单实例内存瓶颈

java 复制代码
@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);
    }
}

3. 历史数据冷启动批量加载优化

技术亮点:多线程分页查询 + Redis 管道批量写入,将 100 亿数据加载时间从数天缩短至数小时

java 复制代码
@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 以内

⚡ 第六步:生产环境进阶优化

  1. 冷热分离:近 3 个月热数据放本地 Guava 布隆过滤器,冷数据放 Redis 布隆过滤器
  2. 监控告警:监控布隆过滤器的填充率、误判率、查询延迟和 Redis 内存使用率
  3. 容灾备份:定期备份 Redis 布隆过滤器数据,故障时可快速恢复
  4. 压测验证:上线前进行全链路压测,验证 100 亿数据量下的性能和稳定性

🎯 面试官追问回答要点

问:布隆过滤器误判了怎么办?

答:不会影响业务正确性,因为还有数据库唯一索引兜底。误判只会导致极少量请求多查一次数据库,0.01% 的误判率对系统性能几乎无影响。

问:订单号达到 100 亿后布隆过滤器满了怎么办?

答:提前规划扩容,当填充率达到 70% 时,启动双写切换扩容流程,创建一个 2 倍容量的新布隆过滤器,同时写入新旧两个,待新过滤器数据同步完成后,切换读流量到新过滤器。

问:为什么不用 Roaring Bitmap?

答:Roaring Bitmap 适合连续整数去重,而订单号通常是雪花算法生成的分布式 ID,分布稀疏。100 亿稀疏 ID 的 Roaring Bitmap 内存占用会达到 50GB 以上,远大于布隆过滤器的 11.5GB。

真实面试模拟

真实面试模拟

面试官 😊:

同学你好,咱们放松聊。先来个场景设计题:有100亿个订单号,需要做去重,你会怎么设计? 可以把思路逐步说清楚。

候选人 🤔:

好的面试官。我先确认一下前提:订单号通常是 Long 型,8字节,100亿条原始数据大约 80GB,单机内存肯定放不下,所以直接用 HashSet 这条路直接堵死了。

我能先问几个问题帮我细化场景吗?

面试官 👍:

当然,你问。

候选人 📋:

  • 第一,订单号是什么生成规则?是连续的纯数字,还是像雪花算法那种随机 Long?
  • 第二,业务允许极小概率的误判吗,还是必须 100% 精确?
  • 第三,是离线的一次性去重(比如 T+1 报表),还是实时流需要立刻去重?

面试官 🧑‍💻:

好,假设订单号是雪花算法生成的 Long,分布很随机;精确性要求是必须精准,不能有漏网;然后咱们分别聊聊 离线批量 和 实时流 两种场景。

候选人 ✨:

明白了。针对随机 Long 且必须精确,我脑中立刻拆成两套打法。我先说 离线批量去重。

🔹 场景一:离线批量精确去重

候选人 💡:

核心思想是"分而治之"。单机装不下 80GB,那就把相同订单号哈希到同一台机器或同一个文件,再局部去重。

具体步骤:

  1. 哈希分片 :对每个订单号 hash(id) % N,均匀写入 N 个小文件(比如 N=1000)。同一个订单号必然落在同一个文件,且每个文件大约 80GB/1000 ≈ 80MB,完全能读进内存。
  2. 单文件精确去重 :依次把每个文件加载到内存 HashSet 去重,或直接排序去重,得到纯净结果。
  3. 汇总输出:每个文件去重后直接输出,因为是分片隔离的,不存在跨文件重复。

面试官 👏:

清晰。那如果变成 实时流 呢?每秒几十万订单进来,还要精确去重,你怎么做?

🔹 场景二:实时流精确去重

候选人 ⚡:

实时就必须用 外部存储 + 快速判断 的架构了。我会用 布隆过滤器 + 分布式KV存储 的组合拳,核心是"布隆做高速闸门,KV 做精确审判"。

面试官 🧐:

展开说说?

候选人 🔧:

  • 布隆过滤器 先驻内存,用极小内存判断"绝对没出现过"。如果布隆说"没有",那一定没有,直接放行,然后写入布隆和下游。
  • 如果布隆说"可能存在 ",这里就存在误判,所以必须去 HBase / RocksDB 这种持久化KV里查一次。确实存在就丢弃,不存在就插入,完成精确去重。

相当于布隆拦截了99.9%的查询,只有极少数会穿透到磁盘。

text 复制代码
实时订单号 ──> [布隆过滤器] ──(可能存在)──> [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 脚本保证原子性,这是有技术亮点的地方。

java 复制代码
// 布隆过滤器(基于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; // 重复
    }
}

技术亮点说明 🔍:

  • 布隆 + Redis 双层架构:布隆拦住 99.99% 请求,Redis 扛住最后的精确校验。
  • Lua 原子化 :GET + SET 在 Redis 单线程中一气呵成,避免高并发下两个请求同时判断为"不存在"而都插入。
  • TTL 自动过期:订单去重通常有时效(如7天),Redis 键自动过期,控制内存。

面试官 🧐:

那如果是离线分片去重,用 MapReduce 或 Spark 实现,核心代码怎么写?

候选人 ⚡:

我用 Spark 的 shuffle 去重,它天然就是 hash 分片 + 局部去重的理想模型,一行核心代码就能搞定:

java 复制代码
// 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 分区:

java 复制代码
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 原子插入也能保证最终一致

面试官 📊:

还有一个实际工程问题:布隆过滤器本身是内存结构,如果服务重启,内存里的布隆数据就丢了,你怎么处理?

候选人 🔄:

确实,这是个常见坑。有几种方案:

  • 定期快照持久化 :定期把布隆的 BitSet 序列化到本地磁盘或 HDFS,重启时加载。Guava 的布隆支持 writeTo / readFrom。
  • Redis 托管布隆:直接用 RedisBloom 模块,布隆数据存在 Redis 里,服务无状态,重启不丢。
  • 冷启动预热:从 HBase 等持久化层回放近期(如7天)订单号重建布隆,虽然耗时但保证完全一致。

一般生产环境选 RedisBloom 或 内存 + 定期快照 就够了。

面试官 😄:

非常全面了,从代码到难点都有实战经验的味道。今天就到这里,感谢你的精彩分享!

候选人 🙏:

谢谢面试官,我也从交流中学到很多!


如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。

专注 Java 面试、源码、高并发实战,回复"Java"领取《大厂面试手册》,持续更新。

相关推荐
Rain的Java大神实战圈16 小时前
Git 冲突全攻略:从原理到实战,一文打通所有场景
场景设计题
Rain的Java大神实战圈16 小时前
保证线程安全的方法有哪些
场景设计题
Rain的Java大神实战圈6 天前
DTO、VO、PO 到底要不要拆?一篇讲透 Java 实体分层的底层逻辑
场景设计题
Rain的Java大神实战圈7 天前
百万数据Excel如何快速导入导出
场景设计题
Rain的Java大神实战圈8 天前
如何快速上传10G文件
场景设计题
Rain的Java大神实战圈11 天前
数据脱敏是怎么做的
场景设计题
Rain的Java大神实战圈12 天前
对加密的手机号如何进行模糊查询
场景设计题
Rain的Java大神实战圈13 天前
如何自定义一个MyBatis插件
场景设计题
Rain的Java大神实战圈14 天前
高并发下的抽奖系统:不只是扣库存,还要防作弊与风控
场景设计题