海量 QQ 号去重 面试被问到 我竟然一句话说不出来?8 年 Java 开发:从业务到代码帮你救场
三年前那次字节跳动的二面,面试官推了推眼镜:“我们有 10 亿条 QQ 号日志,要去重后统计活跃用户数,你怎么实现?” 我盯着屏幕上的 “海量” 二字,脑子里只蹦出 “用 HashSet?”,结果面试官追问 “10 亿个 QQ 号存在 Set 里,需要多少内存?”—— 我当场卡壳,手指攥着笔杆半天没动静。
后来复盘才发现,不是我不会去重,而是没搞懂 “海量” 背后的场景差异,更没形成 “按业务选方案” 的思考框架。今天就从 8 年 Java 开发的实战角度,把海量 QQ 号去重的 “业务场景→技术选型→代码落地→面试应答” 讲透,下次再被问到,你能比面试官还能说。
一、先别慌!面试先问清 “三个关键问题”
八年踩坑总结:90% 的面试卡壳,是因为没先明确需求。遇到 “海量 QQ 号去重”,别上来就说技术,先反问面试官这三个问题 —— 这一步就比 80% 的候选人强:
-
“数据量级是多少?” (百万 / 千万 / 亿 / 十亿?)
百万级和十亿级的方案天差地别,比如百万级用 HashSet 就能搞定,十亿级得用 Spark 离线计算; -
“需要实时去重还是离线统计?” (查一次就走,还是持续写入 + 查询?)
实时场景(比如登录风控:判断 QQ 号是否重复登录)要低延迟,离线场景(比如年度活跃用户统计)更省空间; -
“允许误判吗?” (比如把没出现过的 QQ 号误判为已存在,能接受吗?)
风控场景要 100% 精确(不能误拦正常用户),统计场景可接受 0.1% 误判(节省 90% 空间)。
举个例子:如果面试官说 “电商平台,每天 5000 万 QQ 号的下单日志,要实时去重(防止重复下单),不允许误判”—— 需求瞬间清晰,方案也跟着出来了。
二、按 “量级 + 场景” 选方案:从百万到十亿的演进
不同场景对应不同方案,下面按 “数据量级递增” 拆解,每个方案都附 “核心代码 + 面试考点 + 避坑点”,直接对标面试题。
1. 百万级 QQ 号:最简单的方案,面试别瞧不起
适用场景:部门级数据统计(比如每周 500 万用户的活动参与记录去重)、单服务的本地缓存去重。
核心原理:利用 Java 集合的哈希特性,直接去重。
面试常问:“HashSet 去重的原理?”“为什么不用 TreeSet?”
核心代码(HashSet/HashMap 实现)
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class QQDuplicateRemoval {
// 百万级QQ号去重(QQ号用Long存储,避免String的额外开销)
public Set<Long> removeDuplicatesWithHashSet(List<Long> qqList) {
// 1. 直接用HashSet,add()方法会自动去重(基于equals和hashCode)
Set<Long> uniqueQqs = new HashSet<>(qqList.size()); // 初始化容量,避免扩容开销
for (Long qq : qqList) {
uniqueQqs.add(qq); // 重复的QQ号会自动被过滤
}
return uniqueQqs;
}
// 进阶:如果需要统计每个QQ号的出现次数(面试可能追问“去重+计数”)
public Map<Long, Integer> countQqOccurrences(List<Long> qqList) {
Map<Long, Integer> qqCountMap = new HashMap<>(qqList.size());
for (Long qq : qqList) {
qqCountMap.put(qq, qqCountMap.getOrDefault(qq, 0) + 1);
}
// 去重后的QQ号就是map的keySet()
Set<Long> uniqueQqs = qqCountMap.keySet();
return qqCountMap;
}
}
面试考点拆解
- HashSet 去重原理:底层是 HashMap,把 QQ 号作为 key,value 用一个固定的 Object(PRESENT),add () 时判断 key 是否存在,存在则不添加(实现去重);
- 为什么不用 TreeSet:TreeSet 是基于红黑树的有序集合,去重需要比较(compareTo),时间复杂度 O (nlogn),而 HashSet 是 O (n),百万级场景下 HashSet 更快;
- 避坑点:如果 QQ 号用 String 存储(比如 “123456”),会比 Long 多占用内存(每个 String 至少 24 字节,Long 仅 8 字节),百万级差距不大,但千万级会明显卡顿。
2. 千万 - 亿级 QQ 号:面试高频!布隆过滤器(Bloom Filter)
适用场景:实时去重(比如社交 APP 的 “新用户注册检测”:判断 QQ 号是否已注册)、内存有限的场景(比如嵌入式设备存储 1 亿 QQ 号)。
核心原理:用一个 bit 数组 + 多个哈希函数,把 QQ 号映射到 bit 数组的多个位置(设为 1),查询时如果所有映射位置都是 1,说明 “可能存在”(有误判);只要有一个 0,说明 “一定不存在”。
面试常问:“误判率怎么控制?”“布隆过滤器能删除元素吗?”“1 亿 QQ 号用布隆过滤器占多少内存?”
核心代码(两种实现:自己写 + Guava 工具类)
(1)简易版布隆过滤器(理解原理用)
import java.util.BitSet;
import java.util.Random;
public class SimpleBloomFilter {
private final BitSet bitSet; // 核心:bit数组
private final int bitSetSize; // bit数组大小
private final int hashFunctionCount; // 哈希函数数量
private final Random[] randoms; // 多个随机哈希函数
// 构造函数:需要传入预期数据量n,可接受误判率p
public SimpleBloomFilter(int expectedQqCount, double falsePositiveRate) {
// 1. 计算bit数组大小:m = -n * ln(p) / (ln2)^2
this.bitSetSize = (int) (-expectedQqCount * Math.log(falsePositiveRate) / (Math.log(2) * Math.log(2)));
// 2. 计算哈希函数数量:k = m * ln2 / n
this.hashFunctionCount = (int) (this.bitSetSize * Math.log(2) / expectedQqCount);
this.bitSet = new BitSet(bitSetSize);
// 3. 初始化多个哈希函数(用不同种子)
this.randoms = new Random[hashFunctionCount];
for (int i = 0; i < hashFunctionCount; i++) {
this.randoms[i] = new Random(i); // 不同种子保证哈希函数独立
}
System.out.printf("预期QQ数:%d,误判率:%.4f,bit数组大小:%d(约%.2fMB),哈希函数数:%d%n",
expectedQqCount, falsePositiveRate, bitSetSize, bitSetSize / 8.0 / 1024 / 1024, hashFunctionCount);
}
// 添加QQ号到布隆过滤器
public void add(Long qq) {
for (Random random : randoms) {
// 每个哈希函数计算一个索引,设为1
int index = Math.abs(random.nextInt()) % bitSetSize;
bitSet.set(index);
}
}
// 判断QQ号是否可能存在(true=可能存在,false=一定不存在)
public boolean mightContain(Long qq) {
for (Random random : randoms) {
int index = Math.abs(random.nextInt()) % bitSetSize;
if (!bitSet.get(index)) { // 有一个位置是0,一定不存在
return false;
}
}
return true; // 所有位置都是1,可能存在(有误判)
}
// 测试:1亿QQ号去重
public static void main(String[] args) {
int expectedQqCount = 100_000_000; // 1亿QQ号
double falsePositiveRate = 0.001; // 0.1%误判率
SimpleBloomFilter bloomFilter = new SimpleBloomFilter(expectedQqCount, falsePositiveRate);
// 模拟添加1亿个不同QQ号
for (long i = 1000000000L; i < 1100000000L; i++) {
bloomFilter.add(i);
}
// 测试误判率:检查1000个不存在的QQ号
int falseCount = 0;
for (long i = 2000000000L; i < 2000001000L; i++) {
if (bloomFilter.mightContain(i)) {
falseCount++;
}
}
System.out.printf("实际误判率:%.4f%n", falseCount / 1000.0); // 接近0.1%
}
}
(2)生产级实现(用 Guava 的 BloomFilter,面试说这个更加分)
import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class GuavaBloomFilterDemo {
public static void main(String[] args) {
int expectedQqCount = 100_000_000; // 1亿QQ号
double falsePositiveRate = 0.001; // 0.1%误判率
// 1. 初始化布隆过滤器(用Long类型的Funnel,避免String转换开销)
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), // QQ号用Long存储,效率更高
expectedQqCount,
falsePositiveRate
);
// 2. 模拟添加1亿个QQ号
Random random = new Random();
List<Long> qqList = new ArrayList<>();
for (int i = 0; i < expectedQqCount; i++) {
long qq = 1000000000L + random.nextLong(9000000000L); // 10位QQ号
qqList.add(qq);
bloomFilter.put(qq);
}
// 3. 测试去重效果
int duplicateCount = 0;
for (Long qq : qqList) {
if (bloomFilter.mightContain(qq)) {
duplicateCount++;
}
}
System.out.printf("已存在的QQ号识别率:%.2f%%%n", duplicateCount * 100.0 / qqList.size()); // 100%
// 4. 测试误判率
int falsePositiveCount = 0;
for (int i = 0; i < 10000; i++) {
long fakeQq = 2000000000L + random.nextLong(1000000000L); // 不存在的QQ号
if (bloomFilter.mightContain(fakeQq)) {
falsePositiveCount++;
}
}
System.out.printf("实际误判率:%.4f%%%n", falsePositiveCount * 100.0 / 10000); // 约0.1%
}
}
面试考点拆解
- 内存计算:1 亿 QQ 号,0.1% 误判率,bit 数组大小约 1.4GB(公式计算得出),比用 HashSet(1 亿 Long 占 800MB?不对!HashSet 底层 HashMap,每个 entry 占 32 字节,1 亿个要 3.2GB)省一半空间;
- 误判率控制:误判率越低,需要的 bit 数组越大、哈希函数越多(比如 0.01% 误判率,bit 数组要 2.8GB);
- 不能删除元素:因为一个 bit 可能被多个 QQ 号共享,删除会把其他 QQ 号的映射位置设为 0,导致误判 “不存在”—— 面试常问 “怎么解决删除问题?”,答案是 “用计数布隆过滤器(Counting Bloom Filter),把 bit 换成计数器,但空间开销会增加”;
- 避坑点:别用 String 存储 QQ 号!比如 “1234567890” 作为 String,哈希计算比 Long 慢,还多占内存(每个 String 至少 24 字节)。
3. 亿 - 十亿级 QQ 号:分布式方案(面试必问高并发)
适用场景:全公司级的实时去重(比如微信支付的风控系统,每天 10 亿 QQ 号的交易日志去重)、跨服务共享去重结果。
核心原理:用分布式存储(Redis、HBase)存储去重标识,利用集群扩展容量和并发能力。
面试常问:“Redis 怎么实现海量去重?”“BitMap 和 Set 哪个更省空间?”“Redis 集群怎么分片?”
核心代码(Redis 两种实现:Set vs BitMap)
(1)Redis Set 实现(精确去重,支持删除)
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Component
public class RedisSetQqDeduplication {
private static final String QQ_SET_KEY = "qq:unique:set";
private static final long EXPIRE_DAYS = 30; // 去重结果保留30天
@Resource
private RedisTemplate<String, Long> redisTemplate;
// 添加QQ号并判断是否重复(true=重复,false=新增)
public boolean addAndCheckDuplicate(Long qq) {
// Redis的SADD命令:添加成功返回1,已存在返回0
Boolean isAdded = redisTemplate.opsForSet().add(QQ_SET_KEY, qq);
if (isAdded == null) {
throw new RuntimeException("Redis操作失败");
}
// 设置过期时间(只需要设置一次,避免重复设置)
redisTemplate.expire(QQ_SET_KEY, EXPIRE_DAYS, TimeUnit.DAYS);
return !isAdded; // 返回true表示重复
}
// 批量去重:返回重复的QQ号列表
public Set<Long> batchCheckDuplicate(List<Long> qqList) {
// Redis的SISMEMBER命令:批量判断是否存在
return redisTemplate.opsForSet().intersect(QQ_SET_KEY, qqList);
}
// 统计去重后的总数
public long countUniqueQq() {
return redisTemplate.opsForSet().size(QQ_SET_KEY);
}
}
(2)Redis BitMap 实现(省空间,适合纯数字 QQ 号)
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
@Component
public class RedisBitMapQqDeduplication {
private static final String QQ_BITMAP_KEY = "qq:unique:bitmap";
// QQ号最小是10000,偏移量=QQ号-10000(避免bit数组前面大量0浪费空间)
private static final long QQ_OFFSET_BASE = 10000L;
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 添加QQ号并判断是否重复(true=重复,false=新增)
public boolean addAndCheckDuplicate(Long qq) {
if (qq < QQ_OFFSET_BASE) {
throw new IllegalArgumentException("无效QQ号:" + qq);
}
long offset = qq - QQ_OFFSET_BASE;
// Redis的SETBIT命令:设置bit为1,返回原来的值(0=新增,1=重复)
Boolean oldValue = redisTemplate.opsForValue().setBit(QQ_BITMAP_KEY, offset, true);
if (oldValue == null) {
throw new RuntimeException("Redis操作失败");
}
return oldValue; // 返回true表示重复
}
// 统计去重后的总数(bitCount:统计bit数组中1的个数)
public long countUniqueQq() {
return redisTemplate.execute((connection) -> {
byte[] keyBytes = redisTemplate.getStringSerializer().serialize(QQ_BITMAP_KEY);
return connection.bitCount(keyBytes);
}, true);
}
}
面试考点拆解
-
Set vs BitMap:
- Set:每个 QQ 号存为一个元素,支持删除(SREM),但 1 亿个 QQ 号占约 800MB(每个 Long 8 字节);
- BitMap:每个 QQ 号用 1bit,1 亿个占约 12MB(100000000bit / 8 / 1024 / 1024 ≈ 12MB),省空间但只支持纯数字(QQ 号正好是数字);
-
Redis 集群分片:10 亿 QQ 号用 BitMap 需要约 1.2GB(10^10 bit ≈ 1.2GB),单台 Redis 放不下,需要用 Redis Cluster 分片(比如按 QQ 号尾号分片:QQ%16,分成 16 个槽位);
-
高并发优化:用 Redis Pipeline 批量执行命令(比如批量添加 1000 个 QQ 号),减少网络往返次数;设置合理的过期时间,避免 Redis 内存溢出。
4. 十亿级以上:离线去重(Spark/Hadoop)
适用场景:超大规模的离线统计(比如腾讯年度用户报告,统计 100 亿条 QQ 号日志的去重数)。
核心原理:利用分布式计算框架,把数据分片到多个节点并行去重,最后合并结果。
面试常问:“Spark 去重的原理?”“怎么处理数据倾斜?”“Shuffle 阶段怎么优化?”
核心代码(Spark RDD 实现)
import org.apache.spark.{SparkConf, SparkContext}
object SparkQqDeduplication {
def main(args: Array[String]): Unit = {
// 1. 初始化Spark配置(本地模式/集群模式)
val conf = new SparkConf()
.setAppName("QQDeduplication")
.setMaster("local[*]") // 集群模式改为yarn或spark://xxx:7077
val sc = new SparkContext(conf)
try {
// 2. 读取海量QQ号日志(假设存在HDFS上,每行一个QQ号)
val qqRdd = sc.textFile("hdfs://xxx:9000/qq/logs/*")
.filter(_.nonEmpty) // 过滤空行
.map(_.toLong) // 转换为Long类型(避免String开销)
.distinct() // 核心:RDD的distinct()方法去重(底层是map+reduceByKey)
// 3. 统计去重后的总数
val uniqueCount = qqRdd.count()
println(s"十亿级QQ号去重后总数:$uniqueCount")
// 4. 结果保存到HDFS(供后续分析)
qqRdd.saveAsTextFile("hdfs://xxx:9000/qq/unique_result/")
} finally {
sc.stop() // 关闭SparkContext
}
}
}
面试考点拆解
- 去重原理:Spark 的
distinct()底层是先map(qq => (qq, 1)),再reduceByKey((a,b) => a),最后map(_._1)—— 通过 Shuffle 把相同 QQ 号分到同一个节点,合并后去重; - 数据倾斜解决:如果某类 QQ 号(比如尾号为 0 的)特别多,会导致单个节点压力过大,解决方案是 “加盐分片”:
map(qq => (qq + "_" + Random.nextInt(10), qq)),先分 10 片去重,再合并; - 优化点:用
repartition()调整分区数(建议分区数 = 集群 CPU 核心数的 2-3 倍),避免 Shuffle 阶段数据不均匀。
三、面试应答模板:照着说,再也不会卡壳
最后给一个 “万能应答模板”,不管面试官问什么量级,都能有条理地回答:
“首先我会明确三个业务需求:1. 数据量级是多少(百万 / 亿 / 十亿)?2. 需要实时去重还是离线统计?3. 是否允许误判?
- 如果是百万级离线场景,用 Java 的 HashSet 最直接,优点是简单易实现,缺点是内存占用稍高;
- 如果是千万到亿级实时场景,优先选布隆过滤器(比如 Guava 的实现),0.1% 误判率下 1 亿 QQ 号只占 1.4GB 内存,查询延迟低于 1ms;如果不允许误判,用 Redis 的 BitMap,每个 QQ 号 1bit,省空间还支持分布式;
- 如果是十亿级以上的离线场景,用 Spark 的 RDD.distinct (),通过分布式计算并行去重,还能处理数据倾斜;
- 如果是十亿级实时场景,用 Redis Cluster+BitMap 分片,按 QQ 号尾号分成 16 个槽位,每个节点存一部分数据,既保证高并发,又避免单节点内存溢出。
另外,不管选哪种方案,都要注意 QQ 号的存储类型(用 Long 比 String 更省内存、哈希更快),还要做边界校验(比如无效 QQ 号过滤)。”
四、八年开发的最后提醒
面试问 “海量 QQ 号去重”,考的不是你记住了多少方案,而是你是否有 “按场景选技术” 的思维。我当年卡壳,就是因为上来就想 “标准答案”,却没考虑场景差异。
下次再被问到,先反问需求,再按量级递进说方案,最后补一句 “实际落地时还要考虑工程细节,比如 Redis 的持久化、Spark 的容错”—— 面试官会觉得你不仅懂技术,还有实战经验。
希望这篇文章能帮你摆脱 “一句话说不出来” 的尴尬,下次面试,自信点!