加载中...

100亿订单号如何去重

发布日期:2026-09-28 文章来源:博客园 - zrui-xyu 浏览次数:0

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”即可免费领取,持续更新中。


💎 第三步:最终最优解(布隆过滤器 + 兜底校验)

核心架构图

image

关键参数计算 🧮

对于 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 本地布隆过滤器的分布式一致性问题

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 问题和单实例内存瓶颈

@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 亿数据加载时间从数天缩短至数小时

@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. 汇总输出:每个文件去重后直接输出,因为是分片隔离的,不存在跨文件重复。

image

面试官 👏:

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

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

候选人 ⚡:

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

面试官 🧐:

展开说说?

候选人 🔧:

  • 布隆过滤器 先驻内存,用极小内存判断“绝对没出现过”。如果布隆说“没有”,那一定没有,直接放行,然后写入布隆和下游。
  • 如果布隆说“可能存在”,这里就存在误判,所以必须去 HBase / RocksDB 这种持久化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; // 重复
    }
}

技术亮点说明 🔍:

  • 布隆 + Redis 双层架构:布隆拦住 99.99% 请求,Redis 扛住最后的精确校验。
  • Lua 原子化:GET + SET 在 Redis 单线程中一气呵成,避免高并发下两个请求同时判断为“不存在”而都插入。
  • TTL 自动过期:订单去重通常有时效(如7天),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 原子插入也能保证最终一致

面试官 📊:

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

候选人 🔄:

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

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

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

面试官 😄:

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

候选人 🙏:

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


如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。
专注 Java 面试、源码、高并发实战,回复“Java”领取《大厂面试手册》,持续更新。

0.187500s