知光项目用户关系模块设计与实现:高并发下的关注 / 粉丝体系架构实践

知光项目来源于小红书程序员流年的开源项目,欢迎大家一起学习。

在社交类产品中,用户关系模块(关注 / 粉丝体系)是核心基础设施之一,既要支撑高并发的关注 / 取消关注操作,也要满足粉丝 / 关注列表的低延迟查询,同时保证关注数、粉丝数等核心指标的一致性。本文将以 “知光项目” 为例,深度拆解用户关系模块的设计思路、核心实现与性能优化策略,还原从业务场景到技术落地的完整链路。

一、模块背景与核心挑战

知光项目的用户关系模块需支撑以下核心场景:

  • 高频写操作:百万级用户的关注 / 取消关注行为,要求接口响应时间 < 50ms,且具备限流防刷能力;
  • 高频读操作:用户主页、推荐流等场景需快速查询关注 / 粉丝列表,支持偏移 / 游标两种分页方式;
  • 计数一致性:关注数、粉丝数等核心指标需实时可用,且能应对缓存与数据库的一致性校验;
  • 高并发兼容:大 V 用户(粉丝数≥50 万)的列表查询需避免性能瓶颈。

针对以上挑战,模块设计核心遵循 “空间换时间、读写分离、异步解耦” 三大原则,最终实现了高并发下的高性能与高可用。

二、核心数据模型:关注表与粉丝表的分表设计

用户 A 关注用户 B 是 “单向行为”,但会产生两个异构的业务维度:A 的关注列表(主动维度)、B 的粉丝列表(被动维度)。项目并未采用单表存储fromUserId(关注者)+toUserId(被关注者)的方案,而是拆分出关注表粉丝表,核心原因如下:

1. 极致优化高频查询性能

关注 / 粉丝列表是超高频读场景,分表可针对性设计索引,避免单表多维度查询的性能损耗:

  • 关注表:核心索引fromUserId + toUserId(唯一索引),精准服务 “查 A 关注了谁”(listFollowing),查询时无需额外过滤,直接走覆盖索引;
  • 粉丝表:核心索引toUserId + fromUserId(唯一索引),精准服务 “查谁关注了 B”(listFollowers),避免单表下toUserId维度的回表 / 全表扫描。
// RelationMapper.java 分表查询示例
List<Long> listFollowing(@Param("fromUserId") Long fromUserId,
                         @Param("limit") int limit,
                         @Param("offset") int offset);

List<Long> listFollowers(@Param("toUserId") Long toUserId,
                         @Param("limit") int limit,
                         @Param("offset") int offset);

2. 解耦主动 / 被动业务逻辑

关注是 “用户主动行为”,粉丝是 “被动结果”,分表让两类逻辑完全独立:

  • 关注表:同步写入,用户点击 “关注” 后即时生效,保证操作反馈的实时性;
  • 粉丝表:异步写入,通过 Outbox 事件 + Kafka 异步更新,不阻塞主接口响应。

若合表存储,同步写粉丝维度会增加接口耗时,异步写又会导致 “关注列表生效、粉丝列表未生效” 的感知不一致,分表则完美规避该问题。

3. 简化计数统计与一致性校验

用户维度的 “关注数 / 粉丝数” 是核心指标,分表让计数逻辑更高效:

  • 关注数:直接统计关注表中fromUserId=A的有效行数(countFollowingActive);
  • 粉丝数:直接统计粉丝表中toUserId=B的有效行数(countFollowerActive)。
// RelationController.java 计数校验示例
int dbFollowings = relationMapper.countFollowingActive(userId);
int dbFollowers = relationMapper.countFollowerActive(userId);

4. 避免高并发下的锁竞争

单表存储时,“查大 V 粉丝列表”(toUserId维度)与 “用户点关注”(fromUserId维度)会抢占同一张表的锁,导致接口超时;分表后,关注表与粉丝表的锁完全隔离,锁竞争概率大幅降低。

三、核心流程实现:写、读、计数三大链路

1. 写流程:关注 / 取消关注(同步 + 异步结合)

写流程的核心目标是 “主流程快速响应,被动维度异步兜底”,整体链路如下:

(1)关注操作
// RelationServiceImpl.java 关注核心逻辑
@Transactional
public boolean follow(long fromUserId, long toUserId) {
    // 1. Lua令牌桶限流:防刷,每用户100令牌/秒
    Long ok = redis.execute(tokenScript, List.of("rl:follow:" + fromUserId), "100", "1");
    if (ok == 0L) return false;

    // 2. 同步写入关注表(主动维度)
    long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
    int inserted = mapper.insertFollowing(id, fromUserId, toUserId, 1);

    // 3. 写入Outbox事件,异步更新粉丝表
    if (inserted > 0) {
        String payload = objectMapper.writeValueAsString(new RelationEvent("FollowCreated", fromUserId, toUserId, id));
        outboxMapper.insert(outId, "following", id, "FollowCreated", payload);
        return true;
    }
    return false;
}
(2)异步更新粉丝表

Outbox 表的行变更由 Canal 捕获并转发至 Kafka,消费者消费后更新粉丝表与缓存:

// RelationEventProcessor.java 事件处理
public void process(RelationEvent evt) {
    // 幂等去重:Redis键10分钟过期
    String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : evt.id());
    Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));
    if (!first) return;

    if ("FollowCreated".equals(evt.type())) {
        // 异步插入粉丝表(被动维度)
        mapper.insertFollower(evt.id(), evt.toUserId(), evt.fromUserId(), 1);
        // 更新Redis ZSet缓存
        redis.opsForZSet().add("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()), System.currentTimeMillis());
        redis.opsForZSet().add("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()), System.currentTimeMillis());
        // 更新计数
        userCounterService.incrementFollowings(evt.fromUserId(), 1);
        userCounterService.incrementFollowers(evt.toUserId(), 1);
    }
}
(3)取消关注

逻辑与关注对称:同步更新关注表(逻辑删除),异步更新粉丝表,同时清理缓存、调整计数。

2. 读流程:关注 / 粉丝列表(缓存优先 + 双分页)

读流程的核心目标是 “缓存优先,按需回填,大 V 优化”,支持偏移分页(简单场景)与游标分页(高一致性场景)。

(1)缓存策略:Redis ZSet + 本地 Caffeine 缓存
  • Redis ZSet:存储关注 / 粉丝列表,分值为关注时间戳(毫秒),支持按时间倒序分页,设置 2 小时 TTL 避免陈旧数据;
  • 本地 Caffeine 缓存:针对大 V 用户(粉丝≥50 万),缓存前 500 名粉丝 / 关注列表,降低冷启动回源开销,当有人第一次去查这些大 V 的粉丝列表 / 关注列表时,不用每次都跑到数据库里去翻数据(也就是常说的 “冷启动回源”),既减少了数据库的查询压力,也能让查数据的速度更快。
// RelationServiceImpl.java 偏移分页读取
private List<Long> getListWithOffset(
        String key, // uf:flws:{userId} / uf:fans:{userId}
        int offset,
        int limit,
        IntFunction<Map<Long, Map<String, Object>>> rowsFetcher,
        String idField,
        String tsField,
        Cache<Long, List<Long>> localCache,
        long userId
) {
    // 1. 优先读Redis ZSet
    Set<String> cached = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
    if (cached != null && !cached.isEmpty()) return toLongList(cached);

    // 2. 大V读本地缓存
    List<Long> top = localCache.getIfPresent(userId);
    if (top != null) return top.subList(Math.min(offset, top.size()), Math.min(offset + limit, top.size()));

    // 3. 缓存未命中,从DB回填Redis
    Map<Long, Map<String, Object>> rows = rowsFetcher.apply(Math.min(limit + offset, 1000));
    fillZSet(key, rows, idField, tsField, null);
    redis.expire(key, Duration.ofHours(2));

    // 4. 回填后再次读取
    Set<String> filled = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
    return filled == null ? Collections.emptyList() : toLongList(filled);
}
(2)双分页支持
  • 偏移分页:适用于普通用户列表,简单易实现,但存在 “数据偏移” 问题(分页过程中数据变更);
  • 游标分页:基于 ZSet 的时间戳分值,分页游标为上一页最后一条的时间戳,保证分页一致性,适用于时序性要求高的场景。

3. 计数体系:SDS 结构 + 采样校验

用户计数(关注 / 粉丝 / 发文 / 获赞 / 获藏)采用SDS 固定结构(5×4 字节,大端编码)存储在 Redis 中,核心设计如下:

  • 结构:5 个 4 字节段依次对应关注数、粉丝数、发文数、获赞数、获藏数,单 Key 存储,读取效率极高;
  • 一致性:每 300 秒触发一次采样校验,对比 Redis 计数与 DB 计数,不一致则触发全量重建;
  • 可用性:计数缺失 / 异常时自动重建,重建失败则返回 0,保证接口不报错。
// RelationController.java 计数读取与校验
@GetMapping("/counter")
public Map<String, Long> counter(@RequestParam("userId") long userId) {
    // 1. 读取Redis SDS
    byte[] raw = redis.execute((RedisCallback<byte[]>)
            c -> c.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));

    // 2. 结构异常则重建
    if (raw == null || raw.length < 20) {
        userCounterService.rebuildAllCounters(userId);
        // 重建后二次读取
        raw = redis.execute((RedisCallback<byte[]>) c -> c.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));
        if (raw == null || raw.length < 20) {
            // 返回默认值,保证可用性
            m.put("followings", 0L);
            m.put("followers", 0L);
            return m;
        }
    }

    // 3. 采样校验:每300秒一次
    Boolean doCheck = redis.opsForValue().setIfAbsent(chkKey, "1", Duration.ofSeconds(300));
    if (doCheck) {
        int dbFollowings = relationMapper.countFollowingActive(userId);
        int dbFollowers = relationMapper.countFollowerActive(userId);
        // 不一致则重建
        if (sdsFollowings != (long) dbFollowings || sdsFollowers != (long) dbFollowers) {
            userCounterService.rebuildAllCounters(userId);
        }
    }

    // 4. 解析并返回计数
    IntFunction<Long> read = idx -> {
        int off = (idx - 1) * 4;
        long n = 0;
        for (int i = 0; i < 4; i++) {
            n = (n << 8) | (raw[off + i] & 0xFFL);
        }
        return n;
    };
    m.put("followings", read.apply(1));
    m.put("followers", read.apply(2));
    return m;
}

四、关键优化点与架构亮点

1. 限流防刷:Lua 令牌桶

关注操作通过 Redis Lua 脚本实现令牌桶限流,每用户每秒最多 100 次关注操作,避免恶意刷接口:

第一步:定义令牌桶 Lua 脚本(TOKEN_BUCKET_LUA)

Redis 执行 Lua 脚本是 “单线程原子操作”,能避免并发下令牌计算出错(比如两个请求同时扣令牌,结果扣成负数):

local key = KEYS[1] -- 每个用户的令牌桶Key:rl:follow:用户ID
local capacity = tonumber(ARGV[1]) -- 桶容量:100
local rate = tonumber(ARGV[2]) -- 每秒补令牌数:1
local now = redis.call('TIME')[1] -- 当前时间戳(秒)

-- 读取桶的上次更新时间和剩余令牌数
local last = redis.call('HGET', key, 'last')
local tokens = redis.call('HGET', key, 'tokens')

-- 首次访问:初始化桶(时间=现在,令牌=满容量)
if not last then last = now; tokens = capacity end

-- 计算从上次到现在,该补多少令牌(已过时间×每秒补的数量)
local elapsed = tonumber(now) - tonumber(last)
local add = elapsed * rate
tokens = math.min(capacity, tonumber(tokens) + add) -- 补令牌,但不超过桶容量

-- 没令牌:更新时间和令牌数,返回0(拒绝请求)
if tokens < 1 then 
    redis.call('HSET', key, 'last', now); 
    redis.call('HSET', key, 'tokens', tokens); 
    return 0 
end

-- 有令牌:消耗1个,更新时间和令牌数,返回1(允许请求)
tokens = tokens - 1
redis.call('HSET', key, 'last', now)
redis.call('HSET', key, 'tokens', tokens)
redis.call('PEXPIRE', key, 60000) -- 60秒过期,清理无用桶
return 1
第二步:调用脚本做限流判断(RelationServiceImpl.java)
@Override
@Transactional
public boolean follow(long fromUserId, long toUserId) {
    // 第一步:执行Lua脚本,判断是否允许关注(限流核心)
    Long ok = redis.execute(tokenScript, List.of("rl:follow:" + fromUserId), "100", "1");
    if (ok == 0L) {
        return false; // 令牌不够,拒绝关注请求
    }

    // 第二步:令牌够,才执行真正的关注逻辑(写库+发事件)
    long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
    int inserted = mapper.insertFollowing(id, fromUserId, toUserId, 1);
    // ... 后续发Outbox事件的逻辑
}

2. 异步架构:Outbox + Canal + Kafka

采用 “Outbox 事件模式” 保证消息可靠投递:

  • 关注 / 取消关注操作在事务内写入 Outbox 表;
  • Canal 监听 Outbox 表变更,将事件转发至 Kafka;
  • 消费者消费 Kafka 消息,异步更新粉丝表、缓存、计数;
  • 全程手动 ACK,保证 “至少一次” 语义,结合 Redis 去重键实现幂等。

3. 关系三态查询:高效判断互关

通过两次单表查询(关注表 + 粉丝表),快速判断 “我关注 TA、TA 关注我、互关” 三态,满足社交场景的核心需求:

// RelationServiceImpl.java 关系三态查询
public Map<String, Boolean> relationStatus(long userId, long otherUserId) {
    boolean following = isFollowing(userId, otherUserId);
    boolean followedBy = isFollowing(otherUserId, userId);
    boolean mutual = following && followedBy;
    Map<String, Boolean> m = new LinkedHashMap<>();
    m.put("following", following);
    m.put("followedBy", followedBy);
    m.put("mutual", mutual);
    return m;
}

4.幂等去重:避免重复处理同一事件

1. 先搞懂:为啥会重复处理?

咱们的关注 / 取消关注操作是异步处理的(用户点关注后,先写库 + 发事件,后续缓存更新、粉丝数统计都靠 Kafka 异步做)。但异步场景下很容易出现 “同一事件被处理多次”:

  • 网络卡了,Kafka 重试发送同一条消息;
  • Canal 监听 Outbox 表时,重复抓取到同一条事件;
  • 消费者处理完事件后,位点提交失败,导致重新消费。

如果不做去重,会出现:用户只点了 1 次关注,结果粉丝数加了 2 次;或者取消关注后,缓存里的关注列表被重复删除,数据全乱了。

2. 核心思路:给事件贴 “唯一标签”,处理前先查 “有没有处理过”

就像给每个快递贴唯一单号,处理(签收)前先查单号是否已签收 —— 签收过就跳过,没签收就标记 “已签收”+ 处理。

3. 代码层面怎么实现(逐行拆)

核心代码在 RelationEventProcessor.javaprocess 方法里:

// 第一步:生成事件的唯一去重Key(dk = dedup:rel:事件类型:发起者ID:被关注者ID:事件ID)
String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : String.valueOf(evt.id()));

// 第二步:用Redis的setIfAbsent做“首次处理判断”(分布式锁思想)
Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));

// 第三步:非首次处理直接返回,不做任何操作
if (first == null || !first) {
    return; // 重复事件,跳过
}

// 第四步:首次处理才执行真正的逻辑(更新粉丝表、缓存、计数)
if ("FollowCreated".equals(evt.type())) {
    mapper.insertFollower(...); // 插入粉丝关系
    redis.opsForZSet().add(...); // 更新缓存
    userCounterService.incrementFollowers(...); // 加粉丝数
} else if ("FollowCanceled".equals(evt.type())) {
    // 取消关注的同理操作
}
关键细节解释:
  • 唯一 Key 怎么来?evt.type()(是关注还是取消关注)+ fromUserId(谁操作的)+ toUserId(操作谁)+ evt.id()(事件唯一 ID),这四个维度拼起来,能保证 “同一个事件只有一个 Key”;
  • setIfAbsent 的作用?:Redis 的这个方法是 “原子操作”—— 如果 Key 不存在,就设置值为 1,返回 true;如果 Key 已存在,直接返回 false。相当于给事件打 “已处理” 标签,且不会有并发问题;
  • 为啥设置 10 分钟过期?:事件处理完成后,这个 Key 就没用了,10 分钟后自动删除,避免 Redis 里堆积大量无用 Key,占内存。
4. 最终效果

不管同一个事件被 Kafka 推送多少次,只会被处理 1 次 —— 保证 “关注 1 次只加 1 次粉丝数,取消 1 次只更 1 次缓存”,数据绝对准。

五、总结与展望

知光项目的用户关系模块通过 “分表设计 + 缓存分层 + 异步解耦 + 一致性校验” 的组合策略,成功支撑了高并发下的关注 / 粉丝场景,核心收益如下:

  • 性能:关注 / 取消关注接口响应时间 < 50ms,列表查询 P99<100ms;
  • 可用性:计数体系支持自动重建,接口无失败返回;
  • 扩展性:分表设计让关注 / 粉丝维度的业务扩展互不干扰。

未来可进一步优化的方向:

  • 引入分库分表:应对超大规模用户的表数据膨胀;
  • 缓存预热:针对大 V 用户,主动预热缓存,降低首次查询延迟;
  • 实时计数:基于 Flink 实时计算关注 / 粉丝数,替代采样校验。

用户关系模块的设计本质是 “业务场景驱动技术选型”,分表、缓存、异步等技术手段均围绕 “高并发、高性能、高可用” 的核心目标,这也是社交类产品基础设施设计的通用思路。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐