社交网络架构。京东场景题:亿级用户100Wqps 社交关系如何设计?如何查看我的关注,关注我的?

本文 的 原文 地址

原始的内容,请参考 本文 的 原文 地址

本文 的 原文 地址

尼恩说在前面:

最近大厂机会多了, 在45岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、shein 希音、shopee、百度、网易的面试资格,遇到很多很重要的面试题:

京东场景题:100Wqps 亿级用户的社交关系如何设计?如何查看我的关注,关注我的?

京东场景题: 美国总统选举,要设计一个选票系统,要求 100w tps,1000w qps,选票不可篡改,不可重复,获取我的选票结果,获取最终投票结果。问:接口怎么设计,系统怎么设计

前几天 小伙伴面试 京东,遇到了上面 两个场景题 。

但是由于 没有回答好,导致面试挂了。

过两天吧,尼恩 会 再开一篇文章, 给大家介绍 第 二个场景题。

这篇文章,尼恩给大家介绍第一个场景题。

尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。

当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典》V175版本PDF集群,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,后台回复:领电子书

一、亿级用户的社交关系 总的架构方案

二、核心数据模型 设计

第一、关系语义

维度枚举值说明
方向关注、粉丝、双向用两笔记录冗余,避免JOIN
状态有效、已取消、拉黑软删除,保留历史
时间create_time、update_time支持时序回溯

第二、MySQL表(水平16库×1024表)


CREATE TABLE user_relation_
PARTITION BY HASH(user_id) PARTITIONS 1024
(
    user_id       BIGINT COMMENT '用户A',
    target_id     BIGINT COMMENT '用户B',
    relation_type TINYINT COMMENT '1A->B   2 B->A  3 双向',
    state         TINYINT COMMENT '0取消 1正常 2拉黑',
    create_time   DATETIME,
    PRIMARY KEY (user_id, target_id),
    KEY idx_target(target_id)
) ENGINE=InnoDB;

第三、Redis结构

场景Key类型TTL备注
我关注的人following:{userId}Set永不过期元素=targetId
我的粉丝followers:{userId}Set永不过期元素=fanId
关注数cnt:following:{userId}String永不过期缓存计数,异步同步
粉丝数cnt:followers:{userId}String永不过期同上

三、并发写路径 设计(关注为例)

伪代码 如下


// 一、Redis Lua脚本:原子写
String lua =
  "if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then " +
  "  return 0 " +
  "else " +
  "  redis.call('SADD', KEYS[1], ARGV[1]); " +
  "  redis.call('SADD', KEYS[2], ARGV[2]); " +
  "  redis.call('INCR', KEYS[3]); " +
  "  redis.call('INCR', KEYS[4]); " +
  "  return 1 " +
  "end";

Long ok = redis.execute(lua,
        Arrays.asList("following:"+userId,
                      "followers:"+targetId,
                      "cnt:following:"+userId,
                      "cnt:followers:"+targetId),
        targetId.toString(), userId.toString());

if (ok == 1) {
    rocketMQ.syncSend("follow-topic", new FollowEvent(userId, targetId, 1));
}

四、并发读路径

第一、缓存 穿透保护


// BloomFilter 预加载全部userId,100亿位≈1.2GB内存
if (!bloomFilter.mightContain(targetId)) {
    return false; // 非法用户直接返回
}

第二、三级缓存

层级位置命中率说明
L1本地Caffeine70%热点大V,5s过期
L2Redis99%Set/String,永久
L3MySQL100%降级,binlog回填Redis

五、容量与分片策略

第一、Redis

  • 16分片集群,每片64GB,共1024GB
  • 大V粉丝>1万时,拆分为 followers:{userId}:{shardId},shardId = targetId % 32

第二、MySQL

  • 16库×1024表=16384张表
  • 单表行数<5000万,索引深度≤3
  • 冷热分离:6个月前数据迁移TiDB/ODPS

六、数据一致性保障

数据一致性保障 补偿任务 伪代码


@Scheduled(fixedDelay = 30_000)
public void reconcile() {
    List<FollowEvent> list = canalClient.pullBinlog();
    list.forEach(e -> {
        Boolean inRedis = redis.sIsMember("following:"+e.getUserId(), e.getTargetId());
        if (e.getState() == 1 && !inRedis) {
            redis.sAdd("following:"+e.getUserId(), e.getTargetId());
        } else if (e.getState() == 0 && inRedis) {
            redis.sRem("following:"+e.getUserId(), e.getTargetId());
        }
    });
}

七: redis bigkey 风险点 和攻关

7.1、BigKey 识别体系

第一、实时巡检


// 每 10 min 抽样
if (redis.llen("followers:" + userId) > 10000) {
    alertBigKey(userId);
}

第二、离线扫描


// 每日 Spark 任务
dataset.filter(r -> r.getLong("followers_count") > 1000_0000)
       .foreach(r -> mailToOps(r.getString("user_id")));

7.2 、BigKey 攻关方案

第一、水平拆分(Hash 分桶)


int shard = fanId & 31;
String key   = "followers:" + userId + ":" + shard;
redis.sadd(key, fanId);

第二、读写路由


// 写
int shard = fanId % 32;
redis.sadd("followers:" + userId + ":" + shard, fanId);

// 读
Set<String> all = new HashSet<>();
for (int i = 0; i < 32; i++) {
    all.addAll(redis.smembers("followers:" + userId + ":" + i));
}
return all;

第三、异步迁移


while (redis.scard("followers:" + userId) > 0) {
    Set<String> batch = redis.spop("followers:" + userId, 1000);
    for (String fan : batch) {
        int shard = Long.parseLong(fan) % 32;
        redis.sadd("followers:" + userId + ":" + shard, fan);
    }
}
redis.del("followers:" + userId);

第四、热 Key 二次打散


// 一库32桶仍过热→再拆256微桶
int micro = (fanId % 8192) >> 5;
String key = "followers:" + userId + ":" + shard + ":" + micro;

第五、效果验证

指标优化前优化后
Key 体积1 Key 1 万元素32 Key 每 Key 310元素
单次阻塞50 ms<1 ms
故障率5% 超时0.1% 超时

7.3、BigKey 攻关方案小结

通过「Hash 分桶 + 异步迁移 + 本地缓存」三级策略,彻底消除 BigKey 隐患,保障亿级场景下 Redis 延迟 <1 ms。

8、 100W QPS 亿级用户社交关系 高并发攻关方案

8.1、高并发 业务 压力识别

8.2、高并发 读写 压力识别

第一、读瓶颈
  • 单 Redis 分片 6 W QPS → 需 16 分片才 96 W
  • 热 Key 大 V 集中 → 单分片打满
第二、写瓶颈
  • 单机 MySQL 1 W TPS → 需 100 分库才 100 W
  • 锁冲突、页分裂 → 毛刺 100 ms

8.3、中间件 与 传输 瓶颈分析

  • 网络IO:万兆网卡带宽极限
  • 连接数:单机TCP连接数限制
  • Redis单节点:8-10W QPS天花板
  • MySQL写入:单机写入性能瓶颈

8.4、基础解决方案(支撑 50W QPS)

第一、读写分离架构优化

将读请求与写请求路由至不同存储节点,避免相互干扰:

  • 写路径:Redis 主节点(处理关注 / 取消关注)→ MQ 异步同步 → MySQL 主库
  • 读路径:本地缓存 → Redis 从节点 → MySQL 从库(降级场景)

第二、Redis 集群扩容与分片优化

1、集群分片调整:将原 16 分片扩容至 32 分片,单分片 QPS 从 6.25W 降至 3.125W,降低单节点压力

2、热点分片隔离:将热点用户(如大 V)的 Redis 数据单独分配至 8 个专属分片,避免影响普通分片

3、读写分离配置:每个 Redis 主节点挂载 2 个从节点,读请求优先路由至从节点


// Redis分片路由:热点用户定向路由至专属分片
public RedisNode getRedisNode(Long userId) {
    // 1. 检查是否为热点用户
    List<Long> hotUsers = JSON.parseArray(redis.get("hot:users"), Long.class);
    if (hotUsers.contains(userId)) {
        // 2. 热点用户路由至专属分片(8个分片:0-7)
        int shard = Math.abs(userId.hashCode() % 8);
        return redisCluster.getNodeByShard(shard);
    } else {
        // 3. 普通用户路由至普通分片(24个分片:8-31)
        int shard = 8 + Math.abs(userId.hashCode() % 24);
        return redisCluster.getNodeByShard(shard);
    }
}

第三、批量请求优化

将高频小请求合并为批量请求,减少网络 IO 次数:

  • 批量查询:支持一次查询多个用户的关注状态(如 “检查是否关注 [1001,1002,1003]”)
  • 批量写入:MQ 消费端批量处理关注事件,每 100 条请求合并为 1 次 MySQL 写入

// 批量检查关注状态(支撑单次请求查20个用户)
public List<Boolean> batchCheckFollow(Long userId, List<Long> targetIds) {
    // 1. 构造Redis批量请求
    List<Object> results = redis.pipelined(p -> {
        targetIds.forEach(targetId -> {
            p.sIsMember("following:" + userId, targetId.toString());
        });
    }).get();
    
    // 2. 转换结果
    return results.stream()
        .map(res -> res != null && (Boolean) res)
        .collect(Collectors.toList());
}

// MQ消费端批量写入MySQL
@RabbitListener(queues = "follow-event-queue")
public void batchProcessFollowEvent(List<FollowEvent> events) {
    // 1. 按用户ID分组(避免跨库事务)
    Map<Long, List<FollowEvent>> eventGroup = events.stream()
        .collect(Collectors.groupingBy(FollowEvent::getUserId));
    
    // 2. 批量插入各分组数据
    eventGroup.forEach((userId, groupEvents) -> {
        List<FollowDO> followDOS = groupEvents.stream()
            .map(event -> new FollowDO(
                event.getUserId(),
                event.getTargetId(),
                event.getRelationType()
            )).collect(Collectors.toList());
        
        // 3. 批量插入(一次插入100条)
        if (followDOS.size() >= 100) {
            userRelationMapper.batchInsert(followDOS);
            followDOS.clear();
        }
    });
}

8.4、进阶解决方案(支撑 100W QPS)

第一、多级缓存架构升级

在基础 “本地缓存 + Redis” 两级缓存基础上,新增 “ 分布式本地缓存”,提升读请求命中率至 99.5%:

缓存层级技术选型缓存内容过期时间命中率目标
L1Caffeine(一致性hash本地缓存)热点用户关注状态、列表30 秒40%
L2Redis 从节点普通用户关系数据永久29.5%
L3MySQL 从库降级场景数据-0.5%


// 多级缓存查询:获取用户粉丝数
public Long getFansCount(Long userId) {
    String cacheKey = "cnt:followers:" + userId;
 
    
    // 1. 查本地Caffeine缓存
    Long localVal = caffeineCache.getIfPresent(cacheKey);
    if (localVal != null) return localVal;
    
    // 2. 查Redis缓存
    String redisVal = redis.get(cacheKey);
    if (redisVal != null) {
        Long val = Long.parseLong(redisVal);
        // 回写本地缓存
        caffeineCache.put(cacheKey, val, 30, TimeUnit.SECONDS);
        // 热点用户回写CDN
        if (hotUsers.contains(userId)) {
            cdnClient.set(cacheKey, val.toString(), 300, TimeUnit.SECONDS);
        }
        return val;
    }
    
    // 3. 查MySQL并回填缓存
    Long dbVal = userRelationMapper.countFans(userId);
    redis.set(cacheKey, dbVal.toString());
    caffeineCache.put(cacheKey, dbVal, 30, TimeUnit.SECONDS);
    if (hotUsers.contains(userId)) {
        cdnClient.set(cacheKey, dbVal.toString(), 300, TimeUnit.SECONDS);
    }
    return dbVal;
}

分布式 Caffeine :通过 一致性 Hash , 把「本地内存」当成「分布式环形空间」来用 ,提升本地缓存命中了

分布式 Caffeine 环形定位图

(1) 把 64 位 Hash 空间首尾相接成环。

(2) 每台 Pod 启动时根据「IP+端口」生成 200 个虚拟节点,均匀散落在环上。

(3) 任意 userId 的 Hash 值顺时针遇到的第一个虚拟节点,即为「缓存负责节点」。

第二、写请求削峰与异步化

通过 “前置缓存 + MQ 削峰 + 批量写入” 降低写请求对存储的瞬时压力:

1、前置缓存:写请求先更新 Redis,确保读请求能立即获取最新状态

2、MQ 削峰:将写请求发送至 RocketMQ,按用户 ID 分区,避免消息乱序

3、批量消费:消费端按 “100 条 / 批” 或 “100ms / 批” 触发批量写入 MySQL,降低数据库写 TPS


// 异步关注接口:先更缓存再发MQ
public boolean asyncFollow(Long userId, Long targetId) {
    // 1. Lua脚本原子更新Redis(避免并发问题)
    String lua = "if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then " +
                 "  return 0 " + // 已关注,返回失败
                 "else " +
                 "  redis.call('SADD', KEYS[1], ARGV[1]); " + // 关注列表
                 "  redis.call('SADD', KEYS[2], ARGV[2]); " + // 粉丝列表
                 "  redis.call('INCR', KEYS[3]); " + // 关注数
                 "  redis.call('INCR', KEYS[4]); " + // 粉丝数
                 "  return 1 " + // 成功,返回1
                 "end";
    
    Long result = redis.execute(lua,
        Arrays.asList("following:"+userId, "followers:"+targetId,
                     "cnt:following:"+userId, "cnt:followers:"+targetId),
        targetId.toString(), userId.toString()
    );
    
    if (result == 1) {
        // 2. 发送MQ消息(按userId分区,确保顺序)
        FollowEvent event = new FollowEvent(userId, targetId, 1);
        rocketMQ.send("follow-topic", 
                      MessageSelector.byTag("userId:" + (userId % 100)), 
                      event);
        return true;
    }
    return false;
}

第三、热点用户特殊处理

针对热点用户(如大 V)的高访问量,采用 “数据预加载 + 本地缓存全量存储 + 独立集群” 策略:

1、数据预加载:每天凌晨 3 点,将前 1000 名热点用户的关注 / 粉丝列表全量加载至分布式本地缓存(如 Redis Cluster 本地缓存)

2、本地全量存储:应用服务本地缓存热点用户的完整关注列表(仅前 10 万条,超量部分走分页)

3、独立集群:热点用户的 Redis 数据部署在独立集群,避免占用普通集群资源


// 热点用户数据预加载
@Scheduled(cron = "0 0 3 * * ?")
public void preloadHotUserData() {
    // 1. 获取前1000名热点用户
    List<Long> hotUsers = JSON.parseArray(redis.get("hot:users:top1000"), Long.class);
    
    // 2. 批量预加载关注列表至分布式本地缓存
    for (Long userId : hotUsers) {
        // 2.1 从Redis获取完整关注列表(前10万条)
        Set<String> follows = redis.zRange("following:" + userId, 0, 99999);
        List<Long> followList = follows.stream()
            .map(Long::valueOf)
            .collect(Collectors.toList());
        
        // 2.2 存储至分布式本地缓存(过期时间24小时)
        distributedLocalCache.put("preload:following:" + userId, 
                                  followList, 
                                  86400, 
                                  TimeUnit.SECONDS);
        
        // 2.3 同理预加载粉丝列表
        Set<String> fans = redis.zRange("followers:" + userId, 0, 99999);
        List<Long> fanList = fans.stream()
            .map(Long::valueOf)
            .collect(Collectors.toList());
        distributedLocalCache.put("preload:fans:" + userId, 
                                  fanList, 
                                  86400, 
                                  TimeUnit.SECONDS);
    }
}

// 热点用户关注列表查询:优先查预加载缓存
public List<Long> getHotUserFollows(Long userId, int page, int size) {
    // 1. 检查是否为热点用户
    List<Long> topHotUsers = JSON.parseArray(redis.get("hot:users:top1000"), Long.class);
    if (!topHotUsers.contains(userId)) {
        return getNormalUserFollows(userId, page, size); // 普通用户流程
    }
    
    // 2. 查预加载缓存
    List<Long> preloadList = distributedLocalCache.get("preload:following:" + userId);
    if (preloadList != null) {
        // 3. 分页截取(避免全量返回)
        int start = (page - 1) * size;
        int end = Math.min(start + size, preloadList.size());
        return preloadList.subList(start, end);
    }
    
    // 4. 缓存未命中,走正常流程并回写
    List<Long> result = getNormalUserFollows(userId, page, size);
    distributedLocalCache.put("preload:following:" + userId, 
                              result, 
                              3600, 
                              TimeUnit.SECONDS);
    return result;
}

九: 如何查看我的关注,关注我的

…由于平台篇幅限制, 剩下的内容(5000字+),请参参见原文地址

原始的内容,请参考 本文 的 原文 地址

本文 的 原文 地址

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值