交友 App 核心架构揭秘:几千万活跃用户是如何实现毫秒级随机匹配的?
关注我们,设为星标,每天7:30不见不散,每日java干货分享

案发场景:
你在开发一款类似 Soul 的社交 App,或者一个全网发放 1 亿个唯一验证码的系统。
你需要把这 1 亿个 ID 放进奖池里,供全网用户随时“随机抽取”一个进行匹配。
初级架构的毁灭:
你把 1 亿个 ID 全塞进了一个叫 pool:all_users 的 Set 里。
这个 Key 的体积膨胀到了 8GB!
某天运维执行了一次数据清理 DEL pool:all_users。Redis 为了释放这 8GB 的连续内存,单线程直接卡死 15 秒。期间所有的登录、发消息请求全部超时,App 瞬间炸服。
极客的破局之道:
化整为零。把 1 个 8GB 的大水缸,砸碎成 1024 个 8MB 的小水桶。
抽奖时,先随机挑一个桶,再从桶里随机挑一个球。
这就是完美规避大 Key、并能无限水平扩展的分片打散与两次随机。
1. 核心思想一:化整为零的 Hash 打散 (Sharding)
无论你是 1 亿还是 10 亿数据,绝不能放在同一个 Key 里。我们需要约定一个分片数 (Bucket Size),比如 1024。
写入规则:
当你要把用户 U10086 放入奖池时:
-
1. 计算 U10086的 Hash 值(比如使用 CRC16 或简单的hashCode())。 -
2. 将 Hash 值对 1024 取模: Hash("U10086") % 1024 = 42。 -
3. 把这个用户存入对应的分片 Key 中: SADD pool:bucket:42 U10086。
绝对收益:
原本 1 个包含 1 亿元素的超大 Set,变成了 1024 个只包含 10 万元素的安全小 Set。不仅彻底消灭了大 Key,如果配合 Redis Cluster 集群,这 1024 个 Key 还会被打散到不同的物理机器上,并发吞吐量直接翻了 N 倍!
2. 核心思想二:绝对公平的“两次随机” (Two-Step Random)
数据打散存进去了,那怎么“随机抽一个”出来呢?你总不能遍历 1024 个桶吧?
抽奖规则(两次随机法):
-
1. 第一步(随机选桶): 在应用层(Java 代码里),从 0 到 1023 中随机生成一个整数。比如生成了 88。 -
2. 第二步(随机选球): 向 Redis 发起命令,从第 88 号桶里随机抽一个元素: SPOP pool:bucket:88 1。
灵魂拷问:这样做真的公平吗?
只要你的 Hash 算法足够散列,每个桶里的元素数量是大致相等的(比如都在 9.5 万 ~ 10.5 万之间)。
先等概率(1/1024)选中桶,再等概率选中桶里的球。在概率学上,这无限趋近于从 1 亿个球里等概率盲抽!
3. 三大亿级高频实战场景
场景一:Soul 风格的“灵魂匹配 / 漂流瓶”
-
• 痛点: 几千万活跃用户同时在线,要求点击“匹配”后,毫秒级捞出一个性别匹配且不重复的陌生人。 -
• 实战: 按性别划分大池,按 Hash 分片为 match:female:bucket:{0~1023}。用户点击匹配,App 随机定位一个分片,执行SRANDMEMBER捞出 5 个候选人,在内存里过滤掉自己屏蔽过的人,最后呈现 1 个。
场景二:亿级独立优惠券码/激活码预生成分发
-
• 痛点: 运营提前生成了 1 亿个 16 位的随机充值卡密。用户兑换时必须随机给一个,且不能给重复的。 -
• 实战: 把 1 亿个卡密通过 Hash 路由,灌入 1024 个 coupon:bucket:{id}分片中。用户兑换时,两次随机配合SPOP拿走卡密。即便每秒有 10 万人同时抢,Redis Cluster 的多节点也能轻松抗下这被打散的流量。
场景三:全网大促“集五福”卡片掉落
-
• 实战: “敬业福”非常稀缺。可以用带权重的分片思想。如果是抽普通福卡,路由到 1000 个分片;如果是“敬业福”的发放槽位,只设置 1 个极小容量的分片。通过控制“第一步随机选桶”的逻辑(比如 99% 的概率随机 0-999 号桶,1% 的概率指向敬业福桶),在极简架构下实现精确的概率掉落。
4. 代码落地:Spring Boot 实战演示
下面是一段生产级别的防坑代码。必须考虑到一个边缘场景:如果随机选中的那个桶刚好被抽空了怎么办?
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.ThreadLocalRandom;
@Service
publicclassShardedLotteryService {
privatefinal StringRedisTemplate redisTemplate;
// 约定分片数量为 1024
privatestaticfinalintBUCKET_COUNT=1024;
privatestaticfinalStringPOOL_PREFIX="lottery:bucket:";
publicShardedLotteryService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 将 1 亿个元素打散存入分片
*/
publicvoidaddElementToShards(String elementId) {
// 1. 计算 Hash 值并取模定位桶
intbucketId= Math.abs(elementId.hashCode()) % BUCKET_COUNT;
StringbucketKey= POOL_PREFIX + bucketId;
// 2. 存入对应的 Redis Set 分片中
redisTemplate.opsForSet().add(bucketKey, elementId);
}
/**
* 两次随机抽取核心逻辑 (SPOP 连拿带跑)
*/
public String drawPrizeWithTwoStepRandom() {
intretryCount=0;
// 设置最大重试次数,防止所有桶都空了导致死循环
intmaxRetries=10;
while (retryCount < maxRetries) {
// 第一步:在本地内存中随机选桶 (O(1) 且极快)
intrandomBucketId= ThreadLocalRandom.current().nextInt(BUCKET_COUNT);
StringbucketKey= POOL_PREFIX + randomBucketId;
// 第二步:从选中的 Redis 桶中随机弹出一个元素
Stringprize= redisTemplate.opsForSet().pop(bucketKey);
if (prize != null) {
return prize; // 抽中了,完美返回
}
// 如果返回 null,说明这个桶已经被抽空了。
// retryCount++ 进入下一轮循环,重新随机一个新桶。
retryCount++;
}
// 超过重试次数依然没抽到,说明奖池大概率已经被彻底抽干了
return"PRIZE_POOL_EMPTY";
}
}
5. 避坑指南:数据倾斜的克星
“两次随机”看似完美,但在极端情况下有一个“不绝对公平”的物理硬伤:数据倾斜 (Data Skew)。
假设你的哈希算法不够散列,或者大奖已经被抽走了一半。
-
• 桶 0 里还有 100 个元素。 -
• 桶 1 里只剩下 1 个元素。
如果此时你执行“第一步”,桶 0 和桶 1 被选中的概率依然各是 50%。
但对于元素来说,桶 1 里的那个唯一元素,被抽中的概率高达 50%;而桶 0 里的元素,被抽中的概率只有50% * (1/100) = 0.5%!
实战解法:
对于绝大多数互联网抽奖、盲盒业务,这种程度的概率漂移是完全可以接受的(毕竟抽奖图的就是一个随机)。
但如果你的业务是极其严谨的算法派单,你必须在本地缓存维护一个 Bucket_Size_Array(记录每个桶剩余的实际数量),把第一步的“等概率随机选桶”升级为**“按桶剩余容量加权随机选桶”**。
总结
从单 Set 裸奔,到**“分片存储 + 两次随机”**,是从野生程序员走向资深架构师的必经之路。
它不仅彻底斩断了压垮 Redis 的“大 Key”梦魇,更利用了分布式的分而治之思想,把吞吐量提升了数个数量级。掌握了这个套路,无论是面对春晚级别的抽奖并发,还是千万级别的交友匹配,你都能稳坐钓鱼台。
50个Java代码示例:全面掌握Lambda表达式与Stream API
16 个 Java 代码“痛点”大改造:“一般写法” VS “高级写法”终极对决,看完代码质量飙升!
看完本文有收获?请转发分享给更多人
关注「java干货」加星标,提升java技能

❤️给个「推荐 」,是最大的支持❤️
夜雨聆风
